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

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. 

33 

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: 

36 

37.. code-block:: ReST 

38 

39 .. glci:pipeline-graph:: ../.gitlab-ci.yml 

40 :depth: 1 

41 

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. 

45 

46.. seealso:: 

47 

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 

54 

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 

59 

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 

64 

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 

71 

72if TYPE_CHECKING: # pragma: no cover 

73 from pyTooling.GitLab.PipelineFile import IncludeResolver, Job, PipelineFile 

74 

75 

76__all__ = ["GRAPH_ATTRIBUTES", "CSS_CLASS", "LINK_MARKER"] 

77 

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) 

90 

91#: CSS class of a pipeline graph's ``graphviz`` node, which is also how :func:`resolveLinks` finds it. 

92CSS_CLASS = "glci-pipeline-graph" 

93 

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>[^*]*)\*/") 

97 

98 

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. 

103 

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. 

109 

110 The kind of an element is its node's shape and style: 

111 

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. 

119 

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. 

123 

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

128 

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. 

133 

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. 

145 

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 

173 

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 

180 

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 

185 

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 

196 

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 

207 

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 

215 

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] 

220 

221 graph = pipelineFile.ToPipeline(resolver, depth).ToGraph(reduce=reduce) 

222 self._DrawVertices(list(graph.IterateVertices()), "", "\t") 

223 

224 @readonly 

225 def Jobs(self) -> list[Job]: 

226 """ 

227 Read-only property to access the jobs drawn (:attr:`_jobs`). 

228 

229 A child pipeline expanded twice is drawn twice, but its jobs are listed once. 

230 

231 :returns: The jobs of every pipeline file drawn, in the order they were drawn. 

232 """ 

233 return self._jobs 

234 

235 @readonly 

236 def Files(self) -> list[PipelineFile]: 

237 """ 

238 Read-only property to access the pipeline files drawn (:attr:`_files`). 

239 

240 :returns: The drawn pipeline file, followed by every child pipeline's file expanded into a cluster, each once. 

241 """ 

242 return self._files 

243 

244 @staticmethod 

245 def _Quote(text: str) -> str: 

246 """ 

247 Quote a text as a DOT string, keeping its line breaks as escape sequences. 

248 

249 :param text: The text to quote. 

250 :returns: The text in double quotes. 

251 """ 

252 text = text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") 

253 

254 return f'"{text}"' 

255 

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. 

260 

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>' 

268 

269 return f"<{label}>" 

270 

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. 

275 

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

280 

281 job = element.Definition 

282 lines = [] if isinstance(element.Parent, CIMatrix) else [f"stage: {job.Stage}"] 

283 style = [] 

284 tooltip = [] 

285 

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

294 

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

301 

302 if job.When is RuleOutcome.Manual: 

303 lines.append("manual") 

304 style.append("dashed") 

305 

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

310 

311 tooltip.extend(f"if: {condition}" for condition in conditions) 

312 

313 return lines, style, "\n".join(tooltip) 

314 

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. 

318 

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) 

324 

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"} 

331 

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" 

334 

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

338 

339 if tooltip != "": 

340 statement += f", tooltip={self._Quote(tooltip)}" 

341 

342 if self._link and (name := self._ChildPipelineName(element)) is not None: 

343 statement += f" /*glci-link:{name}*/" 

344 

345 self._statements.append(f"{statement}];") 

346 

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. 

351 

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 

357 

358 if (trigger := element.Definition.Trigger) is None or trigger.Project is not None: 

359 return None 

360 

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 

364 

365 return None 

366 

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. 

370 

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)};") 

384 

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. 

389 

390 An edge of the graph goes from the element needing to the element it needs; its arrow is drawn the other way round. 

391 

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) 

404 

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 

410 

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}*/") 

417 

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) 

423 

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)};}}") 

430 

431 for vertex in vertices: 

432 for edge in vertex.OutboundEdges: 

433 _, tail, tailCluster = anchors[edge.Destination] 

434 head, _, headCluster = anchors[vertex] 

435 

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

439 

440 if headCluster is not None: 

441 attributes.append(f"lhead={self._Quote(headCluster)}") 

442 

443 statement = f"{indent}{self._Quote(tail)} -> {self._Quote(head)}" 

444 if len(attributes) > 0: 

445 statement += f" [{', '.join(attributes)}]" 

446 

447 self._statements.append(f"{statement};") 

448 

449 first = next(vertex for vertex in vertices if vertex.OutboundEdgeCount == 0) 

450 last = [vertex for vertex in vertices if vertex.InboundEdgeCount == 0][-1] 

451 

452 return anchors[first][0], anchors[last][1] 

453 

454 def __str__(self) -> str: 

455 """ 

456 Render the graph. 

457 

458 :returns: The graph in the DOT language. 

459 """ 

460 statements = "\n".join(self._statements) 

461 

462 return f"digraph pipeline {{\n{statements}\n}}" 

463 

464 

465@export 

466class PipelineGraph(BaseDirective): 

467 """ 

468 The ``glci:pipeline-graph`` directive: the pipeline a pipeline file defines, drawn from the file. 

469 

470 One argument, the path of the pipeline file relative to the document using the directive. 

471 

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

477 

478 directiveName: str = "glci:pipeline-graph" #: Name the directive is invoked by. 

479 

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 } 

497 

498 def run(self) -> list[nodes.Node]: 

499 """ 

500 Read the pipeline file and hand its pipeline to :mod:`sphinx.ext.graphviz` for rendering. 

501 

502 Every pipeline file drawn becomes a dependency of the document. 

503 

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) 

510 

511 from pyTooling.GitLab.PipelineFile import IncludeResolver, PipelineFileError 

512 

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)] 

519 

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}" 

530 

531 return [self.state.document.reporter.error(message, line=self.lineno)] 

532 

533 for pipelineFile in graph.Files[1:]: 

534 self.env.note_dependency(str(pipelineFile.Path)) 

535 

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"] 

543 

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] 

548 

549 self.add_name(node) 

550 return [node] 

551 

552 

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. 

558 

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. 

562 

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

571 

572 return 

573 

574 domain = sphinx.env.get_domain("glci") 

575 

576 def link(match: Match[str]) -> str: 

577 """ 

578 Nested function returning the link attributes replacing a marker, or nothing. 

579 

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

586 

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 

590 

591 return f'{match["space"]}URL="{uri}#{anchor}" target="_top"' 

592 

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