Repository: sphinx-doc/sphinx

Issue:
introduce directive for type alias
There's no clear way to document a type alias as distinct from a module-level variable

I suggest adding a `:type:` directive that distinguishes an alias, like
```
DEFAULT_NUMBER_FACTORY = int
""" :var: zero-argument constructor for default numbers. """

Number = int
""" :type: Type used for numbers. """

def foo(a: Number) -> None:
    ...
```

It's possible we could use the fact that [type aliases aren't meant to have annotations themselves](https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases). I'm not sure which is better.


Discussion:
At present, [PEP-613 (Explicit Type Aliases)](https://www.python.org/dev/peps/pep-0613/) has been discussed. So we should follow the spec in the future.

BTW, the PEP will be introduced since python-3.10 or later (maybe). It means we don't have a way to indicate one variable is a kind of type-alias until then. I understand a need for the workaround to do that.

Pros.
* We can indicate a variable is a type-alias.
* We can indicate it before PEP adopted.

Cons.
* Sphinx adopts a non-standard way as a workaround.
* We need to keep backward compatible for a while even after PEP adopted.
@tk0miya those are some good points. I assume there will be a desire to keep compatibility with <=3.9 for a long while now, esp since 3.9 isn't released yet. Is it therefore feasible to wait for that PEP?

Can we draw conclusions from sphinx's experience with the `:rtype:` directive, which is no longer needed with full type hinting in 3.6?
>Is it therefore feasible to wait for that PEP?

This is the first request for type-alias. So nobody in a hurry now as far as I know. So we can wait for the release of the PEP.

In addition, there are no special representation for type-aliases. So we need to consider how Sphinx represent them in output.

>Can we draw conclusions from sphinx's experience with the :rtype: directive, which is no longer needed with full type hinting in 3.6?

If I can design autodoc from scratch now, I'll drop the `:rtype:` field from spec because it is duplicated with the type annotation. (It might be useful for C extensions. So I can't say I'll surely drop it).
I would like this and I look forward to seeing it in a future release!

It would be great to just click on a custom type alias in the documentation and have it take you to the type definition. It complements the autodoc_type_aliases dictionary well - currently this lets you remove the explicit content of the definition in a place where it adds unnecessary clutter, I am interested in where I will put this explicit definition instead.



Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 1f178f5c200..ab609469f98 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 <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)
 
@@ -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(),


Test patch:
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 ('<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')