structure saas with tools

This commit is contained in:
Davidson Gomes
2025-04-25 15:30:54 -03:00
commit 1aef473937
16434 changed files with 6584257 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
# graphviz - create dot, save, render, view
"""Assemble DOT source code and render it with Graphviz.
Example:
>>> import graphviz # doctest: +NO_EXE
>>> dot = graphviz.Digraph(comment='The Round Table')
>>> dot.node('A', 'King Arthur')
>>> dot.node('B', 'Sir Bedevere the Wise')
>>> dot.node('L', 'Sir Lancelot the Brave')
>>> dot.edges(['AB', 'AL'])
>>> dot.edge('B', 'L', constraint='false')
>>> print(dot) #doctest: +NORMALIZE_WHITESPACE
// The Round Table
digraph {
A [label="King Arthur"]
B [label="Sir Bedevere the Wise"]
L [label="Sir Lancelot the Brave"]
A -> B
A -> L
B -> L [constraint=false]
}
"""
from ._defaults import set_default_engine, set_default_format, set_jupyter_format
from .backend import (DOT_BINARY, UNFLATTEN_BINARY,
render, pipe, pipe_string, pipe_lines, pipe_lines_string,
unflatten, version, view)
from .exceptions import (ExecutableNotFound, CalledProcessError,
RequiredArgumentError, FileExistsError,
UnknownSuffixWarning, FormatSuffixMismatchWarning,
DotSyntaxWarning)
from .graphs import Graph, Digraph
from .jupyter_integration import SUPPORTED_JUPYTER_FORMATS
from .parameters import ENGINES, FORMATS, RENDERERS, FORMATTERS
from .quoting import escape, nohtml
from .sources import Source
__all__ = ['ENGINES', 'FORMATS', 'RENDERERS', 'FORMATTERS',
'DOT_BINARY', 'UNFLATTEN_BINARY',
'SUPPORTED_JUPYTER_FORMATS',
'Graph', 'Digraph',
'Source',
'escape', 'nohtml',
'render', 'pipe', 'pipe_string', 'pipe_lines', 'pipe_lines_string',
'unflatten', 'version', 'view',
'ExecutableNotFound', 'CalledProcessError',
'RequiredArgumentError', 'FileExistsError',
'UnknownSuffixWarning', 'FormatSuffixMismatchWarning',
'DotSyntaxWarning',
'set_default_engine', 'set_default_format', 'set_jupyter_format']
__title__ = 'graphviz'
__version__ = '0.20.3'
__author__ = 'Sebastian Bank <sebastian.bank@uni-leipzig.de>'
__license__ = 'MIT, see LICENSE.txt'
__copyright__ = 'Copyright (c) 2013-2024 Sebastian Bank'
ENGINES = ENGINES
""":class:`set` of known layout commands used for rendering
(``'dot'``, ``'neato'``, ...)."""
FORMATS = FORMATS
""":class:`set` of known output formats for rendering
(``'pdf'``, ``'png'``, ...)."""
RENDERERS = RENDERERS
""":class:`set` of known output renderers for rendering
(``'cairo'``, ``'gd'``, ...)."""
FORMATTERS = FORMATTERS
""":class:`set` of known output formatters for rendering
(``'cairo'``, ``'gd'``, ...)."""
SUPPORTED_JUPYTER_FORMATS = SUPPORTED_JUPYTER_FORMATS
""":class:`set` of supported formats for ``_repr_mimebundle_()``
(``'svg'``, ``'png'``, ...)."""
DOT_BINARY = DOT_BINARY
""":class:`pathlib.Path` of rendering command (``Path('dot')``)."""
UNFLATTEN_BINARY = UNFLATTEN_BINARY
""":class:`pathlib.Path` of unflatten command (``Path('unflatten')``)."""
ExecutableNotFound = ExecutableNotFound
CalledProcessError = CalledProcessError
RequiredArgumentError = RequiredArgumentError
FileExistsError = FileExistsError
UnknownSuffixWarning = UnknownSuffixWarning
FormatSuffixMismatchWarning = FormatSuffixMismatchWarning
DotSyntaxWarning = DotSyntaxWarning

View File

@@ -0,0 +1,33 @@
"""Python 3.8 compatibility and platform compatibility."""
import platform
import sys
if sys.version_info < (3, 9): # pragma: no cover
# pytype not supported
import unittest.mock
Literal = unittest.mock.MagicMock(name='Literal')
else: # pragma: no cover
from typing import Literal
Literal = Literal # CAVEAT: use None instead of Literal[None]
def get_startupinfo() -> None:
"""Return None for startupinfo argument of ``subprocess.Popen``."""
return None
assert get_startupinfo() is None, 'get_startupinfo() defaults to a no-op'
if platform.system() == 'Windows': # pragma: no cover
import subprocess
def get_startupinfo() -> subprocess.STARTUPINFO: # pytype: disable=module-attr
"""Return subprocess.STARTUPINFO instance hiding the console window."""
startupinfo = subprocess.STARTUPINFO() # pytype: disable=module-attr
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # pytype: disable=module-attr
startupinfo.wShowWindow = subprocess.SW_HIDE # pytype: disable=module-attr
return startupinfo

View File

@@ -0,0 +1,70 @@
"""Set package-wide default parameters and IPython/Jupyter display format."""
__all_ = ['DEFAULT_SOURCE_EXTENSION',
'set_default_engine', 'set_default_format', 'set_jupyter_format']
DEFAULT_SOURCE_EXTENSION = 'gv'
def set_default_engine(engine: str) -> str:
"""Change the default ``engine`` and return the old default value.
Args:
engine: new default ``engine``
used by all present and newly created instances
without explicitly set ``engine``
(``'dot'``, ``'neato'``, ...).
Returns:
The old default value used for ``engine``.
"""
from . import parameters
parameters.verify_engine(engine)
old_default_engine = parameters.Parameters._engine
parameters.Parameters._engine = engine
return old_default_engine
def set_default_format(format: str) -> str:
"""Change the default ``format`` and return the old default value.
Args:
format: new default ``format``
used by all present and newly created instances
without explicitly set ``format``
(``'pdf'``, ``'png'``, ...).
Returns:
The old default value used for ``format``.
"""
from . import parameters
parameters.verify_format(format)
old_default_format = parameters.Parameters._format
parameters.Parameters._format = format
return old_default_format
def set_jupyter_format(jupyter_format: str) -> str:
"""Change the default mimetype format for ``_repr_mimebundle_()`` and return the old value.
Args:
jupyter_format: new default IPython/Jupyter display format
used by all present and newly created instances
(``'svg'``, ``'png'``, ...).
Returns:
The old default value used for IPython/Jupyter display format.
"""
from . import jupyter_integration
mimetype = jupyter_integration.get_jupyter_format_mimetype(jupyter_format)
old_mimetype = jupyter_integration.JupyterIntegration._jupyter_mimetype
old_format = jupyter_integration.get_jupyter_mimetype_format(old_mimetype)
jupyter_integration.JupyterIntegration._jupyter_mimetype = mimetype
return old_format

View File

@@ -0,0 +1,175 @@
"""Generic re-useable self-contained helper functions."""
import functools
import inspect
import itertools
import logging
import os
import pathlib
import typing
import warnings
__all__ = ['attach',
'mkdirs',
'mapping_items',
'promote_pathlike',
'promote_pathlike_directory',
'deprecate_positional_args']
log = logging.getLogger(__name__)
def attach(object: typing.Any, /, name: str) -> typing.Callable:
"""Return a decorator doing ``setattr(object, name)`` with its argument.
>>> spam = type('Spam', (object,), {})() # doctest: +NO_EXE
>>> @attach(spam, 'eggs')
... def func():
... pass
>>> spam.eggs # doctest: +ELLIPSIS
<function func at 0x...>
"""
def decorator(func):
setattr(object, name, func)
return func
return decorator
def mkdirs(filename: typing.Union[os.PathLike, str], /, *, mode: int = 0o777) -> None:
"""Recursively create directories up to the path of ``filename``
as needed."""
dirname = os.path.dirname(filename)
if not dirname:
return
log.debug('os.makedirs(%r)', dirname)
os.makedirs(dirname, mode=mode, exist_ok=True)
def mapping_items(mapping, /):
"""Return an iterator over the ``mapping`` items,
sort if it's a plain dict.
>>> list(mapping_items({'spam': 0, 'ham': 1, 'eggs': 2})) # doctest: +NO_EXE
[('eggs', 2), ('ham', 1), ('spam', 0)]
>>> from collections import OrderedDict
>>> list(mapping_items(OrderedDict(enumerate(['spam', 'ham', 'eggs']))))
[(0, 'spam'), (1, 'ham'), (2, 'eggs')]
"""
result = iter(mapping.items())
if type(mapping) is dict:
result = iter(sorted(result))
return result
@typing.overload
def promote_pathlike(filepath: typing.Union[os.PathLike, str], /) -> pathlib.Path:
"""Return path object for path-like-object."""
@typing.overload
def promote_pathlike(filepath: None, /) -> None:
"""Return None for None."""
@typing.overload
def promote_pathlike(filepath: typing.Union[os.PathLike, str, None], /,
) -> typing.Optional[pathlib.Path]:
"""Return path object or ``None`` depending on ``filepath``."""
def promote_pathlike(filepath: typing.Union[os.PathLike, str, None]
) -> typing.Optional[pathlib.Path]:
"""Return path-like object ``filepath`` promoted into a path object.
See also:
https://docs.python.org/3/glossary.html#term-path-like-object
"""
return pathlib.Path(filepath) if filepath is not None else None
def promote_pathlike_directory(directory: typing.Union[os.PathLike, str, None], /, *,
default: typing.Union[os.PathLike, str, None] = None,
) -> pathlib.Path:
"""Return path-like object ``directory`` promoted into a path object (default to ``os.curdir``).
See also:
https://docs.python.org/3/glossary.html#term-path-like-object
"""
return pathlib.Path(directory if directory is not None
else default or os.curdir)
def deprecate_positional_args(*,
supported_number: int,
category: typing.Type[Warning] = PendingDeprecationWarning,
stacklevel: int = 1):
"""Mark supported_number of positional arguments as the maximum.
Args:
supported_number: Number of positional arguments
for which no warning is raised.
category: Type of Warning to raise
or None to return a nulldecorator
returning the undecorated function.
stacklevel: See :func:`warning.warn`.
Returns:
Return a decorator raising a category warning
on more than supported_number positional args.
See also:
https://docs.python.org/3/library/exceptions.html#FutureWarning
https://docs.python.org/3/library/exceptions.html#DeprecationWarning
https://docs.python.org/3/library/exceptions.html#PendingDeprecationWarning
"""
assert supported_number > 0, f'supported_number at least one: {supported_number!r}'
if category is None:
def nulldecorator(func):
"""Return the undecorated function."""
return func
return nulldecorator
assert issubclass(category, Warning)
stacklevel += 1
def decorator(func):
signature = inspect.signature(func)
argnames = [name for name, param in signature.parameters.items()
if param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD]
log.debug('deprecate positional args: %s.%s(%r)',
func.__module__, func.__qualname__,
argnames[supported_number:])
@functools.wraps(func)
def wrapper(*args, **kwargs):
if len(args) > supported_number:
call_args = zip(argnames, args)
supported = itertools.islice(call_args, supported_number)
supported = dict(supported)
deprecated = dict(call_args)
assert deprecated
func_name = func.__name__.lstrip('_')
func_name, sep, rest = func_name.partition('_legacy')
assert not set or not rest
wanted = ', '.join(f'{name}={value!r}'
for name, value in deprecated.items())
warnings.warn(f'The signature of {func.__name__} will be reduced'
f' to {supported_number} positional args'
f' {list(supported)}: pass {wanted}'
' as keyword arg(s)',
stacklevel=stacklevel,
category=category)
return func(*args, **kwargs)
return wrapper
return decorator

View File

@@ -0,0 +1,20 @@
"""Execute rendering and unflattening subprocesses, open files in viewer."""
from .dot_command import DOT_BINARY
from .execute import ExecutableNotFound, CalledProcessError
from .mixins import Render, Pipe, Unflatten, View
from .piping import pipe, pipe_string, pipe_lines, pipe_lines_string
from .rendering import render
from .unflattening import UNFLATTEN_BINARY, unflatten
from .upstream_version import version
from .viewing import view
__all__ = ['DOT_BINARY', 'UNFLATTEN_BINARY',
'render',
'pipe', 'pipe_string',
'pipe_lines', 'pipe_lines_string',
'unflatten',
'version',
'view',
'ExecutableNotFound', 'CalledProcessError',
'Render', 'Pipe', 'Unflatten', 'View']

View File

