Add `py:type` directive and role for documenting type aliases (#11989)

Co-authored-by: Adam Turner <9087854+aa-turner@users.noreply.github.com>
This commit is contained in:
Ashley Whetter 2024-07-11 04:15:54 -07:00 committed by GitHub
parent 91c5cd3abd
commit e38a60d3f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 221 additions and 15 deletions

View File

@ -74,6 +74,9 @@ Features added
* #11592: Add :confval:`coverage_modules` to the coverage builder * #11592: Add :confval:`coverage_modules` to the coverage builder
to allow explicitly specifying which modules should be documented. to allow explicitly specifying which modules should be documented.
Patch by Stephen Finucane. 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 Bugs fixed
---------- ----------

View File

@ -124,8 +124,9 @@ The following directives are provided for module and class contents:
.. rst:directive:: .. py:data:: name .. rst:directive:: .. py:data:: name
Describes global data in a module, including both variables and values used Describes global data in a module, including both variables and values used
as "defined constants." Class and object attributes are not documented as "defined constants."
using this environment. Consider using :rst:dir:`py:type` for type aliases instead
and :rst:dir:`py:attribute` for class variables and instance attributes.
.. rubric:: options .. rubric:: options
@ -259,6 +260,7 @@ The following directives are provided for module and class contents:
Describes an object data attribute. The description should include Describes an object data attribute. The description should include
information about the type of the data to be expected and whether it may be information about the type of the data to be expected and whether it may be
changed directly. changed directly.
Type aliases should be documented with :rst:dir:`py:type`.
.. rubric:: options .. rubric:: options
@ -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 Describe the location where the object is defined. The default value is
the module specified by :rst:dir:`py:currentmodule`. 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) .. rst:directive:: .. py:method:: name(parameters)
.. py:method:: name[type parameters](parameters) .. py:method:: name[type parameters](parameters)
@ -649,6 +700,10 @@ a matching identifier is found:
.. note:: The role is also able to refer to property. .. note:: The role is also able to refer to property.
.. rst:role:: py:type
Reference a type alias.
.. rst:role:: py:exc .. rst:role:: py:exc
Reference an exception. A dotted name may be used. Reference an exception. A dotted name may be used.

View File

@ -389,6 +389,45 @@ class PyProperty(PyObject):
return _('%s (%s property)') % (attrname, clsname) 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): class PyModule(SphinxDirective):
""" """
Directive to mark description of a new module. Directive to mark description of a new module.
@ -590,6 +629,7 @@ class PythonDomain(Domain):
'staticmethod': ObjType(_('static method'), 'meth', 'obj'), 'staticmethod': ObjType(_('static method'), 'meth', 'obj'),
'attribute': ObjType(_('attribute'), 'attr', 'obj'), 'attribute': ObjType(_('attribute'), 'attr', 'obj'),
'property': ObjType(_('property'), 'attr', '_prop', 'obj'), 'property': ObjType(_('property'), 'attr', '_prop', 'obj'),
'type': ObjType(_('type alias'), 'type', 'obj'),
'module': ObjType(_('module'), 'mod', 'obj'), 'module': ObjType(_('module'), 'mod', 'obj'),
} }
@ -603,6 +643,7 @@ class PythonDomain(Domain):
'staticmethod': PyStaticMethod, 'staticmethod': PyStaticMethod,
'attribute': PyAttribute, 'attribute': PyAttribute,
'property': PyProperty, 'property': PyProperty,
'type': PyTypeAlias,
'module': PyModule, 'module': PyModule,
'currentmodule': PyCurrentModule, 'currentmodule': PyCurrentModule,
'decorator': PyDecoratorFunction, 'decorator': PyDecoratorFunction,
@ -615,6 +656,7 @@ class PythonDomain(Domain):
'class': PyXRefRole(), 'class': PyXRefRole(),
'const': PyXRefRole(), 'const': PyXRefRole(),
'attr': PyXRefRole(), 'attr': PyXRefRole(),
'type': PyXRefRole(),
'meth': PyXRefRole(fix_parens=True), 'meth': PyXRefRole(fix_parens=True),
'mod': PyXRefRole(), 'mod': PyXRefRole(),
'obj': PyXRefRole(), 'obj': PyXRefRole(),

View File

@ -8,3 +8,4 @@ test-domain-py
module_option module_option
abbr abbr
canonical canonical
type_alias

View File

@ -64,3 +64,6 @@ module
.. py:data:: test2 .. py:data:: test2
:type: typing.Literal[-2] :type: typing.Literal[-2]
.. py:type:: MyType1
:canonical: list[int | str]

View File

@ -5,14 +5,19 @@ roles
.. py:method:: top_level .. py:method:: top_level
.. py:type:: TopLevelType
* :py:class:`TopLevel` * :py:class:`TopLevel`
* :py:meth:`top_level` * :py:meth:`top_level`
* :py:type:`TopLevelType`
.. py:class:: NestedParentA .. py:class:: NestedParentA
* Link to :py:meth:`child_1` * Link to :py:meth:`child_1`
.. py:type:: NestedTypeA
.. py:method:: child_1() .. py:method:: child_1()
* Link to :py:meth:`NestedChildA.subchild_2` * Link to :py:meth:`NestedChildA.subchild_2`
@ -46,3 +51,4 @@ roles
* Link to :py:class:`NestedParentB` * Link to :py:class:`NestedParentB`
* :py:class:`NestedParentA.NestedChildA` * :py:class:`NestedParentA.NestedChildA`
* :py:type:`NestedParentA.NestedTypeA`

View File

@ -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]

View File

