diff --git a/CHANGES b/CHANGES index 4e98b2c8ab4..9bc5b86e125 100644 --- a/CHANGES +++ b/CHANGES @@ -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__()`` diff --git a/doc/usage/extensions/autodoc.rst b/doc/usage/extensions/autodoc.rst index 51a77bbbb3e..b038db59a67 100644 --- a/doc/usage/extensions/autodoc.rst +++ b/doc/usage/extensions/autodoc.rst @@ -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 ------------- diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py index e8330e81cf5..9875a9f4b65 100644 --- a/sphinx/domains/python.py +++ b/sphinx/domains/python.py @@ -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 diff --git a/sphinx/setup_command.py b/sphinx/setup_command.py index 1fc55bc157f..37b6f59aac5 100644 --- a/sphinx/setup_command.py +++ b/sphinx/setup_command.py @@ -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'), diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index d55ebceecbb..f6940d653ec 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -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 @@ -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 @@ -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. @@ -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 # diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index cf4318cdabb..c95422ef7c9 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -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: @@ -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): @@ -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): @@ -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 diff --git a/tests/roots/test-ext-autodoc/target/cached_property.py b/tests/roots/test-ext-autodoc/target/cached_property.py index 63ec09f8eee..b0e697b4c89 100644 --- a/tests/roots/test-ext-autodoc/target/cached_property.py +++ b/tests/roots/test-ext-autodoc/target/cached_property.py @@ -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() diff --git a/tests/roots/test-ext-autodoc/target/properties.py b/tests/roots/test-ext-autodoc/target/properties.py index 561daefb8fb..da30929ef3b 100644 --- a/tests/roots/test-ext-autodoc/target/properties.py +++ b/tests/roots/test-ext-autodoc/target/properties.py @@ -1,3 +1,13 @@ +class Point: + pass + + +class Segment: + @property + def end(self) -> Point: + return Point() + + class Foo: """docstring""" diff --git a/tests/test_build_linkcheck.py b/tests/test_build_linkcheck.py index 2c6244b0a2a..0c55508f8ce 100644 --- a/tests/test_build_linkcheck.py +++ b/tests/test_build_linkcheck.py @@ -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 @@ -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) @@ -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): @@ -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) == { diff --git a/tests/test_domain_py.py b/tests/test_domain_py.py index 8b72f8b7a27..5357781f637 100644 --- a/tests/test_domain_py.py +++ b/tests/test_domain_py.py @@ -19,7 +19,7 @@ from sphinx.addnodes import (desc, desc_addname, desc_annotation, desc_content, desc_name, desc_optional, desc_parameter, desc_parameterlist, desc_returns, desc_sig_name, desc_sig_operator, desc_sig_punctuation, - desc_signature, pending_xref) + desc_signature, pending_xref, pending_xref_condition) from sphinx.domains import IndexEntry from sphinx.domains.python import (PythonDomain, PythonModuleIndex, _parse_annotation, _pseudo_parse_arglist, py_sig_re) @@ -833,20 +833,64 @@ def test_pyproperty(app): entries=[('single', 'prop1 (Class property)', 'Class.prop1', '', None)]) assert_node(doctree[1][1][1], ([desc_signature, ([desc_annotation, "abstract property "], [desc_name, "prop1"], - [desc_annotation, ": str"])], + [desc_annotation, (": ", + [pending_xref, "str"])])], [desc_content, ()])) + assert_node(doctree[1][1][1][0][2][1], pending_xref, **{"py:class": "Class"}) assert_node(doctree[1][1][2], addnodes.index, entries=[('single', 'prop2 (Class property)', 'Class.prop2', '', None)]) assert_node(doctree[1][1][3], ([desc_signature, ([desc_annotation, "class property "], [desc_name, "prop2"], - [desc_annotation, ": str"])], + [desc_annotation, (": ", + [pending_xref, "str"])])], [desc_content, ()])) + assert_node(doctree[1][1][3][0][2][1], pending_xref, **{"py:class": "Class"}) assert 'Class.prop1' in domain.objects assert domain.objects['Class.prop1'] == ('index', 'Class.prop1', 'property', False) assert 'Class.prop2' in domain.objects assert domain.objects['Class.prop2'] == ('index', 'Class.prop2', 'property', False) +def test_pyproperty_type_annotation_crossref(app): + text = (".. py:class:: Class\n" + "\n" + " .. py:property:: prop\n" + " :type: Point\n") + doctree = restructuredtext.parse(app, text) + annotation = doctree[1][1][1][0][2] + assert_node(annotation, desc_annotation, (": ", [pending_xref, "Point"])) + assert_node(annotation[1], pending_xref, + refdomain='py', reftype='class', reftarget='Point', **{"py:class": "Class"}) + + +def test_pyproperty_type_annotation_none(app): + text = (".. py:class:: Class\n" + "\n" + " .. py:property:: prop\n" + " :type: None\n") + doctree = restructuredtext.parse(app, text) + annotation = doctree[1][1][1][0][2] + assert_node(annotation[1], pending_xref, + refdomain='py', reftype='obj', reftarget='None', **{"py:class": "Class"}) + + +def test_pyproperty_type_annotation_python_use_unqualified_type_names(app): + app.config.python_use_unqualified_type_names = True + text = (".. py:class:: Class\n" + "\n" + " .. py:property:: prop\n" + " :type: foo.Point\n") + doctree = restructuredtext.parse(app, text) + annotation = doctree[1][1][1][0][2] + type_xref = annotation[1] + assert_node(type_xref, pending_xref, + refdomain='py', reftype='class', reftarget='foo.Point', **{"py:class": "Class"}) + assert_node(type_xref[0], pending_xref_condition, condition='resolved') + assert type_xref[0].astext() == 'Point' + assert_node(type_xref[1], pending_xref_condition, condition='*') + assert type_xref[1].astext() == 'foo.Point' + + def test_pydecorator_signature(app): text = ".. py:decorator:: deco" domain = app.env.get_domain('py') diff --git a/tests/test_ext_autodoc.py b/tests/test_ext_autodoc.py index 299c1c68170..cf302a9a21a 100644 --- a/tests/test_ext_autodoc.py +++ b/tests/test_ext_autodoc.py @@ -1084,7 +1084,7 @@ def test_autodoc_cached_property(app): '', ' .. py:property:: Foo.prop', ' :module: target.cached_property', - ' :type: int', + ' :type: target.cached_property.Point', '', ] @@ -1401,7 +1401,8 @@ def test_enum_class(app): actual = do_autodoc(app, 'class', 'target.enums.EnumCls', options) assert list(actual) == [ '', - '.. py:class:: EnumCls(value)', + '.. py:class:: EnumCls(value, names=None, *, module=None, qualname=None, type=None, ' + 'start=1, boundary=None)', ' :module: target.enums', '', ' this is enum class', diff --git a/tests/test_ext_autodoc_autoproperty.py b/tests/test_ext_autodoc_autoproperty.py index 47528a99d8c..d4bea13b7a3 100644 --- a/tests/test_ext_autodoc_autoproperty.py +++ b/tests/test_ext_autodoc_autoproperty.py @@ -13,6 +13,10 @@ import pytest +from sphinx import addnodes +from sphinx.testing import restructuredtext +from sphinx.testing.util import assert_node + from .test_ext_autodoc import do_autodoc @@ -53,6 +57,33 @@ def test_cached_properties(app): '', '.. py:property:: Foo.prop', ' :module: target.cached_property', - ' :type: int', + ' :type: target.cached_property.Point', '', ] + + +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_property_type_annotation_links(app): + actual = do_autodoc(app, 'property', 'target.properties.Segment.end') + doctree = restructuredtext.parse(app, '\n'.join(actual)) + signature = doctree[1][0] + annotation = signature[3] + assert_node(annotation, addnodes.desc_annotation) + assert annotation.astext().startswith(': ') + assert_node(annotation[1], addnodes.pending_xref, + refdomain='py', reftype='class', reftarget='target.properties.Point') + assert annotation[1].astext() == 'target.properties.Point' + + +@pytest.mark.skipif(sys.version_info < (3, 8), reason='python 3.8+ is required.') +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_cached_property_type_annotation_links(app): + actual = do_autodoc(app, 'property', 'target.cached_property.Foo.prop') + doctree = restructuredtext.parse(app, '\n'.join(actual)) + signature = doctree[1][0] + annotation = signature[3] + assert_node(annotation, addnodes.desc_annotation) + assert annotation.astext().startswith(': ') + assert_node(annotation[1], addnodes.pending_xref, + refdomain='py', reftype='class', reftarget='target.cached_property.Point') + assert annotation[1].astext() == 'target.cached_property.Point' diff --git a/tests/test_ext_autodoc_configs.py b/tests/test_ext_autodoc_configs.py index 6e0159d1448..9afea4877c7 100644 --- a/tests/test_ext_autodoc_configs.py +++ b/tests/test_ext_autodoc_configs.py @@ -1163,7 +1163,7 @@ def test_autodoc_default_options(app): assert ' Iterate squares of each value.' in actual if not IS_PYPY: assert ' .. py:attribute:: CustomIter.__weakref__' in actual - assert ' list of weak references to the object (if defined)' in actual + assert ' list of weak references to the object' in actual # :exclude-members: None - has no effect. Unlike :members:, # :special-members:, etc. where None == "include all", here None means @@ -1187,7 +1187,7 @@ def test_autodoc_default_options(app): assert ' Iterate squares of each value.' in actual if not IS_PYPY: assert ' .. py:attribute:: CustomIter.__weakref__' in actual - assert ' list of weak references to the object (if defined)' in actual + assert ' list of weak references to the object' in actual assert ' .. py:method:: CustomIter.snafucate()' in actual assert ' Makes this snafucated.' in actual diff --git a/tests/test_util_i18n.py b/tests/test_util_i18n.py index 180350e867b..253c06211e3 100644 --- a/tests/test_util_i18n.py +++ b/tests/test_util_i18n.py @@ -81,10 +81,10 @@ def test_format_date(): format = '%x' assert i18n.format_date(format, date=datet) == 'Feb 7, 2016' format = '%X' - assert i18n.format_date(format, date=datet) == '5:11:17 AM' + assert i18n.format_date(format, date=datet) == '5:11:17\u202fAM' assert i18n.format_date(format, date=date) == 'Feb 7, 2016' format = '%c' - assert i18n.format_date(format, date=datet) == 'Feb 7, 2016, 5:11:17 AM' + assert i18n.format_date(format, date=datet) == 'Feb 7, 2016, 5:11:17\u202fAM' assert i18n.format_date(format, date=date) == 'Feb 7, 2016' # timezone