Enable automatic formatting for `sphinx/ext/graphviz.py`

This commit is contained in:
Adam Turner 2024-12-21 20:20:11 +00:00
parent 13d25d96bf
commit ff8bb72aba
2 changed files with 136 additions and 78 deletions

View File

@ -415,7 +415,6 @@ exclude = [
"sphinx/domains/python/_object.py", "sphinx/domains/python/_object.py",
"sphinx/domains/rst.py", "sphinx/domains/rst.py",
"sphinx/domains/std/__init__.py", "sphinx/domains/std/__init__.py",
"sphinx/ext/graphviz.py",
"sphinx/ext/ifconfig.py", "sphinx/ext/ifconfig.py",
"sphinx/ext/imgconverter.py", "sphinx/ext/imgconverter.py",
"sphinx/ext/imgmath.py", "sphinx/ext/imgmath.py",

View File

@ -1,5 +1,4 @@
"""Allow graphviz-formatted graphs to be included inline in generated documents. """Allow graphviz-formatted graphs to be included inline in generated documents."""
"""
from __future__ import annotations from __future__ import annotations
@ -62,14 +61,15 @@ class ClickableMapDefinition:
def parse(self, dot: str) -> None: def parse(self, dot: str) -> None:
matched = self.maptag_re.match(self.content[0]) matched = self.maptag_re.match(self.content[0])
if not matched: if not matched:
raise GraphvizError('Invalid clickable map file found: %s' % self.filename) msg = f'Invalid clickable map file found: {self.filename}'
raise GraphvizError(msg)
self.id = matched.group(1) self.id = matched.group(1)
if self.id == '%3': if self.id == '%3':
# graphviz generates wrong ID if graph name not specified # graphviz generates wrong ID if graph name not specified
# https://gitlab.com/graphviz/graphviz/issues/1327 # https://gitlab.com/graphviz/graphviz/issues/1327
hashed = sha1(dot.encode(), usedforsecurity=False).hexdigest() hashed = sha1(dot.encode(), usedforsecurity=False).hexdigest()
self.id = 'grapviz%s' % hashed[-10:] self.id = f'grapviz{hashed[-10:]}'
self.content[0] = self.content[0].replace('%3', self.id) self.content[0] = self.content[0].replace('%3', self.id)
for line in self.content: for line in self.content:
@ -91,7 +91,9 @@ class graphviz(nodes.General, nodes.Inline, nodes.Element):
pass pass
def figure_wrapper(directive: SphinxDirective, node: graphviz, caption: str) -> nodes.figure: def figure_wrapper(
directive: SphinxDirective, node: graphviz, caption: str
) -> nodes.figure:
figure_node = nodes.figure('', node) figure_node = nodes.figure('', node)
if 'align' in node: if 'align' in node:
figure_node['align'] = node.attributes.pop('align') figure_node['align'] = node.attributes.pop('align')
@ -131,9 +133,15 @@ class Graphviz(SphinxDirective):
if self.arguments: if self.arguments:
document = self.state.document document = self.state.document
if self.content: if self.content:
return [document.reporter.warning( return [
__('Graphviz directive cannot have both content and ' document.reporter.warning(
'a filename argument'), line=self.lineno)] __(
'Graphviz directive cannot have both content and '
'a filename argument'
),
line=self.lineno,
)
]
argument = search_image_for_language(self.arguments[0], self.env) argument = search_image_for_language(self.arguments[0], self.env)
rel_filename, filename = self.env.relfn2path(argument) rel_filename, filename = self.env.relfn2path(argument)
self.env.note_dependency(rel_filename) self.env.note_dependency(rel_filename)
@ -141,16 +149,23 @@ class Graphviz(SphinxDirective):
with open(filename, encoding='utf-8') as fp: with open(filename, encoding='utf-8') as fp:
dotcode = fp.read() dotcode = fp.read()
except OSError: except OSError:
return [document.reporter.warning( return [
__('External Graphviz file %r not found or reading ' document.reporter.warning(
'it failed') % filename, line=self.lineno)] __('External Graphviz file %r not found or reading it failed')
% filename,
line=self.lineno,
)
]
else: else:
dotcode = '\n'.join(self.content) dotcode = '\n'.join(self.content)
rel_filename = None rel_filename = None
if not dotcode.strip(): if not dotcode.strip():
return [self.state_machine.reporter.warning( return [
self.state_machine.reporter.warning(
__('Ignoring "graphviz" directive without content.'), __('Ignoring "graphviz" directive without content.'),
line=self.lineno)] line=self.lineno,
)
]
node = graphviz() node = graphviz()
node['code'] = dotcode node['code'] = dotcode
node['options'] = {'docname': self.env.docname} node['options'] = {'docname': self.env.docname}
@ -198,8 +213,8 @@ class GraphvizSimple(SphinxDirective):
def run(self) -> list[Node]: def run(self) -> list[Node]:
node = graphviz() node = graphviz()
node['code'] = '%s %s {\n%s\n}\n' % \ dot_code = '\n'.join(self.content)
(self.name, self.arguments[0], '\n'.join(self.content)) node['code'] = f'{self.name} {self.arguments[0]} {{\n{dot_code}\n}}\n'
node['options'] = {'docname': self.env.docname} node['options'] = {'docname': self.env.docname}
if 'graphviz_dot' in self.options: if 'graphviz_dot' in self.options:
node['options']['graphviz_dot'] = self.options['graphviz_dot'] node['options']['graphviz_dot'] = self.options['graphviz_dot']
@ -221,8 +236,9 @@ class GraphvizSimple(SphinxDirective):
return [figure] return [figure]
def fix_svg_relative_paths(self: HTML5Translator | LaTeXTranslator | TexinfoTranslator, def fix_svg_relative_paths(
filepath: str) -> None: self: HTML5Translator | LaTeXTranslator | TexinfoTranslator, filepath: str
) -> None:
"""Change relative links in generated svg files to be relative to imgpath.""" """Change relative links in generated svg files to be relative to imgpath."""
tree = ET.parse(filepath) # NoQA: S314 tree = ET.parse(filepath) # NoQA: S314
root = tree.getroot() root = tree.getroot()
@ -239,7 +255,7 @@ def fix_svg_relative_paths(self: HTML5Translator | LaTeXTranslator | TexinfoTran
# not a relative link # not a relative link
continue continue
docname = self.builder.env.path2doc(self.document["source"]) docname = self.builder.env.path2doc(self.document['source'])
if docname is None: if docname is None:
# This shouldn't happen! # This shouldn't happen!
continue continue
@ -257,18 +273,26 @@ def fix_svg_relative_paths(self: HTML5Translator | LaTeXTranslator | TexinfoTran
tree.write(filepath) tree.write(filepath)
def render_dot(self: HTML5Translator | LaTeXTranslator | TexinfoTranslator, def render_dot(
code: str, options: dict, format: str, self: HTML5Translator | LaTeXTranslator | TexinfoTranslator,
prefix: str = 'graphviz', filename: str | None = None, code: str,
) -> tuple[str | None, str | None]: options: dict,
format: str,
prefix: str = 'graphviz',
filename: str | None = None,
) -> tuple[str | None, str | None]:
"""Render graphviz code into a PNG or PDF output file.""" """Render graphviz code into a PNG or PDF output file."""
graphviz_dot = options.get('graphviz_dot', self.builder.config.graphviz_dot) graphviz_dot = options.get('graphviz_dot', self.builder.config.graphviz_dot)
if not graphviz_dot: if not graphviz_dot:
raise GraphvizError( raise GraphvizError(
__('graphviz_dot executable path must be set! %r') % graphviz_dot, __('graphviz_dot executable path must be set! %r') % graphviz_dot,
) )
hashkey = (code + str(options) + str(graphviz_dot) + hashkey = ''.join((
str(self.builder.config.graphviz_dot_args)).encode() code,
str(options),
str(graphviz_dot),
str(self.builder.config.graphviz_dot_args),
)).encode()
fname = f'{prefix}-{sha1(hashkey, usedforsecurity=False).hexdigest()}.{format}' fname = f'{prefix}-{sha1(hashkey, usedforsecurity=False).hexdigest()}.{format}'
relfn = posixpath.join(self.builder.imgpath, fname) relfn = posixpath.join(self.builder.imgpath, fname)
@ -277,8 +301,7 @@ def render_dot(self: HTML5Translator | LaTeXTranslator | TexinfoTranslator,
if os.path.isfile(outfn): if os.path.isfile(outfn):
return relfn, outfn return relfn, outfn
if (hasattr(self.builder, '_graphviz_warned_dot') and if getattr(self.builder, '_graphviz_warned_dot', {}).get(graphviz_dot):
self.builder._graphviz_warned_dot.get(graphviz_dot)):
return None, None return None, None
ensuredir(os.path.dirname(outfn)) ensuredir(os.path.dirname(outfn))
@ -294,24 +317,34 @@ def render_dot(self: HTML5Translator | LaTeXTranslator | TexinfoTranslator,
cwd = os.path.dirname(os.path.join(self.builder.srcdir, docname)) cwd = os.path.dirname(os.path.join(self.builder.srcdir, docname))
if format == 'png': if format == 'png':
dot_args.extend(['-Tcmapx', '-o%s.map' % outfn]) dot_args.extend(['-Tcmapx', f'-o{outfn}.map'])
try: try:
ret = subprocess.run(dot_args, input=code.encode(), capture_output=True, ret = subprocess.run(
cwd=cwd, check=True) dot_args, input=code.encode(), capture_output=True, cwd=cwd, check=True
)
except OSError: except OSError:
logger.warning(__('dot command %r cannot be run (needed for graphviz ' logger.warning(
'output), check the graphviz_dot setting'), graphviz_dot) __(
'dot command %r cannot be run (needed for graphviz '
'output), check the graphviz_dot setting'
),
graphviz_dot,
)
if not hasattr(self.builder, '_graphviz_warned_dot'): if not hasattr(self.builder, '_graphviz_warned_dot'):
self.builder._graphviz_warned_dot = {} # type: ignore[union-attr] self.builder._graphviz_warned_dot = {} # type: ignore[union-attr]
self.builder._graphviz_warned_dot[graphviz_dot] = True self.builder._graphviz_warned_dot[graphviz_dot] = True
return None, None return None, None
except CalledProcessError as exc: except CalledProcessError as exc:
raise GraphvizError(__('dot exited with error:\n[stderr]\n%r\n' raise GraphvizError(
'[stdout]\n%r') % (exc.stderr, exc.stdout)) from exc __('dot exited with error:\n[stderr]\n%r\n[stdout]\n%r')
% (exc.stderr, exc.stdout)
) from exc
if not os.path.isfile(outfn): if not os.path.isfile(outfn):
raise GraphvizError(__('dot did not produce an output file:\n[stderr]\n%r\n' raise GraphvizError(
'[stdout]\n%r') % (ret.stderr, ret.stdout)) __('dot did not produce an output file:\n[stderr]\n%r\n[stdout]\n%r')
% (ret.stderr, ret.stdout)
)
if format == 'svg': if format == 'svg':
fix_svg_relative_paths(self, outfn) fix_svg_relative_paths(self, outfn)
@ -319,15 +352,23 @@ def render_dot(self: HTML5Translator | LaTeXTranslator | TexinfoTranslator,
return relfn, outfn return relfn, outfn
def render_dot_html(self: HTML5Translator, node: graphviz, code: str, options: dict, def render_dot_html(
prefix: str = 'graphviz', imgcls: str | None = None, self: HTML5Translator,
alt: str | None = None, filename: str | None = None, node: graphviz,
) -> tuple[str, str]: code: str,
options: dict,
prefix: str = 'graphviz',
imgcls: str | None = None,
alt: str | None = None,
filename: str | None = None,
) -> tuple[str, str]:
format = self.builder.config.graphviz_output_format format = self.builder.config.graphviz_output_format
try: try:
if format not in {'png', 'svg'}: if format not in {'png', 'svg'}:
raise GraphvizError(__("graphviz_output_format must be one of 'png', " raise GraphvizError(
"'svg', but is %r") % format) __("graphviz_output_format must be one of 'png', 'svg', but is %r")
% format
)
fname, outfn = render_dot(self, code, options, format, prefix, filename) fname, outfn = render_dot(self, code, options, format, prefix, filename)
except GraphvizError as exc: except GraphvizError as exc:
logger.warning(__('dot code %r: %s'), code, exc) logger.warning(__('dot code %r: %s'), code, exc)
@ -342,30 +383,32 @@ def render_dot_html(self: HTML5Translator, node: graphviz, code: str, options: d
if alt is None: if alt is None:
alt = node.get('alt', self.encode(code).strip()) alt = node.get('alt', self.encode(code).strip())
if 'align' in node: if 'align' in node:
self.body.append('<div align="%s" class="align-%s">' % align = node['align']
(node['align'], node['align'])) self.body.append(f'<div align="{align}" class="align-{align}">')
if format == 'svg': if format == 'svg':
self.body.append('<div class="graphviz">') self.body.append('<div class="graphviz">')
self.body.append('<object data="%s" type="image/svg+xml" class="%s">\n' % self.body.append(
(fname, imgcls)) f'<object data="{fname}" type="image/svg+xml" class="{imgcls}">\n'
self.body.append('<p class="warning">%s</p>' % alt) )
self.body.append(f'<p class="warning">{alt}</p>')
self.body.append('</object></div>\n') self.body.append('</object></div>\n')
else: else:
assert outfn is not None assert outfn is not None
with open(outfn + '.map', encoding='utf-8') as mapfile: with open(f'{outfn}.map', encoding='utf-8') as mapfile:
imgmap = ClickableMapDefinition(outfn + '.map', mapfile.read(), dot=code) map_content = mapfile.read()
imgmap = ClickableMapDefinition(f'{outfn}.map', map_content, dot=code)
if imgmap.clickable: if imgmap.clickable:
# has a map # has a map
self.body.append('<div class="graphviz">') self.body.append('<div class="graphviz">')
self.body.append('<img src="%s" alt="%s" usemap="#%s" class="%s" />' % self.body.append(
(fname, alt, imgmap.id, imgcls)) f'<img src="{fname}" alt="{alt}" usemap="#{imgmap.id}" class="{imgcls}" />'
)
self.body.append('</div>\n') self.body.append('</div>\n')
self.body.append(imgmap.generate_clickable_map()) self.body.append(imgmap.generate_clickable_map())
else: else:
# nothing in image map # nothing in image map
self.body.append('<div class="graphviz">') self.body.append('<div class="graphviz">')
self.body.append('<img src="%s" alt="%s" class="%s" />' % self.body.append(f'<img src="{fname}" alt="{alt}" class="{imgcls}" />')
(fname, alt, imgcls))
self.body.append('</div>\n') self.body.append('</div>\n')
if 'align' in node: if 'align' in node:
self.body.append('</div>\n') self.body.append('</div>\n')
@ -374,12 +417,19 @@ def render_dot_html(self: HTML5Translator, node: graphviz, code: str, options: d
def html_visit_graphviz(self: HTML5Translator, node: graphviz) -> None: def html_visit_graphviz(self: HTML5Translator, node: graphviz) -> None:
render_dot_html(self, node, node['code'], node['options'], filename=node.get('filename')) render_dot_html(
self, node, node['code'], node['options'], filename=node.get('filename')
)
def render_dot_latex(self: LaTeXTranslator, node: graphviz, code: str, def render_dot_latex(
options: dict, prefix: str = 'graphviz', filename: str | None = None, self: LaTeXTranslator,
) -> None: node: graphviz,
code: str,
options: dict,
prefix: str = 'graphviz',
filename: str | None = None,
) -> None:
try: try:
fname, outfn = render_dot(self, code, options, 'pdf', prefix, filename) fname, outfn = render_dot(self, code, options, 'pdf', prefix, filename)
except GraphvizError as exc: except GraphvizError as exc:
@ -401,22 +451,29 @@ def render_dot_latex(self: LaTeXTranslator, node: graphviz, code: str,
elif node['align'] == 'center': elif node['align'] == 'center':
pre = r'{\hfill' pre = r'{\hfill'
post = r'\hspace*{\fill}}' post = r'\hspace*{\fill}}'
self.body.append('\n%s' % pre) self.body.append(f'\n{pre}')
self.body.append(r'\sphinxincludegraphics[]{%s}' % fname) self.body.append(r'\sphinxincludegraphics[]{%s}' % fname)
if not is_inline: if not is_inline:
self.body.append('%s\n' % post) self.body.append(f'{post}\n')
raise nodes.SkipNode raise nodes.SkipNode
def latex_visit_graphviz(self: LaTeXTranslator, node: graphviz) -> None: def latex_visit_graphviz(self: LaTeXTranslator, node: graphviz) -> None:
render_dot_latex(self, node, node['code'], node['options'], filename=node.get('filename')) render_dot_latex(
self, node, node['code'], node['options'], filename=node.get('filename')
)
def render_dot_texinfo(self: TexinfoTranslator, node: graphviz, code: str, def render_dot_texinfo(
options: dict, prefix: str = 'graphviz') -> None: self: TexinfoTranslator,
node: graphviz,
code: str,
options: dict,
prefix: str = 'graphviz',
) -> None:
try: try:
fname, outfn = render_dot(self, code, options, 'png', prefix) fname, outfn = render_dot(self, code, options, 'png', prefix)
except GraphvizError as exc: except GraphvizError as exc:
@ -453,12 +510,14 @@ def on_config_inited(_app: Sphinx, config: Config) -> None:
def setup(app: Sphinx) -> ExtensionMetadata: def setup(app: Sphinx) -> ExtensionMetadata:
app.add_node(graphviz, app.add_node(
graphviz,
html=(html_visit_graphviz, None), html=(html_visit_graphviz, None),
latex=(latex_visit_graphviz, None), latex=(latex_visit_graphviz, None),
texinfo=(texinfo_visit_graphviz, None), texinfo=(texinfo_visit_graphviz, None),
text=(text_visit_graphviz, None), text=(text_visit_graphviz, None),
man=(man_visit_graphviz, None)) man=(man_visit_graphviz, None),
)
app.add_directive('graphviz', Graphviz) app.add_directive('graphviz', Graphviz)
app.add_directive('graph', GraphvizSimple) app.add_directive('graph', GraphvizSimple)
app.add_directive('digraph', GraphvizSimple) app.add_directive('digraph', GraphvizSimple)