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:
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)