gh-153906: Modernize pydoc HTML output (GH-153909)

serhiy-storchaka <[email protected]>
Newsgroups gmane.comp.python.cvs
Message-ID <[email protected]>
https://github.com/python/cpython/commit/cae8ec822b6e5f93df28897255984c2c16fb64a9
commit: cae8ec822b6e5f93df28897255984c2c16fb64a9
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-07-19T09:59:58Z
summary:

gh-153906: Modernize pydoc HTML output (GH-153909)

Follow-up of the HTML5 migration in bpo-10716: replace the layout
tables, the &nbsp;/<br> encoding of docstrings and the obsolete
<a name=...> anchors with semantic markup (header, section, h1-h4,
CSS multi-column lists, white-space: pre-wrap, id= anchors), and
restyle the pages after the python-docs-theme used by docs.python.org,
including dark mode support.  Members inherited from other classes
are now collapsed by default.  The pydoc API is unchanged; obsolete
arguments of HTMLDoc methods are accepted and ignored.  ServerHTMLDoc
in xmlrpc.server is updated to match.

Co-Authored-By: Claude Fable 5 <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-07-18-13-30-00.gh-issue-153906.mHtNdY.rst
M Doc/whatsnew/3.16.rst
M Lib/pydoc.py
M Lib/pydoc_data/_pydoc.css
M Lib/test/test_docxmlrpc.py
M Lib/test/test_pydoc/test_pydoc.py
M Lib/xmlrpc/server.py

diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst
index 0b08d0d0d12d982..c06e6930ac78bff 100644
--- a/Doc/whatsnew/3.16.rst
+++ b/Doc/whatsnew/3.16.rst
@@ -365,6 +365,17 @@ os
   (Contributed by Maurycy Pawłowski-Wieroński in :gh:`149464`.)
 
 
+pydoc
+-----
+
+* Modernize the HTML output of :mod:`pydoc`.  The pages generated by the
+  :mod:`pydoc` HTTP server now use semantic HTML5 markup and a new style
+  sheet based on the python-docs-theme used by `docs.python.org
+  <https://docs.python.org/>`__, with dark mode support.  Class members
+  inherited from other classes are collapsed by default.
+  (Contributed by Serhiy Storchaka in :gh:`153906`.)
+
+
 re
 --
 