@@ -0,0 +1,44 @@
"""Check and assemble commands for running Graphviz ``dot``."""
import os
import pathlib
import typing
from .. import exceptions
from .. import parameters
__all__ = ['DOT_BINARY', 'command']
DOT_BINARY = pathlib.Path('dot')
def command(engine: str, format_: str, *,
renderer: typing.Optional[str] = None,
formatter: typing.Optional[str] = None,
neato_no_op: typing.Union[bool, int, None] = None
) -> typing.List[typing.Union[os.PathLike, str]]:
"""Return ``subprocess.Popen`` argument list for rendering.
See also:
Upstream documentation:
- https://www.graphviz.org/doc/info/command.html#-K
- https://www.graphviz.org/doc/info/command.html#-T
- https://www.graphviz.org/doc/info/command.html#-n
"""
if formatter is not None and renderer is None:
raise exceptions.RequiredArgumentError('formatter given without renderer')
parameters.verify_engine(engine, required=True)
parameters.verify_format(format_, required=True)
parameters.verify_renderer(renderer, required=False)
parameters.verify_formatter(formatter, required=False)
output_format = [f for f in (format_, renderer, formatter) if f is not None]
output_format_flag = ':'.join(output_format)
cmd = [DOT_BINARY, f'-K{engine}', f'-T{output_format_flag}']
if neato_no_op:
cmd.append(f'-n{neato_no_op:d}')
return cmd

View File

@@ -0,0 +1,132 @@
"""Run subprocesses with ``subprocess.run()`` and ``subprocess.Popen()``."""
import errno
import logging
import os
import subprocess
import sys
import typing
from .. import _compat
__all__ = ['run_check', 'ExecutableNotFound', 'CalledProcessError']
log = logging.getLogger(__name__)
BytesOrStrIterator = typing.Union[typing.Iterator[bytes],
typing.Iterator[str]]
@typing.overload
def run_check(cmd: typing.Sequence[typing.Union[os.PathLike, str]], *,
input_lines: typing.Optional[typing.Iterator[bytes]] = ...,
encoding: None = ...,
quiet: bool = ...,
**kwargs) -> subprocess.CompletedProcess:
"""Accept bytes input_lines with default ``encoding=None```."""
@typing.overload
def run_check(cmd: typing.Sequence[typing.Union[os.PathLike, str]], *,
input_lines: typing.Optional[typing.Iterator[str]] = ...,
encoding: str,
quiet: bool = ...,
**kwargs) -> subprocess.CompletedProcess:
"""Accept string input_lines when given ``encoding``."""
@typing.overload
def run_check(cmd: typing.Sequence[typing.Union[os.PathLike, str]], *,
input_lines: typing.Optional[BytesOrStrIterator] = ...,
encoding: typing.Optional[str] = ...,
capture_output: bool = ...,
quiet: bool = ...,
**kwargs) -> subprocess.CompletedProcess:
"""Accept bytes or string input_lines depending on ``encoding``."""
def run_check(cmd: typing.Sequence[typing.Union[os.PathLike, str]], *,
input_lines: typing.Optional[BytesOrStrIterator] = None,
encoding: typing.Optional[str] = None,
quiet: bool = False,
**kwargs) -> subprocess.CompletedProcess:
"""Run the command described by ``cmd``
with ``check=True`` and return its completed process.
Raises:
CalledProcessError: if the returncode of the subprocess is non-zero.
"""
log.debug('run %r', cmd)
if not kwargs.pop('check', True): # pragma: no cover
raise NotImplementedError('check must be True or omited')
if encoding is not None:
kwargs['encoding'] = encoding
kwargs.setdefault('startupinfo', _compat.get_startupinfo())
try:
if input_lines is not None:
assert kwargs.get('input') is None
assert iter(input_lines) is input_lines
if kwargs.pop('capture_output'):
kwargs['stdout'] = kwargs['stderr'] = subprocess.PIPE
proc = _run_input_lines(cmd, input_lines, kwargs=kwargs)
else:
proc = subprocess.run(cmd, **kwargs)
except OSError as e:
if e.errno == errno.ENOENT:
raise ExecutableNotFound(cmd) from e
raise
if not quiet and proc.stderr:
_write_stderr(proc.stderr)
try:
proc.check_returncode()
except subprocess.CalledProcessError as e:
raise CalledProcessError(*e.args)
return proc
def _run_input_lines(cmd, input_lines, *, kwargs):
popen = subprocess.Popen(cmd, stdin=subprocess.PIPE, **kwargs)
stdin_write = popen.stdin.write
for line in input_lines:
stdin_write(line)
stdout, stderr = popen.communicate()
return subprocess.CompletedProcess(popen.args, popen.returncode,
stdout=stdout, stderr=stderr)
def _write_stderr(stderr) -> None:
if isinstance(stderr, bytes):
stderr_encoding = (getattr(sys.stderr, 'encoding', None)
or sys.getdefaultencoding())
stderr = stderr.decode(stderr_encoding)
sys.stderr.write(stderr)
sys.stderr.flush()
return None
class ExecutableNotFound(RuntimeError):
""":exc:`RuntimeError` raised if the Graphviz executable is not found."""
_msg = ('failed to execute {!r}, '
'make sure the Graphviz executables are on your systems\' PATH')
def __init__(self, args) -> None:
super().__init__(self._msg.format(*args))
class CalledProcessError(subprocess.CalledProcessError):
""":exc:`~subprocess.CalledProcessError` raised if a subprocess ``returncode`` is not ``0``.""" # noqa: E501
def __str__(self) -> 'str':
return f'{super().__str__()} [stderr: {self.stderr!r}]'

View File

@@ -0,0 +1,76 @@
"""Mixin classes used by Base subclasses to inherit backend functionality."""
import os
import typing
from .. import parameters
from . import piping
from . import rendering
from . import unflattening
from . import viewing
__all__ = ['Render', 'Pipe', 'Unflatten', 'View']
class Render(parameters.Parameters):
"""Parameters for calling and calling ``graphviz.render()``."""
def _get_render_parameters(self,
outfile: typing.Union[os.PathLike, str, None] = None,
raise_if_result_exists: bool = False,
overwrite_source: bool = False,
**kwargs):
kwargs = self._get_parameters(**kwargs)
kwargs.update(outfile=outfile,
raise_if_result_exists=raise_if_result_exists,
overwrite_filepath=overwrite_source)
return [kwargs.pop('engine'), kwargs.pop('format')], kwargs
@property
def _render(_): # noqa: N805
"""Simplify ``._render()`` mocking."""
return rendering.render
class Pipe(parameters.Parameters):
"""Parameters for calling and calling ``graphviz.pipe()``."""
_get_format = staticmethod(rendering.get_format)
_get_filepath = staticmethod(rendering.get_filepath)
def _get_pipe_parameters(self, **kwargs):
kwargs = self._get_parameters(**kwargs)
return [kwargs.pop('engine'), kwargs.pop('format')], kwargs
@property
def _pipe_lines(_): # noqa: N805
"""Simplify ``._pipe_lines()`` mocking."""
return piping.pipe_lines
@property
def _pipe_lines_string(_): # noqa: N805
"""Simplify ``._pipe_lines_string()`` mocking."""
return piping.pipe_lines_string
class Unflatten:
@property
def _unflatten(_): # noqa: N805
"""Simplify ``._unflatten mocking."""
return unflattening.unflatten
class View:
"""Open filepath with its default viewing application
(platform-specific)."""
_view_darwin = staticmethod(viewing.view_darwin)
_view_freebsd = staticmethod(viewing.view_unixoid)
_view_linux = staticmethod(viewing.view_unixoid)
_view_windows = staticmethod(viewing.view_windows)

View File

@@ -0,0 +1,213 @@
"""Pipe bytes, strings, or string iterators through Graphviz ``dot``."""
import typing
from .. import _tools
from . import dot_command
from . import execute
__all__ = ['pipe', 'pipe_string',
'pipe_lines', 'pipe_lines_string']
@_tools.deprecate_positional_args(supported_number=3)
def pipe(engine: str, format: str, data: bytes,
renderer: typing.Optional[str] = None,
formatter: typing.Optional[str] = None,
neato_no_op: typing.Union[bool, int, None] = None,
quiet: bool = False) -> bytes:
"""Return ``data`` (``bytes``) piped through ``engine`` into ``format`` as ``bytes``.
Args:
engine: Layout engine for rendering (``'dot'``, ``'neato'``, ...).
format: Output format for rendering (``'pdf'``, ``'png'``, ...).
data: Binary (encoded) DOT source bytes to render.
renderer: Output renderer (``'cairo'``, ``'gd'``, ...).
formatter: Output formatter (``'cairo'``, ``'gd'``, ...).
neato_no_op: Neato layout engine no-op flag.
quiet: Suppress ``stderr`` output from the layout subprocess.
Returns:
Binary (encoded) stdout of the layout command.
Raises:
ValueError: If ``engine``, ``format``, ``renderer``, or ``formatter``
are unknown.
graphviz.RequiredArgumentError: If ``formatter`` is given
but ``renderer`` is None.
graphviz.ExecutableNotFound: If the Graphviz ``dot`` executable
is not found.
graphviz.CalledProcessError: If the returncode (exit status)
of the rendering ``dot`` subprocess is non-zero.
Example:
>>> doctest_mark_exe()
>>> import graphviz
>>> graphviz.pipe('dot', 'svg', b'graph { hello -- world }')[:14]
b'<?xml version='
Note:
The layout command is started from the current directory.
"""
cmd = dot_command.command(engine, format,
renderer=renderer,
formatter=formatter,
neato_no_op=neato_no_op)
kwargs = {'input': data}
proc = execute.run_check(cmd, capture_output=True, quiet=quiet, **kwargs)
return proc.stdout
def pipe_string(engine: str, format: str, input_string: str, *,
encoding: str,
renderer: typing.Optional[str] = None,
formatter: typing.Optional[str] = None,
neato_no_op: typing.Union[bool, int, None] = None,
quiet: bool = False) -> str:
"""Return ``input_string`` piped through ``engine`` into ``format`` as string.
Args:
engine: Layout engine for rendering (``'dot'``, ``'neato'``, ...).
format: Output format for rendering (``'pdf'``, ``'png'``, ...).
input_string: Binary (encoded) DOT source bytes to render.
encoding: Encoding to en/decode subprocess stdin and stdout (required).
renderer: Output renderer (``'cairo'``, ``'gd'``, ...).
formatter: Output formatter (``'cairo'``, ``'gd'``, ...).
neato_no_op: Neato layout engine no-op flag.
quiet: Suppress ``stderr`` output from the layout subprocess.
Returns:
Decoded stdout of the layout command.
Raises:
ValueError: If ``engine``, ``format``, ``renderer``, or ``formatter``
are unknown.
graphviz.RequiredArgumentError: If ``formatter`` is given
but ``renderer`` is None.
graphviz.ExecutableNotFound: If the Graphviz ``dot`` executable
is not found.
graphviz.CalledProcessError: If the returncode (exit status)
of the rendering ``dot`` subprocess is non-zero.
Example:
>>> doctest_mark_exe()
>>> import graphviz
>>> graphviz.pipe_string('dot', 'svg', 'graph { spam }',
... encoding='ascii')[:14]
'<?xml version='
Note:
The layout command is started from the current directory.
"""
cmd = dot_command.command(engine, format,
renderer=renderer,
formatter=formatter,
neato_no_op=neato_no_op)
kwargs = {'input': input_string, 'encoding': encoding}
proc = execute.run_check(cmd, capture_output=True, quiet=quiet, **kwargs)
return proc.stdout
def pipe_lines(engine: str, format: str, input_lines: typing.Iterator[str], *,
input_encoding: str,
renderer: typing.Optional[str] = None,
formatter: typing.Optional[str] = None,
neato_no_op: typing.Union[bool, int, None] = None,
quiet: bool = False) -> bytes:
r"""Return ``input_lines`` piped through ``engine`` into ``format`` as ``bytes``.
Args:
engine: Layout engine for rendering (``'dot'``, ``'neato'``, ...).
format: Output format for rendering (``'pdf'``, ``'png'``, ...).
input_lines: DOT source lines to render (including final newline).
input_encoding: Encode input_lines for subprocess stdin (required).
renderer: Output renderer (``'cairo'``, ``'gd'``, ...).
formatter: Output formatter (``'cairo'``, ``'gd'``, ...).
neato_no_op: Neato layout engine no-op flag.
quiet: Suppress ``stderr`` output from the layout subprocess.
Returns:
Binary stdout of the layout command.
Raises:
ValueError: If ``engine``, ``format``, ``renderer``, or ``formatter``
are unknown.
graphviz.RequiredArgumentError: If ``formatter`` is given
but ``renderer`` is None.
graphviz.ExecutableNotFound: If the Graphviz ``dot`` executable
is not found.
graphviz.CalledProcessError: If the returncode (exit status)
of the rendering ``dot`` subprocess is non-zero.
Example:
>>> doctest_mark_exe()
>>> import graphviz
>>> graphviz.pipe_lines('dot', 'svg', iter(['graph { spam }\n']),
... input_encoding='ascii')[:14]
b'<?xml version='
Note:
The layout command is started from the current directory.
"""
cmd = dot_command.command(engine, format,
renderer=renderer,
formatter=formatter,
neato_no_op=neato_no_op)
kwargs = {'input_lines': (line.encode(input_encoding) for line in input_lines)}
proc = execute.run_check(cmd, capture_output=True, quiet=quiet, **kwargs)
return proc.stdout
def pipe_lines_string(engine: str, format: str, input_lines: typing.Iterator[str], *,
encoding: str,
renderer: typing.Optional[str] = None,
formatter: typing.Optional[str] = None,
neato_no_op: typing.Union[bool, int, None] = None,
quiet: bool = False) -> str:
r"""Return ``input_lines`` piped through ``engine`` into ``format`` as string.
Args:
engine: Layout engine for rendering (``'dot'``, ``'neato'``, ...).
format: Output format for rendering (``'pdf'``, ``'png'``, ...).
input_lines: DOT source lines to render (including final newline).
encoding: Encoding to en/decode subprocess stdin and stdout (required).
renderer: Output renderer (``'cairo'``, ``'gd'``, ...).
formatter: Output formatter (``'cairo'``, ``'gd'``, ...).
neato_no_op: Neato layout engine no-op flag.
quiet: Suppress ``stderr`` output from the layout subprocess.
Returns:
Decoded stdout of the layout command.
Raises:
ValueError: If ``engine``, ``format``, ``renderer``, or ``formatter``
are unknown.
graphviz.RequiredArgumentError: If ``formatter`` is given
but ``renderer`` is None.
graphviz.ExecutableNotFound: If the Graphviz ``dot`` executable
is not found.
graphviz.CalledProcessError: If the returncode (exit status)
of the rendering ``dot`` subprocess is non-zero.
Example:
>>> doctest_mark_exe()
>>> import graphviz
>>> graphviz.pipe_lines_string('dot', 'svg', iter(['graph { spam }\n']),
... encoding='ascii')[:14]
'<?xml version='
Note:
The layout command is started from the current directory.
"""
cmd = dot_command.command(engine, format,
renderer=renderer,
formatter=formatter,
neato_no_op=neato_no_op)
kwargs = {'input_lines': input_lines, 'encoding': encoding}
proc = execute.run_check(cmd, capture_output=True, quiet=quiet, **kwargs)
return proc.stdout

