"""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 `. 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 `. """ 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 `. """ 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 `. 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)]