Skip to content

Commit

Permalink
Enable automatic formatting for sphinx/ext/graphviz.py
Browse files Browse the repository at this point in the history
  • Loading branch information
AA-Turner committed Dec 21, 2024
1 parent 13d25d9 commit ff8bb72
Show file tree
Hide file tree
Showing 2 changed files with 140 additions and 82 deletions.
1 change: 0 additions & 1 deletion .ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,6 @@ exclude = [
"sphinx/domains/python/_object.py",
"sphinx/domains/rst.py",
"sphinx/domains/std/__init__.py",
"sphinx/ext/graphviz.py",
"sphinx/ext/ifconfig.py",
"sphinx/ext/imgconverter.py",
"sphinx/ext/imgmath.py",
Expand Down
221 changes: 140 additions & 81 deletions sphinx/ext/graphviz.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -62,14 +61,15 @@ def __init__(self, filename: str, content: str, dot: str = '') -> None:
def parse(self, dot: str) -> None:
matched = self.maptag_re.match(self.content[0])
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)
if self.id == '%3':
# graphviz generates wrong ID if graph name not specified
# https://gitlab.com/graphviz/graphviz/issues/1327
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)

for line in self.content:
Expand All @@ -91,7 +91,9 @@ class graphviz(nodes.General, nodes.Inline, nodes.Element):
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)
if 'align' in node:
figure_node['align'] = node.attributes.pop('align')
Expand Down Expand Up @@ -131,26 +133,39 @@ def run(self) -> list[Node]:
if self.arguments:
document = self.state.document
if self.content:
return [document.reporter.warning(
__('Graphviz directive cannot have both content and '
'a filename argument'), line=self.lineno)]
return [
document.reporter.warning(
__(
'Graphviz directive cannot have both content and '
'a filename argument'
),
line=self.lineno,
)
]
argument = search_image_for_language(self.arguments[0], self.env)
rel_filename, filename = self.env.relfn2path(argument)
self.env.note_dependency(rel_filename)
try:
with open(filename, encoding='utf-8') as fp:
dotcode = fp.read()
except OSError:
return [document.reporter.warning(
__('External Graphviz file %r not found or reading '
'it failed') % filename, line=self.lineno)]
return [
document.reporter.warning(
__('External Graphviz file %r not found or reading it failed')
% filename,
line=self.lineno,
)
]
else:
dotcode = '\n'.join(self.content)
rel_filename = None
if not dotcode.strip():
return [self.state_machine.reporter.warning(
__('Ignoring "graphviz" directive without content.'),
line=self.lineno)]
return [
self.state_machine.reporter.warning(
__('Ignoring "graphviz" directive without content.'),
line=self.lineno,
)
]
node = graphviz()
node['code'] = dotcode
node['options'] = {'docname': self.env.docname}
Expand Down Expand Up @@ -198,8 +213,8 @@ class GraphvizSimple(SphinxDirective):

def run(self) -> list[Node]:
node = graphviz()
node['code'] = '%s %s {\n%s\n}\n' % \
(self.name, self.arguments[0], '\n'.join(self.content))
dot_code = '\n'.join(self.content)
node['code'] = f'{self.name} {self.arguments[0]} {{\n{dot_code}\n}}\n'
node['options'] = {'docname': self.env.docname}
if 'graphviz_dot' in self.options:
node['options']['graphviz_dot'] = self.options['graphviz_dot']
Expand All @@ -221,8 +236,9 @@ def run(self) -> list[Node]:
return [figure]