diff --git a/Lib/pydoc.py b/Lib/pydoc.py
index 041ee26c791f36b..3140723f49b7d0d 100644
--- a/Lib/pydoc.py
+++ b/Lib/pydoc.py
@@ -620,55 +620,41 @@ def page(self, title, contents):
     def heading(self, title, extras=''):
         """Format a page heading."""
         return '''
-<table class="heading">
-<tr class="heading-text decor">
-<td class="title">&nbsp;<br>%s</td>
-<td class="extra">%s</td></tr></table>
-    ''' % (title, extras or '&nbsp;')
-
-    def section(self, title, cls, contents, width=6,
-                prelude='', marginalia=None, gap='&nbsp;'):
-        """Format a section with a heading."""
-        if marginalia is None:
-            marginalia = '<span class="code">' + '&nbsp;' * width + '</span>'
-        result = '''<p>
-<table class="section">
-<tr class="decor %s-decor heading-text">
-<td class="section-title" colspan=3>&nbsp;<br>%s</td></tr>
-    ''' % (cls, title)
+<header class="heading">
+<h1>%s</h1>
+<div class="extra">%s</div>
+</header>
+''' % (title, extras)
+
+    def _section(self, title, cls, contents, prelude, tag):
+        result = '''
+<section class="%s">
+<%s>%s</%s>
+''' % (cls, tag, title, tag)
         if prelude:
-            result = result + '''
-<tr><td class="decor %s-decor" rowspan=2>%s</td>
-<td class="decor %s-decor" colspan=2>%s</td></tr>
-<tr><td>%s</td>''' % (cls, marginalia, cls, prelude, gap)
-        else:
-            result = result + '''
-<tr><td class="decor %s-decor">%s</td><td>%s</td>''' % (cls, marginalia, gap)
+            result = result + '<div class="docstring">%s</div>\n' % prelude
+        return result + '%s\n</section>\n' % contents
+
+    def section(self, title, cls, contents, width=None,
+                prelude='', marginalia=None, gap=None):
+        """Format a section with a heading.
 
-        return result + '\n<td class="singlecolumn">%s</td></tr></table>' % contents
+        The width, marginalia and gap arguments are ignored.
+        """
+        return self._section(title, cls, contents, prelude, 'h3')
 
-    def bigsection(self, title, *args):
+    def bigsection(self, title, cls, contents, *ignored):
         """Format a section with a big heading."""
-        title = '<strong class="bigsection">%s</strong>' % title
-        return self.section(title, *args)
+        return self._section(title, cls, contents, '', 'h2')
 
     def preformat(self, text):
         """Format literal preformatted text."""
-        text = self.escape(text.expandtabs())
-        return replace(text, '\n\n', '\n \n', '\n\n', '\n \n',
-                             ' ', '&nbsp;', '\n', '<br>\n')
+        return self.escape(text.expandtabs())
 
     def multicolumn(self, list, format):
         """Format a list of items into a multi-column list."""
-        result = ''
-        rows = (len(list) + 3) // 4
-        for col in range(4):
-            result = result + '<td class="multicolumn">'
-            for i in range(rows*col, rows*col+rows):
-                if i < len(list):
-                    result = result + format(list[i]) + '<br>\n'
-            result = result + '</td>'
-        return '<table><tr>%s</tr></table>' % result
+        result = ''.join('<li>%s</li>\n' % format(item) for item in list)
+        return '<ul class="multicolumn">\n%s</ul>' % result
 
     def grey(self, text): return '<span class="grey">%s</span>' % text
 
@@ -776,7 +762,7 @@ def formattree(self, tree, modname, parent=None):
         for entry in tree:
             if isinstance(entry, tuple):
                 c, bases = entry
-                result = result + '<dt class="heading-text">'
+                result = result + '<dt>'
                 result = result + self.classlink(c, modname)
                 if bases and bases != (parent,):
                     parents = []
@@ -787,7 +773,7 @@ def formattree(self, tree, modname, parent=None):
             elif isinstance(entry, list):
                 result = result + '<dd>\n%s</dd>\n' % self.formattree(
                     entry, modname, c)
-        return '<dl>\n%s</dl>\n' % result
+        return '<dl class="tree">\n%s</dl>\n' % result
 
     def docmodule(self, object, name=None, mod=None, *ignored):
         """Produce HTML documentation for a module object."""
@@ -800,10 +786,10 @@ def docmodule(self, object, name=None, mod=None, *ignored):
         links = []
         for i in range(len(parts)-1):
             links.append(
-                '<a href="%s.html" class="white">%s</a>' %
+                '<a href="%s.html">%s</a>' %
                 ('.'.join(parts[:i+1]), parts[i]))
         linkedname = '.'.join(links + parts[-1:])
-        head = '<strong class="title">%s</strong>' % linkedname
+        head = linkedname
         try:
             path = inspect.getabsfile(object)
             url = urllib.parse.quote(path)
@@ -861,8 +847,8 @@ def docmodule(self, object, name=None, mod=None, *ignored):
                 data.append((key, value))
 
         doc = self.markup(getdoc(object), self.preformat, fdict, cdict)
-        doc = doc and '<span class="code">%s</span>' % doc
-        result = result + '<p>%s</p>\n' % doc
+        doc = doc and '<div class="docstring">%s</div>\n' % doc
+        result = result + doc
 
         if hasattr(object, '__path__'):
             modpkgs = []
@@ -937,11 +923,26 @@ def maybe(self):
                                                       object.__module__))
             push('</dl>\n')
 
+        # Wrap the groups of members inherited from other classes in
+        # <details> so that they are collapsed by default.
+        is_inherited = False
+
+        def begingroup(msg):
+            if is_inherited:
+                push('<details class="inherited">\n'
+                     '<summary>%s</summary>\n' % msg)
+            else:
+                push('<h4>%s</h4>\n' % msg)
+
+        def endgroup():
+            if is_inherited:
+                push('</details>\n')
+
         def spill(msg, attrs, predicate):
             ok, attrs = _split_list(attrs, predicate)
             if ok:
                 hr.maybe()
-                push(msg)
+                begingroup(msg)
                 for name, kind, homecls, value in ok:
                     try:
                         value = getattr(object, name)
@@ -953,33 +954,37 @@ def spill(msg, attrs, predicate):
                         push(self.document(value, name, mod,
                                         funcs, classes, mdict, object, homecls))
                     push('\n')
+                endgroup()
             return attrs
 
         def spilldescriptors(msg, attrs, predicate):
             ok, attrs = _split_list(attrs, predicate)
             if ok:
                 hr.maybe()
-                push(msg)
+                begingroup(msg)
                 for name, kind, homecls, value in ok:
                     push(self.docdata(value, name, mod))
+                endgroup()
             return attrs
 
         def spilldata(msg, attrs, predicate):
             ok, attrs = _split_list(attrs, predicate)
             if ok:
                 hr.maybe()
-                push(msg)
+                begingroup(msg)
                 for name, kind, homecls, value in ok:
                     base = self.docother(getattr(object, name), name, mod)
                     doc = getdoc(value)
                     if not doc:
-                        push('<dl><dt>%s</dl>\n' % base)
+                        push('<dl class="doc"><dt>%s</dt></dl>\n' % base)
                     else:
                         doc = self.markup(getdoc(value), self.preformat,
                                           funcs, classes, mdict)
-                        doc = '<dd><span class="code">%s</span>' % doc
-                        push('<dl><dt>%s%s</dl>\n' % (base, doc))
+                        push('<dl class="doc"><dt>%s</dt>'
+                             '<dd class="docstring">%s</dd></dl>\n'
+                             % (base, doc))
                     push('\n')
+                endgroup()
             return attrs
 
         attrs = [(name, kind, cls, value)
@@ -1014,10 +1019,12 @@ def spilldata(msg, attrs, predicate):
                 continue
             elif thisclass is object:
                 tag = 'defined here'
+                is_inherited = False
             else:
                 tag = 'inherited from %s' % self.classlink(thisclass,
                                                            object.__module__)
-            tag += ':<br>\n'
+                is_inherited = True
+            tag += ':'
 
             sort_attributes(attrs, object)
 
@@ -1040,10 +1047,10 @@ def spilldata(msg, attrs, predicate):
         contents = ''.join(contents)
 
         if name == realname:
-            title = '<a name="%s">class <strong>%s</strong></a>' % (
+            title = '<a id="%s">class <strong>%s</strong></a>' % (
                 name, realname)
         else:
-            title = '<strong>%s</strong> = <a name="%s">class %s</a>' % (
+            title = '<strong>%s</strong> = <a id="%s">class %s</a>' % (
                 name, name, realname)
         if bases:
             parents = []
@@ -1060,9 +1067,8 @@ def spilldata(msg, attrs, predicate):
         if decl:
             doc = decl + (doc or '')
         doc = self.markup(doc, self.preformat, funcs, classes, mdict)
-        doc = doc and '<span class="code">%s<br>&nbsp;</span>' % doc
 
-        return self.section(title, 'title', contents, 3, doc)
+        return self.section(title, 'title', contents, prelude=doc)
 
     def formatvalue(self, object):
         """Format an argument default value as text."""
@@ -1115,7 +1121,7 @@ def docroutine(self, object, name=None, mod=None,
             asyncqualifier = ''
 
         if name == realname:
-            title = '<a name="%s"><strong>%s</strong></a>' % (anchor, realname)
+            title = '<a id="%s"><strong>%s</strong></a>' % (anchor, realname)
         else:
             if (cl is not None and
                 inspect.getattr_static(cl, realname, []) is object):
@@ -1126,7 +1132,7 @@ def docroutine(self, object, name=None, mod=None,
                     note = ''
             else:
                 reallink = realname
-            title = '<a name="%s"><strong>%s</strong></a> = %s' % (
+            title = '<a id="%s"><strong>%s</strong></a> = %s' % (
                 anchor, name, reallink)
         argspec = None
         if inspect.isroutine(object):
@@ -1142,15 +1148,15 @@ def docroutine(self, object, name=None, mod=None,
             argspec = '(...)'
 
         decl = asyncqualifier + title + self.escape(argspec) + (note and
-               self.grey('<span class="heading-text">%s</span>' % note))
+               '<span class="note">%s</span>' % note)
 
         if skipdocs:
-            return '<dl><dt>%s</dt></dl>\n' % decl
+            return '<dl class="doc"><dt>%s</dt></dl>\n' % decl
         else:
             doc = self.markup(
                 getdoc(object), self.preformat, funcs, classes, methods)
-            doc = doc and '<dd><span class="code">%s</span></dd>' % doc
-            return '<dl><dt>%s</dt>%s</dl>\n' % (decl, doc)
+            doc = doc and '<dd class="docstring">%s</dd>' % doc
+            return '<dl class="doc"><dt>%s</dt>%s</dl>\n' % (decl, doc)
 
     def docdata(self, object, name=None, mod=None, cl=None, *ignored):
         """Produce html documentation for a data descriptor."""
@@ -1158,10 +1164,10 @@ def docdata(self, object, name=None, mod=None, cl=None, *ignored):
         push = results.append
 
         if name:
-            push('<dl><dt><strong>%s</strong></dt>\n' % name)
+            push('<dl class="doc"><dt><strong>%s</strong></dt>\n' % name)
         doc = self.markup(getdoc(object), self.preformat)
         if doc:
-            push('<dd><span class="code">%s</span></dd>\n' % doc)
+            push('<dd class="docstring">%s</dd>\n' % doc)
         push('</dl>\n')
 
         return ''.join(results)
@@ -2471,12 +2477,17 @@ def page(self, title, contents):
                 '<link rel="stylesheet" type="text/css" href="%s">' %
                 css_path)
             return '''\
