Coverage for pyTooling/Documentation/Sphinx/XSDSchemaGraph.py: 99%
57 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +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 .. xsd-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.Documentation.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.Documentation.Sphinx.SchemaGraph import DotGraph, SchemaGraph
64if TYPE_CHECKING: # pragma: no cover
65 from xmlschema.validators import XsdElement, XsdGroup, XsdType
68@export
69class XSDSchemaGraph(SchemaGraph):
70 """
71 The ``xsd-graph`` directive: an XML schema, drawn from the schema itself.
73 One argument, the path of the schema relative to the document using the directive; ``:caption:`` puts a caption
74 under the diagram.
75 """
77 directiveName: str = "xsd-graph" #: Name the directive is invoked by.
79 @staticmethod
80 def _TypeName(xsdType: XsdType) -> str:
81 """
82 Return a readable name for a type.
84 :param xsdType: The type to name.
85 :returns: ``xsd:string`` for a builtin type, the local name for a named one, ``(anonymous)`` otherwise.
86 """
87 if (name := xsdType.name) is None:
88 return "(anonymous)"
90 if (localName := name.removeprefix("{http://www.w3.org/2001/XMLSchema}")) != name:
91 return f"xsd:{localName}"
93 return name
95 @staticmethod
96 def _Cardinality(element: XsdElement) -> str:
97 """
98 Render an element's occurrence.
100 :param element: The element to render the occurrence of.
101 :returns: ``lower..upper``, with ``*`` for an unbounded upper limit.
102 """
103 lower, upper = element.occurs
105 return f"{lower}..{'*' if upper is None else upper}"
107 @classmethod
108 def _RenderGraph(cls, schemaFile: Path) -> str:
109 """
110 Render an XML schema as a Graphviz graph.
112 Every complex type becomes a record of three compartments - its name, its attributes, and its simple-typed child
113 elements with their cardinality - and every complex-typed child element becomes an edge, so containment and
114 recursion are visible as edges rather than as repeated type names. A simple type earns a node of its own only
115 when it is an enumeration, because its values are what a type name cannot say.
117 :param schemaFile: Path of the schema to render.
118 :returns: The graph in the DOT language.
119 :raises MissingDependencyError: If :mod:`xmlschema` isn't installed.
120 """
121 try:
122 from xmlschema import XMLSchema
123 from xmlschema.validators import XsdElement, XsdGroup
124 except ImportError as ex: # pragma: no cover
125 raise MissingDependencyError(dependency="xmlschema", extra="sphinx") from ex
127 def childElements(group: XsdGroup) -> Generator[XsdElement, None, None]:
128 """
129 Yield every element of a content model, flattening the sequences and choices in between.
131 :param group: The content model to walk.
132 :returns: Generator of the elements it holds, at any depth.
133 """
134 for child in group:
135 if isinstance(child, XsdGroup):
136 yield from childElements(child)
137 elif isinstance(child, XsdElement): 137 ↛ 134line 137 didn't jump to line 134 because the condition on line 137 was always true
138 yield child
140 schema = XMLSchema(str(schemaFile))
141 complexTypes = {name: xsdType for name, xsdType in schema.types.items() if xsdType.is_complex()}
142 # sorted, because a set's iteration order varies between interpreter runs and a graph that is redrawn
143 # identically is what lets a rebuilt page be compared to the one before it
144 enumerations = sorted(
145 name for name, xsdType in schema.types.items() if xsdType.is_simple() and xsdType.enumeration is not None
146 )
148 graph = DotGraph()
150 for name, xsdType in complexTypes.items():
151 attributes = [
152 f"{attribute} : {cls._TypeName(xsdType.attributes[attribute].type)}" for attribute in xsdType.attributes
153 ]
154 elements = [
155 f"{child.name} : {cls._TypeName(child.type)} [{cls._Cardinality(child)}]"
156 for child in childElements(xsdType.content) if child.type.is_simple()
157 ]
158 graph.AddRecord(name, name, (attributes, elements))
160 graph.AddSeparator()
161 for name, xsdType in complexTypes.items():
162 for child in childElements(xsdType.content):
163 if child.type.is_complex():
164 graph.AddEdge(name, cls._TypeName(child.type), label=f"{child.name} [{cls._Cardinality(child)}]")
166 graph.AddSeparator()
167 for name in enumerations:
168 graph.AddRecord(name, name, (schema.types[name].enumeration,), style="filled", fillcolor="#f0f0f0")
170 for name, xsdType in complexTypes.items():
171 used = {xsdType.attributes[attribute].type.name for attribute in xsdType.attributes}
172 used |= {child.type.name for child in childElements(xsdType.content) if child.type.is_simple()}
173 for usedType in sorted(usedType for usedType in used if usedType in enumerations):
174 graph.AddEdge(name, usedType, style="dashed", arrowhead="open", constraint="false")
176 graph.AddSeparator()
177 for name, element in schema.elements.items():
178 graph.AddNode(f"<{name}>", name, shape="doublecircle", style="filled", fillcolor="#e8e8ff")
179 graph.AddEdge(f"<{name}>", cls._TypeName(element.type), label="root")
181 return str(graph)