Coverage for pyTooling/Sphinx/SchemaGraph.py: 76%
58 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"""
32The language-neutral half of drawing a schema as a Graphviz graph in a Sphinx document.
34:class:`DotGraph` is a :class:`pyTooling.Graph.GraphViz.Graph` with the look every schema graph shares, and
35:class:`SchemaGraph` is the directive's base-class: its argument becomes a path, the file becomes a build dependency,
36and the DOT is handed to :mod:`sphinx.ext.graphviz`. A schema language adds a module reading its schemas into a
37:class:`DotGraph`, and a subclass naming the directive.
39.. seealso::
41 :mod:`pyTooling.Sphinx.XMLSchemaGraph`
42 |rarr| The ``xmlschema-graph`` directive, drawing an XML schema.
43 :mod:`pyTooling.Graph.GraphViz`
44 |rarr| The DOT model :class:`DotGraph` is built on.
45 :mod:`pyTooling.Sphinx`
46 |rarr| The extension this belongs to, and what else it brings.
47"""
48from __future__ import annotations
50from pathlib import Path
51from typing import Any, Mapping, Optional as Nullable, Sequence
53from docutils import nodes
54from sphinx.ext.graphviz import figure_wrapper, graphviz
56from pyTooling.Common import getFullyQualifiedName
57from pyTooling.Decorators import export
58from pyTooling.Graph.GraphViz import AttributeValue, Graph, Node, RecordField, RecordLabel
59from pyTooling.Sphinx import BaseDirective, strip
62@export
63class DotGraph(Graph):
64 """
65 A Graphviz graph of a schema, with the look every schema graph shares.
67 It flows from left to right, its nodes are records, and its nodes and edges use the same fonts, so two diagrams in
68 one document look alike. A renderer adds records, nodes and edges as to any :class:`~pyTooling.Graph.GraphViz.Graph`.
69 """
71 def __init__(self, identifier: str = "schema") -> None:
72 """
73 Initialize an empty graph carrying the shared attributes.
75 :param identifier: Optional, the graph's identifier. Default: ``schema``.
76 :raises ValueError: If parameter 'identifier' is None.
77 :raises TypeError: If parameter 'identifier' is not a string.
78 """
79 super().__init__(identifier, attributes={"rankdir": "LR", "nodesep": 0.4})
81 if identifier is None:
82 raise ValueError("Parameter 'identifier' is None.")
84 self._nodeDefaults["shape"] = "record"
85 self._nodeDefaults["fontname"] = "sans-serif"
86 self._nodeDefaults["fontsize"] = 10
87 self._edgeDefaults["fontname"] = "sans-serif"
88 self._edgeDefaults["fontsize"] = 9
90 def AddRecord(
91 self,
92 identifier: str,
93 title: str,
94 compartments: Sequence[Sequence[str]] = (),
95 attributes: Nullable[Mapping[str, AttributeValue]] = None
96 ) -> Node:
97 """
98 Add a record node: a titled box divided into compartments, one below the other.
100 :param identifier: Identifier of the node, which an edge names it by.
101 :param title: The record's title, written in guillemets.
102 :param compartments: Optional, the rows of each compartment below the title.
103 :param attributes: Optional, further attributes of the node, by name.
104 :returns: The added node.
105 :raises ValueError: If parameter 'title' is None.
106 :raises TypeError: If parameter 'title' is not a string.
107 """
108 if title is None:
109 raise ValueError("Parameter 'title' is None.")
110 elif not isinstance(title, str):
111 ex = TypeError("Parameter 'title' is not of type 'str'.")
112 ex.add_note(f"Got type '{getFullyQualifiedName(title)}'.")
113 raise ex
115 fields: list[RecordField] = [f"«{title}»"]
116 fields.extend(compartments)
118 return self.AddNode(Node(identifier, RecordLabel(fields, flipped=True), attributes))
120 def GetOrAddNode(self, identifier: str) -> Node:
121 """
122 Return the node with the given identifier, adding a plain node first if there is none.
124 An edge can point to a type the renderer has no record for, like a builtin or an anonymous type. Graphviz would
125 create such a node implicitly; here it is added explicitly and drawn with the node defaults.
127 :param identifier: Identifier of the node.
128 :returns: The node with that identifier.
129 :raises ValueError: If parameter 'identifier' is None.
130 :raises TypeError: If parameter 'identifier' is not a string.
131 """
132 if self.HasNode(identifier):
133 return self.GetNode(identifier)
135 return self.AddNode(Node(identifier))
138@export
139class SchemaGraph(BaseDirective):
140 """
141 Base-class of the directives drawing the schema file given as their argument.
143 It holds everything that isn't the schema's language: the path is resolved against the document using the
144 directive and registered as a dependency - so editing the schema rebuilds the page holding its diagram - and
145 whatever :meth:`_RenderGraph` returns is handed to :mod:`sphinx.ext.graphviz`, wrapped in a figure when a caption
146 was given. A derived class sets :attr:`~pyTooling.Sphinx.BaseDirective.directiveName` and
147 overrides :meth:`_RenderGraph`.
148 """
150 has_content = False #: A boolean; ``True`` if content is allowed.
151 required_arguments = 1 #: Number of required directive arguments: the schema's path.
152 optional_arguments = 0 #: Number of optional arguments after the required ones.
153 final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces.
154 # docutils declares 'option_spec' on 'Directive' and 'BaseDirective' assigns it, so mypy calls every
155 # spelling of this override a conflict with one of them
156 #: Mapping of option names to validator functions.
157 option_spec: dict[str, Any] = { # type: ignore[misc]
158 "caption": strip,
159 }
161 def run(self) -> list[nodes.Node]:
162 """
163 Read the schema and hand its graph to :mod:`sphinx.ext.graphviz` for rendering.
165 :returns: A ``graphviz`` node, wrapped in a figure when a caption was given, or the message of whatever went
166 wrong while reading the schema.
167 """
168 relativePath, absolutePath = self.env.relfn2path(self.arguments[0])
169 self.env.note_dependency(relativePath)
170 schemaFile = Path(absolutePath)
172 try:
173 code = self._RenderGraph(schemaFile)
174 except Exception as ex:
175 return self._internalError(
176 nodes.container(),
177 __name__,
178 f"{self.directiveName}: Couldn't draw schema '{schemaFile}'.",
179 ex
180 )
182 node = graphviz()
183 node["code"] = code
184 node["options"] = {"docname": self.env.docname}
185 node["alt"] = f"Diagram of {schemaFile.name}"
187 if (caption := self.options.get("caption", None)) is not None:
188 return [figure_wrapper(self, node, caption)]
190 return [node]
192 @classmethod
193 def _RenderGraph(cls, schemaFile: Path) -> str:
194 """
195 Render the schema as a graph.
197 :param schemaFile: Path of the schema to render.
198 :returns: The graph in the DOT language.
199 :raises NotImplementedError: If a derived class doesn't override it.
200 """
201 raise NotImplementedError(f"{cls.directiveName}: '_RenderGraph' is not implemented.")