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
« 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.
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:
37.. code-block:: ReST
39 .. xmlschema-graph:: ../../pyTooling/Resources/TestReport-v0.1.xsd
40 :caption: The types of TestReport-v0.1.xsd.
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.
45.. attention::
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.
50.. seealso::
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
57from pathlib import Path
58from typing import TYPE_CHECKING, Generator
60from pyTooling.Decorators import export
61from pyTooling.Exceptions import MissingDependencyError
62from pyTooling.Graph.GraphViz import Edge, Node
63from pyTooling.Sphinx.SchemaGraph import DotGraph, SchemaGraph
65if TYPE_CHECKING: # pragma: no cover
66 from xmlschema.validators import XsdElement, XsdGroup, XsdType
69@export
70class XMLSchemaGraph(SchemaGraph):
71 """
72 The ``xmlschema-graph`` directive: an XML schema, drawn from the schema itself.
74 One argument, the path of the schema relative to the document using the directive; ``:caption:`` puts a caption
75 under the diagram.
76 """
78 directiveName: str = "xmlschema-graph" #: Name the directive is invoked by.
80 @staticmethod
81 def _TypeName(xsdType: XsdType) -> str:
82 """
83 Return a readable name for a type.
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)"
91 if (localName := name.removeprefix("{http://www.w3.org/2001/XMLSchema}")) != name:
92 return f"xsd:{localName}"
94 return name
96 @staticmethod
97 def _Cardinality(element: XsdElement) -> str:
98 """
99 Render an element's occurrence.
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
106 return f"{lower}..{'*' if upper is None else upper}"
108 @classmethod
109 def _RenderGraph(cls, schemaFile: Path) -> str:
110 """
111 Render an XML schema as a Graphviz graph.
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.
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
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.
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
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 )
149 graph = DotGraph()
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))
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 )
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)}]"}))
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 ))
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"}))
194 return str(graph)