-<!DOCTYPE>
+<!DOCTYPE html>
 <html lang="en">
 <head>
 <meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
 <title>Pydoc: %s</title>
-%s</head><body>%s<div style="clear:both;padding-top:.5em;">%s</div>
+%s</head><body>
+%s
+<main>
+%s
+</main>
 </body></html>''' % (title, css_link, html_navbar(), contents)
 
 
@@ -2487,27 +2498,25 @@ def html_navbar():
                                                platform.python_build()[0],
                                                platform.python_compiler()))
         return """
-            <div style='float:left'>
-                Python %s<br>%s
-            </div>
-            <div style='float:right'>
-                <div style='text-align:center'>
-                  <a href="index.html">Module Index</a>
-                  : <a href="topics.html">Topics</a>
-                  : <a href="keywords.html">Keywords</a>
-                </div>
-                <div>
-                    <form action="get" style='display:inline;'>
-                      <input type=text name=key size=15>
-                      <input type=submit value="Get">
-                    </form>&nbsp;
-                    <form action="search" style='display:inline;'>
-                      <input type=text name=key size=15>
-                      <input type=submit value="Search">
-                    </form>
-                </div>
-            </div>
-            """ % (version, html.escape(platform.platform(terse=True)))
+<nav class="navbar">
+  <div class="navbar-version">Python %s<br>%s</div>
+  <ul>
+    <li><a href="index.html">Module Index</a></li>
+    <li><a href="topics.html">Topics</a></li>
+    <li><a href="keywords.html">Keywords</a></li>
+  </ul>
+  <div>
+    <form action="get">
+      <input type="search" name="key" size="15" placeholder="Get help on ...">
+      <input type="submit" value="Get">
+    </form>
+    <form action="search">
+      <input type="search" name="key" size="15" placeholder="Search modules">
+      <input type="submit" value="Search">
+    </form>
+  </div>
+</nav>
+""" % (version, html.escape(platform.platform(terse=True)))
 
     def html_index():
         """Module Index page."""
@@ -2515,13 +2524,11 @@ def html_index():
         def bltinlink(name):
             return '<a href="%s.html">%s</a>' % (name, name)
 
-        heading = html.heading(
-            '<strong class="title">Index of Modules</strong>'
-        )
+        heading = html.heading('Index of Modules')
         names = [name for name in sys.builtin_module_names
                  if name != '__main__']
         contents = html.multicolumn(names, bltinlink)
-        contents = [heading, '<p>' + html.bigsection(
+        contents = [heading, html.bigsection(
             'Built-in Modules', 'index', contents)]
 
         seen = {}
@@ -2529,8 +2536,8 @@ def bltinlink(name):
             contents.append(html.index(dir, seen))
 
         contents.append(
-            '<p align=right class="heading-text grey"><strong>pydoc</strong> by Ka-Ping Yee'
-            '&lt;[email protected]&gt;</p>')
+            '<footer><strong>pydoc</strong> by Ka-Ping Yee'
+            ' &lt;[email protected]&gt;</footer>')
         return 'Index of Modules', ''.join(contents)
 
     def html_search(key):
@@ -2554,13 +2561,11 @@ def bltinlink(name):
             return '<a href="%s.html">%s</a>' % (name, name)
 
         results = []
-        heading = html.heading(
-            '<strong class="title">Search Results</strong>',
-        )
+        heading = html.heading('Search Results')
         for name, desc in search_result:
-            results.append(bltinlink(name) + desc)
+            results.append('<li>%s%s</li>' % (bltinlink(name), desc))
         contents = heading + html.bigsection(
-            'key = %s' % key, 'index', '<br>'.join(results))
+            'key = %s' % key, 'index', '<ul>\n%s\n</ul>' % '\n'.join(results))
         return 'Search Results', contents
 
     def html_topics():
@@ -2569,29 +2574,21 @@ def html_topics():
         def bltinlink(name):
             return '<a href="topic?key=%s">%s</a>' % (name, name)
 
-        heading = html.heading(
-            '<strong class="title">INDEX</strong>',
-        )
+        heading = html.heading('Topics')
         names = sorted(Helper.topics.keys())
 
-        contents = html.multicolumn(names, bltinlink)
-        contents = heading + html.bigsection(
-            'Topics', 'index', contents)
+        contents = heading + html.multicolumn(names, bltinlink)
         return 'Topics', contents
 
     def html_keywords():
         """Index of keywords."""
-        heading = html.heading(
-            '<strong class="title">INDEX</strong>',
-        )
+        heading = html.heading('Keywords')
         names = sorted(Helper.keywords.keys())
 
         def bltinlink(name):
             return '<a href="topic?key=%s">%s</a>' % (name, name)
 
-        contents = html.multicolumn(names, bltinlink)
-        contents = heading + html.bigsection(
-            'Keywords', 'index', contents)
+        contents = heading + html.multicolumn(names, bltinlink)
         return 'Keywords', contents
 
     def html_topicpage(topic):
@@ -2603,11 +2600,9 @@ def html_topicpage(topic):
             title = 'KEYWORD'
         else:
             title = 'TOPIC'
-        heading = html.heading(
-            '<strong class="title">%s</strong>' % title,
-        )
-        contents = '<pre>%s</pre>' % html.markup(contents)
-        contents = html.bigsection(topic , 'index', contents)
+        heading = html.heading('%s %s' % (title.capitalize(), topic))
+        contents = ('<pre class="%s">%s</pre>'
+                    % (title.lower(), html.markup(contents)))
         if xrefs:
             xrefs = sorted(xrefs.split())
 
@@ -2615,7 +2610,7 @@ def bltinlink(name):
                 return '<a href="topic?key=%s">%s</a>' % (name, name)
 
             xrefs = html.multicolumn(xrefs, bltinlink)
-            xrefs = html.section('Related help topics: ', 'index', xrefs)
+            xrefs = html.section('Related help topics', 'index', xrefs)
         return ('%s %s' % (title, topic),
                 ''.join((heading, contents, xrefs)))
 
@@ -2628,9 +2623,7 @@ def html_getobj(url):
         return title, content
 
     def html_error(url, exc):
-        heading = html.heading(
-            '<strong class="title">Error</strong>',
-        )
+        heading = html.heading('Error')
         contents = '<br>'.join(html.escape(line) for line in
                                format_exception_only(type(exc), exc))
         contents = heading + html.bigsection(url, 'error', contents)
diff --git a/Lib/pydoc_data/_pydoc.css b/Lib/pydoc_data/_pydoc.css
index a6aa2e4c1a021ef..9e5c5c2f6f320dc 100644
--- a/Lib/pydoc_data/_pydoc.css
+++ b/Lib/pydoc_data/_pydoc.css
@@ -3,110 +3,253 @@
 
     Contents of this file are subject to change without notice.
 
+    The colors and fonts follow the python-docs-theme used by
+    docs.python.org.
 */
 
+:root {
+    color-scheme: light dark;
+    --text-color: #333;
+    --background-color: #fff;
+    --link-color: #0072aa;
+    --link-visited-color: #6363bb;
+    --link-hover-color: #00b0e4;
+    --muted-color: #707070;
+    --code-background-color: #eee;
+    --border-color: #ccc;
+    --target-background-color: #fbe54e;
+    --block-background-color: #eeffcc;
+    --block-border-color: #ac9;
+    --error-color: #ba2121;
+    --repr-color: #c040c0;
+}
+
+@media (prefers-color-scheme: dark) {
+    :root {
+        --text-color: rgba(255, 255, 255, 0.87);
+        --background-color: #222;
+        --link-color: #77aaff;
+        --link-visited-color: #0099ee;
+        --link-hover-color: #00b0e4;
+        --muted-color: #999;
+        --code-background-color: #424242;
+        --border-color: #616161;
+        --target-background-color: #616161;
+        --block-background-color: #333;
+        --block-border-color: #616161;
+        --error-color: #f44c4e;
+        --repr-color: #d080d0;
+    }
+}
+
 body {
-    background-color: #f0f0f8;
+    font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir,
+        segoe ui, helvetica neue, helvetica, Cantarell, Ubuntu, roboto,
+        noto, arial, sans-serif;
+    color: var(--text-color);
+    background-color: var(--background-color);
+    line-height: 1.4;
+    margin: 0;
+    padding: 0 1em 1em;
 }
 
-table.heading tr {
-    background-color: #7799ee;
+code, pre, .docstring, dl.doc > dt {
+    font-family: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console,
+        monospace;
+    font-size: 96.5%;
 }
 
-.decor {
-    color: #ffffff;
+a:link {
+    color: var(--link-color);
 }
 
-.title-decor {
-    background-color: #ffc8d8;
-    color: #000000;
+a:visited {
+    color: var(--link-visited-color);
 }
 
-.pkg-content-decor {
-    background-color: #aa55cc;
+a:hover {
+    color: var(--link-hover-color);
 }
 
-.index-decor {
-    background-color: #ee77aa;
+/* Page heading */
+
+header.heading {
+    display: flex;
+    flex-wrap: wrap;
+    justify-content: space-between;
+    align-items: flex-end;
+    column-gap: 1em;
+    padding: 0.5em 0;
+    border-bottom: 1px solid var(--border-color);
 }
 
-.functions-decor {
-    background-color: #eeaa77;
+header.heading h1 {
+    margin: 0;
+    font-size: 1.8em;
 }
 
-.data-decor {
-    background-color: #55aa55;
+header.heading .extra {
+    text-align: right;
+    overflow-wrap: anywhere;
 }
 
-.author-decor {
-    background-color: #7799ee;
+/* Sections */
+
+h2, h3, h4 {
+    margin: 0.6em 0 0.4em;
 }
 
-.credits-decor {
-    background-color: #7799ee;
+section > h2 {
+    border-bottom: 1px solid var(--border-color);
+    padding-bottom: 0.2em;
+    font-size: 1.4em;
 }
 
-.error-decor {
-    background-color: #bb0000;
+section > h3 {
+    font-size: 1.1em;
 }
 
-.grey {
-    color: #909090;
+section.error > h2 {
+    color: var(--error-color);
 }
 
-.white {
-    color: #ffffff;
+section section {
+    border: 1px solid var(--border-color);
+    border-radius: 3px;
+    padding: 0 0.8em 0.5em;
+    margin: 0.8em 0;
 }
 
-.repr {
-    color: #c040c0;
+/* Docstrings and other preformatted text */
+
+.docstring {
+    white-space: pre-wrap;
+    margin: 0.3em 0;
 }
 
-table.heading tr td.title {
-    vertical-align: bottom;
+pre.topic, pre.keyword {
+    background-color: var(--block-background-color);
+    border: 1px solid var(--block-border-color);
+    border-radius: 3px;
+    padding: 0.5em 0.8em;
+    overflow-x: auto;
 }
 
-table.heading tr td.extra {
-    vertical-align: bottom;
-    text-align: right;
+/* Documented names (functions, methods, data) */
+
+dl.doc {
+    margin: 0.5em 0;
 }
 
-.heading-text {
-    font-family: helvetica, arial;
+dl.doc > dt {
+    overflow-wrap: anywhere;
 }
 
-.bigsection {
-    font-size: larger;
+a:target {
+    background-color: var(--target-background-color);
 }
 
-.title {
-    font-size: x-large;
+dl.doc > dd {
+    margin: 0.2em 0 0.8em 2em;
 }
 
-.code {
-    font-family: monospace;
+.note {
+    font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir,
+        segoe ui, helvetica neue, helvetica, Cantarell, Ubuntu, roboto,
+        noto, arial, sans-serif;
+    color: var(--muted-color);
 }
 
-table {
-    width: 100%;
-    border-spacing : 0;
-    border-collapse : collapse;
+.grey {
+    color: var(--muted-color);
+}
+
+.repr {
+    color: var(--repr-color);
+}
+
+hr {
     border: 0;
+    border-top: 1px solid var(--border-color);
+    margin: 0.8em 0;
 }
 
-td {
-    padding: 2;
+/* Inherited class members are collapsed by default. */
+
+details.inherited > summary {
+    cursor: pointer;
+    margin: 0.6em 0 0.4em;
 }
 
-td.section-title {
-    vertical-align: bottom;
+/* Multi-column lists of modules, topics and keywords */
+
+ul.multicolumn {
+    list-style-type: none;
+    column-width: 14em;
+    column-gap: 1em;
+    margin: 0.5em 0;
+    padding: 0;
 }
 
-td.multicolumn {
-    width: 25%;
-    vertical-align: bottom;
+ul.multicolumn > li {
+    overflow-wrap: anywhere;
 }
 
-td.singlecolumn {
-    width: 100%;
+/* Class hierarchy trees */
+
+dl.tree, dl.tree dl {
+    margin: 0;
+}
+
+dl.tree dd {
+    margin: 0 0 0 2em;
+}
+
+/* Navigation bar of the pydoc server */
+
+nav.navbar {
+    display: flex;
+    flex-wrap: wrap;
+    justify-content: space-between;
+    align-items: center;
+    column-gap: 1em;
+    row-gap: 0.3em;
+    padding: 0.5em 0;
+    border-bottom: 1px solid var(--border-color);
+    font-size: smaller;
+}
+
+nav.navbar .navbar-version {
+    color: var(--muted-color);
+}
+
+nav.navbar ul {
+    display: flex;
+    flex-wrap: wrap;
+    column-gap: 1em;
+    list-style-type: none;
+    margin: 0;
+    padding: 0;
+}
+
+nav.navbar form {
+    display: inline-block;
+}
+
+nav.navbar input {
+    font-family: inherit;
+    font-size: inherit;
+    color: inherit;
+    background-color: var(--background-color);
+    border: 1px solid #999;
+    border-radius: 3px;
+}
+
+footer {
+    margin-top: 1em;
+    padding-top: 0.3em;
+    border-top: 1px solid var(--border-color);
+    color: var(--muted-color);
+    font-size: smaller;
+    text-align: right;
 }
diff --git a/Lib/test/test_docxmlrpc.py b/Lib/test/test_docxmlrpc.py
index 2ad422079b7f23e..64edc7b7e191d4c 100644
--- a/Lib/test/test_docxmlrpc.py
+++ b/Lib/test/test_docxmlrpc.py
@@ -127,7 +127,7 @@ def test_lambda(self):
         self.client.request("GET", "/")
         response = self.client.getresponse()
 
-        self.assertIn((b'<dl><dt><a name="-&lt;lambda&gt;"><strong>'
+        self.assertIn((b'<dl class="doc"><dt><a id="-&lt;lambda&gt;"><strong>'
                        b'&lt;lambda&gt;</strong></a>(x, y)</dt></dl>'),
                       response.read())
 
@@ -144,16 +144,17 @@ def test_autolinking(self):
         response = self.client.getresponse().read()
 
         self.assertIn(
-            (b'<dl><dt><a name="-add"><strong>add</strong></a>(x, y)</dt><dd>'
-             b'<tt>Add&nbsp;two&nbsp;instances&nbsp;together.&nbsp;This&nbsp;'
-             b'follows&nbsp;<a href="https://peps.python.org/pep-0008/">'
-             b'PEP008</a>,&nbsp;but&nbsp;has&nbsp;nothing<br>\nto&nbsp;do&nbsp;'
-             b'with&nbsp;<a href="https://www.rfc-editor.org/rfc/rfc1952.txt">'
-             b'RFC1952</a>.&nbsp;Case&nbsp;should&nbsp;matter:&nbsp;pEp008&nbsp;'
-             b'and&nbsp;rFC1952.&nbsp;&nbsp;Things<br>\nthat&nbsp;start&nbsp;'
-             b'with&nbsp;http&nbsp;and&nbsp;ftp&nbsp;should&nbsp;be&nbsp;'
-             b'auto-linked,&nbsp;too:<br>\n<a href="http://google.com">'
-             b'http://google.com</a>.</tt></dd></dl>'), response)
+            (b'<dl class="doc"><dt><a id="-add"><strong>add</strong></a>'
+             b'(x, y)</dt><dd class="docstring">'
+             b'Add two instances together. This '
+             b'follows <a href="https://peps.python.org/pep-0008/">'
+             b'PEP008</a>, but has nothing\nto do '
+             b'with <a href="https://www.rfc-editor.org/rfc/rfc1952.txt">'
+             b'RFC1952</a>. Case should matter: pEp008 '
+             b'and rFC1952.  Things\nthat start '
+             b'with http and ftp should be '
+             b'auto-linked, too:\n<a href="http://google.com">'
+             b'http://google.com</a>.</dd></dl>'), response)
 
     @make_request_and_skipIf(sys.flags.optimize >= 2,
                      "Docstrings are omitted with -O2 and above")
@@ -167,22 +168,24 @@ def test_system_methods(self):
         response = self.client.getresponse().read()
 
         self.assertIn(
-            (b'<dl><dt><a name="-system.methodHelp"><strong>system.methodHelp'
-             b'</strong></a>(method_name)</dt><dd><tt><a href="#-system.method'
-             b'Help">system.methodHelp</a>(\'add\')&nbsp;=&gt;&nbsp;"Adds&nbsp;'
-             b'two&nbsp;integers&nbsp;together"<br>\n&nbsp;<br>\nReturns&nbsp;a'
-             b'&nbsp;string&nbsp;containing&nbsp;documentation&nbsp;for&nbsp;'
-             b'the&nbsp;specified&nbsp;method.</tt></dd></dl>\n<dl><dt><a name'
-             b'="-system.methodSignature"><strong>system.methodSignature</strong>'
-             b'</a>(method_name)</dt><dd><tt><a href="#-system.methodSignature">'
-             b'system.methodSignature</a>(\'add\')&nbsp;=&gt;&nbsp;[double,&nbsp;'
-             b'int,&nbsp;int]<br>\n&nbsp;<br>\nReturns&nbsp;a&nbsp;list&nbsp;'
-             b'describing&nbsp;the&nbsp;signature&nbsp;of&nbsp;the&nbsp;method.'
-             b'&nbsp;In&nbsp;the<br>\nabove&nbsp;example,&nbsp;the&nbsp;add&nbsp;'
-             b'method&nbsp;takes&nbsp;two&nbsp;integers&nbsp;as&nbsp;arguments'
-             b'<br>\nand&nbsp;returns&nbsp;a&nbsp;double&nbsp;result.<br>\n&nbsp;'
-             b'<br>\nThis&nbsp;server&nbsp;does&nbsp;NOT&nbsp;support&nbsp;system'
-             b'.methodSignature.</tt></dd></dl>'), response)
+            (b'<dl class="doc"><dt><a id="-system.methodHelp"><strong>'
+             b'system.methodHelp</strong></a>(method_name)</dt>'
+             b'<dd class="docstring"><a href="#-system.method'
+             b'Help">system.methodHelp</a>(\'add\') =&gt; "Adds '
+             b'two integers together"\n\nReturns a'
+             b' string containing documentation for '
+             b'the specified method.</dd></dl>\n<dl class="doc"><dt><a id'
+             b'="-system.methodSignature"><strong>system.methodSignature'
+             b'</strong></a>(method_name)</dt><dd class="docstring">'
+             b'<a href="#-system.methodSignature">'
+             b'system.methodSignature</a>(\'add\') =&gt; [double, '
+             b'int, int]\n\nReturns a list '
+             b'describing the signature of the method.'
+             b' In the\nabove example, the add '
+             b'method takes two integers as arguments'
+             b'\nand returns a double result.\n\n'
+             b'This server does NOT support system'
+             b'.methodSignature.</dd></dl>'), response)
 
     def test_autolink_dotted_methods(self):
         """Test that selfdot values are made strong automatically in the
@@ -190,7 +193,7 @@ def test_autolink_dotted_methods(self):
         self.client.request("GET", "/")
         response = self.client.getresponse()
 
-        self.assertIn(b"""Try&nbsp;self.<strong>add</strong>,&nbsp;too.""",
+        self.assertIn(b"""Try self.<strong>add</strong>, too.""",
                       response.read())
 
     def test_annotations(self):
@@ -198,11 +201,11 @@ def test_annotations(self):
         self.client.request("GET", "/")
         response = self.client.getresponse()
         docstring = (b'' if sys.flags.optimize >= 2 else
-                     b'<dd><tt>Use&nbsp;function&nbsp;annotations.</tt></dd>')
+                     b'<dd class="docstring">Use function annotations.</dd>')
         self.assertIn(
-            (b'<dl><dt><a name="-annotation"><strong>annotation</strong></a>'
-             b'(x: int)</dt>' + docstring + b'</dl>\n'
-             b'<dl><dt><a name="-method_annotation"><strong>'
+            (b'<dl class="doc"><dt><a id="-annotation"><strong>annotation'
+             b'</strong></a>(x: int)</dt>' + docstring + b'</dl>\n'
+             b'<dl class="doc"><dt><a id="-method_annotation"><strong>'
              b'method_annotation</strong></a>(x: bytes)</dt></dl>'),
             response.read())
 
@@ -217,9 +220,11 @@ def test_server_title_escape(self):
 
         generated = self.serv.generate_html_documentation()
         title = re.search(r'<title>(.+?)</title>', generated).group()
-        documentation = re.search(r'<p><tt>(.+?)</tt></p>', generated).group()
+        documentation = re.search(r'<div class="docstring">(.+?)</div>',
+                                  generated).group()
         self.assertEqual('<title>Python: test_title&lt;script&gt;</title>', title)
-        self.assertEqual('<p><tt>test_documentation&lt;script&gt;</tt></p>', documentation)
+        self.assertEqual('<div class="docstring">test_documentation&lt;script&gt;</div>',
+                documentation)
 
 
 if __name__ == '__main__':
diff --git a/Lib/test/test_pydoc/test_pydoc.py b/Lib/test/test_pydoc/test_pydoc.py
index 25f94c4c740e1ea..34d30f54e9c9c7f 100644
--- a/Lib/test/test_pydoc/test_pydoc.py
+++ b/Lib/test/test_pydoc/test_pydoc.py
@@ -369,7 +369,7 @@ def html2text(html):
 
     Tailored for pydoc tests only.
     """
-    html = html.replace("<dd>", "\n")
+    html = re.sub(r"<dd\b[^>]*>", "\n", html)
     html = html.replace("<hr>", "-"*70)
     html = re.sub("<.*?>", "", html)
     html = pydoc.replace(html, "&nbsp;", " ", "&gt;", ">", "&lt;", "<")
@@ -1913,7 +1913,7 @@ async def coro_function(ign) -> int:
 
         html = pydoc.HTMLDoc().document(coro_function)
         self.assertIn(
-            'async <a name="-coro_function"><strong>coro_function',
+            'async <a id="-coro_function"><strong>coro_function',
             html)
 
     def test_async_generator_annotation(self):
@@ -1925,7 +1925,7 @@ async def an_async_generator():
 
         html = pydoc.HTMLDoc().document(an_async_generator)
         self.assertIn(
-            'async <a name="-an_async_generator"><strong>an_async_generator',
+            'async <a id="-an_async_generator"><strong>an_async_generator',
             html)
 
     @requires_docstrings
@@ -2091,7 +2091,7 @@ def test_html_doc_routines_in_module(self):
         doc = pydoc.HTMLDoc()
         result = doc.docmodule(pydocfodder)
         result = html2text(result)
-        lines = self.getsection(result, ' Functions', None)
+        lines = self.getsection(result, 'Functions', None)
         # function alias
         self.assertIn(' global_func_alias = global_func(x, y)', lines)
         self.assertIn(' A_staticmethod(x, y)', lines)
diff --git a/Lib/xmlrpc/server.py b/Lib/xmlrpc/server.py
index 3e6871157d09299..5ef68640a09ba4b 100644
--- a/Lib/xmlrpc/server.py
+++ b/Lib/xmlrpc/server.py
@@ -752,7 +752,7 @@ def docroutine(self, object, name, mod=None,
         anchor = (cl and cl.__name__ or '') + '-' + name
         note = ''
 
-        title = '<a name="%s"><strong>%s</strong></a>' % (
+        title = '<a id="%s"><strong>%s</strong></a>' % (
             self.escape(anchor), self.escape(name))
 
         if callable(object):
@@ -766,13 +766,13 @@ def docroutine(self, object, name, mod=None,
         else:
             docstring = pydoc.getdoc(object)
 
-        decl = title + argspec + (note and self.grey(
-               '<font face="helvetica, arial">%s</font>' % note))
+        decl = title + argspec + (note and
+               '<span class="note">%s</span>' % note)
 
         doc = self.markup(
             docstring, self.preformat, funcs, classes, methods)
-        doc = doc and '<dd><tt>%s</tt></dd>' % doc
-        return '<dl><dt>%s</dt>%s</dl>\n' % (decl, doc)
+        doc = doc and '<dd class="docstring">%s</dd>' % doc
+        return '<dl class="doc"><dt>%s</dt>%s</dl>\n' % (decl, doc)
 
     def docserver(self, server_name, package_documentation, methods):
         """Produce HTML documentation for an XML-RPC server."""
@@ -783,12 +783,11 @@ def docserver(self, server_name, package_documentation, methods):
             fdict[value] = fdict[key]
 
         server_name = self.escape(server_name)
-        head = '<big><big><strong>%s</strong></big></big>' % server_name
-        result = self.heading(head)
+        result = self.heading(server_name)
 
         doc = self.markup(package_documentation, self.preformat, fdict)
-        doc = doc and '<tt>%s</tt>' % doc
-        result = result + '<p>%s</p>\n' % doc
+        doc = doc and '<div class="docstring">%s</div>\n' % doc
+        result = result + doc
 
         contents = []
         method_items = sorted(methods.items())
@@ -807,12 +806,15 @@ def page(self, title, contents):
             '<link rel="stylesheet" type="text/css" href="%s">' %
             css_path)
         return '''\
-<!DOCTYPE>
+<!DOCTYPE html>
 <html lang="en">
 <head>
 <meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
 <title>Python: %s</title>
-%s</head><body>%s</body></html>''' % (title, css_link, contents)
+%s</head><body>
+%s
+</body></html>''' % (title, css_link, contents)
 
 class XMLRPCDocGenerator:
     """Generates documentation for an XML-RPC server.
diff --git a/Misc/NEWS.d/next/Library/2026-07-18-13-30-00.gh-issue-153906.mHtNdY.rst b/Misc/NEWS.d/next/Library/2026-07-18-13-30-00.gh-issue-153906.mHtNdY.rst
new file mode 100644
index 000000000000000..487587145f1a3eb
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-07-18-13-30-00.gh-issue-153906.mHtNdY.rst
@@ -0,0 +1,4 @@
+Modernize the HTML output of :mod:`pydoc`: use semantic HTML5 markup and
+a new style sheet based on the python-docs-theme used by docs.python.org,
+with dark mode support.  Class members inherited from other classes are
+now collapsed by default.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.