Coverage for pyTooling/Documentation/Sphinx/SchemaGraph.py: 88%
112 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"""
32The language-neutral half of drawing a schema as a Graphviz graph in a Sphinx document.
34:class:`DotGraph` assembles the graph in the DOT language, and :class:`SchemaGraph` is the directive's base-class: its
35argument becomes a path, the file becomes a build dependency, and the DOT is handed to :mod:`sphinx.ext.graphviz`. A
36schema language adds a module reading its schemas into a :class:`DotGraph`, and a subclass naming the directive.
38.. seealso::
40 :mod:`pyTooling.Documentation.Sphinx.XSDSchemaGraph`
41 |rarr| The ``xsd-graph`` directive, drawing an XML schema.
42 :mod:`pyTooling.Documentation.Sphinx`
43 |rarr| The extension this belongs to, and what else it brings.
44"""
45from __future__ import annotations
47from pathlib import Path
48from typing import Any, Iterable, Sequence
50from docutils import nodes
51from sphinx.ext.graphviz import figure_wrapper, graphviz
53from pyTooling.Common import getFullyQualifiedName
54from pyTooling.Decorators import export
55from pyTooling.MetaClasses import ExtendedType
56from pyTooling.Documentation.Sphinx.Directives import BaseDirective, strip
59__all__ = ["GRAPH_ATTRIBUTES"]
61#: Attributes every schema graph is drawn with, so two diagrams in one document look alike.
62GRAPH_ATTRIBUTES = (
63 "rankdir=LR;",
64 "nodesep=0.4;",
65 'node [shape=record, fontname="sans-serif", fontsize=10];',
66 'edge [fontname="sans-serif", fontsize=9];',
67)
70@export
71class DotGraph(metaclass=ExtendedType, slots=True):
72 """
73 A Graphviz graph in the DOT language, assembled statement by statement.
75 A renderer states nodes and edges; where the graph flows, how a record is shaped and which fonts it uses are
76 :data:`GRAPH_ATTRIBUTES` and belong to every schema graph alike. Attribute values are quoted without exception,
77 which is always legal in DOT and saves a caller from deciding per value.
78 """
80 _name: str #: Name of the graph, which Graphviz uses as the drawing's identifier.
81 _statements: list[str] #: The statements written so far, one per line, already indented.
83 def __init__(self, name: str = "schema") -> None:
84 """
85 Initialize an empty graph carrying the shared attributes.
87 :param name: Optional, the graph's name.
88 :raises ValueError: If parameter 'name' is None.
89 :raises TypeError: If parameter 'name' is not a string.
90 """
91 if name is None:
92 raise ValueError("Parameter 'name' is None.")
93 elif not isinstance(name, str):
94 ex = TypeError("Parameter 'name' is not of type 'str'.")
95 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
96 raise ex
98 self._name = name
99 self._statements = [f"\t{attribute}" for attribute in GRAPH_ATTRIBUTES]
100 self.AddSeparator()
102 @staticmethod
103 def EscapeLabel(text: str) -> str:
104 """
105 Escape the characters a Graphviz record label gives a meaning to.
107 :param text: The text to escape.
108 :returns: The text, safe to put into a record label.
109 """
110 for character in ("\\", "{", "}", "|", "<", ">", '"'):
111 text = text.replace(character, f"\\{character}")
113 return text
115 @classmethod
116 def _Compartment(cls, rows: Sequence[str]) -> str:
117 """
118 Join the rows of one record compartment, left-aligned.
120 :param rows: The rows to join, unescaped.
121 :returns: The compartment's content, or a single space when there are no rows - an empty compartment
122 collapses, which makes the records of a graph differently shaped.
123 """
124 if len(rows) == 0:
125 return " "
127 return "".join(f"{cls.EscapeLabel(row)}\\l" for row in rows)
129 @staticmethod
130 def _Attributes(attributes: dict[str, str]) -> str:
131 """
132 Render an attribute list, quoting every value.
134 :param attributes: The attributes to render, keyed by name.
135 :returns: The attribute list in brackets, or an empty string when there are none.
136 """
137 if len(attributes) == 0: 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true
138 return ""
140 return "[" + ", ".join(f'{name}="{value}"' for name, value in attributes.items()) + "]"
142 def AddSeparator(self) -> None:
143 """Add a blank line, so the generated DOT reads in the groups it was written in."""
144 self._statements.append("")
146 def AddNode(self, identifier: str, label: str, **attributes: str) -> None:
147 """
148 Add a node.
150 :param identifier: Identifier of the node, which an edge names it by.
151 :param label: The node's label, already escaped.
152 :param attributes: Further attributes of the node.
153 :raises ValueError: If parameter 'identifier' or 'label' is None.
154 :raises TypeError: If parameter 'identifier' or 'label' is not a string.
155 """
156 if identifier is None:
157 raise ValueError("Parameter 'identifier' is None.")
158 elif not isinstance(identifier, str):
159 ex = TypeError("Parameter 'identifier' is not of type 'str'.")
160 ex.add_note(f"Got type '{getFullyQualifiedName(identifier)}'.")
161 raise ex
163 if label is None:
164 raise ValueError("Parameter 'label' is None.")
165 elif not isinstance(label, str):
166 ex = TypeError("Parameter 'label' is not of type 'str'.")
167 ex.add_note(f"Got type '{getFullyQualifiedName(label)}'.")
168 raise ex
170 nodeAttributes = {"label": label}
171 nodeAttributes.update(attributes)
173 self._statements.append(f'\t"{identifier}" {self._Attributes(nodeAttributes)};')
175 def AddRecord(
176 self,
177 identifier: str,
178 title: str,
179 compartments: Iterable[Sequence[str]] = (),
180 **attributes: str
181 ) -> None:
182 """
183 Add a record node: a titled box divided into compartments.
185 :param identifier: Identifier of the node, which an edge names it by.
186 :param title: The record's title, written in guillemets.
187 :param compartments: Optional, the rows of each compartment below the title.
188 :param attributes: Further attributes of the node.
189 :raises ValueError: If parameter 'identifier' or 'title' is None.
190 :raises TypeError: If parameter 'identifier' or 'title' is not a string.
191 """
192 if title is None:
193 raise ValueError("Parameter 'title' is None.")
194 elif not isinstance(title, str):
195 ex = TypeError("Parameter 'title' is not of type 'str'.")
196 ex.add_note(f"Got type '{getFullyQualifiedName(title)}'.")
197 raise ex
199 label = f"«{self.EscapeLabel(title)}»"
200 for rows in compartments:
201 label += f"|{self._Compartment(rows)}"
203 self.AddNode(identifier, f"{{{label}}}", **attributes)
205 def AddEdge(self, source: str, target: str, **attributes: str) -> None:
206 """
207 Add an edge between two nodes.
209 An attribute's value is quoted but not escaped - :meth:`AddRecord` is the only method escaping what it is
210 given. A label assembled from something a schema *author* wrote, rather than from a name a schema language
211 constrains, has to go through :meth:`EscapeLabel` first.
213 :param source: Identifier of the node the edge starts at.
214 :param target: Identifier of the node the edge points to.
215 :param attributes: Further attributes of the edge.
216 :raises ValueError: If parameter 'source' or 'target' is None.
217 :raises TypeError: If parameter 'source' or 'target' is not a string.
218 """
219 if source is None:
220 raise ValueError("Parameter 'source' is None.")
221 elif not isinstance(source, str):
222 ex = TypeError("Parameter 'source' is not of type 'str'.")
223 ex.add_note(f"Got type '{getFullyQualifiedName(source)}'.")
224 raise ex
226 if target is None:
227 raise ValueError("Parameter 'target' is None.")
228 elif not isinstance(target, str):
229 ex = TypeError("Parameter 'target' is not of type 'str'.")
230 ex.add_note(f"Got type '{getFullyQualifiedName(target)}'.")
231 raise ex
233 if len(attributes) == 0:
234 self._statements.append(f'\t"{source}" -> "{target}";')
235 else:
236 self._statements.append(f'\t"{source}" -> "{target}" {self._Attributes(attributes)};')
238 def __str__(self) -> str:
239 """
240 Render the graph.
242 :returns: The graph in the DOT language.
243 """
244 statements = "\n".join(self._statements)
246 return f"digraph {self._name} {{\n{statements}\n}}"
249@export
250class SchemaGraph(BaseDirective):
251 """
252 Base-class of the directives drawing the schema file given as their argument.
254 It holds everything that isn't the schema's language: the path is resolved against the document using the
255 directive and registered as a dependency - so editing the schema rebuilds the page holding its diagram - and
256 whatever :meth:`_RenderGraph` returns is handed to :mod:`sphinx.ext.graphviz`, wrapped in a figure when a caption
257 was given. A derived class sets :attr:`~pyTooling.Documentation.Sphinx.Directives.BaseDirective.directiveName` and
258 overrides :meth:`_RenderGraph`.
259 """
261 has_content = False #: A boolean; ``True`` if content is allowed.
262 required_arguments = 1 #: Number of required directive arguments: the schema's path.
263 optional_arguments = 0 #: Number of optional arguments after the required ones.
264 final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces.
265 # docutils declares 'option_spec' on 'Directive' and 'BaseDirective' assigns it, so mypy calls every
266 # spelling of this override a conflict with one of them
267 #: Mapping of option names to validator functions.
268 option_spec: dict[str, Any] = { # type: ignore[misc]
269 "caption": strip,
270 }
272 def run(self) -> list[nodes.Node]:
273 """
274 Read the schema and hand its graph to :mod:`sphinx.ext.graphviz` for rendering.
276 :returns: A ``graphviz`` node, wrapped in a figure when a caption was given, or the message of whatever went
277 wrong while reading the schema.
278 """
279 relativePath, absolutePath = self.env.relfn2path(self.arguments[0])
280 self.env.note_dependency(relativePath)
281 schemaFile = Path(absolutePath)
283 try:
284 code = self._RenderGraph(schemaFile)
285 except Exception as ex:
286 return self._internalError(
287 nodes.container(),
288 __name__,
289 f"{self.directiveName}: Couldn't draw schema '{schemaFile}'.",
290 ex
291 )
293 node = graphviz()
294 node["code"] = code
295 node["options"] = {"docname": self.env.docname}
296 node["alt"] = f"Diagram of {schemaFile.name}"
298 if (caption := self.options.get("caption", None)) is not None:
299 return [figure_wrapper(self, node, caption)]
301 return [node]
303 @classmethod
304 def _RenderGraph(cls, schemaFile: Path) -> str:
305 """
306 Render the schema as a graph.
308 :param schemaFile: Path of the schema to render.
309 :returns: The graph in the DOT language.
310 :raises NotImplementedError: If a derived class doesn't override it.
311 """
312 raise NotImplementedError(f"{cls.directiveName}: '_RenderGraph' is not implemented.")