Coverage for pyTooling/Documentation/Sphinx/SchemaGraph.py: 88%

112 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-03 23:02 +0000

1# ==================================================================================================================== # 

2# _____ _ _ ____ _ _ _ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | _ \ ___ ___ _ _ _ __ ___ ___ _ __ | |_ __ _| |_(_) ___ _ __ # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | | | |/ _ \ / __| | | | '_ ` _ \ / _ \ '_ \| __/ _` | __| |/ _ \| '_ \ # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| |_| | (_) | (__| |_| | | | | | | __/ | | | || (_| | |_| | (_) | | | |# 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/ \___/ \___|\__,_|_| |_| |_|\___|_| |_|\__\__,_|\__|_|\___/|_| |_|# 

7# |_| |___/ |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany # 

15# # 

16# Licensed under the Apache License, Version 2.0 (the "License"); # 

17# you may not use this file except in compliance with the License. # 

18# You may obtain a copy of the License at # 

19# # 

20# http://www.apache.org/licenses/LICENSE-2.0 # 

21# # 

22# Unless required by applicable law or agreed to in writing, software # 

23# distributed under the License is distributed on an "AS IS" BASIS, # 

24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

25# See the License for the specific language governing permissions and # 

26# limitations under the License. # 

27# # 

28# SPDX-License-Identifier: Apache-2.0 # 

29# ==================================================================================================================== # 

30# 

31""" 

32The language-neutral half of drawing a schema as a Graphviz graph in a Sphinx document. 

33 

34:class:`DotGraph` assembles the graph in the DOT language, and :class:`SchemaGraph` is the directive's base-class: its 

35argument becomes a path, the file becomes a build dependency, and the DOT is handed to :mod:`sphinx.ext.graphviz`. A 

36schema language adds a module reading its schemas into a :class:`DotGraph`, and a subclass naming the directive. 

37 

38.. seealso:: 

39 

40 :mod:`pyTooling.Documentation.Sphinx.XSDSchemaGraph` 

41 |rarr| The ``xsd-graph`` directive, drawing an XML schema. 

42 :mod:`pyTooling.Documentation.Sphinx` 

43 |rarr| The extension this belongs to, and what else it brings. 

44""" 

45from __future__ import annotations 

46 

47from pathlib import Path 

48from typing import Any, Iterable, Sequence 

49 

50from docutils import nodes 

51from sphinx.ext.graphviz import figure_wrapper, graphviz 

52 

53from pyTooling.Common import getFullyQualifiedName 

54from pyTooling.Decorators import export 

55from pyTooling.MetaClasses import ExtendedType 

56from pyTooling.Documentation.Sphinx.Directives import BaseDirective, strip 

57 

58 

59__all__ = ["GRAPH_ATTRIBUTES"] 

60 

61#: Attributes every schema graph is drawn with, so two diagrams in one document look alike. 

62GRAPH_ATTRIBUTES = ( 

63 "rankdir=LR;", 

64 "nodesep=0.4;", 

65 'node [shape=record, fontname="sans-serif", fontsize=10];', 

66 'edge [fontname="sans-serif", fontsize=9];', 

67) 

68 

69 

70@export 

71class DotGraph(metaclass=ExtendedType, slots=True): 

72 """ 

73 A Graphviz graph in the DOT language, assembled statement by statement. 

74 

75 A renderer states nodes and edges; where the graph flows, how a record is shaped and which fonts it uses are 

76 :data:`GRAPH_ATTRIBUTES` and belong to every schema graph alike. Attribute values are quoted without exception, 

77 which is always legal in DOT and saves a caller from deciding per value. 

78 """ 

79 

80 _name: str #: Name of the graph, which Graphviz uses as the drawing's identifier. 

81 _statements: list[str] #: The statements written so far, one per line, already indented. 

82 

83 def __init__(self, name: str = "schema") -> None: 

84 """ 

85 Initialize an empty graph carrying the shared attributes. 

86 

87 :param name: Optional, the graph's name. 

88 :raises ValueError: If parameter 'name' is None. 

89 :raises TypeError: If parameter 'name' is not a string. 

90 """ 

91 if name is None: 

92 raise ValueError("Parameter 'name' is None.") 

93 elif not isinstance(name, str): 

94 ex = TypeError("Parameter 'name' is not of type 'str'.") 

95 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.") 

96 raise ex 

97 

98 self._name = name 

99 self._statements = [f"\t{attribute}" for attribute in GRAPH_ATTRIBUTES] 

