Skip to content

Commit 67f572e

Browse files
committed
Add pydoc support for type aliases
1 parent b62380f commit 67f572e

2 files changed

Lines changed: 255 additions & 3 deletions

File tree

‎Lib/pydoc.py‎

Lines changed: 145 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,9 @@ class or function within a module or module in a package. If the
7171
import textwrap
7272
import time
7373
import tokenize
74+
import typing
7475
import warnings
75-
from annotationlib import Format
76+
from annotationlib import Format, call_evaluate_function
7677
from collections import deque
7778
from reprlib import Repr
7879
from traceback import format_exception_only
@@ -195,7 +196,12 @@ def isdata(object):
195196
"""Check if an object is of a type that probably means it's data."""
196197
return not (inspect.ismodule(object) or inspect.isclass(object) or
197198
inspect.isroutine(object) or inspect.isframe(object) or
198-
inspect.istraceback(object) or inspect.iscode(object))
199+
inspect.istraceback(object) or inspect.iscode(object) or
200+
istypealias(object))
201+
202+
def istypealias(object):
203+
"""Check if an object is a type alias."""
204+
return isinstance(object, typing.TypeAliasType)
199205

200206
def replace(text, *pairs):
201207
"""Do a series of global replacements on a string."""
@@ -466,6 +472,58 @@ def safeimport(path, forceload=0, cache={}):
466472
raise ErrorDuringImport(path, err)
467473
return module
468474

475+
def _typealias_source_value(object):
476+
"""Recover an alias expression when string-format evaluation fails."""
477+
import ast
478+
479+
try:
480+
source = textwrap.dedent(inspect.getsource(object.evaluate_value))
481+
tree = ast.parse(source)
482+
except (OSError, TypeError, SyntaxError):
483+
return None
484+
aliases = [node for node in ast.walk(tree)
485+
if isinstance(node, ast.TypeAlias)
486+
and node.name.id == object.__name__]
487+
if len(aliases) != 1:
488+
return None
489+
return ast.get_source_segment(source, aliases[0].value)
490+
491+
492+
def _typealias_declaration(object):
493+
params = []
494+
for param in object.__type_params__:
495+
if isinstance(param, typing.TypeVarTuple):
496+
prefix = '*'
497+
elif isinstance(param, typing.ParamSpec):
498+
prefix = '**'
499+
else:
500+
prefix = ''
501+
declaration = prefix + param.__name__
502+
for separator, attribute in (
503+
(': ', 'evaluate_constraints'),
504+
(': ', 'evaluate_bound'),
505+
(' = ', 'evaluate_default'),
506+
):
507+
evaluate = getattr(param, attribute, None)
508+
if evaluate is not None:
509+
try:
510+
value = call_evaluate_function(evaluate, Format.STRING)
511+
except Exception:
512+
continue
513+
declaration += separator + str(value)
514+
params.append(declaration)
515+
name = object.__name__
516+
if params:
517+
name += '[%s]' % ', '.join(params)
518+
try:
519+
value = call_evaluate_function(object.evaluate_value, Format.STRING)
520+
except Exception as exc:
521+
value = _typealias_source_value(object)
522+
if value is None:
523+
value = f'<unable to render: {exc!r}>'
524+
return name, str(value)
525+
526+
469527
# ---------------------------------------------------- formatter base class
470528

471529
class Doc:
@@ -486,6 +544,7 @@ def document(self, object, name=None, *args):
486544
if inspect.ismodule(object): return self.docmodule(*args)
487545
if inspect.isclass(object): return self.docclass(*args)
488546
if inspect.isroutine(object): return self.docroutine(*args)
547+
if istypealias(object): return self.doctypealias(*args)
489548
except AttributeError:
490549
pass
491550
if inspect.isdatadescriptor(object): return self.docdata(*args)
@@ -497,7 +556,7 @@ def fail(self, object, name=None, *args):
497556
name and ' ' + repr(name), type(object).__name__)
498557
raise TypeError(message)
499558

500-
docmodule = docclass = docroutine = docother = docproperty = docdata = fail
559+
docmodule = docclass = docroutine = doctypealias = docother = docproperty = docdata = fail
501560

