Coverage for pyTooling/Sphinx/SchemaGraph.py: 76%

58 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-04 01:28 +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` is a :class:`pyTooling.Graph.GraphViz.Graph` with the look every schema graph shares, and 

35:class:`SchemaGraph` is the directive's base-class: its argument becomes a path, the file becomes a build dependency, 

36and the DOT is handed to :mod:`sphinx.ext.graphviz`. A schema language adds a module reading its schemas into a 

37:class:`DotGraph`, and a subclass naming the directive. 

38 

39.. seealso:: 

40 

41 :mod:`pyTooling.Sphinx.XMLSchemaGraph` 

42 |rarr| The ``xmlschema-graph`` directive, drawing an XML schema. 

43 :mod:`pyTooling.Graph.GraphViz` 

44 |rarr| The DOT model :class:`DotGraph` is built on. 

45 :mod:`pyTooling.Sphinx` 

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

47""" 

48from __future__ import annotations 

49 

50from pathlib import Path 

51from typing import Any, Mapping, Optional as Nullable, Sequence 

52 

53from docutils import nodes 

54from sphinx.ext.graphviz import figure_wrapper, graphviz 

55 

56from pyTooling.Common import getFullyQualifiedName 

57from pyTooling.Decorators import export 

58from pyTooling.Graph.GraphViz import AttributeValue, Graph, Node, RecordField, RecordLabel 

59from pyTooling.Sphinx import BaseDirective, strip 

60 

61 

62@export 

63class DotGraph(Graph): 

64 """ 

65 A Graphviz graph of a schema, with the look every schema graph shares. 

66 

67 It flows from left to right, its nodes are records, and its nodes and edges use the same fonts, so two diagrams in 

68 one document look alike. A renderer adds records, nodes and edges as to any :class:`~pyTooling.Graph.GraphViz.Graph`. 

69 """ 

70 

71 def __init__(self, identifier: str = "schema") -> None: 

72 """ 

73 Initialize an empty graph carrying the shared attributes. 

74 

75 :param identifier: Optional, the graph's identifier. Default: ``schema``. 

76 :raises ValueError: If parameter 'identifier' is None. 

77 :raises TypeError: If parameter 'identifier' is not a string. 

78 """ 

79 super().__init__(identifier, attributes={"rankdir": "LR", "nodesep": 0.4}) 

80 

81 if identifier is None: 

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

83 

84 self._nodeDefaults["shape"] = "record" 

85 self._nodeDefaults["fontname"] = "sans-serif" 

86 self._nodeDefaults["fontsize"] = 10 

87 self._edgeDefaults["fontname"] = "sans-serif" 

88 self._edgeDefaults["fontsize"] = 9 

89 

90 def AddRecord( 

91 self, 

92 identifier: str, 

93 title: str, 

94 compartments: Sequence[Sequence[str]] = (), 

95 attributes: Nullable[Mapping[str, AttributeValue]] = None 

96 ) -> Node: 

97 """ 

98 Add a record node: a titled box divided into compartments, one below the other. 

99 

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

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

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

103 :param attributes: Optional, further attributes of the node, by name. 

104 :returns: The added node. 

105 :raises ValueError: If parameter 'title' is None. 

106 :raises TypeError: If parameter 'title' is not a string. 

107 """ 

108 if title is None: 

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

110 elif not isinstance(title, str): 

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

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

113 raise ex 

114 

115 fields: list[RecordField] = [f"«{title}»"] 

116 fields.extend(compartments) 

117 

118 return self.AddNode(Node(identifier, RecordLabel(fields, flipped=True), attributes)) 

119 

120 def GetOrAddNode(self, identifier: str) -> Node: 

121 """ 

122 Return the node with the given identifier, adding a plain node first if there is none. 

123 

124 An edge can point to a type the renderer has no record for, like a builtin or an anonymous type. Graphviz would 

125 create such a node implicitly; here it is added explicitly and drawn with the node defaults. 

126 

127 :param identifier: Identifier of the node. 

128 :returns: The node with that identifier. 

129 :raises ValueError: If parameter 'identifier' is None. 

130 :raises TypeError: If parameter 'identifier' is not a string. 

131 """ 

132 if self.HasNode(identifier): 

133 return self.GetNode(identifier) 

134 

135 return self.AddNode(Node(identifier)) 

136 

137 

138@export 

139class SchemaGraph(BaseDirective): 

140 """ 

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

142 

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

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

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

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

147 overrides :meth:`_RenderGraph`. 

148 """ 

149 

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

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

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

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

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

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

156 #: Mapping of option names to validator functions. 

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

158 "caption": strip, 

159 } 

160 

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

162 """ 

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

164 

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

166 wrong while reading the schema. 

167 """ 

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

169 self.env.note_dependency(relativePath) 

170 schemaFile = Path(absolutePath) 

171 

172 try: 

173 code = self._RenderGraph(schemaFile) 

174 except Exception as ex: 

175 return self._internalError( 

176 nodes.container(), 

177 __name__, 

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

179 ex 

180 ) 

181 

182 node = graphviz() 

183 node["code"] = code 

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

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

186 

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

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

189 

190 return [node] 

191 

192 @classmethod 

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

194 """ 

195 Render the schema as a graph. 

196 

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

198 :returns: The graph in the DOT language. 

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

200 """ 

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