View File

@@ -0,0 +1,331 @@
"""Render DOT source files with Graphviz ``dot``."""
import os
import pathlib
import typing
import warnings
from .._defaults import DEFAULT_SOURCE_EXTENSION
from .. import _tools
from .. import exceptions
from .. import parameters
from . import dot_command
from . import execute
__all__ = ['get_format', 'get_filepath', 'render']
def get_format(outfile: pathlib.Path, *, format: typing.Optional[str]) -> str:
"""Return format inferred from outfile suffix and/or given ``format``.
Args:
outfile: Path for the rendered output file.
format: Output format for rendering (``'pdf'``, ``'png'``, ...).
Returns:
The given ``format`` falling back to the inferred format.
Warns:
graphviz.UnknownSuffixWarning: If the suffix of ``outfile``
is empty/unknown.
graphviz.FormatSuffixMismatchWarning: If the suffix of ``outfile``
does not match the given ``format``.
"""
try:
inferred_format = infer_format(outfile)
except ValueError:
if format is None:
msg = ('cannot infer rendering format'
f' from suffix {outfile.suffix!r}'
f' of outfile: {os.fspath(outfile)!r}'
' (provide format or outfile with a suffix'
f' from {get_supported_suffixes()!r})')
raise exceptions.RequiredArgumentError(msg)
warnings.warn(f'unknown outfile suffix {outfile.suffix!r}'
f' (expected: {"." + format!r})',
category=exceptions.UnknownSuffixWarning)
return format
else:
assert inferred_format is not None
if format is not None and format.lower() != inferred_format:
warnings.warn(f'expected format {inferred_format!r} from outfile'
f' differs from given format: {format!r}',
category=exceptions.FormatSuffixMismatchWarning)
return format
return inferred_format
def get_supported_suffixes() -> typing.List[str]:
"""Return a sorted list of supported outfile suffixes for exception/warning messages.
>>> get_supported_suffixes() # doctest: +ELLIPSIS
['.bmp', ...]
"""
return [f'.{format}' for format in get_supported_formats()]
def get_supported_formats() -> typing.List[str]:
"""Return a sorted list of supported formats for exception/warning messages.
>>> get_supported_formats() # doctest: +ELLIPSIS
['bmp', ...]
"""
return sorted(parameters.FORMATS)
def infer_format(outfile: pathlib.Path) -> str:
"""Return format inferred from outfile suffix.
Args:
outfile: Path for the rendered output file.
Returns:
The inferred format.
Raises:
ValueError: If the suffix of ``outfile`` is empty/unknown.
>>> infer_format(pathlib.Path('spam.pdf')) # doctest: +NO_EXE
'pdf'
>>> infer_format(pathlib.Path('spam.gv.svg'))
'svg'
>>> infer_format(pathlib.Path('spam.PNG'))
'png'
>>> infer_format(pathlib.Path('spam'))
Traceback (most recent call last):
...
ValueError: cannot infer rendering format from outfile: 'spam' (missing suffix)
>>> infer_format(pathlib.Path('spam.wav')) # doctest: +ELLIPSIS +NORMALIZE_WHITESPACE
Traceback (most recent call last):
...
ValueError: cannot infer rendering format from suffix '.wav' of outfile: 'spam.wav'
(unknown format: 'wav', provide outfile with a suffix from ['.bmp', ...])
"""
if not outfile.suffix:
raise ValueError('cannot infer rendering format from outfile:'
f' {os.fspath(outfile)!r} (missing suffix)')
start, sep, format_ = outfile.suffix.partition('.')
assert sep and not start, f"{outfile.suffix!r}.startswith('.')"
format_ = format_.lower()
try:
parameters.verify_format(format_)
except ValueError:
raise ValueError('cannot infer rendering format'
f' from suffix {outfile.suffix!r}'
f' of outfile: {os.fspath(outfile)!r}'
f' (unknown format: {format_!r},'
' provide outfile with a suffix'
f' from {get_supported_suffixes()!r})')
return format_
def get_outfile(filepath: typing.Union[os.PathLike, str], *,
format: str,
renderer: typing.Optional[str] = None,
formatter: typing.Optional[str] = None) -> pathlib.Path:
"""Return ``filepath`` + ``[[.formatter].renderer].format``.
See also:
https://www.graphviz.org/doc/info/command.html#-O
"""
filepath = _tools.promote_pathlike(filepath)
parameters.verify_format(format, required=True)
parameters.verify_renderer(renderer, required=False)
parameters.verify_formatter(formatter, required=False)
suffix_args = (formatter, renderer, format)
suffix = '.'.join(a for a in suffix_args if a is not None)
return filepath.with_suffix(f'{filepath.suffix}.{suffix}')
def get_filepath(outfile: typing.Union[os.PathLike, str]) -> pathlib.Path:
"""Return ``outfile.with_suffix('.gv')``."""
outfile = _tools.promote_pathlike(outfile)
return outfile.with_suffix(f'.{DEFAULT_SOURCE_EXTENSION}')
@typing.overload
def render(engine: str,
format: str,
filepath: typing.Union[os.PathLike, str],
renderer: typing.Optional[str] = ...,
formatter: typing.Optional[str] = ...,
neato_no_op: typing.Union[bool, int, None] = ...,
quiet: bool = ..., *,
outfile: typing.Union[os.PathLike, str, None] = ...,
raise_if_result_exists: bool = ...,
overwrite_filepath: bool = ...) -> str:
"""Require ``format`` and ``filepath`` with default ``outfile=None``."""
@typing.overload
def render(engine: str,
format: typing.Optional[str] = ...,
filepath: typing.Union[os.PathLike, str, None] = ...,
renderer: typing.Optional[str] = ...,
formatter: typing.Optional[str] = ...,
neato_no_op: typing.Union[bool, int, None] = ...,
quiet: bool = False, *,
outfile: typing.Union[os.PathLike, str, None] = ...,
raise_if_result_exists: bool = ...,
overwrite_filepath: bool = ...) -> str:
"""Optional ``format`` and ``filepath`` with given ``outfile``."""
@typing.overload
def render(engine: str,
format: typing.Optional[str] = ...,
filepath: typing.Union[os.PathLike, str, None] = ...,
renderer: typing.Optional[str] = ...,
formatter: typing.Optional[str] = ...,
neato_no_op: typing.Union[bool, int, None] = ...,
quiet: bool = False, *,
outfile: typing.Union[os.PathLike, str, None] = ...,
raise_if_result_exists: bool = ...,
overwrite_filepath: bool = ...) -> str:
"""Required/optional ``format`` and ``filepath`` depending on ``outfile``."""
@_tools.deprecate_positional_args(supported_number=3)
def render(engine: str,
format: typing.Optional[str] = None,
filepath: typing.Union[os.PathLike, str, None] = None,
renderer: typing.Optional[str] = None,
formatter: typing.Optional[str] = None,
neato_no_op: typing.Union[bool, int, None] = None,
quiet: bool = False, *,
outfile: typing.Union[os.PathLike, str, None] = None,
raise_if_result_exists: bool = False,
overwrite_filepath: bool = False) -> str:
r"""Render file with ``engine`` into ``format`` and return result filename.
Args:
engine: Layout engine for rendering (``'dot'``, ``'neato'``, ...).
format: Output format for rendering (``'pdf'``, ``'png'``, ...).
Can be omitted if an ``outfile`` with a known ``format`` is given,
i.e. if ``outfile`` ends with a known ``.{format}`` suffix.
filepath: Path to the DOT source file to render.
Can be omitted if ``outfile`` is given,
in which case it defaults to ``outfile.with_suffix('.gv')``.
renderer: Output renderer (``'cairo'``, ``'gd'``, ...).
formatter: Output formatter (``'cairo'``, ``'gd'``, ...).
neato_no_op: Neato layout engine no-op flag.
quiet: Suppress ``stderr`` output from the layout subprocess.
outfile: Path for the rendered output file.
raise_if_result_exists: Raise :exc:`graphviz.FileExistsError`
if the result file exists.
overwrite_filepath: Allow ``dot`` to write to the file it reads from.
Incompatible with ``raise_if_result_exists``.
Returns:
The (possibly relative) path of the rendered file.
Raises:
ValueError: If ``engine``, ``format``, ``renderer``, or ``formatter``
are unknown.
graphviz.RequiredArgumentError: If ``format`` or ``filepath`` are None
unless ``outfile`` is given.
graphviz.RequiredArgumentError: If ``formatter`` is given
but ``renderer`` is None.
ValueError: If ``outfile`` and ``filename`` are the same file
unless ``overwite_filepath=True``.
graphviz.ExecutableNotFound: If the Graphviz ``dot`` executable
is not found.
graphviz.CalledProcessError: If the returncode (exit status)
of the rendering ``dot`` subprocess is non-zero.
graphviz.FileExistsError: If ``raise_if_exists``
and the result file exists.
Warns:
graphviz.UnknownSuffixWarning: If the suffix of ``outfile``
is empty or unknown.
graphviz.FormatSuffixMismatchWarning: If the suffix of ``outfile``
does not match the given ``format``.
Example:
>>> doctest_mark_exe()
>>> import pathlib
>>> import graphviz
>>> assert pathlib.Path('doctest-output/spam.gv').write_text('graph { spam }') == 14
>>> graphviz.render('dot', 'png', 'doctest-output/spam.gv').replace('\\', '/')
'doctest-output/spam.gv.png'
>>> graphviz.render('dot', filepath='doctest-output/spam.gv',
... outfile='doctest-output/spam.png').replace('\\', '/')
'doctest-output/spam.png'
>>> graphviz.render('dot', outfile='doctest-output/spam.pdf').replace('\\', '/')
'doctest-output/spam.pdf'
Note:
The layout command is started from the directory of ``filepath``,
so that references to external files
(e.g. ``[image=images/camelot.png]``)
can be given as paths relative to the DOT source file.
See also:
Upstream docs: https://www.graphviz.org/doc/info/command.html
"""
if raise_if_result_exists and overwrite_filepath:
raise ValueError('overwrite_filepath cannot be combined'
' with raise_if_result_exists')
filepath, outfile = map(_tools.promote_pathlike, (filepath, outfile))
if outfile is not None:
format = get_format(outfile, format=format)
if filepath is None:
filepath = get_filepath(outfile)
if (not overwrite_filepath and outfile.name == filepath.name
and outfile.resolve() == filepath.resolve()): # noqa: E129
raise ValueError(f'outfile {outfile.name!r} must be different'
f' from input file {filepath.name!r}'
' (pass overwrite_filepath=True to override)')
outfile_arg = (outfile.resolve() if outfile.parent != filepath.parent
else outfile.name)
# https://www.graphviz.org/doc/info/command.html#-o
args = ['-o', outfile_arg, filepath.name]
elif filepath is None:
raise exceptions.RequiredArgumentError('filepath: (required if outfile is not given,'
f' got {filepath!r})')
elif format is None:
raise exceptions.RequiredArgumentError('format: (required if outfile is not given,'
f' got {format!r})')
else:
outfile = get_outfile(filepath,
format=format,
renderer=renderer,
formatter=formatter)
# https://www.graphviz.org/doc/info/command.html#-O
args = ['-O', filepath.name]
cmd = dot_command.command(engine, format,
renderer=renderer,
formatter=formatter,
neato_no_op=neato_no_op)
if raise_if_result_exists and os.path.exists(outfile):
raise exceptions.FileExistsError(f'output file exists: {os.fspath(outfile)!r}')
cmd += args
assert filepath is not None, 'work around pytype false alarm'
execute.run_check(cmd,
cwd=filepath.parent if filepath.parent.parts else None,
quiet=quiet,
capture_output=True)
return os.fspath(outfile)