100 self.AddSeparator() 

101 

102 @staticmethod 

103 def EscapeLabel(text: str) -> str: 

104 """ 

105 Escape the characters a Graphviz record label gives a meaning to. 

106 

107 :param text: The text to escape. 

108 :returns: The text, safe to put into a record label. 

109 """ 

110 for character in ("\\", "{", "}", "|", "<", ">", '"'): 

111 text = text.replace(character, f"\\{character}") 

112 

113 return text 

114 

115 @classmethod 

116 def _Compartment(cls, rows: Sequence[str]) -> str: 

117 """ 

118 Join the rows of one record compartment, left-aligned. 

119 

120 :param rows: The rows to join, unescaped. 

121 :returns: The compartment's content, or a single space when there are no rows - an empty compartment 

122 collapses, which makes the records of a graph differently shaped. 

123 """ 

124 if len(rows) == 0: 

125 return " " 

126 

127 return "".join(f"{cls.EscapeLabel(row)}\\l" for row in rows) 

128 

129 @staticmethod 

130 def _Attributes(attributes: dict[str, str]) -> str: 

131 """ 

132 Render an attribute list, quoting every value. 

133 

134 :param attributes: The attributes to render, keyed by name. 

135 :returns: The attribute list in brackets, or an empty string when there are none. 

136 """ 

137 if len(attributes) == 0: 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true

138 return "" 

139 

140 return "[" + ", ".join(f'{name}="{value}"' for name, value in attributes.items()) + "]" 

141 

142 def AddSeparator(self) -> None: 

143 """Add a blank line, so the generated DOT reads in the groups it was written in.""" 

144 self._statements.append("") 

145 

146 def AddNode(self, identifier: str, label: str, **attributes: str) -> None: 

147 """ 

148 Add a node. 

149 

150 :param identifier: Identifier of the node, which an edge names it by. 

151 :param label: The node's label, already escaped. 

152 :param attributes: Further attributes of the node. 

153 :raises ValueError: If parameter 'identifier' or 'label' is None. 

154 :raises TypeError: If parameter 'identifier' or 'label' is not a string. 

155 """ 

156 if identifier is None: 

157 raise ValueError("Parameter 'identifier' is None.") 

158 elif not isinstance(identifier, str): 

159 ex = TypeError("Parameter 'identifier' is not of type 'str'.") 

160 ex.add_note(f"Got type '{getFullyQualifiedName(identifier)}'.") 

161 raise ex 

162 

163 if label is None: 

164 raise ValueError("Parameter 'label' is None.") 

165 elif not isinstance(label, str): 

166 ex = TypeError("Parameter 'label' is not of type 'str'.") 

167 ex.add_note(f"Got type '{getFullyQualifiedName(label)}'.") 

168 raise ex 

169 

170 nodeAttributes = {"label": label} 

171 nodeAttributes.update(attributes) 

172 

173 self._statements.append(f'\t"{identifier}" {self._Attributes(nodeAttributes)};') 

174 

175 def AddRecord( 

176 self, 

177 identifier: str, 

178 title: str, 

179 compartments: Iterable[Sequence[str]] = (), 

180 **attributes: str 

181 ) -> None: 

182 """ 

183 Add a record node: a titled box divided into compartments. 

184 

185 :param identifier: Identifier of the node, which an edge names it by. 

186 :param title: The record's title, written in guillemets. 

187 :param compartments: Optional, the rows of each compartment below the title. 

188 :param attributes: Further attributes of the node. 

189 :raises ValueError: If parameter 'identifier' or 'title' is None. 

190 :raises TypeError: If parameter 'identifier' or 'title' is not a string. 

191 """ 

192 if title is None: 

193 raise ValueError("Parameter 'title' is None.") 

194 elif not isinstance(title, str): 

195 ex = TypeError("Parameter 'title' is not of type 'str'.") 

196 ex.add_note(f"Got type '{getFullyQualifiedName(title)}'.") 

197 raise ex 

198 

199 label = f"«{self.EscapeLabel(title)}»" 

200 for rows in compartments: 

201 label += f"|{self._Compartment(rows)}" 

202 

203 self.AddNode(identifier, f"{{{label}}}", **attributes) 

204 

205 def AddEdge(self, source: str, target: str, **attributes: str) -> None: 

