diff --git a/CHANGES.rst b/CHANGES.rst index b3e69ef489c..204df1ae5bf 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -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 ---------- diff --git a/doc/usage/domains/python.rst b/doc/usage/domains/python.rst index 96982f12e32..5667bd7a9b6 100644 --- a/doc/usage/domains/python.rst +++ b/doc/usage/domains/python.rst @@ -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 @@ -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 @@ -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 `. + + 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) @@ -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. diff --git a/sphinx/domains/python/__init__.py b/sphinx/domains/python/__init__.py index 75c2cddfba5..8f1c7d6d22d 100644 --- a/sphinx/domains/python/__init__.py +++ b/sphinx/domains/python/__init__.py @@ -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. @@ -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'), } @@ -603,6 +643,7 @@ class PythonDomain(Domain): 'staticmethod': PyStaticMethod, 'attribute': PyAttribute, 'property': PyProperty, + 'type': PyTypeAlias, 'module': PyModule, 'currentmodule': PyCurrentModule, 'decorator': PyDecoratorFunction, @@ -615,6 +656,7 @@ class PythonDomain(Domain): 'class': PyXRefRole(), 'const': PyXRefRole(), 'attr': PyXRefRole(), + 'type': PyXRefRole(), 'meth': PyXRefRole(fix_parens=True), 'mod': PyXRefRole(), 'obj': PyXRefRole(), diff --git a/tests/roots/test-domain-py/index.rst b/tests/roots/test-domain-py/index.rst index b24bbea244a..71e45f744a6 100644 --- a/tests/roots/test-domain-py/index.rst +++ b/tests/roots/test-domain-py/index.rst @@ -8,3 +8,4 @@ test-domain-py module_option abbr canonical + type_alias diff --git a/tests/roots/test-domain-py/module.rst b/tests/roots/test-domain-py/module.rst index 70098f68752..307e786e3ea 100644 --- a/tests/roots/test-domain-py/module.rst +++ b/tests/roots/test-domain-py/module.rst @@ -64,3 +64,6 @@ module .. py:data:: test2 :type: typing.Literal[-2] + +.. py:type:: MyType1 + :canonical: list[int | str] diff --git a/tests/roots/test-domain-py/roles.rst b/tests/roots/test-domain-py/roles.rst index 6bff2d2ca1b..d3492ceefb9 100644 --- a/tests/roots/test-domain-py/roles.rst +++ b/tests/roots/test-domain-py/roles.rst @@ -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` @@ -46,3 +51,4 @@ roles * Link to :py:class:`NestedParentB` * :py:class:`NestedParentA.NestedChildA` +* :py:type:`NestedParentA.NestedTypeA` diff --git a/tests/roots/test-domain-py/type_alias.rst b/tests/roots/test-domain-py/type_alias.rst new file mode 100644 index 00000000000..6a3df44daae --- /dev/null +++ b/tests/roots/test-domain-py/type_alias.rst @@ -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] diff --git a/tests/test_domains/test_domain_py.py b/tests/test_domains/test_domain_py.py index e653c80fcb1..3f45842d8b8 100644 --- a/tests/test_domains/test_domain_py.py +++ b/tests/test_domains/test_domain_py.py @@ -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)) @@ -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)) @@ -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' @@ -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))]) diff --git a/tests/test_domains/test_domain_py_pyobject.py b/tests/test_domains/test_domain_py_pyobject.py index 04f934102e1..adc0453818f 100644 --- a/tests/test_domains/test_domain_py_pyobject.py +++ b/tests/test_domains/test_domain_py_pyobject.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pytest from docutils import nodes from sphinx import addnodes @@ -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 ('type ' + 'module_one.' + 'MyAlias' + ' =' + ' list' + '[' + 'int ' + '| ' + '' + 'module_two.SomeClass' + ']' in content) + assert warning.getvalue() == '' + + def test_pydecorator_signature(app): text = ".. py:decorator:: deco" domain = app.env.get_domain('py')