View File

@@ -0,0 +1,63 @@
"""Pipe DOT source code through ``unflatten``."""
import pathlib
import typing
from ..encoding import DEFAULT_ENCODING
from .. import _tools
from .. import exceptions
from . import execute
__all__ = ['UNFLATTEN_BINARY', 'unflatten']
UNFLATTEN_BINARY = pathlib.Path('unflatten')
@_tools.deprecate_positional_args(supported_number=1)
def unflatten(source: str,
stagger: typing.Optional[int] = None,
fanout: bool = False,
chain: typing.Optional[int] = None,
encoding: str = DEFAULT_ENCODING) -> str:
"""Return DOT ``source`` piped through ``unflatten`` preprocessor as string.
Args:
source: DOT source to process
(improve layout aspect ratio).
stagger: Stagger the minimum length of leaf edges
between 1 and this small integer.
fanout: Fanout nodes with indegree = outdegree = 1
when staggering (requires ``stagger``).
chain: Form disconnected nodes into chains of up to this many nodes.
encoding: Encoding to encode unflatten stdin and decode its stdout.
Returns:
Decoded stdout of the Graphviz unflatten command.
Raises:
graphviz.RequiredArgumentError: If ``fanout`` is given
but no ``stagger``.
graphviz.ExecutableNotFound: If the Graphviz 'unflatten' executable
is not found.
graphviz.CalledProcessError: If the returncode (exit status)
of the unflattening 'unflatten' subprocess is non-zero.
See also:
Upstream documentation:
https://www.graphviz.org/pdf/unflatten.1.pdf
"""
if fanout and stagger is None:
raise exceptions.RequiredArgumentError('fanout given without stagger')
cmd = [UNFLATTEN_BINARY]
if stagger is not None:
cmd += ['-l', str(stagger)]
if fanout:
cmd.append('-f')
if chain is not None:
cmd += ['-c', str(chain)]
proc = execute.run_check(cmd, input=source, encoding=encoding,
capture_output=True)
return proc.stdout

View File

@@ -0,0 +1,62 @@
"""Return the version number from running ``dot -V``."""
import logging
import re
import subprocess
import typing
from . import dot_command
from . import execute
VERSION_PATTERN = re.compile(r'''
graphviz[ ]
version[ ]
(\d+)\.(\d+)
(?:\.(\d+)
(?:
~dev\.\d{8}\.\d{4}
|
\.(\d+)
)?
)?
[ ]
''', re.VERBOSE)
log = logging.getLogger(__name__)
def version() -> typing.Tuple[int, ...]:
"""Return the upstream version number tuple from ``stderr`` of ``dot -V``.
Returns:
Two, three, or four ``int`` version ``tuple``.
Raises:
graphviz.ExecutableNotFound: If the Graphviz executable is not found.
graphviz.CalledProcessError: If the exit status is non-zero.
RuntimeError: If the output cannot be parsed into a version number.
Example:
>>> doctest_mark_exe()
>>> import graphviz
>>> graphviz.version() # doctest: +ELLIPSIS
(...)
Note:
Ignores the ``~dev.<YYYYmmdd.HHMM>`` portion of development versions.
See also:
Upstream release version entry format:
https://gitlab.com/graphviz/graphviz/-/blob/f94e91ba819cef51a4b9dcb2d76153684d06a913/gen_version.py#L17-20
"""
cmd = [dot_command.DOT_BINARY, '-V']
proc = execute.run_check(cmd,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
encoding='ascii')
ma = VERSION_PATTERN.search(proc.stdout)
if ma is None:
raise RuntimeError(f'cannot parse {cmd!r} output: {proc.stdout!r}')
return tuple(int(d) for d in ma.groups() if d is not None)

View File

@@ -0,0 +1,71 @@
"""Open files in platform-specific default viewing application."""
import logging
import os
import platform
import subprocess
import typing
from .. import _tools
__all__ = ['view']
PLATFORM = platform.system().lower()
log = logging.getLogger(__name__)
@_tools.deprecate_positional_args(supported_number=1)
def view(filepath: typing.Union[os.PathLike, str],
quiet: bool = False) -> None:
"""Open filepath with its default viewing application (platform-specific).
Args:
filepath: Path to the file to open in viewer.
quiet: Suppress ``stderr`` output
from the viewer process (ineffective on Windows).
Raises:
RuntimeError: If the current platform is not supported.
Note:
There is no option to wait for the application to close,
and no way to retrieve the application's exit status.
"""
try:
view_func = getattr(view, PLATFORM)
except AttributeError:
raise RuntimeError(f'platform {PLATFORM!r} not supported')
view_func(filepath, quiet=quiet)
@_tools.attach(view, 'darwin')
def view_darwin(filepath: typing.Union[os.PathLike, str], *,
quiet: bool) -> None:
"""Open filepath with its default application (mac)."""
cmd = ['open', filepath]
log.debug('view: %r', cmd)
kwargs = {'stderr': subprocess.DEVNULL} if quiet else {}
subprocess.Popen(cmd, **kwargs)
@_tools.attach(view, 'linux')
@_tools.attach(view, 'freebsd')
def view_unixoid(filepath: typing.Union[os.PathLike, str], *,
quiet: bool) -> None:
"""Open filepath in the user's preferred application (linux, freebsd)."""
cmd = ['xdg-open', filepath]
log.debug('view: %r', cmd)
kwargs = {'stderr': subprocess.DEVNULL} if quiet else {}
subprocess.Popen(cmd, **kwargs)
@_tools.attach(view, 'windows')
def view_windows(filepath: typing.Union[os.PathLike, str], *,
quiet: bool) -> None:
"""Start filepath with its associated application (windows)."""
# TODO: implement quiet=True
filepath = os.path.normpath(filepath)
log.debug('view: %r', filepath)
os.startfile(filepath) # pytype: disable=module-attr

View File

@@ -0,0 +1,32 @@
"""Iterables of DOT source code lines (including final newline)."""
import typing
from . import copying
__all__ = ['Base']
class LineIterable:
"""Iterable of DOT Source code lines
(mimics ``file`` objects in text mode)."""
def __iter__(self) -> typing.Iterator[str]: # pragma: no cover
r"""Yield the generated DOT source line by line.
Yields: Line ending with a newline (``'\n'``).
"""
raise NotImplementedError('to be implemented by concrete subclasses')
# Common base interface for all exposed classes
class Base(LineIterable, copying.CopyBase):
"""LineIterator with ``.source`` attribute, that it returns for ``str()``."""
@property
def source(self) -> str: # pragma: no cover
raise NotImplementedError('to be implemented by concrete subclasses')
def __str__(self) -> str:
"""The DOT source code as string."""
return self.source

View File

@@ -0,0 +1,20 @@
"""Create new instance copies with cooperative ``super()`` calls."""
__all__ = ['CopyBase']
class CopyBase:
"""Create new instance copies with cooperative ``super()`` calls."""
def copy(self):
"""Return a copied instance of the object.
Returns:
An independent copy of the current object.
"""
kwargs = self._copy_kwargs()
return self.__class__(**kwargs)
def _copy_kwargs(self, **kwargs):
"""Return the kwargs to create a copy of the instance."""
return kwargs

View File

