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:

Sphinx

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.")