Skip to content
Merged
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.rst
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ Features added
* #11592: Add :confval:`coverage_modules` to the coverage builder
to allow explicitly specifying which modules should be documented.
Patch by Stephen Finucane.
* #7896, #11989: Add a :rst:dir:`py:type` directiv for documenting type aliases,
and a :rst:role:`py:type` role for linking to them.
Patch by Ashley Whetter.

Bugs fixed
----------
Expand Down
59 changes: 57 additions & 2 deletions doc/usage/domains/python.rst
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,9 @@ The following directives are provided for module and class contents:
.. rst:directive:: .. py:data:: name

Describes global data in a module, including both variables and values used
as "defined constants." Class and object attributes are not documented
using this environment.
as "defined constants."
Consider using :rst:dir:`py:type` for type aliases instead
and :rst:dir:`py:attribute` for class variables and instance attributes.

.. rubric:: options

Expand Down Expand Up @@ -259,6 +260,7 @@ The following directives are provided for module and class contents:
Describes an object data attribute. The description should include
information about the type of the data to be expected and whether it may be
changed directly.
Type aliases should be documented with :rst:dir:`py:type`.

.. rubric:: options

Expand Down Expand Up @@ -315,6 +317,55 @@ The following directives are provided for module and class contents:
Describe the location where the object is defined. The default value is
the module specified by :rst:dir:`py:currentmodule`.

.. rst:directive:: .. py:type:: name

Describe a :ref:`type alias <python:type-aliases>`.

The type that the alias represents should be described
with the :rst:dir:`!canonical` option.
This directive supports an optional description body.

For example:

.. code-block:: rst

.. py:type:: UInt64

Represent a 64-bit positive integer.

will be rendered as follows:

.. py:type:: UInt64
:no-contents-entry:
:no-index-entry:

Represent a 64-bit positive integer.

.. rubric:: options

.. rst:directive:option:: canonical
:type: text

The canonical type represented by this alias, for example:

.. code-block:: rst

.. py:type:: StrPattern
:canonical: str | re.Pattern[str]

Represent a regular expression or a compiled pattern.

This is rendered as:

.. py:type:: StrPattern
:no-contents-entry:
:no-index-entry:
:canonical: str | re.Pattern[str]

Represent a regular expression or a compiled pattern.

.. versionadded:: 7.4

.. rst:directive:: .. py:method:: name(parameters)
.. py:method:: name[type parameters](parameters)

Expand Down Expand Up @@ -649,6 +700,10 @@ a matching identifier is found:

.. note:: The role is also able to refer to property.

.. rst:role:: py:type

Reference a type alias.

.. rst:role:: py:exc

Reference an exception. A dotted name may be used.
Expand Down
42 changes: 42 additions & 0 deletions sphinx/domains/python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,45 @@ def get_index_text(self, modname: str, name_cls: tuple[str, str]) -> str:
return _('%s (%s property)') % (attrname, clsname)


class PyTypeAlias(PyObject):
"""Description of a type alias."""

option_spec: ClassVar[OptionSpec] = PyObject.option_spec.copy()
option_spec.update({
'canonical': directives.unchanged,
})

def get_signature_prefix(self, sig: str) -> list[nodes.Node]:
return [nodes.Text('type'), addnodes.desc_sig_space()]

def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str]:
fullname, prefix = super().handle_signature(sig, signode)
if canonical := self.options.get('canonical'):
canonical_annotations = _parse_annotation(canonical, self.env)
signode += addnodes.desc_annotation(
canonical, '',
addnodes.desc_sig_space(),
addnodes.desc_sig_punctuation('', '='),
addnodes.desc_sig_space(),
*canonical_annotations,
)
return fullname, prefix

def get_index_text(self, modname: str, name_cls: tuple[str, str]) -> str:
name, cls = name_cls
try:
clsname, attrname = name.rsplit('.', 1)
if modname and self.env.config.add_module_names:
clsname = f'{modname}.{clsname}'
except ValueError:
if modname:
return _('%s (in module %s)') % (name, modname)
else:
return name