@@ -0,0 +1,344 @@
"""Create DOT code with method-calls."""
import contextlib
import typing
from . import _tools
from . import base
from . import quoting
__all__ = ['GraphSyntax', 'DigraphSyntax', 'Dot']
def comment(line: str) -> str:
"""Return comment header line."""
return f'// {line}\n'
def graph_head(name: str) -> str:
"""Return DOT graph head line."""
return f'graph {name}{{\n'
def digraph_head(name: str) -> str:
"""Return DOT digraph head line."""
return f'digraph {name}{{\n'
def graph_edge(*, tail: str, head: str, attr: str) -> str:
"""Return DOT graph edge statement line."""
return f'\t{tail} -- {head}{attr}\n'
def digraph_edge(*, tail: str, head: str, attr: str) -> str:
"""Return DOT digraph edge statement line."""
return f'\t{tail} -> {head}{attr}\n'
class GraphSyntax:
"""DOT graph head and edge syntax."""
_head = staticmethod(graph_head)
_edge = staticmethod(graph_edge)
class DigraphSyntax:
"""DOT digraph head and edge syntax."""
_head = staticmethod(digraph_head)
_edge = staticmethod(digraph_edge)
def subgraph(name: str) -> str:
"""Return DOT subgraph head line."""
return f'subgraph {name}{{\n'
def subgraph_plain(name: str) -> str:
"""Return plain DOT subgraph head line."""
return f'{name}{{\n'
def node(left: str, right: str) -> str:
"""Return DOT node statement line."""
return f'\t{left}{right}\n'
class Dot(quoting.Quote, base.Base):
"""Assemble DOT source code."""
directed: bool
_comment = staticmethod(comment)
@staticmethod
def _head(name: str) -> str: # pragma: no cover
"""Return DOT head line."""
raise NotImplementedError('must be implemented by concrete subclasses')
@classmethod
def _head_strict(cls, name: str) -> str:
"""Return DOT strict head line."""
return f'strict {cls._head(name)}'
_tail = '}\n'
_subgraph = staticmethod(subgraph)
_subgraph_plain = staticmethod(subgraph_plain)
_node = _attr = staticmethod(node)
@classmethod
def _attr_plain(cls, left: str) -> str:
return cls._attr(left, '')
@staticmethod
def _edge(*, tail: str, head: str, attr: str) -> str: # pragma: no cover
"""Return DOT edge statement line."""
raise NotImplementedError('must be implemented by concrete subclasses')
@classmethod
def _edge_plain(cls, *, tail: str, head: str) -> str:
"""Return plain DOT edge statement line."""
return cls._edge(tail=tail, head=head, attr='')
def __init__(self, *,
name: typing.Optional[str] = None,
comment: typing.Optional[str] = None,
graph_attr=None, node_attr=None, edge_attr=None, body=None,
strict: bool = False, **kwargs) -> None:
super().__init__(**kwargs)
self.name = name
"""str: DOT source identifier for the ``graph`` or ``digraph`` statement."""
self.comment = comment
"""str: DOT source comment for the first source line."""
self.graph_attr = dict(graph_attr) if graph_attr is not None else {}
"""~typing.Dict[str, str]: Attribute-value pairs applying to the graph."""
self.node_attr = dict(node_attr) if node_attr is not None else {}
"""~typing.Dict[str, str]: Attribute-value pairs applying to all nodes."""
self.edge_attr = dict(edge_attr) if edge_attr is not None else {}
"""~typing.Dict[str, str]: Attribute-value pairs applying to all edges."""
self.body = list(body) if body is not None else []
"""~typing.List[str]: Verbatim DOT source lines including final newline."""
self.strict = strict
"""bool: Rendering should merge multi-edges."""
def _copy_kwargs(self, **kwargs):
"""Return the kwargs to create a copy of the instance."""
return super()._copy_kwargs(name=self.name,
comment=self.comment,
graph_attr=dict(self.graph_attr),
node_attr=dict(self.node_attr),
edge_attr=dict(self.edge_attr),
body=list(self.body),
strict=self.strict)
@_tools.deprecate_positional_args(supported_number=1)
def clear(self, keep_attrs: bool = False) -> None:
"""Reset content to an empty body, clear graph/node/egde_attr mappings.
Args:
keep_attrs (bool): preserve graph/node/egde_attr mappings
"""
if not keep_attrs:
for a in (self.graph_attr, self.node_attr, self.edge_attr):
a.clear()
self.body.clear()
@_tools.deprecate_positional_args(supported_number=1)
def __iter__(self, subgraph: bool = False) -> typing.Iterator[str]:
r"""Yield the DOT source code line by line (as graph or subgraph).
Yields: Line ending with a newline (``'\n'``).
"""
if self.comment:
yield self._comment(self.comment)
if subgraph:
if self.strict:
raise ValueError('subgraphs cannot be strict')
head = self._subgraph if self.name else self._subgraph_plain
else:
head = self._head_strict if self.strict else self._head
yield head(self._quote(self.name) + ' ' if self.name else '')
for kw in ('graph', 'node', 'edge'):
attrs = getattr(self, f'{kw}_attr')
if attrs:
yield self._attr(kw, self._attr_list(None, kwargs=attrs))
yield from self.body
yield self._tail
@_tools.deprecate_positional_args(supported_number=3)
def node(self, name: str,
label: typing.Optional[str] = None,
_attributes=None, **attrs) -> None:
"""Create a node.
Args:
name: Unique identifier for the node inside the source.
label: Caption to be displayed (defaults to the node ``name``).
attrs: Any additional node attributes (must be strings).
Attention:
When rendering ``label``, backslash-escapes
and strings of the form ``<...>`` have a special meaning.
See the sections :ref:`backslash-escapes` and
:ref:`quoting-and-html-like-labels` in the user guide for details.
"""
name = self._quote(name)
attr_list = self._attr_list(label, kwargs=attrs, attributes=_attributes)
line = self._node(name, attr_list)
self.body.append(line)
@_tools.deprecate_positional_args(supported_number=4)
def edge(self, tail_name: str, head_name: str,
label: typing.Optional[str] = None,
_attributes=None, **attrs) -> None:
"""Create an edge between two nodes.
Args:
tail_name: Start node identifier
(format: ``node[:port[:compass]]``).
head_name: End node identifier
(format: ``node[:port[:compass]]``).
label: Caption to be displayed near the edge.
attrs: Any additional edge attributes (must be strings).
Note:
The ``tail_name`` and ``head_name`` strings are separated
by (optional) colon(s) into ``node`` name, ``port`` name,
and ``compass`` (e.g. ``sw``).
See :ref:`details in the User Guide <node-ports-compass>`.
Attention:
When rendering ``label``, backslash-escapes
and strings of the form ``<...>`` have a special meaning.
See the sections :ref:`backslash-escapes` and
:ref:`quoting-and-html-like-labels` in the user guide for details.
"""
tail_name = self._quote_edge(tail_name)
head_name = self._quote_edge(head_name)
attr_list = self._attr_list(label, kwargs=attrs, attributes=_attributes)
line = self._edge(tail=tail_name, head=head_name, attr=attr_list)
self.body.append(line)
def edges(self, tail_head_iter) -> None:
"""Create a bunch of edges.
Args:
tail_head_iter: Iterable of ``(tail_name, head_name)`` pairs
(format:``node[:port[:compass]]``).
Note:
The ``tail_name`` and ``head_name`` strings are separated
by (optional) colon(s) into ``node`` name, ``port`` name,
and ``compass`` (e.g. ``sw``).
See :ref:`details in the User Guide <node-ports-compass>`.
"""
edge = self._edge_plain
quote = self._quote_edge
self.body += [edge(tail=quote(t), head=quote(h))
for t, h in tail_head_iter]
@_tools.deprecate_positional_args(supported_number=2)
def attr(self, kw: typing.Optional[str] = None,
_attributes=None, **attrs) -> None:
"""Add a general or graph/node/edge attribute statement.
Args:
kw: Attributes target
(``None`` or ``'graph'``, ``'node'``, ``'edge'``).
attrs: Attributes to be set (must be strings, may be empty).
See the :ref:`usage examples in the User Guide <attributes>`.
"""
if kw is not None and kw.lower() not in ('graph', 'node', 'edge'):
raise ValueError('attr statement must target graph, node, or edge:'
f' {kw!r}')
if attrs or _attributes:
if kw is None:
a_list = self._a_list(None, kwargs=attrs, attributes=_attributes)
line = self._attr_plain(a_list)
else:
attr_list = self._attr_list(None, kwargs=attrs, attributes=_attributes)
line = self._attr(kw, attr_list)
self.body.append(line)
@_tools.deprecate_positional_args(supported_number=2)
def subgraph(self, graph=None,
name: typing.Optional[str] = None,
comment: typing.Optional[str] = None,
graph_attr=None, node_attr=None, edge_attr=None,
body=None):
"""Add the current content of the given sole ``graph`` argument
as subgraph or return a context manager
returning a new graph instance
created with the given (``name``, ``comment``, etc.) arguments
whose content is added as subgraph
when leaving the context manager's ``with``-block.
Args:
graph: An instance of the same kind
(:class:`.Graph`, :class:`.Digraph`) as the current graph
(sole argument in non-with-block use).
name: Subgraph name (``with``-block use).
comment: Subgraph comment (``with``-block use).
graph_attr: Subgraph-level attribute-value mapping
(``with``-block use).
node_attr: Node-level attribute-value mapping
(``with``-block use).
edge_attr: Edge-level attribute-value mapping
(``with``-block use).
body: Verbatim lines to add to the subgraph ``body``
(``with``-block use).
See the :ref:`usage examples in the User Guide <subgraphs-clusters>`.
When used as a context manager, the returned new graph instance
uses ``strict=None`` and the parent graph's values
for ``directory``, ``format``, ``engine``, and ``encoding`` by default.
Note:
If the ``name`` of the subgraph begins with
``'cluster'`` (all lowercase)
the layout engine will treat it as a special cluster subgraph.
"""
if graph is None:
kwargs = self._copy_kwargs()
kwargs.pop('filename', None)
kwargs.update(name=name, comment=comment,
graph_attr=graph_attr, node_attr=node_attr, edge_attr=edge_attr,
body=body, strict=None)
subgraph = self.__class__(**kwargs)
@contextlib.contextmanager
def subgraph_contextmanager(*, parent):
"""Return subgraph and add to parent on exit."""
yield subgraph
parent.subgraph(subgraph)
return subgraph_contextmanager(parent=self)
args = [name, comment, graph_attr, node_attr, edge_attr, body]
if not all(a is None for a in args):
raise ValueError('graph must be sole argument of subgraph()')
if graph.directed != self.directed:
raise ValueError(f'{self!r} cannot add subgraph of different kind:'
f' {graph!r}')
self.body += [f'\t{line}' for line in graph.__iter__(subgraph=True)]

View File

@@ -0,0 +1,41 @@
"""Encoding parameter handling and default."""
import typing
import codecs
import locale
from . import copying
__all__ = ['DEFAULT_ENCODING', 'Encoding']
DEFAULT_ENCODING = 'utf-8'
class Encoding(copying.CopyBase):
"""Encoding used for input and output with ``'utf-8'`` default."""
_encoding = DEFAULT_ENCODING
def __init__(self, *, encoding: typing.Optional[str] = DEFAULT_ENCODING,
**kwargs) -> None:
super().__init__(**kwargs)
self.encoding = encoding
def _copy_kwargs(self, **kwargs):
"""Return the kwargs to create a copy of the instance."""
return super()._copy_kwargs(encoding=self._encoding, **kwargs)
@property
def encoding(self) -> str:
"""The encoding for the saved source file."""
return self._encoding
@encoding.setter
def encoding(self, encoding: typing.Optional[str]) -> None:
if encoding is None:
encoding = locale.getpreferredencoding()
codecs.lookup(encoding) # raise early
self._encoding = encoding

View File

@@ -0,0 +1,31 @@
"""Commonly used exception classes."""
from .backend.execute import ExecutableNotFound, CalledProcessError
__all__ = ['ExecutableNotFound', 'CalledProcessError',
'RequiredArgumentError', 'FileExistsError',
'UnknownSuffixWarning', 'FormatSuffixMismatchWarning',
'DotSyntaxWarning']
class RequiredArgumentError(TypeError):
""":exc:`TypeError` raised if a required argument is missing."""
class FileExistsError(FileExistsError):
""":exc:`FileExistsError` raised with ``raise_if_exists=True``."""
class UnknownSuffixWarning(RuntimeWarning):
""":exc:`RuntimeWarning` raised if the suffix of ``outfile`` is unknown
and the given ``format`` is used instead."""
class FormatSuffixMismatchWarning(UserWarning):
""":exc:`UserWarning` raised if the suffix ``outfile``
does not match the given ``format``."""
class DotSyntaxWarning(RuntimeWarning):
""":exc:`RuntimeWarning` raised if a quoted string
is expected to cause a ``CalledProcessError`` from rendering."""

View File

@@ -0,0 +1,123 @@
r"""Assemble DOT source code objects.
Example:
>>> doctest_mark_exe()
>>> import graphviz
>>> dot = graphviz.Graph(comment='Mønti Pythøn ik den Hølie Grailen')
>>> dot.node('Møøse')
>>> dot.node('trained_by', 'trained by')
>>> dot.node('tutte', 'TUTTE HERMSGERVORDENBROTBORDA')
>>> dot.edge('Møøse', 'trained_by')
>>> dot.edge('trained_by', 'tutte')
>>> dot.node_attr['shape'] = 'rectangle'
>>> print(dot.source) #doctest: +NORMALIZE_WHITESPACE
// Mønti Pythøn ik den Hølie Grailen
graph {
node [shape=rectangle]
"Møøse"
trained_by [label="trained by"]
tutte [label="TUTTE HERMSGERVORDENBROTBORDA"]
"Møøse" -- trained_by
trained_by -- tutte
}
>>> dot.render('doctest-output/m00se.gv').replace('\\', '/')
'doctest-output/m00se.gv.pdf'
"""
import typing
from .encoding import DEFAULT_ENCODING
from . import _tools
from . import dot
from . import jupyter_integration
from . import piping
from . import rendering
from . import unflattening
__all__ = ['Graph', 'Digraph']
class BaseGraph(dot.Dot,
rendering.Render,
jupyter_integration.JupyterIntegration, piping.Pipe,
unflattening.Unflatten):
"""Dot language creation and source code rendering."""
@_tools.deprecate_positional_args(supported_number=2)
def __init__(self, name: typing.Optional[str] = None,
comment: typing.Optional[str] = None,
filename=None, directory=None,
format: typing.Optional[str] = None,
engine: typing.Optional[str] = None,
encoding: typing.Optional[str] = DEFAULT_ENCODING,
graph_attr=None, node_attr=None, edge_attr=None,
body=None,
strict: bool = False, *,
renderer: typing.Optional[str] = None,
formatter: typing.Optional[str] = None) -> None:
if filename is None and name is not None:
filename = f'{name}.{self._default_extension}'
super().__init__(name=name, comment=comment,
graph_attr=graph_attr,
node_attr=node_attr, edge_attr=edge_attr,
body=body, strict=strict,
filename=filename, directory=directory,
encoding=encoding,
format=format, engine=engine,
renderer=renderer, formatter=formatter)
@property
def source(self) -> str:
"""The generated DOT source code as string."""
return ''.join(self)
class Graph(dot.GraphSyntax, BaseGraph):
"""Graph source code in the DOT language.
Args:
name: Graph name used in the source code.
comment: Comment added to the first line of the source.
filename: Filename for saving the source
(defaults to ``name`` + ``'.gv'``).
directory: (Sub)directory for source saving and rendering.
format: Rendering output format (``'pdf'``, ``'png'``, ...).
engine: Layout command used (``'dot'``, ``'neato'``, ...).
renderer: Output renderer used (``'cairo'``, ``'gd'``, ...).
formatter: Output formatter used (``'cairo'``, ``'gd'``, ...).
encoding: Encoding for saving the source.
graph_attr: Mapping of ``(attribute, value)`` pairs for the graph.
node_attr: Mapping of ``(attribute, value)`` pairs set for all nodes.
edge_attr: Mapping of ``(attribute, value)`` pairs set for all edges.
body: Iterable of verbatim lines (including their final newline)
to add to the graph ``body``.
strict (bool): Rendering should merge multi-edges.
Note:
All parameters are `optional` and can be changed under their
corresponding attribute name after instance creation.
"""
@property
def directed(self) -> bool:
"""``False``"""
return False
class Digraph(dot.DigraphSyntax, BaseGraph):
"""Directed graph source code in the DOT language."""
if Graph.__doc__ is not None:
__doc__ += Graph.__doc__.partition('.')[2]
@property
def directed(self) -> bool:
"""``True``"""
return True

View File

