Source code for pyTooling.Sphinx.XMLSchemaGraph

# ==================================================================================================================== #
#             _____           _ _               ____        _     _                                                    #
#  _ __  _   |_   _|__   ___ | (_)_ __   __ _  / ___| _ __ | |__ (_)_ __ __  __                                        #
# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| '_ \| '_ \| | '_ \\ \/ /                                        #
# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | |_) | | | | | | | |>  <                                         #
# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/| .__/|_| |_|_|_| |_/_/\_\                                        #
# |_|    |___/                          |___/        |_|                                                               #
# ==================================================================================================================== #
# 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


[docs] @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.
[docs] @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
[docs] @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}"
[docs] @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)