diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8f5b9c9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,21 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Please provide a **minimal** reproducible example that developers can run to investigate the problem. +You can find help for creating such an example [here](https://stackoverflow.com/help/minimal-reproducible-example). + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..11fc491 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/codeqa-test.yml b/.github/workflows/codeqa-test.yml new file mode 100644 index 0000000..c068ee3 --- /dev/null +++ b/.github/workflows/codeqa-test.yml @@ -0,0 +1,68 @@ +name: Python codeqa/test + +on: + push: + branches: [master] + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.x + - uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: pip-lint + - name: Install dependencies + run: pip install flake8 isort + - name: Run flake8 + run: flake8 sphinx_autodoc_typehints.py tests + - name: Run isort + run: isort -c sphinx_autodoc_typehints.py tests + + test: + needs: [lint] + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + python-version: [3.6, 3.7, 3.8, 3.9, 3.10.0-alpha.5] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: pip-test-${{ matrix.python-version }}-${{ matrix.os }} + - name: Install dependencies + run: pip install .[test,type_comments] coveralls + - name: Test with pytest + run: coverage run -m pytest + - name: Upload Coverage + run: coveralls --service=github + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COVERALLS_FLAG_NAME: ${{ matrix.test-name }} + COVERALLS_PARALLEL: true + + coveralls: + name: Finish Coveralls + needs: test + runs-on: ubuntu-latest + container: python:3-slim + steps: + - name: Finished + run: | + pip install coveralls + coveralls --service=github --finish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..277940a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,27 @@ +name: Publish packages to PyPI + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+[a-b][0-9]+" + - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.x + - name: Install dependencies + run: pip install build + - name: Create packages + run: python -m build -s -w . + - name: Upload packages + uses: pypa/gh-action-pypi-publish@master + with: + user: __token__ + password: ${{ secrets.pypi_password }} diff --git a/.gitignore b/.gitignore index 8cda48b..c1b90bd 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ dist/ build/ .vscode/ +.pre-commit-config.yaml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f9e3767..0000000 --- a/.travis.yml +++ /dev/null @@ -1,52 +0,0 @@ -dist: xenial -language: python -python: "3.6" - -stages: - - name: static analysis - - name: test - - name: deploy to pypi - if: type = push AND tag =~ ^\d+\.\d+\.\d+ - -jobs: - include: - - stage: static analysis - env: TOXENV=flake8 - - - stage: test - env: TOXENV=py35 - python: "3.5" - after_success: &after_success - - pip install coveralls - - coveralls - - - stage: test - env: TOXENV=py36 - python: "3.6" - after_success: *after_success - - - stage: test - env: TOXENV=py37 - python: "3.7" - after_success: *after_success - - - stage: test - env: TOXENV=py38 - python: "3.8" - after_success: *after_success - - - stage: deploy to pypi - install: skip - script: skip - deploy: - provider: pypi - user: agronholm - password: - secure: duaV12IvSrtlrjcqkbOToB0YTQkFRMM3SADKPVL4JapNYbhGCHsNgauAptnIZrTIFy3B2ZQH5QxOu1xapR3LHbwCrh9VV6QYTU1BFV8ju5gTcnCWcuN0Sr42LuwB3v5sCjijMrNIfo04ovhgJKCPfOiFV3bsXv+PSUm221qLixG8vHmoP2Vqhb+8+McV/JeMMjxfMv/XFb3fWoQwaspERVu/Xt4f/taJ7JFNOJBjYYwYY79mxE6TJOTypgnrgypO0YyqjrvVsjFNuCH3QeQYtDIcJRTekp/Oo9hiNt6T4nuf3X09F9vKhFuGXtpmdwjnIktQb2jkP4FSHGJ3z/6UJP7yPgMXaFezzih5WjBVuMwDu9HOo4EHE+0hgkL5aQfbFulF2moE7PGEqhTWZkEzxGKc/ds+YbfYGigrcpuCm+KvDtQHAUkrIa8mEw5wM5+QGiiBGEzxZ6ifsZzxADEoCNshU3r6rHBWlA4ze5Q0PFCC7Jns2uqe51+9qqBz+cGKjQafn+1DwGBIr/tZusx8cjRJpsvZ116Zq7viCfzBmxEt4yA5UPYmpljS7bBSJrbrXVRNZGmAm9oO5adI99MnrQsDdVMM3KoC0R3JOmiGaKuM573am57EZ6c/hKKqyLs4MS6WLkYbygNPq3N0bQG6JKtvVKGPB1xTA116Mve6cJc= - distributions: sdist bdist_wheel - on: - tags: true - -install: pip install tox - -script: tox diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d04060d..2108876 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,187 +1,178 @@ -1.9.0 -===== +**1.12.0** -* Added support for typing_extensions_ -* Added the ``typehints_document_rtype`` option (PR by Simon-Martin Schröder) -* Fixed metaclasses as annotations causing ``TypeError`` -* Fixed rendering of ``typing.Literal`` -* Fixed OSError when generating docs for SQLAlchemy mapped classes -* Fixed unparametrized generic classes being rendered with their type parameters +- Dropped Python 3.5 support +- Added the simplify_optional_unions config option (PR by tillhainbach) +- Fixed indentation of multiline strings (PR by Yuxin Wu) + +**1.11.1** + +- Changed formatting of ``None`` to point to the Python stdlib docs (PR by Dominic Davis-Foster) +- Updated special dataclass handling (PR by Lihu Ben-Ezri-Ravin) + +**1.11.0** + +- Dropped support for Sphinx < 3.0 +- Added support for alternative parameter names (``arg``, ``argument``, ``parameter``) +- Fixed import path for Signature (PR by Matthew Treinish) +- Fixed ``TypeError`` when formatting a parametrized ``typing.IO`` annotation +- Fixed data class displaying a return type in its ``__init__()`` method + +**1.10.3** + +- Fixed ``TypeError`` (or wrong rendered class name) when an annotation is a generic class that has + a ``name`` property + +**1.10.2** + +- Fixed inner classes missing their parent class name(s) when rendered + +**1.10.1** + +- Fixed ``KeyError`` when encountering mocked annotations (``autodoc_mock_imports``) + +**1.10.0** + +- Rewrote the annotation formatting logic (fixes Python 3.5.2 compatibility regressions and an + ``AttributeError`` regression introduced in v1.9.0) +- Fixed decorator classes not being processed as classes + +**1.9.0** + +- Added support for typing_extensions_ +- Added the ``typehints_document_rtype`` option (PR by Simon-Martin Schröder) +- Fixed metaclasses as annotations causing ``TypeError`` +- Fixed rendering of ``typing.Literal`` +- Fixed OSError when generating docs for SQLAlchemy mapped classes +- Fixed unparametrized generic classes being rendered with their type parameters (e.g. ``Dict[~KT, ~VT]``) .. _typing_extensions: https://pypi.org/project/typing-extensions/ +**1.8.0** -1.8.0 -===== +- Fixed regression which caused ``TypeError`` or ``OSError`` when trying to set annotations due to + PR #87 +- Fixed unintentional mangling of annotation type names +- Added proper ``:py:data`` targets for ``NoReturn``, ``ClassVar`` and ``Tuple`` +- Added support for inline type comments (like ``(int, str) -> None``) (PR by Bernát Gábor) +- Use the native AST parser for type comment support on Python 3.8+ -* Fixed regression which caused ``TypeError`` or ``OSError`` when trying to set annotations due to - PR #87 -* Fixed unintentional mangling of annotation type names -* Added proper ``:py:data`` targets for ``NoReturn``, ``ClassVar`` and ``Tuple`` -* Added support for inline type comments (like ``(int, str) -> None``) (PR by Bernát Gábor) -* Use the native AST parser for type comment support on Python 3.8+ +**1.7.0** +- Dropped support for Python 3.4 +- Fixed unwrapped local functions causing errors (PR by Kimiyuki Onaka) +- Fixed ``AttributeError`` when documenting the ``__init__()`` method of a data class +- Added support for type hint comments (PR by Markus Unterwaditzer) +- Added flag for rendering classes with their fully qualified names (PR by Holly Becker) -1.7.0 -===== +**1.6.0** -* Dropped support for Python 3.4 -* Fixed unwrapped local functions causing errors (PR by Kimiyuki Onaka) -* Fixed ``AttributeError`` when documenting the ``__init__()`` method of a data class -* Added support for type hint comments (PR by Markus Unterwaditzer) -* Added flag for rendering classes with their fully qualified names (PR by Holly Becker) +- Fixed ``TypeError`` when formatting annotations from a class that inherits from a concrete + generic type (report and tests by bpeake-illuscio) +- Added support for ``typing_extensions.Protocol`` (PR by Ian Good) +- Added support for ``typing.NewType`` (PR by George Leslie-Waksman) +**1.5.2** -1.6.0 -===== - -* Fixed ``TypeError`` when formatting annotations from a class that inherits from a concrete - generic type (report and tests by bpeake-illuscio) -* Added support for ``typing_extensions.Protocol`` (PR by Ian Good) -* Added support for ``typing.NewType`` (PR by George Leslie-Waksman) - - -1.5.2 -===== - -* Emit a warning instead of crashing when an unresolvable forward reference is encountered in type +- Emit a warning instead of crashing when an unresolvable forward reference is encountered in type annotations +**1.5.1** -1.5.1 -===== - -* Fixed escape characters in parameter default values getting lost during signature processing -* Replaced use of the ``config-inited`` event (which inadvertently required Sphinx 1.8) with the +- Fixed escape characters in parameter default values getting lost during signature processing +- Replaced use of the ``config-inited`` event (which inadvertently required Sphinx 1.8) with the ``builder-inited`` event +**1.5.0** -1.5.0 -===== - -* The setting of the ``typing.TYPECHECKING`` flag is now configurable using the +- The setting of the ``typing.TYPECHECKING`` flag is now configurable using the ``set_type_checking_flag`` option +**1.4.0** -1.4.0 -===== +- The extension now sets ``typing.TYPECHECKING`` to ``True`` during setup to include conditional + imports which may be used in type annotations +- Fixed parameters with trailing underscores (PR by Daniel Knell) +- Fixed KeyError with private methods (PR by Benito Palacios Sánchez) +- Fixed deprecation warning about the use of formatargspec (PR by Y. Somda) +- The minimum Sphinx version is now v1.7.0 -* The extension now sets ``typing.TYPECHECKING`` to ``True`` during setup to include conditional - imports which may be used in type annotations -* Fixed parameters with trailing underscores (PR by Daniel Knell) -* Fixed KeyError with private methods (PR by Benito Palacios Sánchez) -* Fixed deprecation warning about the use of formatargspec (PR by Y. Somda) -* The minimum Sphinx version is now v1.7.0 +**1.3.1** +- Fixed rendering of generic types outside the typing module (thanks to Tim Poterba for the PR) -1.3.1 -===== +**1.3.0** -* Fixed rendering of generic types outside the typing module (thanks to Tim Poterba for the PR) +- Fixed crash when processing docstrings from nested classes (thanks to dilyanpalauzov for the fix) +- Added support for Python 3.7 +- Dropped support for Python 3.5.0 and 3.5.1 +**1.2.5** -1.3.0 -===== - -* Fixed crash when processing docstrings from nested classes (thanks to dilyanpalauzov for the fix) -* Added support for Python 3.7 -* Dropped support for Python 3.5.0 and 3.5.1 - - -1.2.5 -===== - -* Ensured that ``:rtype:`` doesn't get joined with a paragraph of text +- Ensured that ``:rtype:`` doesn't get joined with a paragraph of text (thanks to Bruce Merry for the PR) +**1.2.4** -1.2.4 -===== - -* Removed support for ``backports.typing`` as it has been removed from the PyPI -* Fixed first parameter being cut out from class methods and static methods +- Removed support for ``backports.typing`` as it has been removed from the PyPI +- Fixed first parameter being cut out from class methods and static methods (thanks to Josiah Wolf Oberholtzer for the PR) +**1.2.3** -1.2.3 -===== +- Fixed `process_signature()` clobbering any explicitly overridden signatures from the docstring -* Fixed `process_signature()` clobbering any explicitly overridden signatures from the docstring +**1.2.2** - -1.2.2 -===== - -* Explicitly prefix ``:class:``, ``:mod:`` et al with ``:py:``, in case ``py`` is not the default +- Explicitly prefix ``:class:``, ``:mod:`` et al with ``:py:``, in case ``py`` is not the default domain of the project (thanks Monty Taylor) +**1.2.1** -1.2.1 -===== - -* Fixed `ValueError` when `getargspec()` encounters a built-in function -* Fixed `AttributeError` when `Any` is combined with another type in a `Union` +- Fixed `ValueError` when `getargspec()` encounters a built-in function +- Fixed `AttributeError` when `Any` is combined with another type in a `Union` (thanks Davis Kirkendall) +**1.2.0** -1.2.0 -===== +- Fixed compatibility with Python 3.6 and 3.5.3 +- Fixed ``NameError`` when processing signatures of wrapped functions with type hints +- Fixed handling of slotted classes with no ``__init__()`` method +- Fixed Sphinx warning about parallel reads +- Fixed return type being added to class docstring from its ``__init__()`` method + (thanks to Manuel Krebber for the patch) +- Fixed return type hints of ``@property`` methods being omitted (thanks to pknight for the patch) +- Added a test suite (thanks Manuel Krebber) -* Fixed compatibility with Python 3.6 and 3.5.3 -* Fixed ``NameError`` when processing signatures of wrapped functions with type hints -* Fixed handling of slotted classes with no ``__init__()`` method -* Fixed Sphinx warning about parallel reads -* Fixed return type being added to class docstring from its ``__init__()`` method - (thanks to Manuel Krebber for the patch) -* Fixed return type hints of ``@property`` methods being omitted (thanks to pknight for the patch) -* Added a test suite (thanks Manuel Krebber) +**1.1.0** +- Added proper support for ``typing.Tuple`` (pull request by Manuel Krebber) -1.1.0 -===== +**1.0.6** -* Added proper support for ``typing.Tuple`` (pull request by Manuel Krebber) +- Fixed wrong placement of ``:rtype:`` if a multi-line ``:param:`` or a ``:returns:`` is used +**1.0.5** -1.0.6 -===== +- Fixed coroutine functions' signatures not being processed when using sphinxcontrib-asyncio -* Fixed wrong placement of ``:rtype:`` if a multi-line ``:param:`` or a ``:returns:`` is used +**1.0.4** +- Fixed compatibility with Sphinx 1.4 -1.0.5 -===== +**1.0.3** -* Fixed coroutine functions' signatures not being processed when using sphinxcontrib-asyncio +- Fixed "self" parameter not being removed from exception class constructor signatures +- Fixed process_signature() erroneously removing the first argument of a static method +**1.0.2** -1.0.4 -===== +- Fixed exception classes not being processed like normal classes -* Fixed compatibility with Sphinx 1.4 +**1.0.1** +- Fixed errors caused by forward references not being looked up with the right globals -1.0.3 -===== +**1.0.0** -* Fixed "self" parameter not being removed from exception class constructor signatures -* Fixed process_signature() erroneously removing the first argument of a static method - - -1.0.2 -===== - -* Fixed exception classes not being processed like normal classes - - -1.0.1 -===== - -* Fixed errors caused by forward references not being looked up with the right globals - - -1.0.0 -===== - -* Initial release +- Initial release diff --git a/README.rst b/README.rst index 47d68a0..fd22eb5 100644 --- a/README.rst +++ b/README.rst @@ -65,7 +65,13 @@ be able to add type info. * ``typehints_document_rtype`` (default: ``True``): If ``False``, never add an ``:rtype:`` directive. If ``True``, add the ``:rtype:`` directive if no existing ``:rtype:`` is found. - +* ``simplify_optional_unions`` (default: ``True``): If ``True``, optional parameters of type "Union[...]" + are simplified as being of type Union[..., None] in the resulting documention + (e.g. Optional[Union[A, B]] -> Union[A, B, None]). + If ``False``, the "Optional"-type is kept. + Note: If ``False``, **any** Union containing ``None`` will be displayed as Optional! + Note: If an optional parameter has only a single type (e.g Optional[A] or Union[A, None]), + it will **always** be displayed as Optional! How it works ------------ diff --git a/pre-commit-config.sample.yaml b/pre-commit-config.sample.yaml new file mode 100644 index 0000000..7f86457 --- /dev/null +++ b/pre-commit-config.sample.yaml @@ -0,0 +1,24 @@ +# This is the configuration file for pre-commit (https://pre-commit.com/). +# To use: +# * Install pre-commit (https://pre-commit.com/#installation) +# * Copy this file as ".pre-commit-config.yaml" +# * Run "pre-commit install". +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.4.0 + hooks: + - id: check-toml + - id: debug-statements + - id: end-of-file-fixer + - id: mixed-line-ending + args: ["--fix=lf"] + - id: trailing-whitespace +- repo: https://github.com/pre-commit/mirrors-autopep8 + rev: v1.5.6 + hooks: + - id: autopep8 +- repo: https://github.com/pycqa/isort + rev: 5.8.0 + hooks: + - id: isort + additional_dependencies: [toml] diff --git a/pyproject.toml b/pyproject.toml index 76510e2..eeb699e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,3 +5,15 @@ "wheel >= 0.29.0", ] build-backend = 'setuptools.build_meta' + +[tool.isort] +skip_gitignore = true +line_length = 99 +multi_line_output = 4 + +[tool.autopep8] +max_line_length = 99 + +[tool.pytest.ini_options] +addopts = "-rsx --tb=short" +testpaths = ["tests"] diff --git a/setup.cfg b/setup.cfg index 5f6e479..c872ff3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,15 +18,16 @@ Topic :: Documentation :: Sphinx Programming Language :: Python Programming Language :: Python :: 3 - Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 [options] py_modules = sphinx_autodoc_typehints -python_requires = >=3.5.2 -install_requires = Sphinx >= 2.1 +python_requires = >=3.6 +install_requires = Sphinx >= 3.0 [options.extras_require] test = @@ -34,6 +35,7 @@ typing_extensions >= 3.5 dataclasses; python_version == "3.6" sphobjinv >= 2.0 + Sphinx >= 3.2.0 type_comments = typed_ast >= 1.4.0; python_version < "3.8" diff --git a/sphinx_autodoc_typehints.py b/sphinx_autodoc_typehints.py index 0cee462..0b69b1c 100644 --- a/sphinx_autodoc_typehints.py +++ b/sphinx_autodoc_typehints.py @@ -2,151 +2,197 @@ import sys import textwrap import typing -from typing import get_type_hints, TypeVar, Generic +from typing import Any, AnyStr, Tuple, TypeVar, get_type_hints from sphinx.util import logging -from sphinx.util.inspect import Signature - -try: - from typing_extensions import Protocol -except ImportError: - Protocol = None +from sphinx.util.inspect import signature as Signature +from sphinx.util.inspect import stringify_signature logger = logging.getLogger(__name__) pydata_annotations = {'Any', 'AnyStr', 'Callable', 'ClassVar', 'Literal', 'NoReturn', 'Optional', 'Tuple', 'Union'} -def format_annotation(annotation, fully_qualified=False): - if inspect.isclass(annotation) and annotation.__module__ == 'builtins': - if annotation.__qualname__ == 'NoneType': - return '``None``' +def get_annotation_module(annotation) -> str: + # Special cases + if annotation is None: + return 'builtins' + + if hasattr(annotation, '__module__'): + return annotation.__module__ + + if hasattr(annotation, '__origin__'): + return annotation.__origin__.__module__ + + raise ValueError('Cannot determine the module of {}'.format(annotation)) + + +def get_annotation_class_name(annotation, module: str) -> str: + # Special cases + if annotation is None: + return 'None' + elif annotation is Any: + return 'Any' + elif annotation is AnyStr: + return 'AnyStr' + elif inspect.isfunction(annotation) and hasattr(annotation, '__supertype__'): + return 'NewType' + + if getattr(annotation, '__qualname__', None): + return annotation.__qualname__ + elif getattr(annotation, '_name', None): # Required for generic aliases on Python 3.7+ + return annotation._name + elif (module in ('typing', 'typing_extensions') + and isinstance(getattr(annotation, 'name', None), str)): + # Required for at least Pattern and Match + return annotation.name + + origin = getattr(annotation, '__origin__', None) + if origin: + if getattr(origin, '__qualname__', None): # Required for Protocol subclasses + return origin.__qualname__ + elif getattr(origin, '_name', None): # Required for Union on Python 3.7+ + return origin._name else: - return ':py:class:`{}`'.format(annotation.__qualname__) - - annotation_cls = annotation if inspect.isclass(annotation) else type(annotation) - if annotation_cls.__module__ in ('typing', 'typing_extensions'): - class_name = str(annotation).split('[')[0].split('.')[-1] - params = None - module = 'typing' - extra = '' - - origin = getattr(annotation, '__origin__', None) - if inspect.isclass(origin): - annotation_cls = annotation.__origin__ - try: - mro = annotation_cls.mro() - if Generic in mro or (Protocol and Protocol in mro): - module = annotation_cls.__module__ - except TypeError: - pass # annotation_cls was either the "type" object or typing.Type - - if class_name == 'Any': - return ':py:data:`{}typing.Any`'.format("" if fully_qualified else "~") - elif class_name == '~AnyStr': - return ':py:data:`{}typing.AnyStr`'.format("" if fully_qualified else "~") - elif isinstance(annotation, TypeVar): - return '\\%r' % annotation - elif class_name == 'Union': - if hasattr(annotation, '__union_params__'): - params = annotation.__union_params__ - elif hasattr(annotation, '__args__'): - params = annotation.__args__ - - if params and len(params) == 2 and (hasattr(params[1], '__qualname__') and - params[1].__qualname__ == 'NoneType'): - class_name = 'Optional' - params = (params[0],) - elif class_name == 'Tuple' and hasattr(annotation, '__tuple_params__'): - params = annotation.__tuple_params__ - if annotation.__tuple_use_ellipsis__: - params += (Ellipsis,) - elif class_name == 'Callable': - arg_annotations = result_annotation = None - if hasattr(annotation, '__result__'): - arg_annotations = annotation.__args__ - result_annotation = annotation.__result__ - elif getattr(annotation, '__args__', None): - arg_annotations = annotation.__args__[:-1] - result_annotation = annotation.__args__[-1] - - if arg_annotations in (Ellipsis, (Ellipsis,)): - params = [Ellipsis, result_annotation] - elif arg_annotations is not None: - params = [ - '\\[{}]'.format( - ', '.join( - format_annotation(param, fully_qualified) - for param in arg_annotations)), - result_annotation - ] - elif class_name == 'Literal': - annotation_args = getattr(annotation, '__args__', ()) or annotation.__values__ - extra = '\\[{}]'.format(', '.join(repr(arg) for arg in annotation_args)) - elif class_name == 'ClassVar' and hasattr(annotation, '__type__'): - # < py3.7 - params = (annotation.__type__,) - elif hasattr(annotation, 'type_var'): - # Type alias - class_name = annotation.name - params = (annotation.type_var,) - elif getattr(annotation, '__args__', None) is not None: - params = annotation.__args__ - elif hasattr(annotation, '__parameters__'): - params = annotation.__parameters__ - - if params and annotation is not getattr(sys.modules[module], class_name): - extra = '\\[{}]'.format(', '.join( - format_annotation(param, fully_qualified) for param in params)) - - return '{prefix}`{qualify}{module}.{name}`{extra}'.format( - prefix=':py:data:' if class_name in pydata_annotations else ':py:class:', - qualify="" if fully_qualified else "~", - module=module, - name=class_name, - extra=extra - ) + return origin.__class__.__qualname__.lstrip('_') # Required for Union on Python < 3.7 + + annotation_cls = annotation if inspect.isclass(annotation) else annotation.__class__ + return annotation_cls.__qualname__.lstrip('_') + + +def get_annotation_args(annotation, module: str, class_name: str) -> Tuple: + try: + original = getattr(sys.modules[module], class_name) + except (KeyError, AttributeError): + pass + else: + if annotation is original: + return () # This is the original, unparametrized type + + # Special cases + if class_name in ('Pattern', 'Match') and hasattr(annotation, 'type_var'): # Python < 3.7 + return annotation.type_var, + elif class_name == 'ClassVar' and hasattr(annotation, '__type__'): # ClassVar on Python < 3.7 + return annotation.__type__, + elif class_name == 'NewType' and hasattr(annotation, '__supertype__'): + return annotation.__supertype__, + elif class_name == 'Literal' and hasattr(annotation, '__values__'): + return annotation.__values__ + elif class_name == 'Generic': + return annotation.__parameters__ + + return getattr(annotation, '__args__', ()) + + +def format_annotation(annotation, + fully_qualified: bool = False, + simplify_optional_unions: bool = True) -> str: + # Special cases + if annotation is None or annotation is type(None): # noqa: E721 + return ':py:obj:`None`' elif annotation is Ellipsis: return '...' - elif (inspect.isfunction(annotation) and annotation.__module__ == 'typing' and - hasattr(annotation, '__name__') and hasattr(annotation, '__supertype__')): - return ':py:func:`{qualify}typing.NewType`\\(:py:data:`~{name}`, {extra})'.format( - qualify="" if fully_qualified else "~", - name=annotation.__name__, - extra=format_annotation(annotation.__supertype__, fully_qualified), - ) - elif inspect.isclass(annotation) or inspect.isclass(getattr(annotation, '__origin__', None)): - if not inspect.isclass(annotation): - annotation_cls = annotation.__origin__ - - extra = '' - try: - mro = annotation_cls.mro() - except TypeError: - pass - else: - if Generic in mro or (Protocol and Protocol in mro): - params = (getattr(annotation, '__parameters__', None) or - getattr(annotation, '__args__', None)) - if params: - extra = '\\[{}]'.format(', '.join( - format_annotation(param, fully_qualified) for param in params)) - - return ':py:class:`{qualify}{module}.{name}`{extra}'.format( - qualify="" if fully_qualified else "~", - module=annotation.__module__, - name=annotation_cls.__qualname__, - extra=extra - ) - - return str(annotation) + + # Type variables are also handled specially + try: + if isinstance(annotation, TypeVar) and annotation is not AnyStr: + return '\\' + repr(annotation) + except TypeError: + pass + + try: + module = get_annotation_module(annotation) + class_name = get_annotation_class_name(annotation, module) + args = get_annotation_args(annotation, module, class_name) + except ValueError: + return str(annotation).strip("'") + + # Redirect all typing_extensions types to the stdlib typing module + if module == 'typing_extensions': + module = 'typing' + + full_name = (module + '.' + class_name) if module != 'builtins' else class_name + prefix = '' if fully_qualified or full_name == class_name else '~' + role = 'data' if class_name in pydata_annotations else 'class' + args_format = '\\[{}]' + formatted_args = '' + + # Some types require special handling + if full_name == 'typing.NewType': + args_format = '\\(:py:data:`~{name}`, {{}})'.format(name=annotation.__name__) + role = 'func' + elif full_name == 'typing.Union' and type(None) in args: + if len(args) == 2: + full_name = 'typing.Optional' + args = tuple(x for x in args if x is not type(None)) # noqa: E721 + elif not simplify_optional_unions: + full_name = 'typing.Optional' + args_format = '\\[:py:data:`{prefix}typing.Union`\\[{{}}]]'.format(prefix=prefix) + args = tuple(x for x in args if x is not type(None)) # noqa: E721 + elif full_name == 'typing.Callable' and args and args[0] is not ...: + formatted_args = '\\[\\[' + ', '.join( + format_annotation( + arg, simplify_optional_unions=simplify_optional_unions) + for arg in args[:-1]) + ']' + formatted_args += ', ' + format_annotation( + args[-1], simplify_optional_unions=simplify_optional_unions) + ']' + elif full_name == 'typing.Literal': + formatted_args = '\\[' + ', '.join(repr(arg) for arg in args) + ']' + + if args and not formatted_args: + formatted_args = args_format.format(', '.join( + format_annotation(arg, fully_qualified, simplify_optional_unions) + for arg in args)) + + return ':py:{role}:`{prefix}{full_name}`{formatted_args}'.format( + role=role, prefix=prefix, full_name=full_name, formatted_args=formatted_args) + + +# reference: https://github.com/pytorch/pytorch/pull/46548/files +def normalize_source_lines(sourcelines: str) -> str: + """ + This helper function accepts a list of source lines. It finds the + indentation level of the function definition (`def`), then it indents + all lines in the function body to a point at or greater than that + level. This allows for comments and continued string literals that + are at a lower indentation than the rest of the code. + Arguments: + sourcelines: source code + Returns: + source lines that have been correctly aligned + """ + sourcelines = sourcelines.split("\n") + + def remove_prefix(text, prefix): + return text[text.startswith(prefix) and len(prefix):] + + # Find the line and line number containing the function definition + for i, l in enumerate(sourcelines): + if l.lstrip().startswith("def"): + idx = i + break + else: + return "\n".join(sourcelines) + fn_def = sourcelines[idx] + + # Get a string representing the amount of leading whitespace + whitespace = fn_def.split("def")[0] + + # Add this leading whitespace to all lines before and after the `def` + aligned_prefix = [whitespace + remove_prefix(s, whitespace) for s in sourcelines[:idx]] + aligned_suffix = [whitespace + remove_prefix(s, whitespace) for s in sourcelines[idx + 1:]] + + # Put it together again + aligned_prefix.append(fn_def) + return "\n".join(aligned_prefix + aligned_suffix) def process_signature(app, what: str, name: str, obj, options, signature, return_annotation): if not callable(obj): return - if what in ('class', 'exception'): + original_obj = obj + if inspect.isclass(obj): obj = getattr(obj, '__init__', getattr(obj, '__new__', None)) if not getattr(obj, '__annotations__', None): @@ -156,17 +202,30 @@ signature = Signature(obj) parameters = [ param.replace(annotation=inspect.Parameter.empty) - for param in signature.signature.parameters.values() + for param in signature.parameters.values() ] - if '' in obj.__qualname__: + # The generated dataclass __init__() and class are weird and need extra checks + # This helper function operates on the generated class and methods + # of a dataclass, not an instantiated dataclass object. As such, + # it cannot be replaced by a call to `dataclasses.is_dataclass()`. + def _is_dataclass(name: str, what: str, qualname: str) -> bool: + if what == 'method' and name.endswith('.__init__'): + # generated __init__() + return True + if what == 'class' and qualname.endswith('.__init__'): + # generated class + return True + return False + + if '' in obj.__qualname__ and not _is_dataclass(name, what, obj.__qualname__): logger.warning( 'Cannot treat a function defined as a local function: "%s" (use @functools.wraps)', name) return if parameters: - if what in ('class', 'exception'): + if inspect.isclass(original_obj) or (what == 'method' and name.endswith('.__init__')): del parameters[0] elif what == 'method': outer = inspect.getmodule(obj) @@ -185,11 +244,11 @@ if not isinstance(method_object, (classmethod, staticmethod)): del parameters[0] - signature.signature = signature.signature.replace( + signature = signature.replace( parameters=parameters, return_annotation=inspect.Signature.empty) - return signature.format_args().replace('\\', '\\\\'), None + return stringify_signature(signature).replace('\\', '\\\\'), None def get_all_type_hints(obj, name): @@ -250,7 +309,8 @@ return children[0] try: - obj_ast = ast.parse(textwrap.dedent(inspect.getsource(obj)), **parse_kwargs) + obj_ast = ast.parse(textwrap.dedent( + normalize_source_lines(inspect.getsource(obj))), **parse_kwargs) except (OSError, TypeError): return {} @@ -345,11 +405,12 @@ def process_docstring(app, what, name, obj, options, lines): + original_obj = obj if isinstance(obj, property): obj = obj.fget if callable(obj): - if what in ('class', 'exception'): + if inspect.isclass(obj): obj = getattr(obj, '__init__') obj = inspect.unwrap(obj) @@ -362,18 +423,21 @@ argname = '{}\\_'.format(argname[:-1]) formatted_annotation = format_annotation( - annotation, fully_qualified=app.config.typehints_fully_qualified) - - searchfor = ':param {}:'.format(argname) + annotation, + fully_qualified=app.config.typehints_fully_qualified, + simplify_optional_unions=app.config.simplify_optional_unions) + + searchfor = [':{} {}:'.format(field, argname) + for field in ('param', 'parameter', 'arg', 'argument')] insert_index = None for i, line in enumerate(lines): - if line.startswith(searchfor): + if any(line.startswith(search_string) for search_string in searchfor): insert_index = i break if insert_index is None and app.config.always_document_param_types: - lines.append(searchfor) + lines.append(':param {}:'.format(argname)) insert_index = len(lines) if insert_index is not None: @@ -382,9 +446,15 @@ ':type {}: {}'.format(argname, formatted_annotation) ) - if 'return' in type_hints and what not in ('class', 'exception'): + if 'return' in type_hints and not inspect.isclass(original_obj): + # This avoids adding a return type for data class __init__ methods + if what == 'method' and name.endswith('.__init__'): + return + formatted_annotation = format_annotation( - type_hints['return'], fully_qualified=app.config.typehints_fully_qualified) + type_hints['return'], fully_qualified=app.config.typehints_fully_qualified, + simplify_optional_unions=app.config.simplify_optional_unions + ) insert_index = len(lines) for i, line in enumerate(lines): @@ -414,6 +484,7 @@ app.add_config_value('always_document_param_types', False, 'html') app.add_config_value('typehints_fully_qualified', False, 'env') app.add_config_value('typehints_document_rtype', True, 'env') + app.add_config_value('simplify_optional_unions', True, 'env') app.connect('builder-inited', builder_ready) app.connect('autodoc-process-signature', process_signature) app.connect('autodoc-process-docstring', process_docstring) diff --git a/tests/conftest.py b/tests/conftest.py index 17637fa..df91a63 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,8 @@ import os +import pathlib +import re +import shutil import sys -import pathlib -import shutil import pytest from sphinx.testing.path import path @@ -13,13 +14,15 @@ @pytest.fixture(scope='session') def inv(pytestconfig): - inv_dict = pytestconfig.cache.get('python/objects.inv', None) + cache_path = 'python{v.major}.{v.minor}/objects.inv'.format(v=sys.version_info) + inv_dict = pytestconfig.cache.get(cache_path, None) if inv_dict is not None: return Inventory(inv_dict) + print("Downloading objects.inv") url = 'https://docs.python.org/{v.major}.{v.minor}/objects.inv'.format(v=sys.version_info) inv = Inventory(url=url) - pytestconfig.cache.set('python/objects.inv', inv.json_dict()) + pytestconfig.cache.set(cache_path, inv.json_dict()) return inv @@ -40,3 +43,12 @@ @pytest.fixture def rootdir(): return path(os.path.dirname(__file__) or '.').abspath() / 'roots' + + +def pytest_ignore_collect(path, config): + version_re = re.compile(r'_py(\d)(\d)\.py$') + match = version_re.search(path.basename) + if match: + version = tuple(int(x) for x in match.groups()) + if sys.version_info < version: + return True diff --git a/tests/roots/test-dummy/conf.py b/tests/roots/test-dummy/conf.py index a4ab10a..d80d845 100644 --- a/tests/roots/test-dummy/conf.py +++ b/tests/roots/test-dummy/conf.py @@ -1,6 +1,5 @@ import pathlib import sys - # Make dummy_module.py available for autodoc. sys.path.insert(0, str(pathlib.Path(__file__).parent)) @@ -12,4 +11,4 @@ 'sphinx.ext.autodoc', 'sphinx.ext.napoleon', 'sphinx_autodoc_typehints', - ] +] diff --git a/tests/roots/test-dummy/dummy_module.py b/tests/roots/test-dummy/dummy_module.py index e753d7b..c707a10 100644 --- a/tests/roots/test-dummy/dummy_module.py +++ b/tests/roots/test-dummy/dummy_module.py @@ -1,11 +1,7 @@ import typing +from dataclasses import dataclass +from mailbox import Mailbox from typing import Callable, Union - -try: - from dataclasses import dataclass -except ImportError: - def dataclass(cls): - return cls def get_local_function(): @@ -141,7 +137,7 @@ """ Function docstring. - :param x: foo + :arg x: foo """ @@ -153,7 +149,7 @@ """ Function docstring. - :param x: foo + :parameter x: foo :param y: bar """ @@ -180,9 +176,19 @@ """ Method docstring. - :param x: foo + :arg x: foo """ return 42 + + def method_without_typehint(self, x): + """ + Method docstring. + """ + # test that multiline str can be correctly indented + multiline_str = """ +test +""" + return multiline_str def function_with_typehint_comment_not_inline(x=None, *y, z, **kwargs): @@ -190,10 +196,10 @@ """ Function docstring. - :param x: foo - :param y: bar - :param z: baz - :param kwargs: some kwargs + :arg x: foo + :argument y: bar + :parameter z: baz + :parameter kwargs: some kwargs """ @@ -237,3 +243,24 @@ @dataclass class DataClass: """Class docstring.""" + + x: int + + +class Decorator: + """ + Initializer docstring. + + :param func: function + """ + + def __init__(self, func: Callable[[int, str], str]): + pass + + +def mocked_import(x: Mailbox): + """ + A docstring. + + :param x: function + """ diff --git a/tests/roots/test-dummy/index.rst b/tests/roots/test-dummy/index.rst index 19ee204..b822977 100644 --- a/tests/roots/test-dummy/index.rst +++ b/tests/roots/test-dummy/index.rst @@ -32,3 +32,7 @@ .. autoclass:: dummy_module.DataClass :undoc-members: :special-members: __init__ + +.. autodecorator:: dummy_module.Decorator + +.. autofunction:: dummy_module.mocked_import diff --git a/tests/test_sphinx_autodoc_typehints.py b/tests/test_sphinx_autodoc_typehints.py index 13d4f03..7ef8d73 100644 --- a/tests/test_sphinx_autodoc_typehints.py +++ b/tests/test_sphinx_autodoc_typehints.py @@ -3,30 +3,16 @@ import sys import textwrap import typing -from collections import defaultdict from typing import ( - Any, AnyStr, Callable, Dict, Generic, Mapping, NewType, Optional, Pattern, - Tuple, TypeVar, Union, Type) + IO, Any, AnyStr, Callable, Dict, Generic, Mapping, Match, NewType, Optional, Pattern, Tuple, + Type, TypeVar, Union) import pytest import typing_extensions -from sphinx_autodoc_typehints import format_annotation, process_docstring - -try: - from typing import ClassVar # not available prior to Python 3.5.3 -except ImportError: - ClassVar = None - -try: - from typing import NoReturn # not available prior to Python 3.6.5 -except ImportError: - NoReturn = None - -try: - from typing import Literal -except ImportError: - Literal = defaultdict(lambda: None) +from sphinx_autodoc_typehints import ( + format_annotation, get_annotation_args, get_annotation_class_name, get_annotation_module, + process_docstring) T = TypeVar('T') U = TypeVar('U', covariant=True) @@ -38,9 +24,13 @@ def get_type(self): return type(self) + class Inner: + pass + class B(Generic[T]): - pass + # This is set to make sure the correct class name ("B") is picked up + name = 'Foo' class C(B[str]): @@ -61,21 +51,52 @@ class Metaclass(type): pass + + +@pytest.mark.parametrize('annotation, module, class_name, args', [ + pytest.param(str, 'builtins', 'str', (), id='str'), + pytest.param(None, 'builtins', 'None', (), id='None'), + pytest.param(Any, 'typing', 'Any', (), id='Any'), + pytest.param(AnyStr, 'typing', 'AnyStr', (), id='AnyStr'), + pytest.param(Dict, 'typing', 'Dict', (), id='Dict'), + pytest.param(Dict[str, int], 'typing', 'Dict', (str, int), id='Dict_parametrized'), + pytest.param(Dict[T, int], 'typing', 'Dict', (T, int), id='Dict_typevar'), + pytest.param(Tuple, 'typing', 'Tuple', (), id='Tuple'), + pytest.param(Tuple[str, int], 'typing', 'Tuple', (str, int), id='Tuple_parametrized'), + pytest.param(Union[str, int], 'typing', 'Union', (str, int), id='Union'), + pytest.param(Callable, 'typing', 'Callable', (), id='Callable'), + pytest.param(Callable[..., str], 'typing', 'Callable', (..., str), id='Callable_returntype'), + pytest.param(Callable[[int, str], str], 'typing', 'Callable', (int, str, str), + id='Callable_all_types'), + pytest.param(Pattern, 'typing', 'Pattern', (), id='Pattern'), + pytest.param(Pattern[str], 'typing', 'Pattern', (str,), id='Pattern_parametrized'), + pytest.param(Match, 'typing', 'Match', (), id='Match'), + pytest.param(Match[str], 'typing', 'Match', (str,), id='Match_parametrized'), + pytest.param(IO, 'typing', 'IO', (), id='IO'), + pytest.param(W, 'typing', 'NewType', (str,), id='W'), + pytest.param(Metaclass, __name__, 'Metaclass', (), id='Metaclass'), + pytest.param(Slotted, __name__, 'Slotted', (), id='Slotted'), + pytest.param(A, __name__, 'A', (), id='A'), + pytest.param(B, __name__, 'B', (), id='B'), + pytest.param(C, __name__, 'C', (), id='C'), + pytest.param(D, __name__, 'D', (), id='D'), + pytest.param(E, __name__, 'E', (), id='E'), + pytest.param(E[int], __name__, 'E', (int,), id='E_parametrized'), + pytest.param(A.Inner, __name__, 'A.Inner', (), id='Inner') +]) +def test_parse_annotation(annotation, module, class_name, args): + assert get_annotation_module(annotation) == module + assert get_annotation_class_name(annotation, module) == class_name + assert get_annotation_args(annotation, module, class_name) == args @pytest.mark.parametrize('annotation, expected_result', [ (str, ':py:class:`str`'), (int, ':py:class:`int`'), - (type(None), '``None``'), + (type(None), ':py:obj:`None`'), (type, ':py:class:`type`'), (Type, ':py:class:`~typing.Type`'), (Type[A], ':py:class:`~typing.Type`\\[:py:class:`~%s.A`]' % __name__), - pytest.param(NoReturn, ':py:data:`~typing.NoReturn`', - marks=[pytest.mark.skipif(NoReturn is None, - reason='typing.NoReturn is not available')]), - pytest.param(ClassVar[str], ':py:data:`~typing.ClassVar`\\[:py:class:`str`]', - marks=[pytest.mark.skipif(ClassVar is None, - reason='typing.ClassVar is not available')]), (Any, ':py:data:`~typing.Any`'), (AnyStr, ':py:data:`~typing.AnyStr`'), (Generic[T], ':py:class:`~typing.Generic`\\[\\~T]'), @@ -100,11 +121,15 @@ (Union, ':py:data:`~typing.Union`'), (Union[str, bool], ':py:data:`~typing.Union`\\[:py:class:`str`, ' ':py:class:`bool`]'), + (Union[str, bool, None], ':py:data:`~typing.Union`\\[:py:class:`str`, ' + ':py:class:`bool`, :py:obj:`None`]'), pytest.param(Union[str, Any], ':py:data:`~typing.Union`\\[:py:class:`str`, ' ':py:data:`~typing.Any`]', marks=pytest.mark.skipif((3, 5, 0) <= sys.version_info[:3] <= (3, 5, 2), reason='Union erases the str on 3.5.0 -> 3.5.2')), (Optional[str], ':py:data:`~typing.Optional`\\[:py:class:`str`]'), + (Optional[Union[str, bool]], ':py:data:`~typing.Union`\\[:py:class:`str`, ' + ':py:class:`bool`, :py:obj:`None`]'), (Callable, ':py:data:`~typing.Callable`'), (Callable[..., int], ':py:data:`~typing.Callable`\\[..., :py:class:`int`]'), (Callable[[int], int], ':py:data:`~typing.Callable`\\[\\[:py:class:`int`], ' @@ -112,20 +137,19 @@ (Callable[[int, str], bool], ':py:data:`~typing.Callable`\\[\\[:py:class:`int`, ' ':py:class:`str`], :py:class:`bool`]'), (Callable[[int, str], None], ':py:data:`~typing.Callable`\\[\\[:py:class:`int`, ' - ':py:class:`str`], ``None``]'), + ':py:class:`str`], :py:obj:`None`]'), (Callable[[T], T], ':py:data:`~typing.Callable`\\[\\[\\~T], \\~T]'), (Pattern, ':py:class:`~typing.Pattern`'), (Pattern[str], ':py:class:`~typing.Pattern`\\[:py:class:`str`]'), - pytest.param(Literal['a', 1], ":py:data:`~typing.Literal`\\['a', 1]", - marks=[pytest.mark.skipif(isinstance(Literal, defaultdict), - reason='Requires Python 3.8+')]), + (IO, ':py:class:`~typing.IO`'), + (IO[str], ':py:class:`~typing.IO`\\[:py:class:`str`]'), (Metaclass, ':py:class:`~%s.Metaclass`' % __name__), (A, ':py:class:`~%s.A`' % __name__), - (B, ':py:class:`~%s.B`\\[\\~T]' % __name__), + (B, ':py:class:`~%s.B`' % __name__), (B[int], ':py:class:`~%s.B`\\[:py:class:`int`]' % __name__), (C, ':py:class:`~%s.C`' % __name__), (D, ':py:class:`~%s.D`' % __name__), - (E, ':py:class:`~%s.E`\\[\\~T]' % __name__), + (E, ':py:class:`~%s.E`' % __name__), (E[int], ':py:class:`~%s.E`\\[:py:class:`int`]' % __name__), (W, ':py:func:`~typing.NewType`\\(:py:data:`~W`, :py:class:`str`)') ]) @@ -133,6 +157,27 @@ result = format_annotation(annotation) assert result == expected_result + # Test with the "simplify_optional_unions" flag turned off: + if re.match(r'^:py:data:`~typing\.Union`\\\[.*``None``.*\]', expected_result): + # strip None - argument and copy string to avoid conflicts with + # subsequent tests + expected_result_not_simplified = expected_result.replace(', ``None``', '') + # encapsulate Union in typing.Optional + expected_result_not_simplified = ':py:data:`~typing.Optional`\\[' + \ + expected_result_not_simplified + expected_result_not_simplified += ']' + assert format_annotation(annotation, simplify_optional_unions=False) == \ + expected_result_not_simplified + + # Test with the "fully_qualified" flag turned on + if 'typing' in expected_result_not_simplified: + expected_result_not_simplified = expected_result_not_simplified.replace('~typing', + 'typing') + assert format_annotation(annotation, + fully_qualified=True, + simplify_optional_unions=False) == \ + expected_result_not_simplified + # Test with the "fully_qualified" flag turned on if 'typing' in expected_result or __name__ in expected_result: expected_result = expected_result.replace('~typing', 'typing') @@ -144,19 +189,19 @@ m = re.match('^:py:(?Pclass|data|func):`~(?P[^`]+)`', result) assert m, 'No match' name = m.group('name') - role = next((o.role for o in inv.objects if o.name == name), None) - if name in {'typing.Pattern', 'typing.Match', 'typing.NoReturn'}: - if sys.version_info < (3, 6): - assert role is None, 'No entry in Python 3.5’s objects.inv' - return - - assert role is not None, 'Name {} not found'.format(name) - assert m.group('role') == ('func' if role == 'function' else role) + expected_role = next((o.role for o in inv.objects if o.name == name), None) + if expected_role: + if expected_role == 'function': + expected_role = 'func' + + assert m.group('role') == expected_role @pytest.mark.parametrize('library', [typing, typing_extensions], ids=['typing', 'typing_extensions']) @pytest.mark.parametrize('annotation, params, expected_result', [ + ('ClassVar', int, ":py:data:`~typing.ClassVar`\\[:py:class:`int`]"), + ('NoReturn', None, ":py:data:`~typing.NoReturn`"), ('Literal', ('a', 1), ":py:data:`~typing.Literal`\\['a', 1]"), ('Type', None, ':py:class:`~typing.Type`'), ('Type', (A,), ':py:class:`~typing.Type`\\[:py:class:`~%s.A`]' % __name__) @@ -188,6 +233,7 @@ sys.path.insert(0, str(test_path)) app.config.always_document_param_types = always_document_param_types + app.config.autodoc_mock_imports = ['mailbox'] app.build() assert 'build succeeded' in status.getvalue() # Build succeeded @@ -196,14 +242,15 @@ warnings = warning.getvalue().strip() assert 'Cannot resolve forward reference in type annotations of ' in warnings, warnings - if always_document_param_types: - undoc_params = ''' - - Parameters: - **x** ("int") --''' - - else: - undoc_params = "" + format_args = {} + for indentation_level in range(2): + key = f'undoc_params_{indentation_level}' + if always_document_param_types: + format_args[key] = textwrap.indent( + '\n\n Parameters:\n **x** ("int") --', ' ' * indentation_level + ) + else: + format_args[key] = '' text_path = pathlib.Path(app.srcdir) / '_build' / 'text' / 'index.txt' with text_path.open('r') as f: @@ -227,7 +274,7 @@ Inner class. - _InnerClass__dunder_inner_method(x) + __dunder_inner_method(x) Dunder inner method. @@ -247,7 +294,7 @@ Return type: "str" - _Class__dunder_method(x) + __dunder_method(x) Dunder method docstring. @@ -357,7 +404,7 @@ Return type: bytes - dummy_module.function_with_escaped_default(x='\\x08') + dummy_module.function_with_escaped_default(x='\\\\x08') Function docstring. @@ -400,6 +447,10 @@ Return type: "int" + method_without_typehint(x) + + Method docstring. + dummy_module.function_with_typehint_comment_not_inline(x=None, *y, z, **kwargs) Function docstring. @@ -438,34 +489,40 @@ Method docstring. Parameters: - **x** (*Callable**[**[**int**, **bytes**]**, **int**]*) -- + **x** ("Optional"["Callable"[["int", "bytes"], "int"]]) -- foo Return type: - ClassWithTypehintsNotInline + "ClassWithTypehintsNotInline" dummy_module.undocumented_function(x) - Hi{undoc_params} + Hi{undoc_params_0} Return type: "str" - class dummy_module.DataClass - - Class docstring. - - __init__() - '''.format(undoc_params=undoc_params)).replace('–', '--') - - if sys.version_info < (3, 6): - expected_contents += ''' - Initialize self. See help(type(self)) for accurate signature. -''' - else: - expected_contents += ''' - Return type: - "None" -''' - + class dummy_module.DataClass(x) + + Class docstring.{undoc_params_0} + + __init__(x) + + Initialize self. See help(type(self)) for accurate signature.{undoc_params_1} + + @dummy_module.Decorator(func) + + Initializer docstring. + + Parameters: + **func** ("Callable"[["int", "str"], "str"]) -- function + + dummy_module.mocked_import(x) + + A docstring. + + Parameters: + **x** ("Mailbox") -- function + ''') + expected_contents = expected_contents.format(**format_args).replace('–', '--') assert text_contents == expected_contents diff --git a/tox.ini b/tox.ini index d71e9cb..54e9bb4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] minversion = 3.3.0 -envlist = py35, py36, py37, py38, flake8 +envlist = py36, py37, py38, py39, py310, flake8 skip_missing_interpreters = true isolated_build = true