502561
def getdocloc(self, object, basedir=None):
503562
"""Return the location of module docs or None"""
@@ -844,6 +903,14 @@ def docmodule(self, object, name=None, mod=None, *ignored):
844903
funcs.append((key, value))
845904
fdict[key] = '#-' + key
846905
if inspect.isfunction(value): fdict[value] = fdict[key]
906+
typealiases = []
907+
for key, value in inspect.getmembers(
908+
object, istypealias):
909+
if (all is not None or
910+
(inspect.getmodule(value) or object) is object):
911+
if visiblename(key, all, object):
912+
typealiases.append((key, value))
913+
cdict[key] = cdict[value] = '#' + key
847914
data = []
848915
for key, value in inspect.getmembers(object, isdata):
849916
if visiblename(key, all, object):
@@ -875,6 +942,12 @@ def docmodule(self, object, name=None, mod=None, *ignored):
875942
contents.append(self.document(value, key, name, fdict, cdict))
876943
result = result + self.bigsection(
877944
'Classes', 'index', ' '.join(contents))
945+
if typealiases:
946+
contents = []
947+
for key, value in typealiases:
948+
contents.append(self.document(value, key, name, fdict, cdict))
949+
result = result + self.bigsection(
950+
'Type Aliases', 'type-aliases', ' '.join(contents))
878951
if funcs:
879952
contents = []
880953
for key, value in funcs:
@@ -1162,6 +1235,35 @@ def docroutine(self, object, name=None, mod=None,
11621235
doc = doc and '<dd class="docstring">%s</dd>' % doc
11631236
return '<dl class="doc"><dt>%s</dt>%s</dl>\n' % (decl, doc)
11641237

1238+
def doctypealias(self, object, name=None, mod=None, funcs={}, classes={},
1239+
*ignored):
1240+
"""Produce HTML documentation for a type alias."""
1241+
realname, value = _typealias_declaration(object)
1242+
name = name or object.__name__
1243+
if name == object.__name__:
1244+
declaration = '<strong>%s</strong>' % self.escape(realname)
1245+
else:
1246+
declaration = '<strong>%s</strong> = type alias %s' % (
1247+
self.escape(name), self.escape(realname))
1248+
title = 'type <a id="%s">%s</a> = %s' % (
1249+
self.escape(name), declaration, self.escape(value))
1250+
contents = ['<h4>Lazy value access:</h4>\n']
1251+
for attr in ('__value__', 'evaluate_value'):
1252+
member = vars(typing.TypeAliasType)[attr]
1253+
if (not inspect.isdatadescriptor(member) or
1254+
not (description := inspect.getdoc(member))):
1255+
continue
1256+
contents.append(
1257+
'<dl class="doc"><dt><strong>%s</strong></dt>'
1258+
'<dd class="docstring">%s</dd></dl>\n' %
1259+
(attr, description))
1260+
contents.append(
1261+
'<p>See <a href="typing.html#TypeAliasType">'
1262+
'typing.TypeAliasType</a> for the full type alias interface.</p>\n')
1263+
doc = self.markup(
1264+
getdoc(object), self.preformat, funcs, classes)
1265+
return self.section(title, 'title', ''.join(contents), prelude=doc)
1266+
11651267
def docdata(self, object, name=None, mod=None, cl=None, *ignored):
11661268
"""Produce html documentation for a data descriptor."""
11671269
results = []
@@ -1319,6 +1421,13 @@ def docmodule(self, object, name=None, mod=None, *ignored):
13191421
or (inspect.getmodule(value) or object) is object):
13201422
if visiblename(key, all, object):
13211423
funcs.append((key, value))
1424+
typealiases = []
1425+
for key, value in inspect.getmembers(
1426+
object, istypealias):
1427+
if (all is not None or
1428+
(inspect.getmodule(value) or object) is object):
1429+
if visiblename(key, all, object):
1430+
typealiases.append((key, value))
13221431
data = []
13231432
for key, value in inspect.getmembers(object, isdata):
13241433
if visiblename(key, all, object):
@@ -1356,6 +1465,13 @@ def docmodule(self, object, name=None, mod=None, *ignored):
13561465
contents.append(self.document(value, key, name))
13571466
result = result + self.section('CLASSES', '\n'.join(contents))
13581467

