Skip to content

Commit

Permalink
Add py:type directive and role for documenting type aliases (sphi…
Browse files Browse the repository at this point in the history
…nx-doc#11989)

Co-authored-by: Adam Turner <[email protected]>
  • Loading branch information
AWhetter and AA-Turner authored Jul 11, 2024
1 parent 91c5cd3 commit e38a60d
Show file tree
Hide file tree
Showing 9 changed files with 221 additions and 15 deletions.
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

0 comments on commit e38a60d

Please sign in to comment.