Skip to content

Commit

Permalink
wip: make dot objects iterables of unicode lines including newline
Browse files Browse the repository at this point in the history
  • Loading branch information
xflr6 committed Nov 26, 2017
1 parent a71e612 commit 0e2bc45
Show file tree
Hide file tree
Showing 9 changed files with 105 additions and 43 deletions.
6 changes: 3 additions & 3 deletions docs/manual.rst
Expand Up @@ -339,14 +339,14 @@ Custom DOT statements

To add arbitrary statements to the created DOT_ source, use the
:attr:`~.Graph.body` attribute of the :class:`.Graph` or :class:`.Digraph`
object. It holds the verbatim list of lines to be written to the source file.
Use its ``append()`` or ``extend()`` method:
object. It holds the verbatim list of lines to be written to the source file
(including their newline). Use its ``append()`` or ``extend()`` method:

.. code:: python
>>> rt = Digraph(comment='The Round Table')
>>> rt.body.append('\t"King Arthur" -> {\n\t\t"Sir Bedevere", "Sir Lancelot"\n\t}')
>>> rt.body.append('\t"King Arthur" -> {\n\t\t"Sir Bedevere", "Sir Lancelot"\n\t}\n')
>>> rt.edge('Sir Bedevere', 'Sir Lancelot', constraint='false')
>>> print(rt.source) # doctest: +NORMALIZE_WHITESPACE
Expand Down
15 changes: 13 additions & 2 deletions graphviz/backend.py
Expand Up @@ -146,6 +146,15 @@ def pipe(engine, format, data, quiet=False):
graphviz.ExecutableNotFound: If the Graphviz executable is not found.
subprocess.CalledProcessError: If the exit status is non-zero.
"""
piped = open_pipe(engine, format, quiet)
with piped as fd:
fd.write(data)
with piped as outs:
return outs


@tools.multi_contextmanager
def open_pipe(engine, format, quiet=False):
args, _ = command(engine, format)

try:
Expand All @@ -158,14 +167,16 @@ def pipe(engine, format, data, quiet=False):
else: # pragma: no cover
raise

outs, errs = proc.communicate(data)
yield proc.stdin

outs, errs = proc.communicate()
if proc.returncode:
if not quiet:
stderr_write_binary(errs)
sys.stderr.flush()
raise subprocess.CalledProcessError(proc.returncode, args, output=outs)

return outs
yield outs


def version():
Expand Down
24 changes: 12 additions & 12 deletions graphviz/dot.py
Expand Up @@ -36,12 +36,12 @@
class Dot(files.File):
"""Assemble, save, and render DOT source code, open result in viewer."""

_comment = '// %s'
_subgraph = 'subgraph %s{'
_subgraph_plain = '%s{'
_node = _attr = '\t%s%s'
_comment = u'// %s\n'
_subgraph = u'subgraph %s{\n'
_subgraph_plain = u'%s{\n'
_node = _attr = u'\t%s%s\n'
_attr_plain = _attr % ('%s', '')
_tail = '}'
_tail = u'}\n'

_quote = staticmethod(lang.quote)
_quote_edge = staticmethod(lang.quote_edge)
Expand Down Expand Up @@ -115,7 +115,7 @@ def __iter__(self, subgraph=False):

def __str__(self):
"""The DOT source code as string."""
return '\n'.join(self)
return ''.join(self)

source = property(__str__, doc=__str__.__doc__)

Expand Down Expand Up @@ -255,9 +255,9 @@ class Graph(Dot):
corresponding attribute name after instance creation.
"""

_head = 'graph %s{'
_head_strict = 'strict %s' % _head
_edge = '\t%s -- %s%s'
_head = u'graph %s{\n'
_head_strict = u'strict %s' % _head
_edge = u'\t%s -- %s%s\n'
_edge_plain = _edge % ('%s', '%s', '')

@property
Expand All @@ -271,9 +271,9 @@ class Digraph(Dot):

__doc__ += Graph.__doc__.partition('.')[2]

_head = 'digraph %s{'
_head_strict = 'strict %s' % _head
_edge = '\t%s -> %s%s'
_head = u'digraph %s{\n'
_head_strict = u'strict %s' % _head
_edge = u'\t%s -> %s%s\n'
_edge_plain = _edge % ('%s', '%s', '')

@property
Expand Down
21 changes: 13 additions & 8 deletions graphviz/files.py
Expand Up @@ -119,11 +119,14 @@ def pipe(self, format=None):
if format is None:
format = self._format

data = text_type(self.source).encode(self._encoding)
piped = backend.open_pipe(self._engine, format)

outs = backend.pipe(self._engine, format, data)
with piped as fd:
for uline in self:
fd.write((uline).encode(self._encoding))

return outs
with piped as outs:
return outs

@property
def filepath(self):
Expand All @@ -146,12 +149,9 @@ def save(self, filename=None, directory=None):
filepath = self.filepath
tools.mkdirs(filepath)

data = text_type(self.source)

with io.open(filepath, 'w', encoding=self.encoding) as fd:
fd.write(data)
if not data.endswith(u'\n'):
fd.write(u'\n')
for uline in self:
fd.write(uline)

return filepath

Expand Down Expand Up @@ -266,3 +266,8 @@ def _kwargs(self):
result = super(Source, self)._kwargs()
result['source'] = self.source
return result

def __iter__(self):
yield text_type(self.source)
if not self.source.endswith(u'\n'):
yield u'\n'
39 changes: 38 additions & 1 deletion graphviz/tools.py
@@ -1,10 +1,11 @@
# tools.py

import os
import functools

from . import _compat