@@ -0,0 +1,112 @@
"""Display rendered graph as SVG in Jupyter Notebooks and QtConsole."""
import typing
from . import piping
__all__ = ['JUPYTER_FORMATS',
'SUPPORTED_JUPYTER_FORMATS', 'DEFAULT_JUPYTER_FORMAT',
'get_jupyter_format_mimetype',
'JupyterIntegration']
_IMAGE_JPEG = 'image/jpeg'
JUPYTER_FORMATS = {'jpeg': _IMAGE_JPEG,
'jpg': _IMAGE_JPEG,
'png': 'image/png',
'svg': 'image/svg+xml'}
SUPPORTED_JUPYTER_FORMATS = set(JUPYTER_FORMATS)
DEFAULT_JUPYTER_FORMAT = next(_ for _ in SUPPORTED_JUPYTER_FORMATS if _ == 'svg')
MIME_TYPES = {'image/jpeg': '_repr_image_jpeg',
'image/png': '_repr_image_png',
'image/svg+xml': '_repr_image_svg_xml'}
assert MIME_TYPES.keys() == set(JUPYTER_FORMATS.values())
SVG_ENCODING = 'utf-8'
def get_jupyter_format_mimetype(jupyter_format: str) -> str:
try:
return JUPYTER_FORMATS[jupyter_format]
except KeyError:
raise ValueError(f'unknown jupyter_format: {jupyter_format!r}'
f' (must be one of {sorted(JUPYTER_FORMATS)})')
def get_jupyter_mimetype_format(mimetype: str) -> str:
if mimetype not in MIME_TYPES:
raise ValueError(f'unsupported mimetype: {mimetype!r}'
f' (must be one of {sorted(MIME_TYPES)})')
assert mimetype in JUPYTER_FORMATS.values()
for format, jupyter_mimetype in JUPYTER_FORMATS.items():
if jupyter_mimetype == mimetype:
return format
raise RuntimeError # pragma: no cover
class JupyterIntegration(piping.Pipe):
"""Display rendered graph as SVG in Jupyter Notebooks and QtConsole."""
_jupyter_mimetype = get_jupyter_format_mimetype(DEFAULT_JUPYTER_FORMAT)
def _repr_mimebundle_(self,
include: typing.Optional[typing.Iterable[str]] = None,
exclude: typing.Optional[typing.Iterable[str]] = None,
**_) -> typing.Dict[str, typing.Union[bytes, str]]:
r"""Return the rendered graph as IPython mimebundle.
Args:
include: Iterable of mimetypes to include in the result.
If not given or ``None``: ``['image/sxg+xml']``.
exclude: Iterable of minetypes to exclude from the result.
Overrides ``include``.
Returns:
Mapping from mimetypes to data.
Example:
>>> doctest_mark_exe()
>>> import graphviz
>>> dot = graphviz.Graph()
>>> dot._repr_mimebundle_() # doctest: +ELLIPSIS
{'image/svg+xml': '<?xml version=...
>>> dot._repr_mimebundle_(include=['image/png']) # doctest: +ELLIPSIS
{'image/png': b'\x89PNG...
>>> dot._repr_mimebundle_(include=[])
{}
>>> dot._repr_mimebundle_(include=['image/svg+xml', 'image/jpeg'],
... exclude=['image/svg+xml']) # doctest: +ELLIPSIS
{'image/jpeg': b'\xff...
>>> list(dot._repr_mimebundle_(include=['image/png', 'image/jpeg']))
['image/jpeg', 'image/png']
See also:
IPython documentation:
- https://ipython.readthedocs.io/en/stable/api/generated/IPython.display.html#functions
- https://ipython.readthedocs.io/en/stable/config/integrating.html#MyObject._repr_mimebundle_ # noqa: E501
- https://nbviewer.org/github/ipython/ipython/blob/master/examples/IPython%20Kernel/Custom%20Display%20Logic.ipynb#Custom-Mimetypes-with-_repr_mimebundle_ # noqa: E501
"""
include = set(include) if include is not None else {self._jupyter_mimetype}
include -= set(exclude or [])
return {mimetype: getattr(self, method_name)()
for mimetype, method_name in MIME_TYPES.items()
if mimetype in include}
def _repr_image_jpeg(self) -> bytes:
"""Return the rendered graph as JPEG bytes."""
return self.pipe(format='jpeg')
def _repr_image_png(self) -> bytes:
"""Return the rendered graph as PNG bytes."""
return self.pipe(format='png')
def _repr_image_svg_xml(self) -> str:
"""Return the rendered graph as SVG string."""
return self.pipe(format='svg', encoding=SVG_ENCODING)

View File

@@ -0,0 +1,13 @@
"""Hold and verify parameters for running Graphviz ``dot``."""
from .engines import ENGINES, verify_engine
from .formats import FORMATS, verify_format
from .renderers import RENDERERS, verify_renderer
from .formatters import FORMATTERS, verify_formatter
from . mixins import Parameters
__all__ = ['ENGINES', 'FORMATS', 'RENDERERS', 'FORMATTERS',
'verify_engine', 'verify_format',
'verify_renderer', 'verify_formatter',
'Parameters']

View File

@@ -0,0 +1,16 @@
"""Rendering parameter handling."""
from .. import copying
__all__ = ['ParameterBase']
class ParameterBase(copying.CopyBase):
"""Rendering parameter."""
def _getattr_from_dict(self, attrname: str, *, default=None):
"""Return self.attrname if attrname is in the instance dictionary
(as oposed to on the type)."""
if attrname in self.__dict__:
return getattr(self, attrname)
return default

View File

@@ -0,0 +1,62 @@
"""Rendering engine parameter handling."""
import typing
from . import base
__all__ = ['ENGINES', 'verify_engine', 'Engine']
ENGINES = {'dot', # https://www.graphviz.org/pdf/dot.1.pdf
'neato',
'twopi',
'circo',
'fdp',
'sfdp',
'patchwork',
'osage'}
DEFAULT_ENGINE = 'dot'
REQUIRED = True
def verify_engine(engine: str, *, required: bool = REQUIRED) -> None:
if engine is None:
if required:
raise ValueError('missing engine')
elif engine.lower() not in ENGINES:
raise ValueError(f'unknown engine: {engine!r}'
f' (must be one of {sorted(ENGINES)})')
class Engine(base.ParameterBase):
"""Rendering engine parameter with ``'dot''`` default."""
_engine = DEFAULT_ENGINE
_verify_engine = staticmethod(verify_engine)
def __init__(self, *, engine: typing.Optional[str] = None, **kwargs) -> None:
super().__init__(**kwargs)
if engine is not None:
self.engine = engine
def _copy_kwargs(self, **kwargs):
"""Return the kwargs to create a copy of the instance."""
engine = self._getattr_from_dict('_engine')
if engine is not None:
kwargs['engine'] = engine
return super()._copy_kwargs(**kwargs)
@property
def engine(self) -> str:
"""The layout engine used for rendering
(``'dot'``, ``'neato'``, ...)."""
return self._engine
@engine.setter
def engine(self, engine: str) -> None:
engine = engine.lower()
self._verify_engine(engine)
self._engine = engine

View File

@@ -0,0 +1,90 @@
"""Rendering format parameter handling."""
import typing
from . import base
__all__ = ['FORMATS', 'verify_format', 'Format']
FORMATS = {'bmp', # https://graphviz.org/docs/outputs/
'canon', 'dot', 'gv', 'xdot', 'xdot1.2', 'xdot1.4',
'cgimage',
'cmap',
'eps',
'exr',
'fig',
'gd', 'gd2',
'gif',
'gtk',
'ico',
'imap', 'cmapx',
'imap_np', 'cmapx_np',
'ismap',
'jp2',
'jpg', 'jpeg', 'jpe',
'json', 'json0', 'dot_json', 'xdot_json', # Graphviz 2.40
'pct', 'pict',
'pdf',
'pic',
'plain', 'plain-ext',
'png',
'pov',
'ps',
'ps2',
'psd',
'sgi',
'svg', 'svgz',
'tga',
'tif', 'tiff',
'tk',
'vml', 'vmlz',
'vrml',
'wbmp',
'webp',
'xlib', 'x11'}
DEFAULT_FORMAT = 'pdf'
REQUIRED = True
def verify_format(format: str, *, required: bool = REQUIRED) -> None:
if format is None:
if required:
raise ValueError('missing format')
elif format.lower() not in FORMATS:
raise ValueError(f'unknown format: {format!r}'
f' (must be one of {sorted(FORMATS)})')
class Format(base.ParameterBase):
"""Rendering format parameter with ``'pdf'`` default."""
_format = DEFAULT_FORMAT
_verify_format = staticmethod(verify_format)
def __init__(self, *, format: typing.Optional[str] = None, **kwargs) -> None:
super().__init__(**kwargs)
if format is not None:
self.format = format
def _copy_kwargs(self, **kwargs):
"""Return the kwargs to create a copy of the instance."""
format = self._getattr_from_dict('_format')
if format is not None:
kwargs['format'] = format
return super()._copy_kwargs(**kwargs)
@property
def format(self) -> str:
"""The output format used for rendering
(``'pdf'``, ``'png'``, ...)."""
return self._format
@format.setter
def format(self, format: str) -> None:
format = format.lower()
self._verify_format(format)
self._format = format

View File

@@ -0,0 +1,61 @@
"""Rendering formatter parameter handling."""
import typing
from . import base
__all__ = ['FORMATTERS', 'verify_formatter', 'Formatter']
FORMATTERS = {'cairo',
'core',
'gd',
'gdiplus',
'gdwbmp',
'xlib'}
REQUIRED = False
def verify_formatter(formatter: typing.Optional[str], *,
required: bool = REQUIRED) -> None:
if formatter is None:
if required:
raise ValueError('missing formatter')
elif formatter.lower() not in FORMATTERS:
raise ValueError(f'unknown formatter: {formatter!r}'
f' (must be None or one of {sorted(FORMATTERS)})')
class Formatter(base.ParameterBase):
"""Rendering engine parameter (no default)."""
_formatter = None
_verify_formatter = staticmethod(verify_formatter)
def __init__(self, *, formatter: typing.Optional[str] = None, **kwargs) -> None:
super().__init__(**kwargs)
self.formatter = formatter
def _copy_kwargs(self, **kwargs):
"""Return the kwargs to create a copy of the instance."""
formatter = self._getattr_from_dict('_formatter')
if formatter is not None:
kwargs['formatter'] = formatter
return super()._copy_kwargs(**kwargs)
@property
def formatter(self) -> typing.Optional[str]:
"""The output formatter used for rendering
(``'cairo'``, ``'gd'``, ...)."""
return self._formatter
@formatter.setter
def formatter(self, formatter: typing.Optional[str]) -> None:
if formatter is None:
self.__dict__.pop('_formatter', None)
else:
formatter = formatter.lower()
self._verify_formatter(formatter)
self._formatter = formatter

View File

@@ -0,0 +1,46 @@
"""Mixin classes used to inherit parameter functionality."""
import typing
from . import engines
from . import formats
from . import renderers
from . import formatters
__all__ = ['Parameters']
class Parameters(engines.Engine, formats.Format,
renderers.Renderer, formatters.Formatter):
"""Parameters for calling ``graphviz.render()`` and ``graphviz.pipe()``."""
def _get_parameters(self, *,
engine: typing.Optional[str] = None,
format: typing.Optional[str] = None,
renderer: typing.Optional[str] = None,
formatter: typing.Optional[str] = None,
verify: bool = False,
**kwargs):
if engine is None:
engine = self.engine
elif verify:
self._verify_engine(engine)
if format is None:
format = self.format
elif verify:
self._verify_format(format)
if renderer is None:
renderer = self.renderer
elif verify:
self._verify_renderer(renderer)
if formatter is None:
formatter = self.formatter
elif verify:
self._verify_formatter(formatter)
kwargs.update(engine=engine, format=format,
renderer=renderer, formatter=formatter)
return kwargs

View File

@@ -0,0 +1,70 @@
"""Rendering renderer parameter handling."""
import typing
from . import base
__all__ = ['RENDERERS', 'verify_renderer', 'Renderer']
RENDERERS = {'cairo', # $ dot -T:
'dot',
'fig',
'gd',
'gdiplus',
'map',
'pic',
'pov',
'ps',
'svg',
'tk',
'vml',
'vrml',
'xdot'}
REQUIRED = False
def verify_renderer(renderer: typing.Optional[str], *,
required: bool = REQUIRED) -> None:
if renderer is None:
if required:
raise ValueError('missing renderer')
elif renderer.lower() not in RENDERERS:
raise ValueError(f'unknown renderer: {renderer!r}'
f' (must be None or one of {sorted(RENDERERS)})')
class Renderer(base.ParameterBase):
"""Rendering renderer parameter (no default)."""
_renderer = None
_verify_renderer = staticmethod(verify_renderer)
def __init__(self, *, renderer: typing.Optional[str] = None, **kwargs) -> None:
super().__init__(**kwargs)
self.renderer = renderer
def _copy_kwargs(self, **kwargs):
"""Return the kwargs to create a copy of the instance."""
renderer = self._getattr_from_dict('_renderer')
if renderer is not None:
kwargs['renderer'] = renderer
return super()._copy_kwargs(**kwargs)
@property
def renderer(self) -> typing.Optional[str]:
"""The output renderer used for rendering
(``'cairo'``, ``'gd'``, ...)."""
return self._renderer
@renderer.setter
def renderer(self, renderer: typing.Optional[str]) -> None:
if renderer is None:
self.__dict__.pop('_renderer', None)
else:
renderer = renderer.lower()
self._verify_renderer(renderer)
self._renderer = renderer

View File

