1"""Render DOT source files with Graphviz ``dot``."""
2
3import os
4import pathlib
5from typing import overload
6import warnings
7
8from .._defaults import DEFAULT_SOURCE_EXTENSION
9from .. import _tools
10from .. import exceptions
11from .. import parameters
12
13from . import dot_command
14from . import execute
15
16__all__ = ['get_format', 'get_filepath', 'render']
17
18DOUBLE_SUFFIXES = {f'.{fmt}' for fmt in parameters.FORMATS
19 if fmt in ('xdot1.2', 'xdot1.4')}
20
21
22def get_format(outfile: pathlib.Path, *, format: str | None) -> str:
23 """Return format inferred from outfile suffix and/or given ``format``.
24
25 Args:
26 outfile: Path for the rendered output file.
27 format: Output format for rendering (``'pdf'``, ``'png'``, ...).
28
29 Returns:
30 The given ``format`` falling back to the inferred format.
31
32 Warns:
33 graphviz.UnknownSuffixWarning: If the suffix of ``outfile``
34 is empty/unknown.
35 graphviz.FormatSuffixMismatchWarning: If the suffix of ``outfile``
36 does not match the given ``format``.
37 """
38 try:
39 inferred_format = infer_format(outfile)
40 except ValueError:
41 if format is None:
42 msg = ('cannot infer rendering format'
43 f' from suffix {outfile.suffix!r}'
44 f' of outfile: {os.fspath(outfile)!r}'
45 ' (provide format or outfile with a suffix'
46 f' from {get_supported_suffixes()!r})')
47 raise exceptions.RequiredArgumentError(msg)
48
49 warnings.warn(f'unknown outfile suffix {outfile.suffix!r}'
50 f' (expected: {"." + format!r})',
51 category=exceptions.UnknownSuffixWarning)
52 return format
53 else:
54 assert inferred_format is not None
55 if format is not None and format.lower() != inferred_format:
56 warnings.warn(f'expected format {inferred_format!r} from outfile'
57 f' differs from given format: {format!r}',
58 category=exceptions.FormatSuffixMismatchWarning)
59 return format
60
61 return inferred_format
62
63
64def get_supported_suffixes() -> list[str]:
65 """Return a sorted list of supported outfile suffixes for exception/warning messages.
66
67 >>> get_supported_suffixes() # doctest: +ELLIPSIS
68 ['.bmp', ...]
69 """
70 return [f'.{format}' for format in get_supported_formats()]
71
72
73def get_supported_formats() -> list[str]:
74 """Return a sorted list of supported formats for exception/warning messages.
75
76 >>> get_supported_formats() # doctest: +ELLIPSIS
77 ['bmp', ...]
78 """
79 return sorted(parameters.FORMATS)
80
81
82def infer_format(outfile: pathlib.Path) -> str:
83 """Return format inferred from outfile suffix.
84
85 Args:
86 outfile: Path for the rendered output file.
87
88 Returns:
89 The inferred format.
90
91 Raises:
92 ValueError: If the suffix of ``outfile`` is empty/unknown.
93
94 >>> infer_format(pathlib.Path('spam.pdf')) # doctest: +NO_EXE
95 'pdf'
96
97 >>> infer_format(pathlib.Path('spam.gv.svg'))
98 'svg'
99
100 >>> infer_format(pathlib.Path('spam.PNG'))
101 'png'
102
103 >>> infer_format(pathlib.Path('spam'))
104 Traceback (most recent call last):
105 ...
106 ValueError: cannot infer rendering format from outfile: 'spam' (missing suffix)
107
108 >>> infer_format(pathlib.Path('spam.wav')) # doctest: +ELLIPSIS +NORMALIZE_WHITESPACE
109 Traceback (most recent call last):
110 ...
111 ValueError: cannot infer rendering format from suffix '.wav' of outfile: 'spam.wav'
112 (unknown format: 'wav', provide outfile with a suffix from ['.bmp', ...])
113 """
114 if not outfile.suffix:
115 raise ValueError('cannot infer rendering format from outfile:'
116 f' {os.fspath(outfile)!r} (missing suffix)')
117
118 if (double_sfx := ''.join(outfile.suffixes[-2:]).lower()) in DOUBLE_SUFFIXES:
119 return double_sfx.removeprefix('.')
120
121 (start, sep, format_) = outfile.suffix.partition('.')
122 assert sep and not start, f"{outfile.suffix!r}.startswith('.')"
123 format_ = format_.lower()
124
125 try:
126 parameters.verify_format(format_)
127 except ValueError:
128 raise ValueError('cannot infer rendering format'
129 f' from suffix {outfile.suffix!r}'
130 f' of outfile: {os.fspath(outfile)!r}'
131 f' (unknown format: {format_!r},'
132 ' provide outfile with a suffix'
133 f' from {get_supported_suffixes()!r})')
134 return format_
135
136
137def get_outfile(filepath: os.PathLike[str] | str, *,
138 format: str,
139 renderer: str | None = None,
140 formatter: str | None = None) -> pathlib.Path:
141 """Return ``filepath`` + ``[[.formatter].renderer].format``.
142
143 See also:
144 https://www.graphviz.org/doc/info/command.html#-O
145 """
146 filepath = _tools.promote_pathlike(filepath)
147
148 parameters.verify_format(format, required=True)
149 parameters.verify_renderer(renderer, required=False)
150 parameters.verify_formatter(formatter, required=False)
151
152 suffix_args = (formatter, renderer, format)
153 suffix = '.'.join(a for a in suffix_args if a is not None)
154 return filepath.with_suffix(f'{filepath.suffix}.{suffix}')
155
156
157def get_filepath(outfile: os.PathLike[str] | str) -> pathlib.Path:
158 """Return ``outfile.with_suffix('.gv')``."""
159 outfile = _tools.promote_pathlike(outfile)
160 return outfile.with_suffix(f'.{DEFAULT_SOURCE_EXTENSION}')
161
162
163@overload
164def render(engine: str,
165 format: str,
166 filepath: os.PathLike[str] | str,
167 renderer: str | None = ...,
168 formatter: str | None = ...,
169 neato_no_op: bool | int | None = ...,
170 quiet: bool = ..., *,
171 outfile: None = ...,
172 raise_if_result_exists: bool = ...,
173 overwrite_filepath: bool = ...) -> str:
174 """Require ``format`` and ``filepath`` with default ``outfile=None``."""
175
176
177@overload
178def render(engine: str,
179 format: str | None = ...,
180 filepath: os.PathLike[str] | str | None = ...,
181 renderer: str | None = ...,
182 formatter: str | None = ...,
183 neato_no_op: bool | int | None = ...,
184 quiet: bool = False, *,
185 outfile: os.PathLike[str] | str,
186 raise_if_result_exists: bool = ...,
187 overwrite_filepath: bool = ...) -> str:
188 """Optional ``format`` and ``filepath`` with given ``outfile``."""
189
190
191@overload
192def render(engine: str,
193 format: str | None = ...,
194 filepath: os.PathLike[str] | str | None = ...,
195 renderer: str | None = ...,
196 formatter: str | None = ...,
197 neato_no_op: bool | int | None = ...,
198 quiet: bool = False, *,
199 outfile: os.PathLike[str] | str | None = ...,
200 raise_if_result_exists: bool = ...,
201 overwrite_filepath: bool = ...) -> str:
202 """Required/optional ``format`` and ``filepath`` depending on ``outfile``."""
203
204
205@_tools.deprecate_positional_args(supported_number=3)
206def render(engine: str,
207 format: str | None = None,
208 filepath: os.PathLike[str] | str | None = None,
209 renderer: str | None = None,
210 formatter: str | None = None,
211 neato_no_op: bool | int | None = None,
212 quiet: bool = False, *,
213 outfile: os.PathLike[str] | str | None = None,
214 raise_if_result_exists: bool = False,
215 overwrite_filepath: bool = False) -> str:
216 r"""Render file with ``engine`` into ``format`` and return result filename.
217
218 Args:
219 engine: Layout engine for rendering (``'dot'``, ``'neato'``, ...).
220 format: Output format for rendering (``'pdf'``, ``'png'``, ...).
221 Can be omitted if an ``outfile`` with a known ``format`` is given,
222 i.e. if ``outfile`` ends with a known ``.{format}`` suffix.
223 filepath: Path to the DOT source file to render.
224 Can be omitted if ``outfile`` is given,
225 in which case it defaults to ``outfile.with_suffix('.gv')``.
226 renderer: Output renderer (``'cairo'``, ``'gd'``, ...).
227 formatter: Output formatter (``'cairo'``, ``'gd'``, ...).
228 neato_no_op: Neato layout engine no-op flag.
229 quiet: Suppress ``stderr`` output from the layout subprocess.
230 outfile: Path for the rendered output file.
231 raise_if_result_exists: Raise :exc:`graphviz.FileExistsError`
232 if the result file exists.
233 overwrite_filepath: Allow ``dot`` to write to the file it reads from.
234 Incompatible with ``raise_if_result_exists``.
235
236 Returns:
237 The (possibly relative) path of the rendered file.
238
239 Raises:
240 ValueError: If ``engine``, ``format``, ``renderer``, or ``formatter``
241 are unknown.
242 graphviz.RequiredArgumentError: If ``format`` or ``filepath`` are None
243 unless ``outfile`` is given.
244 graphviz.RequiredArgumentError: If ``formatter`` is given
245 but ``renderer`` is None.
246 ValueError: If ``outfile`` and ``filename`` are the same file
247 unless ``overwite_filepath=True``.
248 graphviz.ExecutableNotFound: If the Graphviz ``dot`` executable
249 is not found.
250 graphviz.CalledProcessError: If the returncode (exit status)
251 of the rendering ``dot`` subprocess is non-zero.
252 graphviz.FileExistsError: If ``raise_if_exists``
253 and the result file exists.
254
255 Warns:
256 graphviz.UnknownSuffixWarning: If the suffix of ``outfile``
257 is empty or unknown.
258 graphviz.FormatSuffixMismatchWarning: If the suffix of ``outfile``
259 does not match the given ``format``.
260
261 Example:
262 >>> doctest_mark_exe()
263 >>> import pathlib
264 >>> import graphviz
265 >>> assert pathlib.Path('doctest-output/spam.gv').write_text('graph { spam }') == 14
266 >>> graphviz.render('dot', 'png', 'doctest-output/spam.gv').replace('\\', '/')
267 'doctest-output/spam.gv.png'
268 >>> graphviz.render('dot', filepath='doctest-output/spam.gv',
269 ... outfile='doctest-output/spam.png').replace('\\', '/')
270 'doctest-output/spam.png'
271 >>> graphviz.render('dot', outfile='doctest-output/spam.pdf').replace('\\', '/')
272 'doctest-output/spam.pdf'
273
274 Note:
275 The layout command is started from the directory of ``filepath``,
276 so that references to external files
277 (e.g. ``[image=images/camelot.png]``)
278 can be given as paths relative to the DOT source file.
279
280 See also:
281 Upstream docs: https://www.graphviz.org/doc/info/command.html
282 """
283 if raise_if_result_exists and overwrite_filepath:
284 raise ValueError('overwrite_filepath cannot be combined'
285 ' with raise_if_result_exists')
286
287 (filepath, outfile) = map(_tools.promote_pathlike, (filepath, outfile))
288
289 if outfile is not None:
290 format = get_format(outfile, format=format)
291
292 if filepath is None:
293 filepath = get_filepath(outfile)
294
295 if (not overwrite_filepath and outfile.name == filepath.name
296 and outfile.resolve() == filepath.resolve()): # noqa: E129
297 raise ValueError(f'outfile {outfile.name!r} must be different'
298 f' from input file {filepath.name!r}'
299 ' (pass overwrite_filepath=True to override)')
300
301 outfile_arg = (outfile.resolve() if outfile.parent != filepath.parent
302 else outfile.name)
303
304 # https://www.graphviz.org/doc/info/command.html#-o
305 args = ['-o', outfile_arg, filepath.name]
306 elif filepath is None:
307 raise exceptions.RequiredArgumentError('filepath: (required if outfile is not given,'
308 f' got {filepath!r})')
309 elif format is None:
310 raise exceptions.RequiredArgumentError('format: (required if outfile is not given,'
311 f' got {format!r})')
312 else:
313 outfile = get_outfile(filepath,
314 format=format,
315 renderer=renderer,
316 formatter=formatter)
317 # https://www.graphviz.org/doc/info/command.html#-O
318 args = ['-O', filepath.name]
319
320 cmd = dot_command.command(engine, format,
321 renderer=renderer,
322 formatter=formatter,
323 neato_no_op=neato_no_op)
324
325 if raise_if_result_exists and os.path.exists(outfile):
326 raise exceptions.FileExistsError(f'output file exists: {os.fspath(outfile)!r}')
327
328 cmd += args
329
330 execute.run_check(cmd,
331 cwd=filepath.parent if filepath.parent.parts else None,
332 quiet=quiet,
333 capture_output=True)
334
335 return os.fspath(outfile)