__all__ = ['attach', 'mkdirs', 'mapping_items']
__all__ = ['attach', 'mkdirs', 'mapping_items', 'multi_contextmanager']


def attach(object, name):
Expand Down Expand Up @@ -44,3 +45,39 @@ def mapping_items(mapping, _iteritems=_compat.iteritems):
if type(mapping) is dict:
return iter(sorted(_iteritems(mapping)))
return _iteritems(mapping)


def multi_contextmanager(func):
"""
>>> @multi_contextmanager
... def spam():
... yield 'spam'
... print('and')
... yield 'eggs'
>>> s = spam()
>>> with s as x:
... print(x)
spam
>>> with s as x:
... print(x)
and
eggs
"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
return GeneratorContextmanager(func(*args, **kwargs))
return wrapper


class GeneratorContextmanager(object):

def __init__(self, generator):
self._iter = iter(generator)

def __enter__(self):
return next(self._iter)

def __exit__(self, type, value, tb):
pass
4 changes: 2 additions & 2 deletions tests/conftest.py
Expand Up @@ -70,8 +70,8 @@ def quiet(request):


@pytest.fixture
def pipe(mocker):
yield mocker.patch('graphviz.backend.pipe')
def open_pipe(mocker):
yield mocker.patch('graphviz.backend.open_pipe')


@pytest.fixture
Expand Down
6 changes: 4 additions & 2 deletions tests/test_backend.py
Expand Up @@ -103,7 +103,8 @@ def test_pipe_pipe_invalid_data_mocked(mocker, py2, Popen, quiet): # noqa: N803
Popen.assert_called_once_with(['dot', '-Tpng'],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, startupinfo=STARTUPINFO)
proc.communicate.assert_called_once_with(b'nongraph')
proc.stdin.write.assert_called_once_with(b'nongraph')
proc.communicate.assert_called_once_with()
if not quiet:
if py2:
stderr.write.assert_called_once_with(errs)
Expand All @@ -123,7 +124,8 @@ def test_pipe_mocked(mocker, Popen): # noqa: N803
Popen.assert_called_once_with(['dot', '-Tpng'],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, startupinfo=STARTUPINFO)
proc.communicate.assert_called_once_with(b'nongraph')
proc.stdin.write.assert_called_once_with(b'nongraph')
proc.communicate.assert_called_once_with()


@pytest.mark.usefixtures('empty_path')
Expand Down
16 changes: 9 additions & 7 deletions tests/test_dot.py
Expand Up @@ -63,8 +63,8 @@ def test_iter_subgraph_strict(cls):


def test_iter_strict():
assert Graph(strict=True).source == 'strict graph {\n}'
assert Digraph(strict=True).source == 'strict digraph {\n}'
assert Graph(strict=True).source == 'strict graph {\n}\n'
assert Digraph(strict=True).source == 'strict digraph {\n}\n'


def test_attr_invalid_kw(cls):
Expand All @@ -75,14 +75,14 @@ def test_attr_invalid_kw(cls):
def test_attr_kw_none():
dot = Graph()
dot.attr(spam='eggs')
assert dot.source == 'graph {\n\tspam=eggs\n}'
assert dot.source == 'graph {\n\tspam=eggs\n}\n'


def test_subgraph_graph_none():
dot = Graph()
with dot.subgraph(name='name', comment='comment'):
pass
assert dot.source == 'graph {\n\t// comment\n\tsubgraph name {\n\t}\n}'
assert dot.source == 'graph {\n\t// comment\n\tsubgraph name {\n\t}\n}\n'


def test_subgraph_graph_notsole(cls):
Expand All @@ -99,7 +99,7 @@ def test_subgraph_mixed(classes):
def test_subgraph_reflexive(): # guard against potential infinite loop
dot = Graph()
dot.subgraph(dot)
assert dot.source == 'graph {\n\t{\n\t}\n}'
assert dot.source == 'graph {\n\t{\n\t}\n}\n'


def test_subgraph():
Expand Down Expand Up @@ -144,7 +144,8 @@ def test_subgraph():
A -- D
B -- E
C -- F
}'''
}
'''


def test_label_html():
Expand Down Expand Up @@ -220,4 +221,5 @@ def test_label_html():
</TABLE>>]
struct1:f1 -> struct2:f0
struct1:f2 -> struct3:here
}'''
}
'''
17 changes: 11 additions & 6 deletions tests/test_files.py
Expand Up @@ -60,20 +60,25 @@ def test__repr_svg_(mocker, source):
pipe.return_value.decode.assert_called_once_with(source.encoding)


def test_pipe_format(pipe, source, format_='svg'):
def test_pipe_format(open_pipe, source, format_='svg'):
assert source.format != format_
multi_context = open_pipe.return_value.__enter__.return_value

assert source.pipe(format=format_) is pipe.return_value
assert source.pipe(format=format_) is multi_context

data = source.source.encode(source.encoding)
pipe.assert_called_once_with(source.engine, format_, data)
open_pipe.assert_called_once_with(source.engine, format_)
multi_context.write.call_args_list == [((data,), {}), ((b'\n',), {})]


def test_pipe(pipe, source):
assert source.pipe() is pipe.return_value
def test_pipe(open_pipe, source):
multi_context = open_pipe.return_value.__enter__.return_value

assert source.pipe() is multi_context

data = source.source.encode(source.encoding)
pipe.assert_called_once_with(source.engine, source.format, data)
open_pipe.assert_called_once_with(source.engine, source.format)
multi_context.write.call_args_list == [((data,), {}), ((b'\n',), {})]


def test_filepath(test_platform, source):
Expand Down

0 comments on commit 0e2bc45

Please sign in to comment.