1468+
if typealiases:
1469+
contents = []
1470+
for key, value in typealiases:
1471+
contents.append(self.document(value, key, name))
1472+
result = result + self.section(
1473+
'TYPE ALIASES', '\n'.join(contents))
1474+
13591475
if funcs:
13601476
contents = []
13611477
for key, value in funcs:
@@ -1536,6 +1652,29 @@ def spilldata(msg, attrs, predicate):
15361652
return title + '\n'
15371653
return title + '\n' + self.indent(contents.rstrip(), ' | ') + '\n'
15381654

1655+
def doctypealias(self, object, name=None, mod=None, *ignored):
1656+
"""Produce text documentation for a type alias."""
1657+
realname, value = _typealias_declaration(object)
1658+
name = name or object.__name__
1659+
contents = []
1660+
if doc := getdoc(object):
1661+
contents.extend((self._format_doc(doc), ''))
1662+
contents.extend(('Lazy value access:', ''))
1663+
for attr in ('__value__', 'evaluate_value'):
1664+
member = vars(typing.TypeAliasType)[attr]
1665+
if (not inspect.isdatadescriptor(member) or
1666+
not (description := inspect.getdoc(member))):
1667+
continue
1668+
contents.extend((self.bold(attr), self.indent(description), ''))
1669+
contents.append(
1670+
'See help(typing.TypeAliasType) for the full type alias interface.')
1671+
if name == object.__name__:
1672+
title = self.bold(realname)
1673+
else:
1674+
title = self.bold(name) + ' = type alias ' + realname
1675+
title = 'type ' + title + ' = ' + value
1676+
return title + '\n' + self.indent('\n'.join(contents).rstrip()) + '\n'
1677+
15391678
def formatvalue(self, object):
15401679
"""Format an argument default value as text."""
15411680
return '=' + self.repr(object)
@@ -1693,6 +1832,8 @@ def describe(thing):
16931832
return 'method descriptor ' + thing.__name__
16941833
except AttributeError:
16951834
pass
1835+
if istypealias(thing):
1836+
return 'type alias ' + thing.__name__
16961837
return type(thing).__name__
16971838

16981839
def locate(path, forceload=0):
@@ -1751,6 +1892,7 @@ def render_doc(thing, title='Python Library Documentation: %s', forceload=0,
17511892
inspect.isclass(object) or
17521893
inspect.isroutine(object) or
17531894
inspect.isdatadescriptor(object) or
1895+
istypealias(object) or
17541896
_getdoc(object)):
17551897
# If the passed object is a piece of data or an instance,
17561898
# document its available methods instead of its value.

‎Lib/test/test_pydoc/test_pydoc.py‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1524,6 +1524,116 @@ def test_union_type(self):
15241524
if not MISSING_C_DOCSTRINGS:
15251525
self.assertIn(types.UnionType.__doc__.strip().splitlines()[0], doc)
15261526