return _('%s (type alias in %s)') % (attrname, clsname)


class PyModule(SphinxDirective):
"""
Directive to mark description of a new module.
Expand Down Expand Up @@ -590,6 +629,7 @@ class PythonDomain(Domain):
'staticmethod': ObjType(_('static method'), 'meth', 'obj'),
'attribute': ObjType(_('attribute'), 'attr', 'obj'),
'property': ObjType(_('property'), 'attr', '_prop', 'obj'),
'type': ObjType(_('type alias'), 'type', 'obj'),
'module': ObjType(_('module'), 'mod', 'obj'),
}

Expand All @@ -603,6 +643,7 @@ class PythonDomain(Domain):
'staticmethod': PyStaticMethod,
'attribute': PyAttribute,
'property': PyProperty,
'type': PyTypeAlias,
'module': PyModule,
'currentmodule': PyCurrentModule,
'decorator': PyDecoratorFunction,
Expand All @@ -615,6 +656,7 @@ class PythonDomain(Domain):
'class': PyXRefRole(),
'const': PyXRefRole(),
'attr': PyXRefRole(),
'type': PyXRefRole(),
'meth': PyXRefRole(fix_parens=True),
'mod': PyXRefRole(),
'obj': PyXRefRole(),
Expand Down
1 change: 1 addition & 0 deletions tests/roots/test-domain-py/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ test-domain-py
module_option
abbr
canonical
type_alias
3 changes: 3 additions & 0 deletions tests/roots/test-domain-py/module.rst
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,6 @@ module

.. py:data:: test2
:type: typing.Literal[-2]

.. py:type:: MyType1
:canonical: list[int | str]
6 changes: 6 additions & 0 deletions tests/roots/test-domain-py/roles.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@ roles

.. py:method:: top_level

.. py:type:: TopLevelType

* :py:class:`TopLevel`
* :py:meth:`top_level`
* :py:type:`TopLevelType`


.. py:class:: NestedParentA

* Link to :py:meth:`child_1`

.. py:type:: NestedTypeA

.. py:method:: child_1()

* Link to :py:meth:`NestedChildA.subchild_2`
Expand Down Expand Up @@ -46,3 +51,4 @@ roles
* Link to :py:class:`NestedParentB`

* :py:class:`NestedParentA.NestedChildA`
* :py:type:`NestedParentA.NestedTypeA`
15 changes: 15 additions & 0 deletions tests/roots/test-domain-py/type_alias.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Type Alias
==========

.. py:module:: module_two

.. py:class:: SomeClass

:py:type:`.MyAlias`
:any:`MyAlias`
:any:`module_one.MyAlias`

.. py:module:: module_one

.. py:type:: MyAlias
:canonical: list[int | module_two.SomeClass]
36 changes: 23 additions & 13 deletions tests/test_domains/test_domain_py.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,19 +92,21 @@ def assert_refnode(node, module_name, class_name, target, reftype=None,
refnodes = list(doctree.findall(pending_xref))
assert_refnode(refnodes[0], None, None, 'TopLevel', 'class')
assert_refnode(refnodes[1], None, None, 'top_level', 'meth')
assert_refnode(refnodes[2], None, 'NestedParentA', 'child_1', 'meth')
assert_refnode(refnodes[3], None, 'NestedParentA', 'NestedChildA.subchild_2', 'meth')
assert_refnode(refnodes[4], None, 'NestedParentA', 'child_2', 'meth')
assert_refnode(refnodes[5], False, 'NestedParentA', 'any_child', domain='')
assert_refnode(refnodes[6], None, 'NestedParentA', 'NestedChildA', 'class')
assert_refnode(refnodes[7], None, 'NestedParentA.NestedChildA', 'subchild_2', 'meth')
assert_refnode(refnodes[8], None, 'NestedParentA.NestedChildA',
assert_refnode(refnodes[2], None, None, 'TopLevelType', 'type')
assert_refnode(refnodes[3], None, 'NestedParentA', 'child_1', 'meth')
assert_refnode(refnodes[4], None, 'NestedParentA', 'NestedChildA.subchild_2', 'meth')
assert_refnode(refnodes[5], None, 'NestedParentA', 'child_2', 'meth')
assert_refnode(refnodes[6], False, 'NestedParentA', 'any_child', domain='')
assert_refnode(refnodes[7], None, 'NestedParentA', 'NestedChildA', 'class')
assert_refnode(refnodes[8], None, 'NestedParentA.NestedChildA', 'subchild_2', 'meth')
assert_refnode(refnodes[9], None, 'NestedParentA.NestedChildA',
'NestedParentA.child_1', 'meth')
assert_refnode(refnodes[9], None, 'NestedParentA', 'NestedChildA.subchild_1', 'meth')
assert_refnode(refnodes[10], None, 'NestedParentB', 'child_1', 'meth')
assert_refnode(refnodes[11], None, 'NestedParentB', 'NestedParentB', 'class')
assert_refnode(refnodes[12], None, None, 'NestedParentA.NestedChildA', 'class')
assert len(refnodes) == 13
assert_refnode(refnodes[10], None, 'NestedParentA', 'NestedChildA.subchild_1', 'meth')
assert_refnode(refnodes[11], None, 'NestedParentB', 'child_1', 'meth')
assert_refnode(refnodes[12], None, 'NestedParentB', 'NestedParentB', 'class')
assert_refnode(refnodes[13], None, None, 'NestedParentA.NestedChildA', 'class')
assert_refnode(refnodes[14], None, None, 'NestedParentA.NestedTypeA', 'type')
assert len(refnodes) == 15

doctree = app.env.get_doctree('module')
refnodes = list(doctree.findall(pending_xref))
Expand Down Expand Up @@ -135,7 +137,10 @@ def assert_refnode(node, module_name, class_name, target, reftype=None,
assert_refnode(refnodes[15], False, False, 'index', 'doc', domain='std')
assert_refnode(refnodes[16], False, False, 'typing.Literal', 'obj', domain='py')
assert_refnode(refnodes[17], False, False, 'typing.Literal', 'obj', domain='py')
assert len(refnodes) == 18
assert_refnode(refnodes[18], False, False, 'list', 'class', domain='py')
assert_refnode(refnodes[19], False, False, 'int', 'class', domain='py')
assert_refnode(refnodes[20], False, False, 'str', 'class', domain='py')
assert len(refnodes) == 21

doctree = app.env.get_doctree('module_option')
refnodes = list(doctree.findall(pending_xref))
Expand Down Expand Up @@ -191,7 +196,9 @@ def test_domain_py_objects(app, status, warning):

assert objects['TopLevel'][2] == 'class'
assert objects['top_level'][2] == 'method'
assert objects['TopLevelType'][2] == 'type'
assert objects['NestedParentA'][2] == 'class'
assert objects['NestedParentA.NestedTypeA'][2] == 'type'
assert objects['NestedParentA.child_1'][2] == 'method'
assert objects['NestedParentA.any_child'][2] == 'method'
assert objects['NestedParentA.NestedChildA'][2] == 'class'
Expand Down Expand Up @@ -233,6 +240,9 @@ def find_obj(modname, prefix, obj_name, obj_type, searchmode=0):
assert (find_obj(None, None, 'NONEXISTANT', 'class') == [])
assert (find_obj(None, None, 'NestedParentA', 'class') ==
[('NestedParentA', ('roles', 'NestedParentA', 'class', False))])
assert (find_obj(None, None, 'NestedParentA.NestedTypeA', 'type') ==
[('NestedParentA.NestedTypeA',
('roles', 'NestedParentA.NestedTypeA', 'type', False))])
assert (find_obj(None, None, 'NestedParentA.NestedChildA', 'class') ==
[('NestedParentA.NestedChildA',
('roles', 'NestedParentA.NestedChildA', 'class', False))])
Expand Down
71 changes: 71 additions & 0 deletions tests/test_domains/test_domain_py_pyobject.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import pytest
from docutils import nodes

from sphinx import addnodes
Expand Down Expand Up @@ -362,6 +363,76 @@ def test_pyproperty(app):
assert domain.objects['Class.prop2'] == ('index', 'Class.prop2', 'property', False)


def test_py_type_alias(app):
text = (".. py:module:: example\n"
".. py:type:: Alias1\n"
" :canonical: list[str | int]\n"
"\n"
".. py:class:: Class\n"
"\n"
" .. py:type:: Alias2\n"
" :canonical: int\n")
domain = app.env.get_domain('py')
doctree = restructuredtext.parse(app, text)
assert_node(doctree, (addnodes.index,
addnodes.index,
nodes.target,
[desc, ([desc_signature, ([desc_annotation, ('type', desc_sig_space)],
[desc_addname, 'example.'],
[desc_name, 'Alias1'],
[desc_annotation, (desc_sig_space,
[desc_sig_punctuation, '='],
desc_sig_space,
[pending_xref, 'list'],
[desc_sig_punctuation, '['],
[pending_xref, 'str'],
desc_sig_space,
[desc_sig_punctuation, '|'],
desc_sig_space,
[pending_xref, 'int'],
[desc_sig_punctuation, ']'],
)])],
[desc_content, ()])],
addnodes.index,
[desc, ([desc_signature, ([desc_annotation, ('class', desc_sig_space)],
[desc_addname, 'example.'],
[desc_name, 'Class'])],
[desc_content, (addnodes.index,
desc)])]))
assert_node(doctree[5][1][0], addnodes.index,
entries=[('single', 'Alias2 (type alias in example.Class)', 'example.Class.Alias2', '', None)])
assert_node(doctree[5][1][1], ([desc_signature, ([desc_annotation, ('type', desc_sig_space)],
[desc_name, 'Alias2'],
[desc_annotation, (desc_sig_space,
[desc_sig_punctuation, '='],
desc_sig_space,
[pending_xref, 'int'])])],
[desc_content, ()]))
assert 'example.Alias1' in domain.objects
assert domain.objects['example.Alias1'] == ('index', 'example.Alias1', 'type', False)
assert 'example.Class.Alias2' in domain.objects
assert domain.objects['example.Class.Alias2'] == ('index', 'example.Class.Alias2', 'type', False)


@pytest.mark.sphinx('html', testroot='domain-py', freshenv=True)
def test_domain_py_type_alias(app, status, warning):
app.build(force_all=True)

content = (app.outdir / 'type_alias.html').read_text(encoding='utf8')
assert ('<em class="property"><span class="pre">type</span><span class="w"> </span></em>'
'<span class="sig-prename descclassname"><span class="pre">module_one.</span></span>'
'<span class="sig-name descname"><span class="pre">MyAlias</span></span>'
'<em class="property"><span class="w"> </span><span class="p"><span class="pre">=</span></span>'
'<span class="w"> </span><span class="pre">list</span>'
'<span class="p"><span class="pre">[</span></span>'
'<span class="pre">int</span><span class="w"> </span>'
'<span class="p"><span class="pre">|</span></span><span class="w"> </span>'
'<a class="reference internal" href="#module_two.SomeClass" title="module_two.SomeClass">'
'<span class="pre">module_two.SomeClass</span></a>'
'<span class="p"><span class="pre">]</span></span></em>' in content)
assert warning.getvalue() == ''


def test_pydecorator_signature(app):
text = ".. py:decorator:: deco"
domain = app.env.get_domain('py')
Expand Down