@@ -0,0 +1,161 @@
"""Pipe DOT code objects through Graphviz ``dot``."""
import codecs
import logging
import typing
from . import _tools
from . import backend
from . import exceptions
from . import base
from . import encoding
__all__ = ['Pipe']
log = logging.getLogger(__name__)
class Pipe(encoding.Encoding, base.Base, backend.Pipe):
"""Pipe source lines through the Graphviz layout command."""
@typing.overload
def pipe(self,
format: typing.Optional[str] = ...,
renderer: typing.Optional[str] = ...,
formatter: typing.Optional[str] = ...,
neato_no_op: typing.Union[bool, int, None] = ...,
quiet: bool = ..., *,
engine: typing.Optional[str] = ...,
encoding: None = ...) -> bytes:
"""Return bytes with default ``encoding=None``."""
@typing.overload
def pipe(self,
format: typing.Optional[str] = ...,
renderer: typing.Optional[str] = ...,
formatter: typing.Optional[str] = ...,
neato_no_op: typing.Union[bool, int, None] = ...,
quiet: bool = ..., *,
engine: typing.Optional[str] = ...,
encoding: str) -> str:
"""Return string when given encoding."""
@typing.overload
def pipe(self,
format: typing.Optional[str] = ...,
renderer: typing.Optional[str] = ...,
formatter: typing.Optional[str] = ...,
neato_no_op: typing.Union[bool, int, None] = ...,
quiet: bool = ..., *,
engine: typing.Optional[str] = ...,
encoding: typing.Optional[str]) -> typing.Union[bytes, str]:
"""Return bytes or string depending on encoding argument."""
def pipe(self,
format: typing.Optional[str] = None,
renderer: typing.Optional[str] = None,
formatter: typing.Optional[str] = None,
neato_no_op: typing.Union[bool, int, None] = None,
quiet: bool = False, *,
engine: typing.Optional[str] = None,
encoding: typing.Optional[str] = None) -> typing.Union[bytes, str]:
"""Return the source piped through the Graphviz layout command.
Args:
format: The output format used for rendering
(``'pdf'``, ``'png'``, etc.).
renderer: The output renderer used for rendering
(``'cairo'``, ``'gd'``, ...).
formatter: The output formatter used for rendering
(``'cairo'``, ``'gd'``, ...).
neato_no_op: Neato layout engine no-op flag.
quiet (bool): Suppress ``stderr`` output
from the layout subprocess.
engine: Layout engine for rendering
(``'dot'``, ``'neato'``, ...).
encoding: Encoding for decoding the stdout.
Returns:
Bytes or if encoding is given decoded string
(stdout of the layout command).
Raises:
ValueError: If ``engine``, ``format``, ``renderer``, or ``formatter``
are unknown.
graphviz.RequiredArgumentError: If ``formatter`` is given
but ``renderer`` is None.
graphviz.ExecutableNotFound: If the Graphviz ``dot`` executable
is not found.
graphviz.CalledProcessError: If the returncode (exit status)
of the rendering ``dot`` subprocess is non-zero.
Example:
>>> doctest_mark_exe()
>>> import graphviz
>>> source = 'graph { spam }'
>>> graphviz.Source(source, format='svg').pipe()[:14]
b'<?xml version='
>>> graphviz.Source(source, format='svg').pipe(encoding='ascii')[:14]
'<?xml version='
>>> graphviz.Source(source, format='svg').pipe(encoding='utf-8')[:14]
'<?xml version='
"""
return self._pipe_legacy(format,
renderer=renderer,
formatter=formatter,
neato_no_op=neato_no_op,
quiet=quiet,
engine=engine,
encoding=encoding)
@_tools.deprecate_positional_args(supported_number=2)
def _pipe_legacy(self,
format: typing.Optional[str] = None,
renderer: typing.Optional[str] = None,
formatter: typing.Optional[str] = None,
neato_no_op: typing.Union[bool, int, None] = None,
quiet: bool = False, *,
engine: typing.Optional[str] = None,
encoding: typing.Optional[str] = None) -> typing.Union[bytes, str]:
return self._pipe_future(format,
renderer=renderer,
formatter=formatter,
neato_no_op=neato_no_op,
quiet=quiet,
engine=engine,
encoding=encoding)
def _pipe_future(self, format: typing.Optional[str] = None, *,
renderer: typing.Optional[str] = None,
formatter: typing.Optional[str] = None,
neato_no_op: typing.Union[bool, int, None] = None,
quiet: bool = False,
engine: typing.Optional[str] = None,
encoding: typing.Optional[str] = None) -> typing.Union[bytes, str]:
args, kwargs = self._get_pipe_parameters(engine=engine,
format=format,
renderer=renderer,
formatter=formatter,
neato_no_op=neato_no_op,
quiet=quiet,
verify=True)
args.append(iter(self))
if encoding is not None:
if codecs.lookup(encoding) is codecs.lookup(self.encoding):
# common case: both stdin and stdout need the same encoding
return self._pipe_lines_string(*args, encoding=encoding, **kwargs)
try:
raw = self._pipe_lines(*args, input_encoding=self.encoding, **kwargs)
except exceptions.CalledProcessError as e:
*args, output, stderr = e.args
if output is not None:
output = output.decode(self.encoding)
if stderr is not None:
stderr = stderr.decode(self.encoding)
raise e.__class__(*args, output=output, stderr=stderr)
else:
return raw.decode(encoding)
return self._pipe_lines(*args, input_encoding=self.encoding, **kwargs)

View File

@@ -0,0 +1,221 @@
"""Quote strings to be valid DOT identifiers, assemble quoted attribute lists."""
import functools
import re
import typing
import warnings
from . import _tools
from . import exceptions
__all__ = ['quote', 'quote_edge',
'a_list', 'attr_list',
'escape', 'nohtml']
# https://www.graphviz.org/doc/info/lang.html
# https://www.graphviz.org/doc/info/attrs.html#k:escString
HTML_STRING = re.compile(r'<.*>$', re.DOTALL)
ID = re.compile(r'([a-zA-Z_][a-zA-Z0-9_]*|-?(\.[0-9]+|[0-9]+(\.[0-9]*)?))$')
KEYWORDS = {'node', 'edge', 'graph', 'digraph', 'subgraph', 'strict'}
COMPASS = {'n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw', 'c', '_'} # TODO
FINAL_ODD_BACKSLASHES = re.compile(r'(?<!\\)(?:\\{2})*\\$')
QUOTE_WITH_OPTIONAL_BACKSLASHES = re.compile(r'''
(?P<escaped_backslashes>(?:\\{2})*)
\\? # treat \" same as "
(?P<literal_quote>")
''', flags=re.VERBOSE)
ESCAPE_UNESCAPED_QUOTES = functools.partial(QUOTE_WITH_OPTIONAL_BACKSLASHES.sub,
r'\g<escaped_backslashes>'
r'\\'
r'\g<literal_quote>')
@_tools.deprecate_positional_args(supported_number=1)
def quote(identifier: str,
is_html_string=HTML_STRING.match,
is_valid_id=ID.match,
dot_keywords=KEYWORDS,
endswith_odd_number_of_backslashes=FINAL_ODD_BACKSLASHES.search,
escape_unescaped_quotes=ESCAPE_UNESCAPED_QUOTES) -> str:
r"""Return DOT identifier from string, quote if needed.
>>> quote('') # doctest: +NO_EXE
'""'
>>> quote('spam')
'spam'
>>> quote('spam spam')
'"spam spam"'
>>> quote('-4.2')
'-4.2'
>>> quote('.42')
'.42'
>>> quote('<<b>spam</b>>')
'<<b>spam</b>>'
>>> quote(nohtml('<>'))
'"<>"'
>>> print(quote('"'))
"\""
>>> print(quote('\\"'))
"\""
>>> print(quote('\\\\"'))
"\\\""
>>> print(quote('\\\\\\"'))
"\\\""
"""
if is_html_string(identifier) and not isinstance(identifier, NoHtml):
pass
elif not is_valid_id(identifier) or identifier.lower() in dot_keywords:
if endswith_odd_number_of_backslashes(identifier):
warnings.warn('expect syntax error scanning invalid quoted string:'
f' {identifier!r}',
category=exceptions.DotSyntaxWarning)
return f'"{escape_unescaped_quotes(identifier)}"'
return identifier
def quote_edge(identifier: str) -> str:
"""Return DOT edge statement node_id from string, quote if needed.
>>> quote_edge('spam') # doctest: +NO_EXE
'spam'
>>> quote_edge('spam spam:eggs eggs')
'"spam spam":"eggs eggs"'
>>> quote_edge('spam:eggs:s')
'spam:eggs:s'
"""
node, _, rest = identifier.partition(':')
parts = [quote(node)]
if rest:
port, _, compass = rest.partition(':')
parts.append(quote(port))
if compass:
parts.append(compass)
return ':'.join(parts)
@_tools.deprecate_positional_args(supported_number=1)
def a_list(label: typing.Optional[str] = None,
kwargs=None, attributes=None) -> str:
"""Return assembled DOT a_list string.
>>> a_list('spam', kwargs={'spam': None, 'ham': 'ham ham', 'eggs': ''}) # doctest: +NO_EXE
'label=spam eggs="" ham="ham ham"'
"""
result = [f'label={quote(label)}'] if label is not None else []
if kwargs:
result += [f'{quote(k)}={quote(v)}'
for k, v in _tools.mapping_items(kwargs) if v is not None]
if attributes:
if hasattr(attributes, 'items'):
attributes = _tools.mapping_items(attributes)
result += [f'{quote(k)}={quote(v)}'
for k, v in attributes if v is not None]
return ' '.join(result)
@_tools.deprecate_positional_args(supported_number=1)
def attr_list(label: typing.Optional[str] = None,
kwargs=None, attributes=None) -> str:
"""Return assembled DOT attribute list string.
Sorts ``kwargs`` and ``attributes`` if they are plain dicts
(to avoid unpredictable order from hash randomization in Python < 3.7).
>>> attr_list() # doctest: +NO_EXE
''
>>> attr_list('spam spam', kwargs={'eggs': 'eggs', 'ham': 'ham ham'})
' [label="spam spam" eggs=eggs ham="ham ham"]'
>>> attr_list(kwargs={'spam': None, 'eggs': ''})
' [eggs=""]'
"""
content = a_list(label, kwargs=kwargs, attributes=attributes)
if not content:
return ''
return f' [{content}]'
class Quote:
"""Quote strings to be valid DOT identifiers, assemble quoted attribute lists."""
_quote = staticmethod(quote)
_quote_edge = staticmethod(quote_edge)
_a_list = staticmethod(a_list)
_attr_list = staticmethod(attr_list)
def escape(s: str) -> str:
r"""Return string disabling special meaning of backslashes and ``'<...>'``.
Args:
s: String in which backslashes and ``'<...>'``
should be treated as literal.
Returns:
Escaped string subclass instance.
Raises:
TypeError: If ``s`` is not a ``str``.
Example:
>>> import graphviz # doctest: +NO_EXE
>>> print(graphviz.escape(r'\l'))
\\l
See also:
Upstream documentation:
https://www.graphviz.org/doc/info/attrs.html#k:escString
"""
return nohtml(s.replace('\\', '\\\\'))
class NoHtml(str):
"""String subclass that does not treat ``'<...>'`` as DOT HTML string."""
__slots__ = ()
def nohtml(s: str) -> str:
"""Return string not treating ``'<...>'`` as DOT HTML string in quoting.
Args:
s: String in which leading ``'<'`` and trailing ``'>'``
should be treated as literal.
Returns:
String subclass instance.
Raises:
TypeError: If ``s`` is not a ``str``.
Example:
>>> import graphviz # doctest: +NO_EXE
>>> g = graphviz.Graph()
>>> g.node(graphviz.nohtml('<>-*-<>'))
>>> print(g.source) # doctest: +NORMALIZE_WHITESPACE
graph {
"<>-*-<>"
}
"""
return NoHtml(s)

View File

