Coverage for pyTooling/GitLab/Sphinx/Graph.py: 95%
254 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-18 15:00 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-18 15:00 +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"""
32A Sphinx directive drawing the pipeline a GitLab CI/CD pipeline file defines as a Graphviz graph.
34The jobs of a pipeline file and the dependencies between them - the ``needs``, and the stages for a job without - are
35its pipeline. This directive draws it at build time from the pipeline file:
37.. code-block:: ReST
39 .. glci:pipeline-graph:: ../.gitlab-ci.yml
40 :depth: 1
42A job is a node labelled with its name and stage. A trigger job starting a child pipeline, whose files are found
43locally, is expanded into a cluster of the child pipeline's jobs, as many levels deep as ``:depth:`` says. In HTML, a
44trigger job links to the page documenting its child pipeline, if the ``glci`` domain knows one.
46.. seealso::
48 :mod:`pyTooling.GitLab.PipelineFile`
49 |rarr| The model of a pipeline file the graph is drawn from.
50 :mod:`pyTooling.CI`
51 |rarr| The service-independent model of a pipeline, which a pipeline file is converted into.
52"""
53from __future__ import annotations
55from html import escape as html_escape
56from pathlib import Path, PurePosixPath
57from re import Match, compile as re_compile
58from typing import TYPE_CHECKING, Any, Optional as Nullable
60from docutils import nodes
61from docutils.parsers.rst import directives
62from sphinx.application import Sphinx
63from sphinx.ext.graphviz import align_spec, figure_wrapper, graphviz
65from pyTooling.CI import Base as CIBase, Matrix as CIMatrix, RuleOutcome, Workflow as CIWorkflow
66from pyTooling.Common import getFullyQualifiedName
67from pyTooling.Decorators import export, readonly
68from pyTooling.Graph import Vertex
69from pyTooling.MetaClasses import ExtendedType
70from pyTooling.Sphinx import BaseDirective, SphinxExtensionError, strip, stripAndNormalize
72if TYPE_CHECKING: # pragma: no cover
73 from pyTooling.GitLab.PipelineFile import IncludeResolver, Job, PipelineFile
76__all__ = ["GRAPH_ATTRIBUTES", "CSS_CLASS", "LINK_MARKER"]
78#: Attributes every pipeline graph is drawn with.
79GRAPH_ATTRIBUTES = (
80 "compound=true;",
81 "newrank=true;",
82 'fontname="sans-serif";',
83 'fontsize="10";',
84 "nodesep=0.25;",
85 "ranksep=0.4;",
86 'node [shape="box", style="rounded,filled", color="#5f6b7a", fillcolor="#fbe7d6", penwidth="1.0",',
87 ' fontname="sans-serif", fontsize="10"];',
88 'edge [color="#5f6b7a", arrowsize="0.7"];',
89)
91#: CSS class of a pipeline graph's ``graphviz`` node, which is also how :func:`resolveLinks` finds it.
92CSS_CLASS = "glci-pipeline-graph"
94#: A DOT comment, and the whitespace before it, standing where a trigger job's link belongs, until :func:`resolveLinks`
95#: replaces it by the link or removes it.
96LINK_MARKER = re_compile(r"(?P<space>\s*)/\*glci-link:(?P<name>[^*]*)\*/")
99@export
100class PipelineDotGraph(metaclass=ExtendedType, slots=True):
101 """
102 The pipeline of a pipeline file in the DOT language: its jobs as nodes, their dependencies as edges.
104 The pipeline file is converted by :meth:`PipelineFile.ToPipeline
105 <pyTooling.GitLab.PipelineFile.PipelineFile.ToPipeline>` into a :mod:`pyTooling.CI` model, and that by
106 :meth:`~pyTooling.CI.Workflow.ToGraph` into a :class:`~pyTooling.Graph.Graph`, which is drawn: a vertex is a node, an
107 edge an edge, and a trigger job or a parallel job with a :class:`~pyTooling.Graph.Subgraph` a cluster of the vertices
108 it links to.
110 The kind of an element is its node's shape and style:
112 * a job running a script is a box with square corners, labelled with its name and stage;
113 * a trigger job starting a child pipeline is a rounded box, labelled with the child pipeline's files; one starting
114 another project's pipeline is a white rounded box, labelled with the project - it is never expanded;
115 * a job whose rules have conditions - ``rules:if`` - is dashed, and its tooltip lists them, one per rule; a manual
116 job is dashed too, and labelled *manual*;
117 * a job with ``parallel`` is a cluster of its instances, labelled with the matrix' dimensions or the count of
118 instances.
120 A trigger job whose child pipeline :class:`~pyTooling.GitLab.PipelineFile.IncludeResolver` reads is drawn as a
121 cluster of the child pipeline's jobs instead, until the depth is used up. An edge to or from a job drawn as a cluster
122 ends at the cluster's border.
124 The graph's edges read *needs*; an arrow is drawn the other way round, from the needed job to the job needing it,
125 in the direction the pipeline runs. The graph is drawn in file order, so the same file is always drawn as the same
126 DOT.
127 """
129 _link: bool #: Whether a trigger job starting a child pipeline is marked for a link.
130 _statements: list[str] #: The statements written so far, one per line, already indented.
131 _jobs: list[Job] #: The jobs drawn, each once, in the order they were drawn.
132 _files: list[PipelineFile] #: The pipeline files drawn, each once, the drawn file first.
134 def __init__(
135 self,
136 pipelineFile: PipelineFile,
137 resolver: Nullable[IncludeResolver] = None,
138 direction: str = "LR",
139 depth: int = 0,
140 reduce: bool = True,
141 link: bool = True
142 ) -> None:
143 """
144 Initializes the graph by drawing a pipeline file.
146 :param pipelineFile: The pipeline file to draw.
147 :param resolver: Optional, the resolver reading the files of the child pipelines. Without it, no child
148 pipeline is expanded. Default: ``None``.
149 :param direction: Optional, direction the pipeline flows in: ``LR`` (left to right) or ``TB`` (top to
150 bottom). Default: ``LR``.
151 :param depth: Optional, levels of child pipelines expanded into clusters. Default: ``0``.
152 :param reduce: Optional, drop an edge a longer path implies. Default: ``True``.
153 :param link: Optional, mark a trigger job starting a child pipeline for :func:`resolveLinks`.
154 Default: ``True``.
155 :raises ValueError: If parameter 'pipelineFile' is None.
156 :raises TypeError: If parameter 'pipelineFile' is not of type
157 :class:`~pyTooling.GitLab.PipelineFile.PipelineFile`.
158 :raises TypeError: If parameter 'resolver' is not of type
159 :class:`~pyTooling.GitLab.PipelineFile.IncludeResolver`.
160 :raises ValueError: If parameter 'direction' is None.
161 :raises TypeError: If parameter 'direction' is not of type :class:`str`.
162 :raises ValueError: If parameter 'direction' is neither ``LR`` nor ``TB``.
163 :raises ValueError: If parameter 'depth' is None.
164 :raises TypeError: If parameter 'depth' is not of type :class:`int`.
165 :raises ValueError: If parameter 'depth' is negative.
166 :raises ValueError: If parameter 'reduce' is None.
167 :raises TypeError: If parameter 'reduce' is not of type :class:`bool`.
168 :raises ValueError: If parameter 'link' is None.
169 :raises TypeError: If parameter 'link' is not of type :class:`bool`.
170 :raises PipelineFileError: If a child pipeline's file to expand is not a well-formed configuration.
171 """
172 from pyTooling.GitLab.PipelineFile import IncludeResolver, PipelineFile
174 if pipelineFile is None:
175 raise ValueError("Parameter 'pipelineFile' is None.")
176 elif not isinstance(pipelineFile, PipelineFile):
177 ex = TypeError("Parameter 'pipelineFile' is not of type 'PipelineFile'.")
178 ex.add_note(f"Got type '{getFullyQualifiedName(pipelineFile)}'.")
179 raise ex
181 if resolver is not None and not isinstance(resolver, IncludeResolver):
182 ex = TypeError("Parameter 'resolver' is not of type 'IncludeResolver'.")
183 ex.add_note(f"Got type '{getFullyQualifiedName(resolver)}'.")
184 raise ex
186 if direction is None:
187 raise ValueError("Parameter 'direction' is None.")
188 elif not isinstance(direction, str):
189 ex = TypeError("Parameter 'direction' is not of type 'str'.")
190 ex.add_note(f"Got type '{getFullyQualifiedName(direction)}'.")
191 raise ex
192 elif direction not in ("LR", "TB"):
193 ex = ValueError("Parameter 'direction' is neither 'LR' nor 'TB'.")
194 ex.add_note(f"Got value '{direction}'.")
195 raise ex
197 if depth is None:
198 raise ValueError("Parameter 'depth' is None.")
199 elif not isinstance(depth, int) or isinstance(depth, bool):
200 ex = TypeError("Parameter 'depth' is not of type 'int'.")
201 ex.add_note(f"Got type '{getFullyQualifiedName(depth)}'.")
202 raise ex
203 elif depth < 0:
204 ex = ValueError("Parameter 'depth' is negative.")
205 ex.add_note(f"Got value '{depth}'.")
206 raise ex
208 for parameterName, value in (("reduce", reduce), ("link", link)):
209 if value is None:
210 raise ValueError(f"Parameter '{parameterName}' is None.")
211 elif not isinstance(value, bool):
212 ex = TypeError(f"Parameter '{parameterName}' is not of type 'bool'.")
213 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
214 raise ex
216 self._link = link
217 self._statements = [f"\trankdir={direction};", *(f"\t{attribute}" for attribute in GRAPH_ATTRIBUTES), ""]
218 self._jobs = []
219 self._files = [pipelineFile]
221 graph = pipelineFile.ToPipeline(resolver, depth).ToGraph(reduce=reduce)
222 self._DrawVertices(list(graph.IterateVertices()), "", "\t")
224 @readonly
225 def Jobs(self) -> list[Job]:
226 """
227 Read-only property to access the jobs drawn (:attr:`_jobs`).
229 A child pipeline expanded twice is drawn twice, but its jobs are listed once.
231 :returns: The jobs of every pipeline file drawn, in the order they were drawn.
232 """
233 return self._jobs
235 @readonly
236 def Files(self) -> list[PipelineFile]:
237 """
238 Read-only property to access the pipeline files drawn (:attr:`_files`).
240 :returns: The drawn pipeline file, followed by every child pipeline's file expanded into a cluster, each once.
241 """
242 return self._files
244 @staticmethod
245 def _Quote(text: str) -> str:
246 """
247 Quote a text as a DOT string, keeping its line breaks as escape sequences.
249 :param text: The text to quote.
250 :returns: The text in double quotes.
251 """
252 text = text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
254 return f'"{text}"'
256 @staticmethod
257 def _Label(title: str, lines: list[str]) -> str:
258 """
259 Render an HTML-like label: a title and, below it, lines in a smaller font.
261 :param title: The title, unescaped.
262 :param lines: The lines below the title, unescaped.
263 :returns: The label in angle brackets, as DOT writes an HTML-like label.
264 """
265 label = html_escape(title, quote=False)
266 for line in lines:
267 label += f'<br/><font point-size="8" color="#3d4652">{html_escape(line, quote=False)}</font>'
269 return f"<{label}>"
271 @staticmethod
272 def _Describe(element: CIBase) -> tuple[list[str], list[str], str]:
273 """
274 Describe an element of the pipeline: the lines of its label, the parts of its style, and its tooltip.
276 :param element: The job, matrix or trigger job to describe.
277 :returns: The label's lines below the element's name, the style's parts, and the tooltip - the pipeline
278 started and the rules' conditions, one per line, or an empty string.
279 """
281 job = element.Definition
282 lines = [] if isinstance(element.Parent, CIMatrix) else [f"stage: {job.Stage}"]
283 style = []
284 tooltip = []
286 if (trigger := job.Trigger) is not None:
287 if trigger.Project is not None:
288 lines.append(f"project: {trigger.Project}")
289 tooltip.append(f"trigger: {trigger.Project}")
290 else:
291 locations = [include.Location for include in trigger.Includes]
292 lines.extend(locations)
293 tooltip.append(f"trigger: {', '.join(locations)}")
295 if isinstance(element, CIMatrix) and (parallel := job.Parallel) is not None:
296 if parallel.IsMatrix:
297 dimensions = dict.fromkeys(name for entry in parallel.Matrix for name in entry)
298 lines.append(f"matrix: {', '.join(dimensions)}")
299 else:
300 lines.append(f"parallel: {parallel.Count}")
302 if job.When is RuleOutcome.Manual:
303 lines.append("manual")
304 style.append("dashed")
306 conditions = [rule.Condition for rule in element.Rules if rule.Condition is not None]
307 if len(conditions) > 0:
308 if "dashed" not in style: 308 ↛ 311line 308 didn't jump to line 311 because the condition on line 308 was always true
309 style.append("dashed")
311 tooltip.extend(f"if: {condition}" for condition in conditions)
313 return lines, style, "\n".join(tooltip)
315 def _DrawNode(self, element: CIBase, identifier: str, indent: str) -> None:
316 """
317 Draw a job, a matrix or a trigger job that isn't expanded as a node.
319 :param element: The element to draw.
320 :param identifier: Identifier of the node.
321 :param indent: Indentation of the node's statement.
322 """
323 lines, style, tooltip = self._Describe(element)
325 if (trigger := element.Definition.Trigger) is None:
326 attributes = {"style": ",".join(("filled", *style)), "fillcolor": "#f2f2f2"}
327 elif trigger.Project is None:
328 attributes = {"style": ",".join(("rounded", "filled", *style))}
329 else:
330 attributes = {"style": ",".join(("rounded", "filled", *style)), "fillcolor": "#ffffff"}
332 if isinstance(element, CIMatrix): 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true
333 attributes["peripheries"] = "2"
335 statement = f"{indent}{self._Quote(identifier)} [label={self._Label(str(element), lines)}"
336 for name, value in attributes.items():
337 statement += f", {name}={self._Quote(value)}"
339 if tooltip != "":
340 statement += f", tooltip={self._Quote(tooltip)}"
342 if self._link and (name := self._ChildPipelineName(element)) is not None:
343 statement += f" /*glci-link:{name}*/"
345 self._statements.append(f"{statement}];")
347 @staticmethod
348 def _ChildPipelineName(element: CIBase) -> Nullable[str]:
349 """
350 Return the name a trigger job's child pipeline is documented by: the stem of its first local file.
352 :param element: The element.
353 :returns: The stem, as ``child`` for ``/ci/child.yml``, or ``None`` if the element starts no child pipeline
354 from a local file.
355 """
356 from pyTooling.GitLab.PipelineFile import IncludeKind
358 if (trigger := element.Definition.Trigger) is None or trigger.Project is not None:
359 return None
361 for include in trigger.Includes: 361 ↛ 365line 361 didn't jump to line 365 because the loop on line 361 didn't complete
362 if include.Kind is IncludeKind.Local: 362 ↛ 361line 362 didn't jump to line 361 because the condition on line 362 was always true
363 return PurePosixPath(include.Location).stem
365 return None
367 def _OpenCluster(self, element: CIBase, cluster: str, indent: str) -> None:
368 """
369 Open a cluster drawing a trigger job or a matrix: write its ``subgraph`` statement and its attributes.
371 :param element: The trigger job or the matrix.
372 :param cluster: Identifier of the cluster.
373 :param indent: Indentation of the cluster's statement.
374 """
375 lines, style, tooltip = self._Describe(element)
376 self._statements.append(f"{indent}subgraph {self._Quote(cluster)} {{")
377 self._statements.append(f"{indent}\tlabel={self._Label(str(element), lines)};")
378 self._statements.append(f'{indent}\tlabeljust="l";')
379 self._statements.append(f"{indent}\tstyle={self._Quote(','.join(('rounded', 'filled', *style)))};")
380 self._statements.append(f'{indent}\tcolor="#8a9bb2";')
381 self._statements.append(f'{indent}\tfillcolor="#fdf6f0";')
382 if tooltip != "":
383 self._statements.append(f"{indent}\ttooltip={self._Quote(tooltip)};")
385 def _DrawVertices(self, vertices: list[Vertex], prefix: str, indent: str) -> tuple[str, str]:
386 """
387 Draw the vertices of one level of the graph - the graph itself, or the subgraph of a trigger job or of a matrix -
388 and their edges.
390 An edge of the graph goes from the element needing to the element it needs; its arrow is drawn the other way round.
392 :param vertices: The vertices, in file order.
393 :param prefix: Prefix of the identifiers of their nodes: the path of the jobs containing them.
394 :param indent: Indentation of their statements.
395 :returns: Identifiers of the node an arrow into the level ends at - the first vertex needing nothing - and of
396 the node an arrow out of it starts at - the last vertex nothing needs.
397 """
398 # per vertex: the node an arrow into it ends at, the node an arrow out of it starts at, and its cluster
399 anchors: dict[Vertex, tuple[str, str, Nullable[str]]] = {}
400 for vertex in vertices:
401 element = vertex.Value
402 if element.Definition not in self._jobs:
403 self._jobs.append(element.Definition)
405 identifier = f"{prefix}{element}"
406 if not isinstance(element, (CIWorkflow, CIMatrix)) or vertex.OutboundLinkCount == 0:
407 self._DrawNode(element, identifier, indent)
408 anchors[vertex] = (identifier, identifier, None)
409 continue
411 cluster = f"cluster_{identifier}"
412 self._OpenCluster(element, cluster, indent)
413 if isinstance(element, CIWorkflow):
414 self._files.extend(childFile for childFile in element.ChildFiles if childFile not in self._files)
415 if self._link and (name := self._ChildPipelineName(element)) is not None: 415 ↛ 418line 415 didn't jump to line 418 because the condition on line 415 was always true
416 self._statements.append(f"{indent}\t/*glci-link:{name}*/")
418 entryNode, exitNode = self._DrawVertices(
419 [link.Destination for link in vertex.OutboundLinks], f"{identifier}/", f"{indent}\t"
420 )
421 self._statements.append(f"{indent}}}")
422 anchors[vertex] = (entryNode, exitNode, cluster)
424 # the vertices needing nothing start together: in the first rank of the graph, or side by side in their cluster
425 roots = [self._Quote(anchors[vertex][0]) for vertex in vertices if vertex.OutboundEdgeCount == 0]
426 if prefix == "":
427 self._statements.append(f"{indent}{{rank=min; {'; '.join(roots)};}}")
428 elif len(roots) > 1:
429 self._statements.append(f"{indent}{{rank=same; {'; '.join(roots)};}}")
431 for vertex in vertices:
432 for edge in vertex.OutboundEdges:
433 _, tail, tailCluster = anchors[edge.Destination]
434 head, _, headCluster = anchors[vertex]
436 attributes = []
437 if tailCluster is not None: 437 ↛ 438line 437 didn't jump to line 438 because the condition on line 437 was never true
438 attributes.append(f"ltail={self._Quote(tailCluster)}")
440 if headCluster is not None:
441 attributes.append(f"lhead={self._Quote(headCluster)}")
443 statement = f"{indent}{self._Quote(tail)} -> {self._Quote(head)}"
444 if len(attributes) > 0:
445 statement += f" [{', '.join(attributes)}]"
447 self._statements.append(f"{statement};")
449 first = next(vertex for vertex in vertices if vertex.OutboundEdgeCount == 0)
450 last = [vertex for vertex in vertices if vertex.InboundEdgeCount == 0][-1]
452 return anchors[first][0], anchors[last][1]
454 def __str__(self) -> str:
455 """
456 Render the graph.
458 :returns: The graph in the DOT language.
459 """
460 statements = "\n".join(self._statements)
462 return f"digraph pipeline {{\n{statements}\n}}"
465@export
466class PipelineGraph(BaseDirective):
467 """
468 The ``glci:pipeline-graph`` directive: the pipeline a pipeline file defines, drawn from the file.
470 One argument, the path of the pipeline file relative to the document using the directive.
472 A trigger job's child pipeline is read from ``glci_repository_root`` - relative to the source directory, and by
473 default the directory of the drawn pipeline file - and expanded, while ``:depth:`` allows. The files are read by the
474 resolver of the ``glci`` domain, which reads every file once per build; without ``glci_repository_root``, by a
475 resolver of its own.
476 """
478 directiveName: str = "glci:pipeline-graph" #: Name the directive is invoked by.
480 has_content = False #: A boolean; ``True`` if content is allowed.
481 required_arguments = 1 #: Number of required directive arguments: the pipeline file's path.
482 optional_arguments = 0 #: Number of optional arguments after the required ones.
483 final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces.
484 # docutils declares 'option_spec' on 'Directive' and 'BaseDirective' assigns it, so mypy calls every
485 # spelling of this override a conflict with one of them
486 #: Mapping of option names to validator functions.
487 option_spec: dict[str, Any] = { # type: ignore[misc]
488 "align": align_spec,
489 "alt": strip,
490 "caption": strip,
491 "depth": directives.nonnegative_int,
492 "direction": strip,
493 "link": stripAndNormalize,
494 "name": strip,
495 "reduce": stripAndNormalize,
496 }
498 def run(self) -> list[nodes.Node]:
499 """
500 Read the pipeline file and hand its pipeline to :mod:`sphinx.ext.graphviz` for rendering.
502 Every pipeline file drawn becomes a dependency of the document.
504 :returns: A ``graphviz`` node, wrapped in a figure when a caption was given, or an error node when the options or
505 the pipeline files are wrong.
506 """
507 relativePath, absolutePath = self.env.relfn2path(self.arguments[0])
508 self.env.note_dependency(relativePath)
509 path = Path(absolutePath)
511 from pyTooling.GitLab.PipelineFile import IncludeResolver, PipelineFileError
513 try:
514 direction = self._ParseStringOption("direction", "LR", "(?i)(LR|TB)$").upper()
515 reduce = self._ParseBooleanOption("reduce", True)
516 link = self._ParseBooleanOption("link", True)
517 except SphinxExtensionError as ex:
518 return [self.state.document.reporter.error(str(ex), line=self.lineno)]
520 domain = self.env.get_domain("glci")
521 try:
522 resolver = domain.Resolver if domain.Resolver is not None else IncludeResolver(path.parent)
523 graph = PipelineDotGraph(
524 domain.LoadPipelineFile(path), resolver, direction, self.options.get("depth", 0), reduce, link
525 )
526 except (OSError, ValueError, PipelineFileError) as ex:
527 message = f"{self.directiveName}: Couldn't draw '{self.arguments[0]}': {ex}"
528 for note in getattr(ex, "__notes__", []):
529 message += f" {note}"
531 return [self.state.document.reporter.error(message, line=self.lineno)]
533 for pipelineFile in graph.Files[1:]:
534 self.env.note_dependency(str(pipelineFile.Path))
536 node = graphviz()
537 node["code"] = str(graph)
538 node["options"] = {"docname": self.env.docname}
539 node["alt"] = self.options.get("alt", f"Pipeline of {path.name}")
540 node["classes"] = [CSS_CLASS]
541 if "align" in self.options: 541 ↛ 542line 541 didn't jump to line 542 because the condition on line 541 was never true
542 node["align"] = self.options["align"]
544 if (caption := self.options.get("caption", None)) is not None: 544 ↛ 545line 544 didn't jump to line 545 because the condition on line 544 was never true
545 figure = figure_wrapper(self, node, caption)
546 self.add_name(figure)
547 return [figure]
549 self.add_name(node)
550 return [node]
553@export
554def resolveLinks(sphinx: Sphinx, doctree: nodes.document, docname: str) -> None:
555 """
556 Call-back for Sphinx' ``doctree-resolved`` event, linking the trigger jobs of a pipeline graph to their child
557 pipelines.
559 A trigger job starting a child pipeline from a local file links to the page documenting that pipeline, as the
560 ``glci`` domain resolves the file's stem with ``ResolvePipeline``. Without a page for the pipeline, or in a format
561 other than HTML, the job has no link.
563 :param sphinx: The Sphinx application.
564 :param doctree: The resolved document.
565 :param docname: Name of the document.
566 """
567 if sphinx.builder.format != "html":
568 for node in doctree.findall(graphviz):
569 if CSS_CLASS in node["classes"]: 569 ↛ 568line 569 didn't jump to line 568 because the condition on line 569 was always true
570 node["code"] = LINK_MARKER.sub("", node["code"])
572 return
574 domain = sphinx.env.get_domain("glci")
576 def link(match: Match[str]) -> str:
577 """
578 Nested function returning the link attributes replacing a marker, or nothing.
580 :param match: The marker.
581 :returns: The whitespace before the marker and the attributes ``URL`` and ``target``, or an empty string when
582 there is nothing to link to.
583 """
584 if (target := domain.ResolvePipeline(match["name"])) is None:
585 return ""
587 targetDocname, anchor = target
588 if (uri := sphinx.builder.get_relative_uri(docname, targetDocname)) == "": 588 ↛ 589line 588 didn't jump to line 589 because the condition on line 588 was never true
589 uri = PurePosixPath(sphinx.builder.get_target_uri(targetDocname)).name
591 return f'{match["space"]}URL="{uri}#{anchor}" target="_top"'
593 for node in doctree.findall(graphviz):
594 if CSS_CLASS in node["classes"]: 594 ↛ 593line 594 didn't jump to line 593 because the condition on line 594 was always true
595 node["code"] = LINK_MARKER.sub(link, node["code"])