diff --git a/CHANGES b/CHANGES index 706d13929..51ea6083b 100644 --- a/CHANGES +++ b/CHANGES @@ -34,6 +34,10 @@ Deprecated Features added -------------- +* #9445: autodoc: Support class properties +* #9445: py domain: ``:py:property:`` directive supports ``:classmethod:`` + option to describe the class property + Bugs fixed ---------- @@ -49,6 +53,9 @@ Dependencies Incompatible changes -------------------- +* #9435: linkcheck: Disable checking automatically generated anchors on + github.com (ex. anchors in reST/Markdown documents) + Deprecated ---------- @@ -58,6 +65,12 @@ Features added Bugs fixed ---------- +* #9489: autodoc: Custom types using ``typing.NewType`` are not displayed well + with the HEAD of 3.10 +* #9490: autodoc: Some objects under ``typing`` module are not displayed well + with the HEAD of 3.10 +* #9435: linkcheck: Failed to check anchors in github.com + Testing -------- diff --git a/doc/latex.rst b/doc/latex.rst index f6d2987e2..f0b5a4aa8 100644 --- a/doc/latex.rst +++ b/doc/latex.rst @@ -1151,6 +1151,20 @@ Miscellany Formerly, use of *fncychap* with other styles than ``Bjarne`` was dysfunctional. +- Docutils :dudir:`container` directives are supported in LaTeX output: to + let a container class with name ``foo`` influence the final PDF via LaTeX, + it is only needed to define in the preamble an environment + ``sphinxclassfoo``. A simple example would be: + + .. code-block:: latex + + \newenvironment{sphinxclassred}{\color{red}}{} + + Currently the class names must contain only ascii characters and avoid + characters special to LaTeX such as ``\``. + + .. versionadded:: 4.1.0 + .. hint:: As an experimental feature, Sphinx can use user-defined template file for diff --git a/doc/usage/restructuredtext/domains.rst b/doc/usage/restructuredtext/domains.rst index 23844886e..abece4213 100644 --- a/doc/usage/restructuredtext/domains.rst +++ b/doc/usage/restructuredtext/domains.rst @@ -329,6 +329,13 @@ The following directives are provided for module and class contents: Indicate the property is abstract. + .. rst:directive:option:: classmethod + :type: no value + + Indicate the property is a classmethod. + + .. versionaddedd: 4.2 + .. rst:directive:option:: type: type of the property :type: text diff --git a/sphinx/builders/linkcheck.py b/sphinx/builders/linkcheck.py index 6819bd3df..58e5da94b 100644 --- a/sphinx/builders/linkcheck.py +++ b/sphinx/builders/linkcheck.py @@ -714,7 +714,10 @@ def setup(app: Sphinx) -> Dict[str, Any]: app.add_event('linkcheck-process-uri') app.connect('config-inited', compile_linkcheck_allowed_redirects, priority=800) - app.connect('linkcheck-process-uri', rewrite_github_anchor) + + # FIXME: Disable URL rewrite handler for github.com temporarily. + # ref: https://github.com/sphinx-doc/sphinx/issues/9435 + # app.connect('linkcheck-process-uri', rewrite_github_anchor) return { 'version': 'builtin', diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py index d79de154e..e8330e81c 100644 --- a/sphinx/domains/python.py +++ b/sphinx/domains/python.py @@ -852,6 +852,7 @@ class PyProperty(PyObject): option_spec = PyObject.option_spec.copy() option_spec.update({ 'abstractmethod': directives.flag, + 'classmethod': directives.flag, 'type': directives.unchanged, }) @@ -865,10 +866,13 @@ class PyProperty(PyObject): return fullname, prefix def get_signature_prefix(self, sig: str) -> str: - prefix = ['property'] + prefix = [] if 'abstractmethod' in self.options: - prefix.insert(0, 'abstract') + prefix.append('abstract') + if 'classmethod' in self.options: + prefix.append('class') + prefix.append('property') return ' '.join(prefix) + ' ' def get_index_text(self, modname: str, name_cls: Tuple[str, str]) -> str: diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index 1cecb1f79..fd5ca62a2 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -718,7 +718,7 @@ class Documenter: isattr = False doc = getdoc(member, self.get_attr, self.config.autodoc_inherit_docstrings, - self.parent, self.object_name) + self.object, membername) if not isinstance(doc, str): # Ignore non-string __doc__ doc = None @@ -2661,7 +2661,32 @@ class PropertyDocumenter(DocstringStripSignatureMixin, ClassLevelDocumenter): # @classmethod def can_document_member(cls, member: Any, membername: str, isattr: bool, parent: Any ) -> bool: - return inspect.isproperty(member) and isinstance(parent, ClassDocumenter) + if isinstance(parent, ClassDocumenter): + if inspect.isproperty(member): + return True + else: + __dict__ = safe_getattr(parent.object, '__dict__', {}) + obj = __dict__.get(membername) + return isinstance(obj, classmethod) and inspect.isproperty(obj.__func__) + else: + return False + + def import_object(self, raiseerror: bool = False) -> bool: + """Check the exisitence of uninitialized instance attribute when failed to import + the attribute.""" + ret = super().import_object(raiseerror) + if ret and not inspect.isproperty(self.object): + __dict__ = safe_getattr(self.parent, '__dict__', {}) + obj = __dict__.get(self.objpath[-1]) + if isinstance(obj, classmethod) and inspect.isproperty(obj.__func__): + self.object = obj.__func__ + self.isclassmethod = True + return True + else: + return False + + self.isclassmethod = False + return ret def document_members(self, all_members: bool = False) -> None: pass @@ -2675,6 +2700,8 @@ class PropertyDocumenter(DocstringStripSignatureMixin, ClassLevelDocumenter): # sourcename = self.get_sourcename() if inspect.isabstractmethod(self.object): self.add_line(' :abstractmethod:', sourcename) + if self.isclassmethod: + self.add_line(' :classmethod:', sourcename) if safe_getattr(self.object, 'fget', None) and self.config.autodoc_typehints != 'none': try: diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index 23dd9e930..2bb900bd7 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -211,12 +211,15 @@ def getslots(obj: Any) -> Optional[Dict]: def isNewType(obj: Any) -> bool: """Check the if object is a kind of NewType.""" - __module__ = safe_getattr(obj, '__module__', None) - __qualname__ = safe_getattr(obj, '__qualname__', None) - if __module__ == 'typing' and __qualname__ == 'NewType..new_type': - return True + if sys.version_info >= (3, 10): + return isinstance(obj, typing.NewType) else: - return False + __module__ = safe_getattr(obj, '__module__', None) + __qualname__ = safe_getattr(obj, '__qualname__', None) + if __module__ == 'typing' and __qualname__ == 'NewType..new_type': + return True + else: + return False def isenumclass(x: Any) -> bool: @@ -245,12 +248,17 @@ def ispartial(obj: Any) -> bool: return isinstance(obj, (partial, partialmethod)) -def isclassmethod(obj: Any) -> bool: +def isclassmethod(obj: Any, cls: Any = None, name: str = None) -> bool: """Check if the object is classmethod.""" if isinstance(obj, classmethod): return True elif inspect.ismethod(obj) and obj.__self__ is not None and isclass(obj.__self__): return True + elif cls and name: + for basecls in getmro(cls): + meth = basecls.__dict__.get(name) + if meth: + return isclassmethod(meth) return False @@ -837,6 +845,12 @@ def getdoc(obj: Any, attrgetter: Callable = safe_getattr, * inherited docstring * inherited decorated methods """ + if cls and name and isclassmethod(obj, cls, name): + for basecls in getmro(cls): + meth = basecls.__dict__.get(name) + if meth: + return getdoc(meth.__func__) + doc = attrgetter(obj, '__doc__', None) if ispartial(obj) and doc == obj.__class__.__doc__: return getdoc(obj.func) diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index 4e1b184e0..4b809e835 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -171,17 +171,17 @@ def _restify_py37(cls: Optional[Type]) -> str: text += r"\ [%s]" % ", ".join(restify(a) for a in cls.__args__) return text - elif hasattr(cls, '__qualname__'): - if cls.__module__ == 'typing': - return ':class:`~%s.%s`' % (cls.__module__, cls.__qualname__) - else: - return ':class:`%s.%s`' % (cls.__module__, cls.__qualname__) elif hasattr(cls, '_name'): # SpecialForm if cls.__module__ == 'typing': return ':obj:`~%s.%s`' % (cls.__module__, cls._name) else: return ':obj:`%s.%s`' % (cls.__module__, cls._name) + elif hasattr(cls, '__qualname__'): + if cls.__module__ == 'typing': + return ':class:`~%s.%s`' % (cls.__module__, cls.__qualname__) + else: + return ':class:`%s.%s`' % (cls.__module__, cls.__qualname__) elif isinstance(cls, ForwardRef): return ':class:`%s`' % cls.__forward_arg__ else: @@ -309,8 +309,11 @@ def stringify(annotation: Any) -> str: elif annotation in INVALID_BUILTIN_CLASSES: return INVALID_BUILTIN_CLASSES[annotation] elif (getattr(annotation, '__module__', None) == 'builtins' and - hasattr(annotation, '__qualname__')): - return annotation.__qualname__ + getattr(annotation, '__qualname__', None)): + if hasattr(annotation, '__args__'): # PEP 585 generic + return repr(annotation) + else: + return annotation.__qualname__ elif annotation is Ellipsis: return '...' diff --git a/tests/roots/test-ext-autodoc/target/properties.py b/tests/roots/test-ext-autodoc/target/properties.py index 409fc2b5d..561daefb8 100644 --- a/tests/roots/test-ext-autodoc/target/properties.py +++ b/tests/roots/test-ext-autodoc/target/properties.py @@ -2,5 +2,10 @@ class Foo: """docstring""" @property - def prop(self) -> int: + def prop1(self) -> int: + """docstring""" + + @classmethod + @property + def prop2(self) -> int: """docstring""" diff --git a/tests/roots/test-linkcheck/links.txt b/tests/roots/test-linkcheck/links.txt index c21968250..4ff0c9f52 100644 --- a/tests/roots/test-linkcheck/links.txt +++ b/tests/roots/test-linkcheck/links.txt @@ -13,8 +13,7 @@ Some additional anchors to exercise ignore code * `Complete nonsense `_ * `Example valid local file `_ * `Example invalid local file `_ -* https://github.com/sphinx-doc/sphinx#documentation -* https://github.com/sphinx-doc/sphinx#user-content-testing +* https://github.com/sphinx-doc/sphinx/blob/4.x/sphinx/__init__.py#L2 .. image:: https://www.google.com/image.png .. figure:: https://www.google.com/image2.png diff --git a/tests/test_build_linkcheck.py b/tests/test_build_linkcheck.py index 6db0e7512..2c6244b0a 100644 --- a/tests/test_build_linkcheck.py +++ b/tests/test_build_linkcheck.py @@ -66,8 +66,8 @@ def test_defaults_json(app): "info"]: assert attr in row - assert len(content.splitlines()) == 12 - assert len(rows) == 12 + assert len(content.splitlines()) == 11 + assert len(rows) == 11 # the output order of the rows is not stable # due to possible variance in network latency rowsby = {row["uri"]: row for row in rows} @@ -88,7 +88,7 @@ def test_defaults_json(app): assert dnerow['uri'] == 'https://localhost:7777/doesnotexist' assert rowsby['https://www.google.com/image2.png'] == { 'filename': 'links.txt', - 'lineno': 20, + 'lineno': 19, 'status': 'broken', 'code': 0, 'uri': 'https://www.google.com/image2.png', @@ -102,10 +102,6 @@ def test_defaults_json(app): # images should fail assert "Not Found for url: https://www.google.com/image.png" in \ rowsby["https://www.google.com/image.png"]["info"] - # The anchor of the URI for github.com is automatically modified - assert 'https://github.com/sphinx-doc/sphinx#documentation' not in rowsby - assert 'https://github.com/sphinx-doc/sphinx#user-content-documentation' in rowsby - assert 'https://github.com/sphinx-doc/sphinx#user-content-testing' in rowsby @pytest.mark.sphinx( diff --git a/tests/test_domain_py.py b/tests/test_domain_py.py index e4fb08155..8b72f8b7a 100644 --- a/tests/test_domain_py.py +++ b/tests/test_domain_py.py @@ -813,8 +813,12 @@ def test_pyattribute(app): def test_pyproperty(app): text = (".. py:class:: Class\n" "\n" - " .. py:property:: prop\n" + " .. py:property:: prop1\n" " :abstractmethod:\n" + " :type: str\n" + "\n" + " .. py:property:: prop2\n" + " :classmethod:\n" " :type: str\n") domain = app.env.get_domain('py') doctree = restructuredtext.parse(app, text) @@ -822,15 +826,25 @@ def test_pyproperty(app): [desc, ([desc_signature, ([desc_annotation, "class "], [desc_name, "Class"])], [desc_content, (addnodes.index, + desc, + addnodes.index, desc)])])) assert_node(doctree[1][1][0], addnodes.index, - entries=[('single', 'prop (Class property)', 'Class.prop', '', None)]) + entries=[('single', 'prop1 (Class property)', 'Class.prop1', '', None)]) assert_node(doctree[1][1][1], ([desc_signature, ([desc_annotation, "abstract property "], - [desc_name, "prop"], + [desc_name, "prop1"], [desc_annotation, ": str"])], [desc_content, ()])) - assert 'Class.prop' in domain.objects - assert domain.objects['Class.prop'] == ('index', 'Class.prop', 'property', False) + 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_content, ()])) + 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_pydecorator_signature(app): diff --git a/tests/test_ext_autodoc_autoclass.py b/tests/test_ext_autodoc_autoclass.py index 59ee1a948..24617bf0a 100644 --- a/tests/test_ext_autodoc_autoclass.py +++ b/tests/test_ext_autodoc_autoclass.py @@ -212,12 +212,20 @@ def test_properties(app): ' docstring', '', '', - ' .. py:property:: Foo.prop', + ' .. py:property:: Foo.prop1', ' :module: target.properties', ' :type: int', '', ' docstring', '', + '', + ' .. py:property:: Foo.prop2', + ' :module: target.properties', + ' :classmethod:', + ' :type: int', + '', + ' docstring', + '', ] diff --git a/tests/test_ext_autodoc_autoproperty.py b/tests/test_ext_autodoc_autoproperty.py index ee25aa8b7..2b4e5c12a 100644 --- a/tests/test_ext_autodoc_autoproperty.py +++ b/tests/test_ext_autodoc_autoproperty.py @@ -16,13 +16,28 @@ from .test_ext_autodoc import do_autodoc @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_properties(app): - actual = do_autodoc(app, 'property', 'target.properties.Foo.prop') + actual = do_autodoc(app, 'property', 'target.properties.Foo.prop1') assert list(actual) == [ '', - '.. py:property:: Foo.prop', + '.. py:property:: Foo.prop1', ' :module: target.properties', ' :type: int', '', ' docstring', '', ] + + +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_class_properties(app): + actual = do_autodoc(app, 'property', 'target.properties.Foo.prop2') + assert list(actual) == [ + '', + '.. py:property:: Foo.prop2', + ' :module: target.properties', + ' :classmethod:', + ' :type: int', + '', + ' docstring', + '', + ] diff --git a/tests/test_util_typing.py b/tests/test_util_typing.py index d0c511d25..04125a73a 100644 --- a/tests/test_util_typing.py +++ b/tests/test_util_typing.py @@ -175,6 +175,18 @@ def test_stringify_type_hints_containers(): assert stringify(Generator[None, None, None]) == "Generator[None, None, None]" +@pytest.mark.skipif(sys.version_info < (3, 9), reason='python 3.9+ is required.') +def test_stringify_type_hints_pep_585(): + assert stringify(list[int]) == "list[int]" + assert stringify(list[str]) == "list[str]" + assert stringify(dict[str, float]) == "dict[str, float]" + assert stringify(tuple[str, str, str]) == "tuple[str, str, str]" + assert stringify(tuple[str, ...]) == "tuple[str, ...]" + assert stringify(tuple[()]) == "tuple[()]" + assert stringify(list[dict[str, tuple]]) == "list[dict[str, tuple]]" + assert stringify(type[int]) == "type[int]" + + @pytest.mark.skipif(sys.version_info < (3, 9), reason='python 3.9+ is required.') def test_stringify_Annotated(): from typing import Annotated # type: ignore