Coverage for pyTooling/Sphinx/XMLSchemaGraph.py: 99%

57 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""" 

32A Sphinx directive drawing an XML schema as a Graphviz graph. 

33 

34A schema's source says what a document may contain, in a shape that hides the structure: a reader looking for *what 

35contains what* has to follow named types through the file. This directive draws that structure instead: 

36 

37.. code-block:: ReST 

38 

39 .. xmlschema-graph:: ../../pyTooling/Resources/TestReport-v0.1.xsd 

40 :caption: The types of TestReport-v0.1.xsd. 

41 

42The model comes from :mod:`xmlschema`, so the picture is the schema as a validator sees it rather than as its source 

43text is laid out, and it is drawn at build time from the shipped file, so it cannot drift from it. 

44 

45.. attention:: 

46 

47 :mod:`xmlschema` is imported when the directive **runs**, not when this module is imported, so a project using the 

48 extension for its roles alone doesn't need it installed. It is part of the ``sphinx`` extra. 

49 

50.. seealso:: 

51 

52 :mod:`pyTooling.Sphinx.SchemaGraph` 

53 |rarr| The graph and the directive's base-class, shared by every schema language. 

54""" 

55from __future__ import annotations 

56 

57from pathlib import Path 

58from typing import TYPE_CHECKING, Generator 

59 

60from pyTooling.Decorators import export 

61from pyTooling.Exceptions import MissingDependencyError 

62from pyTooling.Graph.GraphViz import Edge, Node 

63from pyTooling.Sphinx.SchemaGraph import DotGraph, SchemaGraph 

64 

65if TYPE_CHECKING: # pragma: no cover 

66 from xmlschema.validators import XsdElement, XsdGroup, XsdType 

67 

68 

69@export 

70class XMLSchemaGraph(SchemaGraph): 

71 """ 

72 The ``xmlschema-graph`` directive: an XML schema, drawn from the schema itself. 

73 

74 One argument, the path of the schema relative to the document using the directive; ``:caption:`` puts a caption 

75 under the diagram. 

76 """ 

77 

78 directiveName: str = "xmlschema-graph" #: Name the directive is invoked by. 

79 

80 @staticmethod 

81 def _TypeName(xsdType: XsdType) -> str: 

82 """ 

83 Return a readable name for a type. 

84 

85 :param xsdType: The type to name. 

86 :returns: ``xsd:string`` for a builtin type, the local name for a named one, ``(anonymous)`` otherwise. 

87 """ 

88 if (name := xsdType.name) is None: 

89 return "(anonymous)" 

90 

91 if (localName := name.removeprefix("{http://www.w3.org/2001/XMLSchema}")) != name: 

92 return f"xsd:{localName}" 

93 

94 return name 

95 

96 @staticmethod 

97 def _Cardinality(element: XsdElement) -> str: 

98 """ 

99 Render an element's occurrence. 

100 

101 :param element: The element to render the occurrence of. 

102 :returns: ``lower..upper``, with ``*`` for an unbounded upper limit. 

103 """ 

104 lower, upper = element.occurs 

105 

106 return f"{lower}..{'*' if upper is None else upper}" 

107 

108 @classmethod 

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

110 """ 

111 Render an XML schema as a Graphviz graph. 

112 

113 Every complex type becomes a record of three compartments - its name, its attributes, and its simple-typed child 

114 elements with their cardinality - and every complex-typed child element becomes an edge, so containment and 

115 recursion are visible as edges rather than as repeated type names. A simple type earns a node of its own only 

116 when it is an enumeration, because its values are what a type name cannot say. 

117 

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

119 :returns: The graph in the DOT language. 

120 :raises MissingDependencyError: If :mod:`xmlschema` isn't installed. 

121 """ 

122 try: 

123 from xmlschema import XMLSchema 

124 from xmlschema.validators import XsdElement, XsdGroup 

125 except ImportError as ex: # pragma: no cover 

126 raise MissingDependencyError(dependency="xmlschema") from ex 

127 

128 def childElements(group: XsdGroup) -> Generator[XsdElement, None, None]: 

129 """ 

130 Yield every element of a content model, flattening the sequences and choices in between. 

131 

132 :param group: The content model to walk. 

133 :returns: Generator of the elements it holds, at any depth. 

134 """ 

135 for child in group: 

136 if isinstance(child, XsdGroup): 

137 yield from childElements(child) 

138 elif isinstance(child, XsdElement): 138 ↛ 135line 138 didn't jump to line 135 because the condition on line 138 was always true

139 yield child 

140 

141 schema = XMLSchema(str(schemaFile)) 

142 complexTypes = {name: xsdType for name, xsdType in schema.types.items() if xsdType.is_complex()} 

143 # sorted, because a set's iteration order varies between interpreter runs and a graph that is redrawn 

144 # identically is what lets a rebuilt page be compared to the one before it 

145 enumerations = sorted( 

146 name for name, xsdType in schema.types.items() if xsdType.is_simple() and xsdType.enumeration is not None 

147 ) 

148 

149 graph = DotGraph() 

150 

151 records: dict[str, Node] = {} 

152 for name, xsdType in complexTypes.items(): 

153 attributes = [ 

154 f"{attribute} : {cls._TypeName(xsdType.attributes[attribute].type)}" for attribute in xsdType.attributes 

155 ] 

156 elements = [ 

157 f"{child.name} : {cls._TypeName(child.type)} [{cls._Cardinality(child)}]" 

158 for child in childElements(xsdType.content) if child.type.is_simple() 

159 ] 

160 records[name] = graph.AddRecord(name, name, (attributes, elements)) 

161 

162 for name in enumerations: 

163 records[name] = graph.AddRecord( 

164 name, 

165 name, 

166 (schema.types[name].enumeration,), 

167 {"style": "filled", "fillcolor": "#f0f0f0"} 

168 ) 

169 

170 for name, xsdType in complexTypes.items(): 

171 for child in childElements(xsdType.content): 

172 if child.type.is_complex(): 

173 target = graph.GetOrAddNode(cls._TypeName(child.type)) 

174 graph.AddEdge(Edge(records[name], target, {"label": f"{child.name} [{cls._Cardinality(child)}]"})) 

175 

176 for name, xsdType in complexTypes.items(): 

177 used = {xsdType.attributes[attribute].type.name for attribute in xsdType.attributes} 

178 used |= {child.type.name for child in childElements(xsdType.content) if child.type.is_simple()} 

179 for usedType in sorted(usedType for usedType in used if usedType in enumerations): 

180 graph.AddEdge(Edge( 

181 records[name], 

182 records[usedType], 

183 {"style": "dashed", "arrowhead": "open", "constraint": False} 

184 )) 

185 

186 for name, element in schema.elements.items(): 

187 root = graph.AddNode(Node( 

188 f"<{name}>", 

189 name, 

190 {"shape": "doublecircle", "style": "filled", "fillcolor": "#e8e8ff"} 

191 )) 

192 graph.AddEdge(Edge(root, graph.GetOrAddNode(cls._TypeName(element.type)), {"label": "root"})) 

193 

194 return str(graph)