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