pyTooling.Sphinx.XMLSchemaGraph

pyTooling/Sphinx/XMLSchemaGraph.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# ==================================================================================================================== #
#             _____           _ _               ____        _     _                                                    #
#  _ __  _   |_   _|__   ___ | (_)_ __   __ _  / ___| _ __ | |__ (_)_ __ __  __                                        #
# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| '_ \| '_ \| | '_ \\ \/ /                                        #
# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | |_) | | | | | | | |>  <                                         #
# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/| .__/|_| |_|_|_| |_/_/\_\                                        #
# |_|    |___/                          |___/        |_|                                                               #
# ==================================================================================================================== #
# Authors:                                                                                                             #
#   Patrick Lehmann                                                                                                    #
#                                                                                                                      #
# License:                                                                                                             #
# ==================================================================================================================== #
# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany                                                             #
#                                                                                                                      #
# Licensed under the Apache License, Version 2.0 (the "License");                                                      #
# you may not use this file except in compliance with the License.                                                     #
# You may obtain a copy of the License at                                                                              #
#                                                                                                                      #
#   http://www.apache.org/licenses/LICENSE-2.0                                                                         #
#                                                                                                                      #
# Unless required by applicable law or agreed to in writing, software                                                  #
# distributed under the License is distributed on an "AS IS" BASIS,                                                    #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.                                             #
# See the License for the specific language governing permissions and                                                  #
# limitations under the License.                                                                                       #
#                                                                                                                      #
# SPDX-License-Identifier: Apache-2.0                                                                                  #
# ==================================================================================================================== #
#
"""
A Sphinx directive drawing an XML schema as a Graphviz graph.

A schema's source says what a document may contain, in a shape that hides the structure: a reader looking for *what
contains what* has to follow named types through the file. This directive draws that structure instead:

.. code-block:: ReST

   .. xmlschema-graph:: ../../pyTooling/Resources/TestReport-v0.1.xsd
      :caption: The types of TestReport-v0.1.xsd.

The model comes from :mod:`xmlschema`, so the picture is the schema as a validator sees it rather than as its source
text is laid out, and it is drawn at build time from the shipped file, so it cannot drift from it.

.. attention::

   :mod:`xmlschema` is imported when the directive **runs**, not when this module is imported, so a project using the
   extension for its roles alone doesn't need it installed. It is part of the ``sphinx`` extra.

.. seealso::

   :mod:`pyTooling.Sphinx.SchemaGraph`
      |rarr| The graph and the directive's base-class, shared by every schema language.
"""
from __future__                   import annotations

from pathlib                      import Path
from typing                       import TYPE_CHECKING, Generator

from pyTooling.Decorators         import export
from pyTooling.Exceptions         import MissingDependencyError
from pyTooling.Graph.GraphViz     import Edge, Node
from pyTooling.Sphinx.SchemaGraph import DotGraph, SchemaGraph

if TYPE_CHECKING:  # pragma: no cover
	from xmlschema.validators       import XsdElement, XsdGroup, XsdType


@export
class XMLSchemaGraph(SchemaGraph):
	"""
	The ``xmlschema-graph`` directive: an XML schema, drawn from the schema itself.

	One argument, the path of the schema relative to the document using the directive; ``:caption:`` puts a caption
	under the diagram.
	"""

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

	@staticmethod
	def _TypeName(xsdType: XsdType) -> str:
		"""
		Return a readable name for a type.

		:param xsdType: The type to name.
		:returns:       ``xsd:string`` for a builtin type, the local name for a named one, ``(anonymous)`` otherwise.
		"""
		if (name := xsdType.name) is None:
			return "(anonymous)"

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

		return name

	@staticmethod
	def _Cardinality(element: XsdElement) -> str:
		"""
		Render an element's occurrence.

		:param element: The element to render the occurrence of.
		:returns:       ``lower..upper``, with ``*`` for an unbounded upper limit.
		"""
		lower, upper = element.occurs

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

	@classmethod
	def _RenderGraph(cls, schemaFile: Path) -> str:
		"""
		Render an XML schema as a Graphviz graph.

		Every complex type becomes a record of three compartments - its name, its attributes, and its simple-typed child
		elements with their cardinality - and every complex-typed child element becomes an edge, so containment and
		recursion are visible as edges rather than as repeated type names. A simple type earns a node of its own only
		when it is an enumeration, because its values are what a type name cannot say.

		:param schemaFile:              Path of the schema to render.
		:returns:                       The graph in the DOT language.
		:raises MissingDependencyError: If :mod:`xmlschema` isn't installed.
		"""
		try:
			from xmlschema            import XMLSchema
			from xmlschema.validators import XsdElement, XsdGroup
		except ImportError as ex:  # pragma: no cover
			raise MissingDependencyError(dependency="xmlschema") from ex

		def childElements(group: XsdGroup) -> Generator[XsdElement, None, None]:
			"""
			Yield every element of a content model, flattening the sequences and choices in between.

			:param group: The content model to walk.
			:returns:     Generator of the elements it holds, at any depth.
			"""
			for child in group:
				if isinstance(child, XsdGroup):
					yield from childElements(child)
				elif isinstance(child, XsdElement):
					yield child

		schema = XMLSchema(str(schemaFile))
		complexTypes = {name: xsdType for name, xsdType in schema.types.items() if xsdType.is_complex()}
		# sorted, because a set's iteration order varies between interpreter runs and a graph that is redrawn
		# identically is what lets a rebuilt page be compared to the one before it
		enumerations = sorted(
			name for name, xsdType in schema.types.items() if xsdType.is_simple() and xsdType.enumeration is not None
		)

		graph = DotGraph()

		records: dict[str, Node] = {}
		for name, xsdType in complexTypes.items():
			attributes = [
				f"{attribute} : {cls._TypeName(xsdType.attributes[attribute].type)}" for attribute in xsdType.attributes
			]
			elements = [
				f"{child.name} : {cls._TypeName(child.type)} [{cls._Cardinality(child)}]"
				for child in childElements(xsdType.content) if child.type.is_simple()
			]
			records[name] = graph.AddRecord(name, name, (attributes, elements))

		for name in enumerations:
			records[name] = graph.AddRecord(
				name,
				name,
				(schema.types[name].enumeration,),
				{"style": "filled", "fillcolor": "#f0f0f0"}
			)

		for name, xsdType in complexTypes.items():
			for child in childElements(xsdType.content):
				if child.type.is_complex():
					target = graph.GetOrAddNode(cls._TypeName(child.type))
					graph.AddEdge(Edge(records[name], target, {"label": f"{child.name} [{cls._Cardinality(child)}]"}))

		for name, xsdType in complexTypes.items():
			used = {xsdType.attributes[attribute].type.name for attribute in xsdType.attributes}
			used |= {child.type.name for child in childElements(xsdType.content) if child.type.is_simple()}
			for usedType in sorted(usedType for usedType in used if usedType in enumerations):
				graph.AddEdge(Edge(
					records[name],
					records[usedType],
					{"style": "dashed", "arrowhead": "open", "constraint": False}
				))

		for name, element in schema.elements.items():
			root = graph.AddNode(Node(
				f"<{name}>",
				name,
				{"shape": "doublecircle", "style": "filled", "fillcolor": "#e8e8ff"}
			))
			graph.AddEdge(Edge(root, graph.GetOrAddNode(cls._TypeName(element.type)), {"label": "root"}))

		return str(graph)