XMLSchemaGraph.py

Summary

Lines:

57 of 57 covered (100.0%)

Excluded lines:

4

Branches:

25 of 26 taken (96.2%), 1 lines partially

Coverage:

98.8%

Directory:

Sphinx

Source

pyTooling/Sphinx/XMLSchemaGraph.pycoveredpartialuncoveredexcludednot executable
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):
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)