1"""Save DOT code objects, render with Graphviz dot, and open in viewer."""
2
3import locale
4import logging
5import os
6import typing
7
8from .encoding import DEFAULT_ENCODING
9from . import _tools
10from . import saving
11from . import jupyter_integration
12from . import piping
13from . import rendering
14from . import unflattening
15
16__all__ = ['Source']
17
18
19log = logging.getLogger(__name__)
20
21
22class Source(rendering.Render, saving.Save,
23 jupyter_integration.JupyterIntegration, piping.Pipe,
24 unflattening.Unflatten):
25 """Verbatim DOT source code string to be rendered by Graphviz.
26
27 Args:
28 source: The verbatim DOT source code string.
29 filename: Filename for saving the source (defaults to ``'Source.gv'``).
30 directory: (Sub)directory for source saving and rendering.
31 format: Rendering output format (``'pdf'``, ``'png'``, ...).
32 engine: Layout engine used (``'dot'``, ``'neato'``, ...).
33 encoding: Encoding for saving the source.
34
35 Note:
36 All parameters except ``source`` are optional. All of them
37 can be changed under their corresponding attribute name
38 after instance creation.
39 """
40
41 @classmethod
42 @_tools.deprecate_positional_args(supported_number=1, ignore_arg='cls')
43 def from_file(cls, filename: typing.Union[os.PathLike, str],
44 directory: typing.Union[os.PathLike, str, None] = None,
45 format: typing.Optional[str] = None,
46 engine: typing.Optional[str] = None,
47 encoding: typing.Optional[str] = DEFAULT_ENCODING,
48 renderer: typing.Optional[str] = None,
49 formatter: typing.Optional[str] = None) -> 'Source':
50 """Return an instance with the source string read from the given file.
51
52 Args:
53 filename: Filename for loading/saving the source.
54 directory: (Sub)directory for source loading/saving and rendering.
55 format: Rendering output format (``'pdf'``, ``'png'``, ...).
56 engine: Layout command used (``'dot'``, ``'neato'``, ...).
57 encoding: Encoding for loading/saving the source.
58 """
59 directory = _tools.promote_pathlike_directory(directory)
60 filepath = (os.path.join(directory, filename) if directory.parts
61 else os.fspath(filename))
62
63 if encoding is None:
64 encoding = locale.getpreferredencoding()
65
66 log.debug('read %r with encoding %r', filepath, encoding)
67 with open(filepath, encoding=encoding) as fd:
68 source = fd.read()
69
70 return cls(source,
71 filename=filename, directory=directory,
72 format=format, engine=engine, encoding=encoding,
73 renderer=renderer, formatter=formatter,
74 loaded_from_path=filepath)
75
76 @_tools.deprecate_positional_args(supported_number=1, ignore_arg='self')
77 def __init__(self, source: str,
78 filename: typing.Union[os.PathLike, str, None] = None,
79 directory: typing.Union[os.PathLike, str, None] = None,
80 format: typing.Optional[str] = None,
81 engine: typing.Optional[str] = None,
82 encoding: typing.Optional[str] = DEFAULT_ENCODING, *,
83 renderer: typing.Optional[str] = None,
84 formatter: typing.Optional[str] = None,
85 loaded_from_path: typing.Optional[os.PathLike] = None) -> None:
86 super().__init__(filename=filename, directory=directory,
87 format=format, engine=engine,
88 renderer=renderer, formatter=formatter,
89 encoding=encoding)
90 self._loaded_from_path = loaded_from_path
91 self._source = source
92
93 # work around pytype false alarm
94 _source: str
95 _loaded_from_path: typing.Optional[os.PathLike]
96
97 def _copy_kwargs(self, **kwargs):
98 """Return the kwargs to create a copy of the instance."""
99 return super()._copy_kwargs(source=self._source,
100 loaded_from_path=self._loaded_from_path,
101 **kwargs)
102
103 def __iter__(self) -> typing.Iterator[str]:
104 r"""Yield the DOT source code read from file line by line.
105
106 Yields: Line ending with a newline (``'\n'``).
107 """
108 lines = self._source.splitlines(keepends=True)
109 yield from lines[:-1]
110 for line in lines[-1:]:
111 suffix = '\n' if not line.endswith('\n') else ''
112 yield line + suffix
113
114 @property
115 def source(self) -> str:
116 """The DOT source code as string.
117
118 Normalizes so that the string always ends in a final newline.
119 """
120 source = self._source
121 if not source.endswith('\n'):
122 source += '\n'
123 return source
124
125 @_tools.deprecate_positional_args(supported_number=1, ignore_arg='self')
126 def save(self, filename: typing.Union[os.PathLike, str, None] = None,
127 directory: typing.Union[os.PathLike, str, None] = None, *,
128 skip_existing: typing.Optional[bool] = None) -> str:
129 """Save the DOT source to file. Ensure the file ends with a newline.
130
131 Args:
132 filename: Filename for saving the source (defaults to ``name`` + ``'.gv'``)
133 directory: (Sub)directory for source saving and rendering.
134 skip_existing: Skip write if file exists (default: ``None``).
135 By default skips if instance was loaded from the target path:
136 ``.from_file(self.filepath)``.
137
138 Returns:
139 The (possibly relative) path of the saved source file.
140 """
141 skip = (skip_existing is None and self._loaded_from_path
142 and os.path.samefile(self._loaded_from_path, self.filepath))
143 if skip:
144 log.debug('.save(skip_existing=None) skip writing Source.from_file(%r)',
145 self.filepath)
146 return super().save(filename=filename, directory=directory,
147 skip_existing=skip)