@ -92,19 +92,21 @@ def test_domain_py_xrefs(app, status, warning):
refnodes = list(doctree.findall(pending_xref)) refnodes = list(doctree.findall(pending_xref))
assert_refnode(refnodes[0], None, None, 'TopLevel', 'class') assert_refnode(refnodes[0], None, None, 'TopLevel', 'class')
assert_refnode(refnodes[1], None, None, 'top_level', 'meth') assert_refnode(refnodes[1], None, None, 'top_level', 'meth')
assert_refnode(refnodes[2], None, 'NestedParentA', 'child_1', 'meth') assert_refnode(refnodes[2], None, None, 'TopLevelType', 'type')
assert_refnode(refnodes[3], None, 'NestedParentA', 'NestedChildA.subchild_2', 'meth') assert_refnode(refnodes[3], None, 'NestedParentA', 'child_1', 'meth')
assert_refnode(refnodes[4], None, 'NestedParentA', 'child_2', 'meth') assert_refnode(refnodes[4], None, 'NestedParentA', 'NestedChildA.subchild_2', 'meth')
assert_refnode(refnodes[5], False, 'NestedParentA', 'any_child', domain='') assert_refnode(refnodes[5], None, 'NestedParentA', 'child_2', 'meth')
assert_refnode(refnodes[6], None, 'NestedParentA', 'NestedChildA', 'class') assert_refnode(refnodes[6], False, 'NestedParentA', 'any_child', domain='')
assert_refnode(refnodes[7], None, 'NestedParentA.NestedChildA', 'subchild_2', 'meth') assert_refnode(refnodes[7], None, 'NestedParentA', 'NestedChildA', 'class')
assert_refnode(refnodes[8], None, 'NestedParentA.NestedChildA', assert_refnode(refnodes[8], None, 'NestedParentA.NestedChildA', 'subchild_2', 'meth')
assert_refnode(refnodes[9], None, 'NestedParentA.NestedChildA',
'NestedParentA.child_1', 'meth') 'NestedParentA.child_1', 'meth')
assert_refnode(refnodes[9], None, 'NestedParentA', 'NestedChildA.subchild_1', 'meth') assert_refnode(refnodes[10], None, 'NestedParentA', 'NestedChildA.subchild_1', 'meth')
assert_refnode(refnodes[10], None, 'NestedParentB', 'child_1', 'meth') assert_refnode(refnodes[11], None, 'NestedParentB', 'child_1', 'meth')
assert_refnode(refnodes[11], None, 'NestedParentB', 'NestedParentB', 'class') assert_refnode(refnodes[12], None, 'NestedParentB', 'NestedParentB', 'class')
assert_refnode(refnodes[12], None, None, 'NestedParentA.NestedChildA', 'class') assert_refnode(refnodes[13], None, None, 'NestedParentA.NestedChildA', 'class')
assert len(refnodes) == 13 assert_refnode(refnodes[14], None, None, 'NestedParentA.NestedTypeA', 'type')
assert len(refnodes) == 15
doctree = app.env.get_doctree('module') doctree = app.env.get_doctree('module')
refnodes = list(doctree.findall(pending_xref)) refnodes = list(doctree.findall(pending_xref))
@ -135,7 +137,10 @@ def test_domain_py_xrefs(app, status, warning):
assert_refnode(refnodes[15], False, False, 'index', 'doc', domain='std') assert_refnode(refnodes[15], False, False, 'index', 'doc', domain='std')
assert_refnode(refnodes[16], False, False, 'typing.Literal', 'obj', domain='py') assert_refnode(refnodes[16], False, False, 'typing.Literal', 'obj', domain='py')
assert_refnode(refnodes[17], 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') doctree = app.env.get_doctree('module_option')
refnodes = list(doctree.findall(pending_xref)) refnodes = list(doctree.findall(pending_xref))
@ -191,7 +196,9 @@ def test_domain_py_objects(app, status, warning):
assert objects['TopLevel'][2] == 'class' assert objects['TopLevel'][2] == 'class'
assert objects['top_level'][2] == 'method' assert objects['top_level'][2] == 'method'
assert objects['TopLevelType'][2] == 'type'
assert objects['NestedParentA'][2] == 'class' assert objects['NestedParentA'][2] == 'class'
assert objects['NestedParentA.NestedTypeA'][2] == 'type'
assert objects['NestedParentA.child_1'][2] == 'method' assert objects['NestedParentA.child_1'][2] == 'method'
assert objects['NestedParentA.any_child'][2] == 'method' assert objects['NestedParentA.any_child'][2] == 'method'
assert objects['NestedParentA.NestedChildA'][2] == 'class' assert objects['NestedParentA.NestedChildA'][2] == 'class'
@ -233,6 +240,9 @@ def test_domain_py_find_obj(app, status, warning):
assert (find_obj(None, None, 'NONEXISTANT', 'class') == []) assert (find_obj(None, None, 'NONEXISTANT', 'class') == [])
assert (find_obj(None, None, 'NestedParentA', 'class') == assert (find_obj(None, None, 'NestedParentA', 'class') ==
[('NestedParentA', ('roles', 'NestedParentA', 'class', False))]) [('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') == assert (find_obj(None, None, 'NestedParentA.NestedChildA', 'class') ==
[('NestedParentA.NestedChildA', [('NestedParentA.NestedChildA',
('roles', 'NestedParentA.NestedChildA', 'class', False))]) ('roles', 'NestedParentA.NestedChildA', 'class', False))])

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import pytest
from docutils import nodes from docutils import nodes
from sphinx import addnodes from sphinx import addnodes
@ -362,6 +363,76 @@ def test_pyproperty(app):
assert domain.objects['Class.prop2'] == ('index', 'Class.prop2', 'property', False) 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): def test_pydecorator_signature(app):
text = ".. py:decorator:: deco" text = ".. py:decorator:: deco"
domain = app.env.get_domain('py') domain = app.env.get_domain('py')