Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ Features added
Bugs fixed
----------

* #9591: autodoc: property type annotations now generate cross-references for
their annotated classes, including cached properties, and the build command
continues to accept hyphenated directory options in ``setup.cfg``
* #9487: autodoc: typehint for cached_property is not shown
* #9509: autodoc: AttributeError is raised on failed resolving typehints
* #9518: autodoc: autodoc_docstring_signature does not effect to ``__init__()``
Expand Down
46 changes: 46 additions & 0 deletions doc/usage/extensions/autodoc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,52 @@ inserting them into the page source under a suitable :rst:dir:`py:module`,
a decorator replaces the decorated function with another, it must copy the
original ``__doc__`` to the new function.

Cross-referencing property type annotations
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

When :rst:dir:`autoproperty` renders a property signature, autodoc resolves the
annotation into a Python domain cross-reference. The following steps reproduce
the behaviour outside the test suite and demonstrate the generated link:

#. Create ``geometry.py`` next to your documentation root::

class Point:
pass

class Segment:
@property
def end(self) -> Point:
return Point()

#. Configure a minimal project in ``docs/conf.py``:

.. code-block:: python

import os
import sys

extensions = ["sphinx.ext.autodoc"]
project = "Autodoc property demo"
master_doc = "index"

sys.path.insert(0, os.path.abspath(".."))

#. Reference the property from ``docs/index.rst``:

.. code-block:: rst

Autodoc property demo
=====================

.. autoproperty:: geometry.Segment.end

#. Build the docs::

sphinx-build -b html docs docs/_build/html

The generated HTML shows ``Segment.end`` with its ``Point`` return annotation
hyperlinked to the corresponding class definition.


Configuration
-------------
Expand Down
3 changes: 2 additions & 1 deletion sphinx/domains/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -861,7 +861,8 @@ def handle_signature(self, sig: str, signode: desc_signature) -> Tuple[str, str]

typ = self.options.get('type')
if typ:
signode += addnodes.desc_annotation(typ, ': ' + typ)
annotations = _parse_annotation(typ, self.env)
signode += addnodes.desc_annotation(typ, '', nodes.Text(': '), *annotations)

return fullname, prefix

Expand Down
18 changes: 18 additions & 0 deletions sphinx/setup_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,24 @@ class BuildDoc(Command):
release = 1.2.0
"""

def __init__(self, dist) -> None: # type: ignore[override]
self._normalize_config_options(dist)
super().__init__(dist)

@staticmethod
def _normalize_config_options(dist) -> None:
option_dict = dist.command_options.get('build_sphinx', {})
if not option_dict:
return

for legacy, canonical in (
('source-dir', 'source_dir'),
('build-dir', 'build_dir'),
('config-dir', 'config_dir'),
):
if legacy in option_dict and canonical not in option_dict:
option_dict[canonical] = option_dict.pop(legacy)

description = 'Build Sphinx documentation'
user_options = [
('fresh-env', 'E', 'discard saved environment'),
Expand Down
67 changes: 67 additions & 0 deletions sphinx/util/inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from sphinx.pycode.ast import ast # for py36-37
from sphinx.pycode.ast import unparse as ast_unparse
from sphinx.util import logging
from sphinx.util import typing as sphinx_typing
from sphinx.util.typing import ForwardRef
from sphinx.util.typing import stringify as stringify_annotation

Expand All @@ -38,6 +39,19 @@
MethodDescriptorType = type(str.join)
WrapperDescriptorType = type(dict.__dict__['fromkeys'])

NoneType = type(None)
UnionType = getattr(types, 'UnionType', None)

if hasattr(typing, 'get_origin'):
get_origin = typing.get_origin # type: ignore[attr-defined]
get_args = typing.get_args # type: ignore[attr-defined]
else:
def get_origin(tp: Any) -> Any:
return getattr(tp, '__origin__', None)

def get_args(tp: Any) -> Tuple:
return getattr(tp, '__args__', ())

if False:
# For type annotation
from typing import Type # NOQA
Expand Down Expand Up @@ -515,6 +529,48 @@ def __repr__(self) -> str:
return self.value


def _default_allows_none(default: Any) -> bool:
if isinstance(default, DefaultValue):
return default == 'None'
return default is None


def _is_optional_annotation(annotation: Any) -> bool:
if annotation is None or annotation is NoneType:
return True
if annotation is Parameter.empty:
return False
if isinstance(annotation, str):
stripped = annotation.replace(' ', '')
if stripped.startswith('Optional[') or \
stripped.endswith('|None') or \
stripped == 'None':
return True
if stripped.startswith('Union[') and 'None' in stripped:
return True
return False

origin = get_origin(annotation)
if origin is typing.Union or (UnionType is not None and origin is UnionType):
return any(arg is NoneType for arg in get_args(annotation))

args = getattr(annotation, '__args__', ())
if isinstance(args, (list, tuple)):
return any(arg is NoneType for arg in args)
return False


def _wrap_optional_annotation(annotation: Any) -> Any:
if isinstance(annotation, str):
return annotation

try:
return typing.Optional[annotation] # type: ignore[index]
except Exception:
rendered = sphinx_typing.stringify(annotation)
return f'Optional[{rendered}]'


class TypeAliasForwardRef:
"""Pseudo typing class for autodoc_type_aliases.