@@ -0,0 +1,186 @@
"""Save DOT code objects, render with Graphviz ``dot``, and open in viewer."""
import logging
import os
import pathlib
import typing
from . import _tools
from . import backend
from . import saving
__all__ = ['Render']
log = logging.getLogger(__name__)
class Render(saving.Save, backend.Render, backend.View):
"""Write source lines to file and render with Graphviz."""
@_tools.deprecate_positional_args(supported_number=2)
def render(self,
filename: typing.Union[os.PathLike, str, None] = None,
directory: typing.Union[os.PathLike, str, None] = None,
view: bool = False,
cleanup: bool = False,
format: typing.Optional[str] = None,
renderer: typing.Optional[str] = None,
formatter: typing.Optional[str] = None,
neato_no_op: typing.Union[bool, int, None] = None,
quiet: bool = False,
quiet_view: bool = False, *,
outfile: typing.Union[os.PathLike, str, None] = None,
engine: typing.Optional[str] = None,
raise_if_result_exists: bool = False,
overwrite_source: bool = False) -> str:
r"""Save the source to file and render with the Graphviz engine.
Args:
filename: Filename for saving the source
(defaults to ``name`` + ``'.gv'``).s
directory: (Sub)directory for source saving and rendering.
view (bool): Open the rendered result
with the default application.
cleanup (bool): Delete the source file
after successful rendering.
format: The output format used for rendering
(``'pdf'``, ``'png'``, etc.).
renderer: The output renderer used for rendering
(``'cairo'``, ``'gd'``, ...).
formatter: The output formatter used for rendering
(``'cairo'``, ``'gd'``, ...).
neato_no_op: Neato layout engine no-op flag.
quiet (bool): Suppress ``stderr`` output
from the layout subprocess.
quiet_view (bool): Suppress ``stderr`` output
from the viewer process
(implies ``view=True``, ineffective on Windows platform).
outfile: Path for the rendered output file.
engine: Layout engine for rendering
(``'dot'``, ``'neato'``, ...).
raise_if_result_exists: Raise :exc:`graphviz.FileExistsError`
if the result file exists.
overwrite_source: Allow ``dot`` to write to the file it reads from.
Incompatible with ``raise_if_result_exists``.
Returns:
The (possibly relative) path of the rendered file.
Raises:
ValueError: If ``engine``, ``format``, ``renderer``, or ``formatter``
are unknown.
graphviz.RequiredArgumentError: If ``formatter`` is given
but ``renderer`` is None.
ValueError: If ``outfile`` is the same file as the source file
unless ``overwite_source=True``.
graphviz.ExecutableNotFound: If the Graphviz ``dot`` executable
is not found.
graphviz.CalledProcessError: If the returncode (exit status)
of the rendering ``dot`` subprocess is non-zero.
RuntimeError: If viewer opening is requested but not supported.
Example:
>>> doctest_mark_exe()
>>> import graphviz
>>> dot = graphviz.Graph(name='spam', directory='doctest-output')
>>> dot.render(format='png').replace('\\', '/')
'doctest-output/spam.gv.png'
>>> dot.render(outfile='spam.svg').replace('\\', '/')
'doctest-output/spam.svg'
Note:
The layout command is started from the directory of ``filepath``,
so that references to external files
(e.g. ``[image=images/camelot.png]``)
can be given as paths relative to the DOT source file.
"""
outfile = _tools.promote_pathlike(outfile)
if outfile is not None:
format = self._get_format(outfile, format=format)
if directory is None:
outfile = pathlib.Path(self.directory, outfile)
args, kwargs = self._get_render_parameters(engine=engine,
format=format,
renderer=renderer,
formatter=formatter,
neato_no_op=neato_no_op,
quiet=quiet,
outfile=outfile,
raise_if_result_exists=raise_if_result_exists,
overwrite_source=overwrite_source,
verify=True)
if outfile is not None and filename is None:
filename = self._get_filepath(outfile)
filepath = self.save(filename, directory=directory, skip_existing=None)
args.append(filepath)
rendered = self._render(*args, **kwargs)
if cleanup:
log.debug('delete %r', filepath)
os.remove(filepath)
if quiet_view or view:
self._view(rendered, format=self._format, quiet=quiet_view)
return rendered
def _view(self, filepath: typing.Union[os.PathLike, str], *,
format: str, quiet: bool) -> None:
"""Start the right viewer based on file format and platform."""
methodnames = [
f'_view_{format}_{backend.viewing.PLATFORM}',
f'_view_{backend.viewing.PLATFORM}',
]
for name in methodnames:
view_method = getattr(self, name, None)
if view_method is not None:
break
else:
raise RuntimeError(f'{self.__class__!r} has no built-in viewer'
f' support for {format!r}'
f' on {backend.viewing.PLATFORM!r} platform')
view_method(filepath, quiet=quiet)
@_tools.deprecate_positional_args(supported_number=2)
def view(self,
filename: typing.Union[os.PathLike, str, None] = None,
directory: typing.Union[os.PathLike, str, None] = None,
cleanup: bool = False,
quiet: bool = False,
quiet_view: bool = False) -> str:
"""Save the source to file, open the rendered result in a viewer.
Convenience short-cut for running ``.render(view=True)``.
Args:
filename: Filename for saving the source
(defaults to ``name`` + ``'.gv'``).
directory: (Sub)directory for source saving and rendering.
cleanup (bool): Delete the source file after successful rendering.
quiet (bool): Suppress ``stderr`` output from the layout subprocess.
quiet_view (bool): Suppress ``stderr`` output
from the viewer process (ineffective on Windows).
Returns:
The (possibly relative) path of the rendered file.
Raises:
graphviz.ExecutableNotFound: If the Graphviz executable
is not found.
graphviz.CalledProcessError: If the exit status is non-zero.
RuntimeError: If opening the viewer is not supported.
Short-cut method for calling :meth:`.render` with ``view=True``.
Note:
There is no option to wait for the application to close,
and no way to retrieve the application's exit status.
"""
return self.render(filename=filename, directory=directory, view=True,
cleanup=cleanup, quiet=quiet, quiet_view=quiet_view)

View File

@@ -0,0 +1,83 @@
"""Save DOT source lines to a file."""
import logging
import os
import typing
from . import _defaults
from . import _tools
from . import base
from . import encoding
__all__ = ['Save']
log = logging.getLogger(__name__)
class Save(encoding.Encoding, base.Base):
"""Save DOT source lines to file."""
directory: typing.Union[str, bytes] = ''
_default_extension = _defaults.DEFAULT_SOURCE_EXTENSION
_mkdirs = staticmethod(_tools.mkdirs)
def __init__(self, *,
filename: typing.Union[os.PathLike, str],
directory: typing.Union[os.PathLike, str, None] = None,
**kwargs) -> None:
super().__init__(**kwargs)
if filename is None:
filename = f'{self.__class__.__name__}.{self._default_extension}'
self.filename = os.fspath(filename)
"""str: Target file name for saving the DOT source file."""
if directory is not None:
self.directory = os.fspath(directory)
def _copy_kwargs(self, **kwargs):
"""Return the kwargs to create a copy of the instance."""
assert 'directory' not in kwargs
if 'directory' in self.__dict__:
kwargs['directory'] = self.directory
return super()._copy_kwargs(filename=self.filename, **kwargs)
@property
def filepath(self) -> str:
"""The target path for saving the DOT source file."""
return os.path.join(self.directory, self.filename)
@_tools.deprecate_positional_args(supported_number=2)
def save(self, filename: typing.Union[os.PathLike, str, None] = None,
directory: typing.Union[os.PathLike, str, None] = None, *,
skip_existing: typing.Optional[bool] = False) -> str:
"""Save the DOT source to file. Ensure the file ends with a newline.
Args:
filename: Filename for saving the source (defaults to ``name`` + ``'.gv'``)
directory: (Sub)directory for source saving and rendering.
skip_existing: Skip write if file exists (default: ``False``).
Returns:
The (possibly relative) path of the saved source file.
"""
if filename is not None:
self.filename = filename
if directory is not None:
self.directory = directory
filepath = self.filepath
if skip_existing and os.path.exists(filepath):
return filepath
self._mkdirs(filepath)
log.debug('write lines to %r', filepath)
with open(filepath, 'w', encoding=self.encoding) as fd:
for uline in self:
fd.write(uline)
return filepath

View File

@@ -0,0 +1,147 @@
"""Save DOT code objects, render with Graphviz dot, and open in viewer."""
import locale
import logging
import os
import typing
from .encoding import DEFAULT_ENCODING
from . import _tools
from . import saving
from . import jupyter_integration
from . import piping
from . import rendering
from . import unflattening
__all__ = ['Source']
log = logging.getLogger(__name__)
class Source(rendering.Render, saving.Save,
jupyter_integration.JupyterIntegration, piping.Pipe,
unflattening.Unflatten):
"""Verbatim DOT source code string to be rendered by Graphviz.
Args:
source: The verbatim DOT source code string.
filename: Filename for saving the source (defaults to ``'Source.gv'``).
directory: (Sub)directory for source saving and rendering.
format: Rendering output format (``'pdf'``, ``'png'``, ...).
engine: Layout engine used (``'dot'``, ``'neato'``, ...).
encoding: Encoding for saving the source.
Note:
All parameters except ``source`` are optional. All of them
can be changed under their corresponding attribute name
after instance creation.
"""
@classmethod
@_tools.deprecate_positional_args(supported_number=2)
def from_file(cls, filename: typing.Union[os.PathLike, str],
directory: typing.Union[os.PathLike, str, None] = None,
format: typing.Optional[str] = None,
engine: typing.Optional[str] = None,
encoding: typing.Optional[str] = DEFAULT_ENCODING,
renderer: typing.Optional[str] = None,
formatter: typing.Optional[str] = None) -> 'Source':
"""Return an instance with the source string read from the given file.
Args:
filename: Filename for loading/saving the source.
directory: (Sub)directory for source loading/saving and rendering.
format: Rendering output format (``'pdf'``, ``'png'``, ...).
engine: Layout command used (``'dot'``, ``'neato'``, ...).
encoding: Encoding for loading/saving the source.
"""
directory = _tools.promote_pathlike_directory(directory)
filepath = (os.path.join(directory, filename) if directory.parts
else os.fspath(filename))
if encoding is None:
encoding = locale.getpreferredencoding()
log.debug('read %r with encoding %r', filepath, encoding)
with open(filepath, encoding=encoding) as fd:
source = fd.read()
return cls(source,
filename=filename, directory=directory,
format=format, engine=engine, encoding=encoding,
renderer=renderer, formatter=formatter,
loaded_from_path=filepath)
@_tools.deprecate_positional_args(supported_number=2)
def __init__(self, source: str,
filename: typing.Union[os.PathLike, str, None] = None,
directory: typing.Union[os.PathLike, str, None] = None,
format: typing.Optional[str] = None,
engine: typing.Optional[str] = None,
encoding: typing.Optional[str] = DEFAULT_ENCODING, *,
renderer: typing.Optional[str] = None,
formatter: typing.Optional[str] = None,
loaded_from_path: typing.Optional[os.PathLike] = None) -> None:
super().__init__(filename=filename, directory=directory,
format=format, engine=engine,
renderer=renderer, formatter=formatter,
encoding=encoding)
self._loaded_from_path = loaded_from_path
self._source = source
# work around pytype false alarm
_source: str
_loaded_from_path: typing.Optional[os.PathLike]
def _copy_kwargs(self, **kwargs):
"""Return the kwargs to create a copy of the instance."""
return super()._copy_kwargs(source=self._source,
loaded_from_path=self._loaded_from_path,
**kwargs)
def __iter__(self) -> typing.Iterator[str]:
r"""Yield the DOT source code read from file line by line.
Yields: Line ending with a newline (``'\n'``).
"""
lines = self._source.splitlines(keepends=True)
yield from lines[:-1]
for line in lines[-1:]:
suffix = '\n' if not line.endswith('\n') else ''
yield line + suffix
@property
def source(self) -> str:
"""The DOT source code as string.
Normalizes so that the string always ends in a final newline.
"""
source = self._source
if not source.endswith('\n'):
source += '\n'
return source
@_tools.deprecate_positional_args(supported_number=2)
def save(self, filename: typing.Union[os.PathLike, str, None] = None,
directory: typing.Union[os.PathLike, str, None] = None, *,
skip_existing: typing.Optional[bool] = None) -> str:
"""Save the DOT source to file. Ensure the file ends with a newline.
Args:
filename: Filename for saving the source (defaults to ``name`` + ``'.gv'``)
directory: (Sub)directory for source saving and rendering.
skip_existing: Skip write if file exists (default: ``None``).
By default skips if instance was loaded from the target path:
``.from_file(self.filepath)``.
Returns:
The (possibly relative) path of the saved source file.
"""
skip = (skip_existing is None and self._loaded_from_path
and os.path.samefile(self._loaded_from_path, self.filepath))
if skip:
log.debug('.save(skip_existing=None) skip writing Source.from_file(%r)',
self.filepath)
return super().save(filename=filename, directory=directory,
skip_existing=skip)

View File

@@ -0,0 +1,63 @@
"""Pipe source through the Graphviz *unflatten* preprocessor."""
import typing
import graphviz
from . import _tools
from . import base
from . import backend
from . import encoding
__all__ = ['Unflatten']
class Unflatten(encoding.Encoding, base.Base, backend.Unflatten):
"""Pipe source through the Graphviz *unflatten* preprocessor."""
@_tools.deprecate_positional_args(supported_number=1)
def unflatten(self,
stagger: typing.Optional[int] = None,
fanout: bool = False,
chain: typing.Optional[int] = None) -> 'graphviz.Source':
"""Return a new :class:`.Source` instance with the source
piped through the Graphviz *unflatten* preprocessor.
Args:
stagger: Stagger the minimum length
of leaf edges between 1 and this small integer.
fanout: Fanout nodes with indegree = outdegree = 1
when staggering (requires ``stagger``).
chain: Form disconnected nodes into chains
of up to this many nodes.
Returns:
Prepocessed DOT source code (improved layout aspect ratio).
Raises:
graphviz.RequiredArgumentError: If ``fanout`` is given
but ``stagger`` is None.
graphviz.ExecutableNotFound: If the Graphviz ``unflatten`` executable
is not found.
graphviz.CalledProcessError: If the returncode (exit status)
of the unflattening 'unflatten' subprocess is non-zero.
See also:
Upstream documentation:
https://www.graphviz.org/pdf/unflatten.1.pdf
"""
from . import sources
out = self._unflatten(self.source,
stagger=stagger, fanout=fanout, chain=chain,
encoding=self.encoding)
kwargs = self._copy_kwargs()
return sources.Source(out,
filename=kwargs.get('filename'),
directory=kwargs.get('directory'),
format=kwargs.get('format'),
engine=kwargs.get('engine'),
encoding=kwargs.get('encoding'),
renderer=kwargs.get('renderer'),
formatter=kwargs.get('formatter'),
loaded_from_path=None)