1527+
def test_type_alias(self):
1528+
type Pair[T] = tuple[T, T]
1529+
"""A pair of values of the same type."""
1530+
1531+
self.assertEqual(pydoc.describe(Pair), 'type alias Pair')
1532+
doc = pydoc.render_doc(Pair, renderer=pydoc.plaintext)
1533+
self.assertIn(
1534+
'Python Library Documentation: type alias Pair in module '
1535+
+ __name__, doc)
1536+
self.assertIn('type Pair[T] = tuple[T, T]', doc)
1537+
self.assertNotIn('__name__', doc)
1538+
self.assertNotIn('__type_params__', doc)
1539+
self.assertNotIn(' | ', doc)
1540+
self.assertIn('See help(typing.TypeAliasType)', doc)
1541+
self.assertIn(' A pair of values of the same type.', doc)
1542+
self.assertIn(' Lazy value access:', doc)
1543+
if not MISSING_C_DOCSTRINGS:
1544+
self.assertIn(
1545+
' __value__\n'
1546+
' Lazily evaluated value of the type alias.', doc)
1547+
1548+
html = pydoc.render_doc(Pair, renderer=pydoc.HTMLDoc())
1549+
self.assertIn('<strong>Pair[T]</strong></a> = tuple[T, T]', html)
1550+
self.assertIn('A pair of values of the same type.', html)
1551+
if not MISSING_C_DOCSTRINGS:
1552+
self.assertIn('<strong>__value__</strong>', html)
1553+
self.assertIn('<strong>evaluate_value</strong>', html)
1554+
self.assertIn('type <a ', html)
1555+
self.assertIn('href="typing.html#TypeAliasType"', html)
1556+
self.assertNotIn('<strong>__name__</strong>', html)
1557+
1558+
def test_type_alias_without_docstring(self):
1559+
type Alias = int
1560+
1561+
doc = pydoc.render_doc(Alias, renderer=pydoc.plaintext)
1562+
self.assertIn('type alias Alias in module ' + __name__, doc)
1563+
self.assertIn('Alias = int', doc)
1564+
self.assertIn(' Lazy value access:', doc)
1565+
self.assertNotIn('class TypeAliasType', doc)
1566+
1567+
def test_type_alias_parameters(self):
1568+
type Alias[T: int, U: (str, bytes) = str] = tuple[T, U]
1569+
1570+
doc = pydoc.render_doc(Alias, renderer=pydoc.plaintext)
1571+
self.assertIn(
1572+
'Alias[T: int, U: (str, bytes) = str] = tuple[T, U]', doc)
1573+
1574+
def test_type_alias_source_fallback(self):
1575+
type Broken = (lambda: 1 / 0)()
1576+
1577+
text = pydoc.render_doc(Broken, renderer=pydoc.plaintext)
1578+
self.assertIn('Broken = (lambda: 1 / 0)()', text)
1579+
1580+
html = pydoc.render_doc(Broken, renderer=pydoc.HTMLDoc())
1581+
self.assertIn('Broken</strong></a> = (lambda: 1 / 0)()', html)
1582+
1583+
def test_type_alias_source_fallback_multiline(self):
1584+
type Broken[T] = (
1585+
(lambda: "<tag>" + str(1 / 0))()
1586+
)
1587+
1588+
text = pydoc.render_doc(Broken, renderer=pydoc.plaintext)
1589+
self.assertIn('Broken[T] = (lambda: "<tag>" + str(1 / 0))()', text)
1590+
1591+
html = pydoc.render_doc(Broken, renderer=pydoc.HTMLDoc())
1592+
self.assertIn('&lt;tag&gt;', html)
1593+
self.assertNotIn('<tag>', html)
1594+
1595+
def test_type_alias_unrenderable_value(self):
1596+
ns = {}
1597+
exec(compile('type Broken = (lambda: 1 / 0)()',
1598+
'<pydoc alias without source>', 'exec'), ns)
1599+
broken = ns['Broken']
1600+
1601+
text = pydoc.render_doc(broken, renderer=pydoc.plaintext)
1602+
self.assertIn(
1603+
"Broken = <unable to render: ZeroDivisionError('division by zero')>",
1604+
text)
1605+
1606+
html = pydoc.render_doc(broken, renderer=pydoc.HTMLDoc())
1607+
self.assertIn(
1608+
'Broken</strong></a> = '
1609+
"&lt;unable to render: ZeroDivisionError('division by zero')&gt;",
1610+
html)
1611+
1612+
def test_type_alias_in_module(self):
1613+
type Pair[T] = tuple[T, T]
1614+
"""A pair of values of the same type."""
1615+
1616+
module = types.ModuleType('_test_pydoc_type_aliases')
1617+
Pair.__module__ = module.__name__
1618+
module.Pair = Pair
1619+
module.ExportedPair = Pair
1620+
1621+
with unittest.mock.patch.dict(sys.modules, {module.__name__: module}):
1622+
text = pydoc.plain(pydoc.TextDoc().docmodule(module))
1623+
html = pydoc.HTMLDoc().docmodule(module)
1624+
self.assertIn('TYPE ALIASES\n', text)
1625+
self.assertNotIn('DATA\n', text)
1626+
self.assertIn('Pair[T] = tuple[T, T]', text)
1627+
self.assertIn(
1628+
'ExportedPair = type alias Pair[T] = tuple[T, T]', text)
1629+
1630+
self.assertIn('<h2>Type Aliases</h2>', html)
1631+
self.assertNotIn('<h2>Data</h2>', html)
1632+
self.assertIn('<a id="Pair"><strong>Pair[T]</strong></a>', html)
1633+
self.assertIn(
1634+
'<a id="ExportedPair"><strong>ExportedPair</strong> = '
1635+
'type alias Pair[T]</a>', html)
1636+
15271637
def test_special_form(self):
15281638
self.assertEqual(pydoc.describe(typing.NoReturn), '_SpecialForm')
15291639
doc = pydoc.render_doc(typing.NoReturn, renderer=pydoc.plaintext)

0 commit comments

Comments
 (0)