def fix_svg_relative_paths(self: HTML5Translator | LaTeXTranslator | TexinfoTranslator,
filepath: str) -> None:
def fix_svg_relative_paths(
self: HTML5Translator | LaTeXTranslator | TexinfoTranslator, filepath: str
) -> None:
"""Change relative links in generated svg files to be relative to imgpath."""
tree = ET.parse(filepath) # NoQA: S314
root = tree.getroot()
Expand All @@ -239,7 +255,7 @@ def fix_svg_relative_paths(self: HTML5Translator | LaTeXTranslator | TexinfoTran
# not a relative link
continue

docname = self.builder.env.path2doc(self.document["source"])
docname = self.builder.env.path2doc(self.document['source'])
if docname is None:
# This shouldn't happen!
continue
Expand All @@ -257,18 +273,26 @@ def fix_svg_relative_paths(self: HTML5Translator | LaTeXTranslator | TexinfoTran
tree.write(filepath)


def render_dot(self: HTML5Translator | LaTeXTranslator | TexinfoTranslator,
code: str, options: dict, format: str,
prefix: str = 'graphviz', filename: str | None = None,
) -> tuple[str | None, str | None]:
def render_dot(
self: HTML5Translator | LaTeXTranslator | TexinfoTranslator,
code: str,
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."""
graphviz_dot = options.get('graphviz_dot', self.builder.config.graphviz_dot)
if not graphviz_dot:
raise GraphvizError(
__('graphviz_dot executable path must be set! %r') % graphviz_dot,
)
hashkey = (code + str(options) + str(graphviz_dot) +
str(self.builder.config.graphviz_dot_args)).encode()
hashkey = ''.join((
code,
str(options),
str(graphviz_dot),
str(self.builder.config.graphviz_dot_args),
)).encode()

fname = f'{prefix}-{sha1(hashkey, usedforsecurity=False).hexdigest()}.{format}'
relfn = posixpath.join(self.builder.imgpath, fname)
Expand All @@ -277,8 +301,7 @@ def render_dot(self: HTML5Translator | LaTeXTranslator | TexinfoTranslator,
if os.path.isfile(outfn):
return relfn, outfn

if (hasattr(self.builder, '_graphviz_warned_dot') and
self.builder._graphviz_warned_dot.get(graphviz_dot)):
if getattr(self.builder, '_graphviz_warned_dot', {}).get(graphviz_dot):
return None, None

ensuredir(os.path.dirname(outfn))
Expand All @@ -294,40 +317,58 @@ def render_dot(self: HTML5Translator | LaTeXTranslator | TexinfoTranslator,
cwd = os.path.dirname(os.path.join(self.builder.srcdir, docname))

if format == 'png':
dot_args.extend(['-Tcmapx', '-o%s.map' % outfn])
dot_args.extend(['-Tcmapx', f'-o{outfn}.map'])

try:
ret = subprocess.run(dot_args, input=code.encode(), capture_output=True,
cwd=cwd, check=True)
ret = subprocess.run(
dot_args, input=code.encode(), capture_output=True, cwd=cwd, check=True
)
except OSError:
logger.warning(__('dot command %r cannot be run (needed for graphviz '
'output), check the graphviz_dot setting'), graphviz_dot)
logger.warning(
__(
'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'):
self.builder._graphviz_warned_dot = {} # type: ignore[union-attr]
self.builder._graphviz_warned_dot[graphviz_dot] = True
return None, None
except CalledProcessError as exc:
raise GraphvizError(__('dot exited with error:\n[stderr]\n%r\n'
'[stdout]\n%r') % (exc.stderr, exc.stdout)) from exc
raise GraphvizError(
__('dot exited with error:\n[stderr]\n%r\n[stdout]\n%r')
% (exc.stderr, exc.stdout)
) from exc
if not os.path.isfile(outfn):
raise GraphvizError(__('dot did not produce an output file:\n[stderr]\n%r\n'
'[stdout]\n%r') % (ret.stderr, ret.stdout))
raise GraphvizError(
__('dot did not produce an output file:\n[stderr]\n%r\n[stdout]\n%r')
% (ret.stderr, ret.stdout)
)

if format == 'svg':
fix_svg_relative_paths(self, outfn)

return relfn, outfn


def render_dot_html(self: HTML5Translator, node: graphviz, code: str, options: dict,
prefix: str = 'graphviz', imgcls: str | None = None,
alt: str | None = None, filename: str | None = None,
) -> tuple[str, str]:
def render_dot_html(
self: HTML5Translator,
node: graphviz,
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
try:
if format not in {'png', 'svg'}:
raise GraphvizError(__("graphviz_output_format must be one of 'png', "
"'svg', but is %r") % format)
raise GraphvizError(
__("graphviz_output_format must be one of 'png', 'svg', but is %r")
% format
)
fname, outfn = render_dot(self, code, options, format, prefix, filename)
except GraphvizError as exc:
logger.warning(__('dot code %r: %s'), code, exc)
Expand All @@ -342,44 +383,53 @@ def render_dot_html(self: HTML5Translator, node: graphviz, code: str, options: d
if alt is None:
alt = node.get('alt', self.encode(code).strip())
if 'align' in node:
self.body.append('<div align="%s" class="align-%s">' %
(node['align'], node['align']))
align = node['align']
self.body.append(f'<div align="{align}" class="align-{align}">')
if format == 'svg':
self.body.append('<div class="graphviz">')
self.body.append('<object data="%s" type="image/svg+xml" class="%s">\n' %
(fname, imgcls))
self.body.append('<p class="warning">%s</p>' % alt)
self.body.append(
f'<object data="{fname}" type="image/svg+xml" class="{imgcls}">\n'
)
self.body.append(f'<p class="warning">{alt}</p>')
self.body.append('</object></div>\n')
else:
assert outfn is not None
with open(outfn + '.map', encoding='utf-8') as mapfile:
imgmap = ClickableMapDefinition(outfn + '.map', mapfile.read(), dot=code)
if imgmap.clickable:
# has a map
self.body.append('<div class="graphviz">')
self.body.append('<img src="%s" alt="%s" usemap="#%s" class="%s" />' %
(fname, alt, imgmap.id, imgcls))
self.body.append('</div>\n')
self.body.append(imgmap.generate_clickable_map())
else:
# nothing in image map
self.body.append('<div class="graphviz">')
self.body.append('<img src="%s" alt="%s" class="%s" />' %
(fname, alt, imgcls))
self.body.append('</div>\n')
with open(f'{outfn}.map', encoding='utf-8') as mapfile:
map_content = mapfile.read()
imgmap = ClickableMapDefinition(f'{outfn}.map', map_content, dot=code)
if imgmap.clickable:
# has a map
self.body.append('<div class="graphviz">')
self.body.append(
f'<img src="{fname}" alt="{alt}" usemap="#{imgmap.id}" class="{imgcls}" />'
)
self.body.append('</div>\n')
self.body.append(imgmap.generate_clickable_map())
else:
# nothing in image map
self.body.append('<div class="graphviz">')
self.body.append(f'<img src="{fname}" alt="{alt}" class="{imgcls}" />')
self.body.append('</div>\n')
if 'align' in node:
self.body.append('</div>\n')

raise nodes.SkipNode


def html_visit_graphviz(self: HTML5Translator, node: graphviz) -> None:
render_dot_html(self, node, node['code'], node['options'], filename=node.get('filename'))


def render_dot_latex(self: LaTeXTranslator, node: graphviz, code: str,
options: dict, prefix: str = 'graphviz', filename: str | None = None,
) -> None:
render_dot_html(
self, node, node['code'], node['options'], filename=node.get('filename')
)


def render_dot_latex(
self: LaTeXTranslator,
node: graphviz,
code: str,
options: dict,
prefix: str = 'graphviz',
filename: str | None = None,
) -> None:
try:
fname, outfn = render_dot(self, code, options, 'pdf', prefix, filename)
except GraphvizError as exc:
Expand All @@ -401,22 +451,29 @@ def render_dot_latex(self: LaTeXTranslator, node: graphviz, code: str,
elif node['align'] == 'center':
pre = r'{\hfill'
post = r'\hspace*{\fill}}'
self.body.append('\n%s' % pre)
self.body.append(f'\n{pre}')

self.body.append(r'\sphinxincludegraphics[]{%s}' % fname)

if not is_inline:
self.body.append('%s\n' % post)
self.body.append(f'{post}\n')

raise nodes.SkipNode


def latex_visit_graphviz(self: LaTeXTranslator, node: graphviz) -> None:
render_dot_latex(self, node, node['code'], node['options'], filename=node.get('filename'))


def render_dot_texinfo(self: TexinfoTranslator, node: graphviz, code: str,
options: dict, prefix: str = 'graphviz') -> None:
render_dot_latex(
self, node, node['code'], node['options'], filename=node.get('filename')
)


def render_dot_texinfo(
self: TexinfoTranslator,
node: graphviz,
code: str,
options: dict,
prefix: str = 'graphviz',
) -> None:
try:
fname, outfn = render_dot(self, code, options, 'png', prefix)
except GraphvizError as exc:
Expand Down Expand Up @@ -453,12 +510,14 @@ def on_config_inited(_app: Sphinx, config: Config) -> None:


def setup(app: Sphinx) -> ExtensionMetadata:
app.add_node(graphviz,
html=(html_visit_graphviz, None),
latex=(latex_visit_graphviz, None),
texinfo=(texinfo_visit_graphviz, None),
text=(text_visit_graphviz, None),
man=(man_visit_graphviz, None))
app.add_node(
graphviz,
html=(html_visit_graphviz, None),
latex=(latex_visit_graphviz, None),
texinfo=(texinfo_visit_graphviz, None),
text=(text_visit_graphviz, None),
man=(man_visit_graphviz, None),
)
app.add_directive('graphviz', Graphviz)
app.add_directive('graph', GraphvizSimple)
app.add_directive('digraph', GraphvizSimple)
Expand Down

0 comments on commit ff8bb72

Please sign in to comment.