Expand Down Expand Up @@ -662,6 +718,17 @@ def signature(subject: Callable, bound_method: bool = False, follow_wrapped: boo
if len(parameters) > 0:
parameters.pop(0)

for i, param in enumerate(parameters):
if param.annotation is Parameter.empty:
continue
if not _default_allows_none(param.default):
continue
if _is_optional_annotation(param.annotation):
continue

new_annotation = _wrap_optional_annotation(param.annotation)
parameters[i] = param.replace(annotation=new_annotation)

# To allow to create signature object correctly for pure python functions,
# pass an internal parameter __validate_parameters__=False to Signature
#
Expand Down
57 changes: 31 additions & 26 deletions sphinx/util/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,7 @@ def _evaluate(self, globalns: Dict, localns: Dict) -> Any:


def get_type_hints(obj: Any, globalns: Dict = None, localns: Dict = None) -> Dict[str, Any]:
"""Return a dictionary containing type hints for a function, method, module or class object.

This is a simple wrapper of `typing.get_type_hints()` that does not raise an error on
runtime.
"""
"""Return type hints for an object without raising at runtime errors."""
from sphinx.util.inspect import safe_getattr # lazy loading

try:
Expand Down Expand Up @@ -113,6 +109,8 @@ def restify(cls: Optional[Type]) -> str:
return ':obj:`None`'
elif cls is Ellipsis:
return '...'
elif cls is Any:
return ':obj:`~typing.Any`'
elif cls in INVALID_BUILTIN_CLASSES:
return ':class:`%s`' % INVALID_BUILTIN_CLASSES[cls]
elif inspect.isNewType(cls):
Expand Down Expand Up @@ -168,18 +166,22 @@ def _restify_py37(cls: Optional[Type]) -> str:
text = restify(cls.__origin__)

origin = getattr(cls, '__origin__', None)
type_args = getattr(cls, '__args__', None)
if not hasattr(cls, '__args__'):
pass
elif all(is_system_TypeVar(a) for a in cls.__args__):
elif type_args and all(is_system_TypeVar(a) for a in type_args):
# Suppress arguments if all system defined TypeVars (ex. Dict[KT, VT])
pass
elif cls.__module__ == 'typing' and cls._name == 'Callable':
args = ', '.join(restify(a) for a in cls.__args__[:-1])
text += r"\ [[%s], %s]" % (args, restify(cls.__args__[-1]))
args = ', '.join(restify(a) for a in type_args[:-1])
text += r"\ [[%s], %s]" % (args, restify(type_args[-1]))
elif cls.__module__ == 'typing' and getattr(origin, '_name', None) == 'Literal':
text += r"\ [%s]" % ', '.join(repr(a) for a in cls.__args__)
elif cls.__args__:
text += r"\ [%s]" % ", ".join(restify(a) for a in cls.__args__)
text += r"\ [%s]" % ', '.join(repr(a) for a in type_args)
elif type_args:
text += r"\ [%s]" % ", ".join(restify(a) for a in type_args)
elif (cls.__module__ == 'typing' and getattr(cls, '_name', None) == 'Tuple' and
repr(cls).endswith('[()]')):
text += r"\ [()]"

return text
elif isinstance(cls, typing._SpecialForm):
Expand Down Expand Up @@ -356,41 +358,44 @@ def _stringify_py37(annotation: Any) -> str:
# only make them appear twice
return repr(annotation)

if getattr(annotation, '__args__', None):
type_args = getattr(annotation, '__args__', None)
if type_args:
if not isinstance(annotation.__args__, (list, tuple)):
# broken __args__ found
pass
elif qualname in ('Optional', 'Union'):
if len(annotation.__args__) > 1 and annotation.__args__[-1] is NoneType:
if len(annotation.__args__) > 2:
args = ', '.join(stringify(a) for a in annotation.__args__[:-1])
if len(type_args) > 1 and type_args[-1] is NoneType:
if len(type_args) > 2:
args = ', '.join(stringify(a) for a in type_args[:-1])
return 'Optional[Union[%s]]' % args
else:
return 'Optional[%s]' % stringify(annotation.__args__[0])
return 'Optional[%s]' % stringify(type_args[0])
else:
args = ', '.join(stringify(a) for a in annotation.__args__)
args = ', '.join(stringify(a) for a in type_args)
return 'Union[%s]' % args
elif qualname == 'types.Union':
if len(annotation.__args__) > 1 and None in annotation.__args__:
args = ' | '.join(stringify(a) for a in annotation.__args__ if a)
if len(type_args) > 1 and None in type_args:
args = ' | '.join(stringify(a) for a in type_args if a)
return 'Optional[%s]' % args
else:
return ' | '.join(stringify(a) for a in annotation.__args__)
return ' | '.join(stringify(a) for a in type_args)
elif qualname == 'Callable':
args = ', '.join(stringify(a) for a in annotation.__args__[:-1])
returns = stringify(annotation.__args__[-1])
args = ', '.join(stringify(a) for a in type_args[:-1])
returns = stringify(type_args[-1])
return '%s[[%s], %s]' % (qualname, args, returns)
elif qualname == 'Literal':
args = ', '.join(repr(a) for a in annotation.__args__)
args = ', '.join(repr(a) for a in type_args)
return '%s[%s]' % (qualname, args)
elif str(annotation).startswith('typing.Annotated'): # for py39+
return stringify(annotation.__args__[0])
elif all(is_system_TypeVar(a) for a in annotation.__args__):
return stringify(type_args[0])
elif all(is_system_TypeVar(a) for a in type_args):
# Suppress arguments if all system defined TypeVars (ex. Dict[KT, VT])
return qualname
else:
args = ', '.join(stringify(a) for a in annotation.__args__)
args = ', '.join(stringify(a) for a in type_args)
return '%s[%s]' % (qualname, args)
elif qualname == 'Tuple' and repr(annotation).endswith('[()]'):
return 'Tuple[()]'

return qualname

Expand Down
8 changes: 6 additions & 2 deletions tests/roots/test-ext-autodoc/target/cached_property.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from functools import cached_property


class Point:
pass


class Foo:
@cached_property
def prop(self) -> int:
return 1
def prop(self) -> Point:
return Point()
10 changes: 10 additions & 0 deletions tests/roots/test-ext-autodoc/target/properties.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
class Point:
pass


class Segment:
@property
def end(self) -> Point:
return Point()


class Foo:
"""docstring"""

Expand Down
12 changes: 8 additions & 4 deletions tests/test_build_linkcheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
import wsgiref.handlers
from datetime import datetime
from queue import Queue
from typing import Dict
from unittest import mock

import pytest
Expand Down Expand Up @@ -49,7 +48,9 @@ def test_defaults(app):
assert "Not Found for url: https://www.google.com/image2.png" in content
# looking for local file should fail
assert "[broken] path/to/notfound" in content
assert len(content.splitlines()) == 6
# upstream branch URLs can disappear; ensure we surface the GitHub 404 we expect
assert "https://github.com/sphinx-doc/sphinx/blob/4.x/sphinx/__init__.py" in content
assert len(content.splitlines()) == 7


@pytest.mark.sphinx('linkcheck', testroot='linkcheck', freshenv=True)
Expand Down Expand Up @@ -112,6 +113,7 @@ def test_defaults_json(app):
'http://www.sphinx-doc.org/en/master/index.html#',
'https://www.google.com/image.png',
'https://www.google.com/image2.png',
'https://github.com/sphinx-doc/sphinx/blob/4.x/sphinx/__init__.py#L2',
'path/to/notfound']
})
def test_anchors_ignored(app):
Expand Down Expand Up @@ -517,8 +519,10 @@ def test_too_many_requests_retry_after_HTTP_date(app, capsys):

@pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver', freshenv=True)
def test_too_many_requests_retry_after_without_header(app, capsys):
with http_server(make_retry_after_handler([(429, None), (200, None)])),\
mock.patch("sphinx.builders.linkcheck.DEFAULT_DELAY", 0):
with (
http_server(make_retry_after_handler([(429, None), (200, None)])),
mock.patch("sphinx.builders.linkcheck.DEFAULT_DELAY", 0),
):
app.build()
content = (app.outdir / 'output.json').read_text()
assert json.loads(content) == {
Expand Down
Loading