Make it work on newer mkdocstrings, fix deprecation warnings (#4626)

* Make it work on newer mkdocstrings, fix deprecation warnings

* Bump documentation dependency versions
This commit is contained in:
Zephyr Lykos 2025-12-15 00:36:41 +08:00 committed by GitHub
parent ec73fb7247
commit 7bce22571a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 251 additions and 148 deletions

View File

@ -24,7 +24,7 @@ jobs:
run: |
sudo apt update
sudo apt install doxygen
pip install mkdocs-material==9.5.25 mkdocstrings==0.26.1 mike==2.1.1
pip install mkdocs-material==9.7.0 mkdocstrings==1.0.0 mike==2.1.3 typing_extensions==4.15.0
cmake -E make_directory ${{runner.workspace}}/build
# Workaround https://github.com/actions/checkout/issues/13:
git config --global user.name "$(git --no-pager log --format=format:'%an' -n 1)"

View File

@ -2,48 +2,62 @@
# Copyright (c) 2012 - present, Victor Zverovich
# https://github.com/fmtlib/fmt/blob/master/LICENSE
# pyright: strict
import os
import xml.etree.ElementTree as ElementTree
import xml.etree.ElementTree as ET
from pathlib import Path
from subprocess import PIPE, STDOUT, CalledProcessError, Popen
from typing import Any, List, Mapping, Optional
from mkdocstrings.handlers.base import BaseHandler
from markupsafe import Markup
from mkdocstrings import BaseHandler
from typing_extensions import TYPE_CHECKING, Any, ClassVar, final, override
if TYPE_CHECKING:
from collections.abc import Mapping, MutableMapping
from mkdocs.config.defaults import MkDocsConfig
from mkdocstrings import CollectorItem, HandlerOptions
@final
class Definition:
"""A definition extracted by Doxygen."""
def __init__(self, name: str, kind: Optional[str] = None,
node: Optional[ElementTree.Element] = None,
is_member: bool = False):
def __init__(
self,
name: str,
kind: "str | None" = None,
node: "ET.Element | None" = None,
is_member: bool = False,
):
self.name = name
self.kind = kind if kind is not None else node.get('kind')
self.desc = None
self.id = name if not is_member else None
self.members = None
self.params = None
self.template_params = None
self.trailing_return_type = None
self.type = None
self.kind: "str | None" = None
if kind is not None:
self.kind = kind
elif node is not None:
self.kind = node.get("kind")
self.desc: "list[ET.Element[str]] | None" = None
self.id: "str | None" = name if not is_member else None
self.members: "list[Definition] | None" = None
self.params: "list[Definition] | None" = None
self.template_params: "list[Definition] | None" = None
self.trailing_return_type: "str | None" = None
self.type: "str | None" = None
# A map from Doxygen to HTML tags.
tag_map = {
'bold': 'b',
'emphasis': 'em',
'computeroutput': 'code',
'para': 'p',
'itemizedlist': 'ul',
'listitem': 'li'
"bold": "b",
"emphasis": "em",
"computeroutput": "code",
"para": "p",
"itemizedlist": "ul",
"listitem": "li",
}
# A map from Doxygen tags to text.
tag_text_map = {
'codeline': '',
'highlight': '',
'sp': ' '
}
tag_text_map = {"codeline": "", "highlight": "", "sp": " "}
def escape_html(s: str) -> str:
@ -51,157 +65,211 @@ def escape_html(s: str) -> str:
# Converts a node from doxygen to HTML format.
def convert_node(node: ElementTree.Element, tag: str, attrs: dict = {}):
out = '<' + tag
def convert_node(
node: ET.Element, tag: str, attrs: "Mapping[str, str] | None" = None
) -> str:
if attrs is None:
attrs = {}
out: str = "<" + tag
for key, value in attrs.items():
out += ' ' + key + '="' + value + '"'
out += '>'
out += " " + key + '="' + value + '"'
out += ">"
if node.text:
out += escape_html(node.text)
out += doxyxml2html(list(node))
out += '</' + tag + '>'
out += "</" + tag + ">"
if node.tail:
out += node.tail
return out
def doxyxml2html(nodes: List[ElementTree.Element]):
out = ''
def doxyxml2html(nodes: "list[ET.Element]"):
out = ""
for n in nodes:
tag = tag_map.get(n.tag)
if tag:
out += convert_node(n, tag)
continue
if n.tag == 'programlisting' or n.tag == 'verbatim':
out += '<pre>'
out += convert_node(n, 'code', {'class': 'language-cpp'})
out += '</pre>'
if n.tag == "programlisting" or n.tag == "verbatim":
out += "<pre>"
out += convert_node(n, "code", {"class": "language-cpp"})
out += "</pre>"
continue
if n.tag == 'ulink':
out += convert_node(n, 'a', {'href': n.attrib['url']})
if n.tag == "ulink":
out += convert_node(n, "a", {"href": n.attrib["url"]})
continue
out += tag_text_map[n.tag]
return out
def convert_template_params(node: ElementTree.Element) -> Optional[List[Definition]]:
template_param_list = node.find('templateparamlist')
def convert_template_params(node: ET.Element) -> "list[Definition] | None":
template_param_list = node.find("templateparamlist")
if template_param_list is None:
return None
params = []
for param_node in template_param_list.findall('param'):
name = param_node.find('declname')
param = Definition(name.text if name is not None else '', 'param')
param.type = param_node.find('type').text
params: "list[Definition]" = []
for param_node in template_param_list.findall("param"):
name = param_node.find("declname")
if name is not None:
name = name.text
if name is None:
name = ""
param = Definition(name, "param")
param_type = param_node.find("type")
if param_type is not None:
param.type = param_type.text
params.append(param)
return params
def get_description(node: ElementTree.Element) -> List[ElementTree.Element]:
return node.findall('briefdescription/para') + \
node.findall('detaileddescription/para')
def get_description(node: ET.Element) -> list[ET.Element]:
return node.findall("briefdescription/para") + node.findall(
"detaileddescription/para"
)
def normalize_type(type_: str) -> str:
type_ = type_.replace('< ', '<').replace(' >', '>')
return type_.replace(' &', '&').replace(' *', '*')
type_ = type_.replace("< ", "<").replace(" >", ">")
return type_.replace(" &", "&").replace(" *", "*")
def convert_type(type_: ElementTree.Element) -> Optional[str]:
def convert_type(type_: "ET.Element | None") -> "str | None":
if type_ is None:
return None
result = type_.text if type_.text else ''
result = type_.text if type_.text else ""
for ref in type_:
if ref.text is None:
raise ValueError
result += ref.text
if ref.tail:
result += ref.tail
if type_.tail is None:
raise ValueError
result += type_.tail.strip()
return normalize_type(result)
def convert_params(func: ElementTree.Element) -> List[Definition]:
params = []
for p in func.findall('param'):
d = Definition(p.find('declname').text, 'param')
d.type = convert_type(p.find('type'))
def convert_params(func: ET.Element) -> list[Definition]:
params: "list[Definition]" = []
for p in func.findall("param"):
declname = p.find("declname")
if declname is None or declname.text is None:
raise ValueError
d = Definition(declname.text, "param")
d.type = convert_type(p.find("type"))
params.append(d)
return params
def convert_return_type(d: Definition, node: ElementTree.Element) -> None:
def convert_return_type(d: Definition, node: ET.Element) -> None:
d.trailing_return_type = None
if d.type == 'auto' or d.type == 'constexpr auto':
parts = node.find('argsstring').text.split(' -> ')
if d.type == "auto" or d.type == "constexpr auto":
argsstring = node.find("argsstring")
if argsstring is None or argsstring.text is None:
raise ValueError
parts = argsstring.text.split(" -> ")
if len(parts) > 1:
d.trailing_return_type = normalize_type(parts[1])
def render_param(param: Definition) -> str:
return param.type + (f'&nbsp;{param.name}' if len(param.name) > 0 else '')
if param.type is None:
raise ValueError
return param.type + (f"&nbsp;{param.name}" if len(param.name) > 0 else "")
def render_decl(d: Definition) -> str:
text = ''
text = ""
if d.id is not None:
text += f'<a id="{d.id}">\n'
text += '<pre><code class="language-cpp decl">'
text += '<div>'
text += "<div>"
if d.template_params is not None:
text += 'template &lt;'
text += ', '.join([render_param(p) for p in d.template_params])
text += '&gt;\n'
text += '</div>'
text += "template &lt;"
text += ", ".join([render_param(p) for p in d.template_params])
text += "&gt;\n"
text += "</div>"
text += '<div>'
end = ';'
if d.kind == 'function' or d.kind == 'variable':
text += d.type + ' ' if len(d.type) > 0 else ''
elif d.kind == 'typedef':
text += 'using '
elif d.kind == 'define':
end = ''
text += "<div>"
end = ";"
if d.kind is None:
raise ValueError
if d.kind == "function" or d.kind == "variable":
if d.type is None:
raise ValueError
text += d.type + " " if len(d.type) > 0 else ""
elif d.kind == "typedef":
text += "using "
elif d.kind == "define":
end = ""
else:
text += d.kind + ' '
text += d.kind + " "
text += d.name
if d.params is not None:
params = ', '.join([
(p.type + ' ' if p.type else '') + p.name for p in d.params])
text += '(' + escape_html(params) + ')'
params = ", ".join([
(p.type + " " if p.type else "") + p.name for p in d.params
])
text += "(" + escape_html(params) + ")"
if d.trailing_return_type:
text += ' -&NoBreak;>&nbsp;' + escape_html(d.trailing_return_type)
elif d.kind == 'typedef':
text += ' = ' + escape_html(d.type)
text += " -&NoBreak;>&nbsp;" + escape_html(d.trailing_return_type)
elif d.kind == "typedef":
if d.type is None:
raise ValueError
text += " = " + escape_html(d.type)
text += end
text += '</div>'
text += '</code></pre>\n'
text += "</div>"
text += "</code></pre>\n"
if d.id is not None:
text += f'</a>\n'
text += "</a>\n"
return text
@final
class CxxHandler(BaseHandler):
def __init__(self, **kwargs: Any) -> None:
super().__init__(handler='cxx', **kwargs)
name: ClassVar[str] = "cxx"
domain: ClassVar[str] = "cxx"
def __init__(
self, config: "Mapping[str, Any]", base_dir: Path, **kwargs: Any
) -> None:
super().__init__(**kwargs)
self.config = config
"""The handler configuration."""
self.base_dir = base_dir
"""The base directory of the project."""
headers = [
'args.h', 'base.h', 'chrono.h', 'color.h', 'compile.h', 'format.h',
'os.h', 'ostream.h', 'printf.h', 'ranges.h', 'std.h', 'xchar.h'
"args.h",
"base.h",
"chrono.h",
"color.h",
"compile.h",
"format.h",
"os.h",
"ostream.h",
"printf.h",
"ranges.h",
"std.h",
"xchar.h",
]
# Run doxygen.
cmd = ['doxygen', '-']
cmd = ["doxygen", "-"]
support_dir = Path(__file__).parents[3]
top_dir = os.path.dirname(support_dir)
include_dir = os.path.join(top_dir, 'include', 'fmt')
self._ns2doxyxml = {}
build_dir = os.path.join(top_dir, 'build')
include_dir = os.path.join(top_dir, "include", "fmt")
self._ns2doxyxml: "dict[str, ET.ElementTree[ET.Element[str]]]" = {}
build_dir = os.path.join(top_dir, "build")
os.makedirs(build_dir, exist_ok=True)
self._doxyxml_dir = os.path.join(build_dir, 'doxyxml')
self._doxyxml_dir = os.path.join(build_dir, "doxyxml")
p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=STDOUT)
_, _ = p.communicate(input=r'''
_, _ = p.communicate(
input=r"""
PROJECT_NAME = fmt
GENERATE_XML = YES
GENERATE_LATEX = NO
@ -221,18 +289,20 @@ class CxxHandler(BaseHandler):
"FMT_BEGIN_NAMESPACE=namespace fmt {{" \
"FMT_END_NAMESPACE=}}" \
"FMT_DOC=1"
'''.format(
' '.join([os.path.join(include_dir, h) for h in headers]),
self._doxyxml_dir).encode('utf-8'))
""".format(
" ".join([os.path.join(include_dir, h) for h in headers]),
self._doxyxml_dir,
).encode("utf-8")
)
if p.returncode != 0:
raise CalledProcessError(p.returncode, cmd)
# Merge all file-level XMLs into one to simplify search.
self._file_doxyxml = None
self._file_doxyxml: "ET.ElementTree[ET.Element[str]] | None" = None
for h in headers:
filename = h.replace(".h", "_8h.xml")
with open(os.path.join(self._doxyxml_dir, filename)) as f:
doxyxml = ElementTree.parse(f)
doxyxml = ET.parse(f)
if self._file_doxyxml is None:
self._file_doxyxml = doxyxml
continue
@ -240,33 +310,43 @@ class CxxHandler(BaseHandler):
for node in doxyxml.getroot():
root.append(node)
def collect_compound(self, identifier: str,
cls: List[ElementTree.Element]) -> Definition:
def collect_compound(self, identifier: str, cls: "list[ET.Element]") -> Definition:
"""Collect a compound definition such as a struct."""
path = os.path.join(self._doxyxml_dir, cls[0].get('refid') + '.xml')
refid = cls[0].get("refid")
if refid is None:
raise ValueError
path = os.path.join(self._doxyxml_dir, refid + ".xml")
with open(path) as f:
xml = ElementTree.parse(f)
node = xml.find('compounddef')
xml = ET.parse(f)
node = xml.find("compounddef")
if node is None:
raise ValueError
d = Definition(identifier, node=node)
d.template_params = convert_template_params(node)
d.desc = get_description(node)
d.members = []
for m in \
node.findall('sectiondef[@kind="public-attrib"]/memberdef') + \
node.findall('sectiondef[@kind="public-func"]/memberdef'):
name = m.find('name').text
for m in node.findall(
'sectiondef[@kind="public-attrib"]/memberdef'
) + node.findall('sectiondef[@kind="public-func"]/memberdef'):
name = m.find("name")
if name is None or name.text is None:
raise ValueError
name = name.text
# Doxygen incorrectly classifies members of private unnamed unions as
# public members of the containing class.
if name.endswith('_'):
if name.endswith("_"):
continue
desc = get_description(m)
if len(desc) == 0:
continue
kind = m.get('kind')
member = Definition(name if name else '', kind=kind, is_member=True)
type_text = m.find('type').text
member.type = type_text if type_text else ''
if kind == 'function':
kind = m.get("kind")
member = Definition(name if name else "", kind=kind, is_member=True)
type_ = m.find("type")
if type_ is None:
raise ValueError
type_text = type_.text
member.type = type_text if type_text else ""
if kind == "function":
member.params = convert_params(m)
convert_return_type(member, m)
member.template_params = None
@ -274,48 +354,60 @@ class CxxHandler(BaseHandler):
d.members.append(member)
return d
def collect(self, identifier: str, _config: Mapping[str, Any]) -> Definition:
qual_name = 'fmt::' + identifier
@override
def collect(self, identifier: str, options: "Mapping[str, Any]") -> Definition:
qual_name = "fmt::" + identifier
param_str = None
paren = qual_name.find('(')
paren = qual_name.find("(")
if paren > 0:
qual_name, param_str = qual_name[:paren], qual_name[paren + 1 : -1]
colons = qual_name.rfind('::')
colons = qual_name.rfind("::")
namespace, name = qual_name[:colons], qual_name[colons + 2 :]
# Load XML.
doxyxml = self._ns2doxyxml.get(namespace)
if doxyxml is None:
path = f'namespace{namespace.replace("::", "_1_1")}.xml'
path = f"namespace{namespace.replace('::', '_1_1')}.xml"
with open(os.path.join(self._doxyxml_dir, path)) as f:
doxyxml = ElementTree.parse(f)
doxyxml = ET.parse(f)
self._ns2doxyxml[namespace] = doxyxml
nodes = doxyxml.findall(
f"compounddef/sectiondef/memberdef/name[.='{name}']/..")
nodes = doxyxml.findall(f"compounddef/sectiondef/memberdef/name[.='{name}']/..")
if len(nodes) == 0:
if self._file_doxyxml is None:
raise ValueError
nodes = self._file_doxyxml.findall(
f"compounddef/sectiondef/memberdef/name[.='{name}']/..")
candidates = []
f"compounddef/sectiondef/memberdef/name[.='{name}']/.."
)
candidates: "list[str]" = []
for node in nodes:
# Process a function or a typedef.
params = None
params: "list[Definition] | None" = None
d = Definition(name, node=node)
if d.kind == 'function':
if d.kind == "function":
params = convert_params(node)
node_param_str = ', '.join([p.type for p in params])
params_type: "list[str]" = []
for p in params:
if p.type is None:
raise ValueError
else:
params_type.append(p.type)
node_param_str = ", ".join(params_type)
if param_str and param_str != node_param_str:
candidates.append(f'{name}({node_param_str})')
candidates.append(f"{name}({node_param_str})")
continue
elif d.kind == 'define':
elif d.kind == "define":
params = []
for p in node.findall('param'):
param = Definition(p.find('defname').text, kind='param')
for p in node.findall("param"):
defname = p.find("defname")
if defname is None or defname.text is None:
raise ValueError
param = Definition(defname.text, kind="param")
param.type = None
params.append(param)
d.type = convert_type(node.find('type'))
d.type = convert_type(node.find("type"))
d.template_params = convert_template_params(node)
d.params = params
convert_return_type(d, node)
@ -324,31 +416,42 @@ class CxxHandler(BaseHandler):
cls = doxyxml.findall(f"compounddef/innerclass[.='{qual_name}']")
if not cls:
raise Exception(f'Cannot find {identifier}. Candidates: {candidates}')
raise Exception(f"Cannot find {identifier}. Candidates: {candidates}")
return self.collect_compound(identifier, cls)
def render(self, d: Definition, config: dict) -> str:
@override
def render(
self,
data: "CollectorItem",
options: "HandlerOptions",
*,
locale: "str | None" = None,
) -> str:
d = data
if d.id is not None:
self.do_heading('', 0, id=d.id)
_ = self.do_heading(Markup(), 0, id=d.id)
if d.desc is None:
raise ValueError
text = '<div class="docblock">\n'
text += render_decl(d)
text += '<div class="docblock-desc">\n'
text += doxyxml2html(d.desc)
if d.members is not None:
for m in d.members:
text += self.render(m, config)
text += '</div>\n'
text += '</div>\n'
text += self.render(m, options, locale=locale)
text += "</div>\n"
text += "</div>\n"
return text
def get_handler(theme: str, custom_templates: Optional[str] = None,
**_config: Any) -> CxxHandler:
def get_handler(
handler_config: "MutableMapping[str, Any]", tool_config: "MkDocsConfig", **kwargs: Any
) -> CxxHandler:
"""Return an instance of `CxxHandler`.
Arguments:
theme: The theme to use when rendering contents.
custom_templates: Directory containing custom templates.
**_config: Configuration passed to the handler.
handler_config: The handler configuration.
tool_config: The tool (SSG) configuration.
"""
return CxxHandler(theme=theme, custom_templates=custom_templates)
base_dir = Path(tool_config.config_file_path or "./mkdocs.yml").parent
return CxxHandler(config=handler_config, base_dir=base_dir, **kwargs)