206 """ 

207 Add an edge between two nodes. 

208 

209 An attribute's value is quoted but not escaped - :meth:`AddRecord` is the only method escaping what it is 

210 given. A label assembled from something a schema *author* wrote, rather than from a name a schema language 

211 constrains, has to go through :meth:`EscapeLabel` first. 

212 

213 :param source: Identifier of the node the edge starts at. 

214 :param target: Identifier of the node the edge points to. 

215 :param attributes: Further attributes of the edge. 

216 :raises ValueError: If parameter 'source' or 'target' is None. 

217 :raises TypeError: If parameter 'source' or 'target' is not a string. 

218 """ 

219 if source is None: 

220 raise ValueError("Parameter 'source' is None.") 

221 elif not isinstance(source, str): 

222 ex = TypeError("Parameter 'source' is not of type 'str'.") 

223 ex.add_note(f"Got type '{getFullyQualifiedName(source)}'.") 

224 raise ex 

225 

226 if target is None: 

227 raise ValueError("Parameter 'target' is None.") 

228 elif not isinstance(target, str): 

229 ex = TypeError("Parameter 'target' is not of type 'str'.") 

230 ex.add_note(f"Got type '{getFullyQualifiedName(target)}'.") 

231 raise ex 

232 

233 if len(attributes) == 0: 

234 self._statements.append(f'\t"{source}" -> "{target}";') 

235 else: 

236 self._statements.append(f'\t"{source}" -> "{target}" {self._Attributes(attributes)};') 

237 

238 def __str__(self) -> str: 

239 """ 

240 Render the graph. 

241 

242 :returns: The graph in the DOT language. 

243 """ 

244 statements = "\n".join(self._statements) 

245 

246 return f"digraph {self._name} {{\n{statements}\n}}" 

247 

248 

249@export 

250class SchemaGraph(BaseDirective): 

251 """ 

252 Base-class of the directives drawing the schema file given as their argument. 

253 

254 It holds everything that isn't the schema's language: the path is resolved against the document using the 

255 directive and registered as a dependency - so editing the schema rebuilds the page holding its diagram - and 

256 whatever :meth:`_RenderGraph` returns is handed to :mod:`sphinx.ext.graphviz`, wrapped in a figure when a caption 

257 was given. A derived class sets :attr:`~pyTooling.Documentation.Sphinx.Directives.BaseDirective.directiveName` and 

258 overrides :meth:`_RenderGraph`. 

259 """ 

260 

261 has_content = False #: A boolean; ``True`` if content is allowed. 

262 required_arguments = 1 #: Number of required directive arguments: the schema's path. 

263 optional_arguments = 0 #: Number of optional arguments after the required ones. 

264 final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces. 

265 # docutils declares 'option_spec' on 'Directive' and 'BaseDirective' assigns it, so mypy calls every 

266 # spelling of this override a conflict with one of them 

267 #: Mapping of option names to validator functions. 

268 option_spec: dict[str, Any] = { # type: ignore[misc] 

269 "caption": strip, 

270 } 

271 

272 def run(self) -> list[nodes.Node]: 

273 """ 

274 Read the schema and hand its graph to :mod:`sphinx.ext.graphviz` for rendering. 

275 

276 :returns: A ``graphviz`` node, wrapped in a figure when a caption was given, or the message of whatever went 

277 wrong while reading the schema. 

278 """ 

279 relativePath, absolutePath = self.env.relfn2path(self.arguments[0]) 

280 self.env.note_dependency(relativePath) 

281 schemaFile = Path(absolutePath) 

282 

283 try: 

284 code = self._RenderGraph(schemaFile) 

285 except Exception as ex: 

286 return self._internalError( 

287 nodes.container(), 

288 __name__, 

289 f"{self.directiveName}: Couldn't draw schema '{schemaFile}'.", 

290 ex 

291 ) 

292 

293 node = graphviz() 

294 node["code"] = code 

295 node["options"] = {"docname": self.env.docname} 

296 node["alt"] = f"Diagram of {schemaFile.name}" 

297 

298 if (caption := self.options.get("caption", None)) is not None: 

299 return [figure_wrapper(self, node, caption)] 

300 

301 return [node] 

302 

303 @classmethod 

304 def _RenderGraph(cls, schemaFile: Path) -> str: 

305 """ 

306 Render the schema as a graph. 

307 

308 :param schemaFile: Path of the schema to render. 

309 :returns: The graph in the DOT language. 

310 :raises NotImplementedError: If a derived class doesn't override it. 

311 """ 

312 raise NotImplementedError(f"{cls.directiveName}: '_RenderGraph' is not implemented.")