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]

Reply via email to