Coverage for pyTooling/GitLab/Sphinx/Reference.py: 95%

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

32Directives of the ``glci`` domain summarizing the current pipeline file: its parameters, interface, dependencies and 

33YAML. 

34 

35Each of them reads the pipeline file of the preceding ``glci:pipeline``: 

36 

37.. code-block:: ReST 

38 

39 .. glci:pipeline:: Package 

40 

41 .. glci:parameter-table:: 

42 :kinds: inputs 

43 

44 .. glci:interface:: 

45 

46 .. glci:dependencies:: 

47 

48 * pip 

49 

50 .. glci:yaml:: 

51 :job: Package 

52 

53 .. glci:autoinputs:: 

54 

55* ``glci:parameter-table`` - the summary tables of the inputs and variables; 

56* ``glci:interface`` - the contract with a pipeline including the file: the required inputs, the variables of the run 

57 form, the stages to declare, the jobs and hidden jobs it adds; 

58* ``glci:dependencies`` - the files included, the child and downstream pipelines triggered, the container images used, 

59 merged with hand-written ones; 

60* ``glci:yaml`` - the pipeline file or a part of it, as a code block linked to the file on GitLab; 

61* ``glci:autoinputs`` - an entry for every input the document has no ``glci:input`` for. 

62 

63When a document was read, an input of its pipeline file without an entry is a ``glci.drift`` warning 

64(:func:`checkUndocumentedInputs`). 

65 

66.. seealso:: 

67 

68 :mod:`pyTooling.GitLab.Sphinx` 

69 |rarr| The domain ``glci``, its pipelines and parameter entries. 

70""" 

71from __future__ import annotations 

72 

73from pathlib import Path 

74from typing import TYPE_CHECKING, Any, Callable, Optional as Nullable, TypeVar 

75 

76from docutils import nodes 

77from docutils.parsers.rst import directives 

78from sphinx import addnodes 

79from sphinx.application import Sphinx 

80from sphinx.directives.code import container_wrapper 

81from sphinx.transforms import SphinxTransform 

82from sphinx.util.logging import getLogger 

83 

84from pyTooling.Decorators import export 

85from pyTooling.Sphinx import BaseDirective, SphinxExtensionError, strip 

86from pyTooling.GitLab.Sphinx import NO_DEFAULT, WARNING_TYPE, InputDirective, formatValue 

87 

88if TYPE_CHECKING: # pragma: no cover 

89 from pyTooling.GitLab.PipelineFile import Include, PipelineFile, ValueT 

90 

91 

92__all__ = ["KINDS", "SECTIONS", "MAX_DEFAULT_LENGTH"] 

93 

94#: The kinds of parameters ``glci:parameter-table`` summarizes, in the order it shows them by default: kind |rarr| the 

95#: columns. 

96KINDS = { 

97 "inputs": ("Parameter Name", "Required", "Type", "Default"), 

98 "variables": ("Variable Name", "Default", "Description"), 

99} 

100 

101#: The parts of a pipeline file ``glci:yaml`` shows by option ``:section:``: the inputs of the header's ``spec``, and 

102#: the top-level keys of the configuration. 

103SECTIONS = ("inputs", "variables", "stages", "include", "workflow", "default") 

104 

105#: The length a default is shortened to in a summary table; a multi-line default is shortened to its first line. 

106MAX_DEFAULT_LENGTH = 120 

107 

108_logger = getLogger(__name__) 

109 

110_ResultType = TypeVar("_ResultType") 

111 

112 

113@export 

114class PipelineReferenceDirective(BaseDirective): 

115 """ 

116 Base-class of the directives summarizing the current pipeline file, as set by the preceding ``glci:pipeline``. 

117 """ 

118 

119 @staticmethod 

120 def _Reference(objectType: str, target: str, text: str) -> addnodes.pending_xref: 

121 """ 

122 Create a reference to an object of the domain, shown as literal text if the object isn't documented. 

123 

124 :param objectType: The object type, as ``input``. 

125 :param target: The object's name, as ``Package.package_name``. 

126 :param text: The text of the reference. 

127 :returns: The pending reference. 

128 """ 

129 return addnodes.pending_xref( 

130 "", nodes.literal(text, text), refdomain="glci", reftype=objectType, reftarget=target, refexplicit=True, 

131 refwarn=False 

132 ) 

133 

134 def _GitLabURL(self, path: Path, first: Nullable[int] = None, last: Nullable[int] = None) -> Nullable[str]: 

135 """ 

136 Return the URL of a file of the documented repository on GitLab, at the documented ref. 

137 

138 :param path: The file's path. 

139 :param first: Optional, the first line to mark. Default: ``None``. 

140 :param last: Optional, the last line to mark. Default: ``None``. 

141 :returns: The URL, or ``None`` if ``glci_project``, ``glci_ref`` or ``glci_repository_root`` isn't configured, 

142 or the file isn't below the repository's root. 

143 """ 

144 project = self.config.glci_project 

145 ref = self.config.glci_ref 

146 if project is None or ref is None or (root := self.env.get_domain("glci").RepositoryRoot) is None: 

147 return None 

148 

149 try: 

150 relative = path.resolve().relative_to(root).as_posix() 

151 except ValueError: 

152 return None 

153 

154 url = f"{self.config.glci_server.rstrip('/')}/{project}/-/blob/{ref}/{relative}" 

155 if first is None: 155 ↛ 157line 155 didn't jump to line 157 because the condition on line 155 was always true

156 return url 

157 elif last is None or last == first: 

158 return f"{url}#L{first}" 

159 

160 return f"{url}#L{first}-{last}" 

161 

162 def _CurrentPipeline(self) -> Nullable[PipelineFile]: 

163 """ 

164 Return the model of the current pipeline file, and warn if there is no ``glci:pipeline`` before the directive. 

165 

166 :returns: The pipeline file, or ``None`` if there is no ``glci:pipeline`` before the directive, or its file couldn't 

167 be read - which the ``glci:pipeline`` directive reported already. 

168 """ 

169 if self.env.ref_context.get("glci:pipeline", None) is None: 

170 _logger.warning( 

171 f"{self.directiveName} is not preceded by a glci:pipeline.", 

172 location=self.get_location(), type=WARNING_TYPE, subtype="pipeline" 

173 ) 

174 return None 

175 

176 return self.env.get_domain("glci").GetCurrentPipeline() 

177 

178 def _ResolveOrWarn(self, resolve: Callable[[], _ResultType], fallback: Callable[[], _ResultType]) -> _ResultType: 

179 """ 

180 Read a file a pipeline file includes or triggers, and report a file that is malformed as a warning. 

181 

182 :param resolve: The operation reading the file, as :meth:`IncludeResolver.Resolve 

183 <pyTooling.GitLab.PipelineFile.IncludeResolver.Resolve>`. 

184 :param fallback: The operation giving the result, when the file couldn't be read. 

185 :returns: The result of ``resolve``, or of ``fallback`` after a warning. 

186 """ 

187 from pyTooling.GitLab.PipelineFile import PipelineFileError 

188 

189 try: 

190 return resolve() 

191 except PipelineFileError as ex: 

192 _logger.warning( 

193 f"{self.directiveName}: {ex}", location=self.get_location(), type=WARNING_TYPE, subtype="pipeline" 

194 ) 

195 return fallback() 

196 

197 

198@export 

199class ParameterTable(PipelineReferenceDirective): 

200 """ 

201 The directive ``glci:parameter-table``: summary tables of the current pipeline file's inputs and variables. 

202 

203 .. code-block:: ReST 

204 

205 .. glci:parameter-table:: 

206 :kinds: inputs 

207 

208 A table per kind, in the order ``:kinds:`` names them, by default the order of :data:`KINDS`. A table lists the 

209 parameters in file order, each name linked to its entry. An input's row states whether it is required, its type and 

210 its default; a variable's row its value and its description - a long or multi-line default is shortened, the entry 

211 shows it in full. 

212 

213 Without ``:kinds:``, a table is shown for every kind the pipeline file has parameters of. A kind named explicitly, of 

214 which the file has none, is a table with a single row saying so. 

215 """ 

216 

217 directiveName: str = "glci:parameter-table" #: Name the directive is invoked by. 

218 

219 has_content = False #: A boolean; ``True`` if content is allowed. 

220 required_arguments = 0 #: Number of required directive arguments. 

221 optional_arguments = 0 #: Number of optional arguments after the required ones. 

222 final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces. 

223 option_spec: dict[str, Any] = { # type: ignore[misc] 

224 "kinds": strip, 

225 } #: Mapping of option names to validator functions. 

226 

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

228 """ 

229 Create the summary tables. 

230 

231 :returns: A table per kind, an error node if option ``:kinds:`` names an unknown kind, or nothing without a 

232 current pipeline. 

233 """ 

234 try: 

235 kinds = self._ParseKinds() 

236 except SphinxExtensionError as ex: 

237 return [self.state.document.reporter.error(str(ex), line=self.lineno)] 

238 

239 if (pipelineFile := self._CurrentPipeline()) is None: 

240 return [] 

241 

242 pipelineName = self.env.ref_context["glci:pipeline"] 

243 tables = [] 

244 for kind in kinds: 

245 parameters = getattr(pipelineFile, kind.capitalize()) 

246 if len(parameters) == 0 and "kinds" not in self.options: 

247 continue 

248 

249 columns = KINDS[kind] 

250 tableGroup = self._CreateSingleRowTableHeader( 

251 columns=[(title, None) for title in columns], 

252 identifier=f"{pipelineName}-{kind}", 

253 classes=["glci-parameter-table", f"glci-{kind}"] 

254 ) 

255 tableGroup += (tableBody := nodes.tbody()) 

256 

257 if len(parameters) == 0: 

258 entry = nodes.entry("", nodes.paragraph("", "", nodes.emphasis(text=f"No {kind}")), morecols=len(columns) - 1) 

259 tableBody += nodes.row("", entry) 

260 

261 for name, parameter in parameters.items(): 

262 row = nodes.row() 

263 row += nodes.entry("", nodes.paragraph("", "", self._Reference(kind[:-1], f"{pipelineName}.{name}", name))) 

264 if kind == "inputs": 

265 row += nodes.entry("", nodes.paragraph(text="yes" if parameter.Required else "no")) 

266 row += nodes.entry("", nodes.paragraph(text=parameter.Type.value)) 

267 row += nodes.entry("", self._DefaultParagraph(parameter.Default)) 

268 else: 

269 description = "" if parameter.Description is None else parameter.Description.strip() 

270 row += nodes.entry("", self._DefaultParagraph(parameter.Value)) 

271 row += nodes.entry("", nodes.paragraph(description, description)) 

272 

273 tableBody += row 

274 

275 tables.append(tableGroup.parent) 

276 

277 return tables 

278 

279 def _ParseKinds(self) -> tuple[str, ...]: 

280 """ 

281 Read option ``:kinds:``, a list of kinds separated by spaces or commas. 

282 

283 :returns: The kinds named, in the order written and each once, or every kind of :data:`KINDS` 

284 without the option. 

285 :raises SphinxExtensionError: If a kind is not one of :data:`KINDS`. 

286 """ 

287 if "kinds" not in self.options: 

288 return tuple(KINDS) 

289 

290 kinds = tuple(dict.fromkeys(self.options["kinds"].replace(",", " ").split())) 

291 for kind in kinds: 

292 if kind not in KINDS: 

293 raise SphinxExtensionError(f"{self.directiveName}::kinds: '{kind}' is not one of {', '.join(KINDS)}.") 

294 

295 return kinds 

296 

297 @staticmethod 

298 def _DefaultParagraph(value: ValueT) -> nodes.paragraph: 

299 """ 

300 Create the paragraph of a table cell showing a default. 

301 

302 A multi-line string is shortened to its first line, and a longer text to :data:`MAX_DEFAULT_LENGTH` characters, 

303 each followed by ``…``. 

304 

305 :param value: The default, as :attr:`Input.Default <pyTooling.GitLab.PipelineFile.Input.Default>`. 

306 :returns: The paragraph, holding :data:`~pyTooling.GitLab.Sphinx.NO_DEFAULT` for ``None``, or the default as 

307 literal text. 

308 """ 

309 if value is None: 

310 return nodes.paragraph(NO_DEFAULT, NO_DEFAULT) 

311 

312 if isinstance(value, str): 

313 lines = value.splitlines() 

314 first = lines[0] if len(lines) > 0 else "" 

315 if len(lines) > 1 or len(first) > MAX_DEFAULT_LENGTH: 

316 value = f"{first[:MAX_DEFAULT_LENGTH]}…" 

317 

318 text = formatValue(value) 

319 return nodes.paragraph("", "", nodes.literal(text, text)) 

320 

321 

322@export 

323class Interface(PipelineReferenceDirective): 

324 """ 

325 The directive ``glci:interface``: the contract of the current pipeline file with a pipeline including it, as a field 

326 list. 

327 

328 .. code-block:: ReST 

329 

330 .. glci:interface:: 

331 

332 * *Required Inputs* - the inputs without a default, which ``include:inputs`` has to set, each linked to its entry. 

333 * *Variables* - the default variables with a ``description``, which the form running a pipeline by hand prefills. 

334 * *Stages* - the stages the file's jobs run in, which the including pipeline has to declare, in the file's order; a 

335 stage given by an input, as ``$[[ inputs.stage ]]``, as written. 

336 * *Jobs* and *Hidden Jobs* - the jobs the file adds to a pipeline, and the hidden jobs others may ``extends``. 

337 

338 The files the pipeline file includes are listed by :class:`Dependencies`. 

339 """ 

340 

341 directiveName: str = "glci:interface" #: Name the directive is invoked by. 

342 

343 has_content = False #: A boolean; ``True`` if content is allowed. 

344 required_arguments = 0 #: Number of required directive arguments. 

345 optional_arguments = 0 #: Number of optional arguments after the required ones. 

346 final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces. 

347 option_spec: dict[str, Any] = {} #: Mapping of option names to validator functions. 

348 

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

350 """ 

351 Create the field list. 

352 

353 :returns: The field list, or nothing without a current pipeline. 

354 """ 

355 if (pipelineFile := self._CurrentPipeline()) is None: 355 ↛ 356line 355 didn't jump to line 356 because the condition on line 355 was never true

356 return [] 

357 

358 pipelineName = self.env.ref_context["glci:pipeline"] 

359 

360 fieldList = nodes.field_list(classes=["glci-interface"]) 

361 fieldList += self._Field("Required Inputs", [ 

362 [self._Reference("input", f"{pipelineName}.{name}", name)] 

363 for name, parameter in pipelineFile.Inputs.items() if parameter.Required 

364 ]) 

365 fieldList += self._Field("Variables", [ 

366 [self._Reference("variable", f"{pipelineName}.{name}", name)] 

367 for name, variable in pipelineFile.Variables.items() if variable.Description is not None 

368 ]) 

369 used = dict.fromkeys(job.Stage for job in pipelineFile.IterateJobs()) 

370 stages = [stage for stage in pipelineFile.Stages if stage in used] 

371 stages.extend(stage for stage in used if stage not in pipelineFile.Stages) 

372 fieldList += self._Field("Stages", [[nodes.literal(stage, stage)] for stage in stages]) 

373 fieldList += self._Field("Jobs", [[nodes.literal(name, name)] for name in pipelineFile.Jobs]) 

374 fieldList += self._Field("Hidden Jobs", [[nodes.literal(name, name)] for name in pipelineFile.HiddenJobs]) 

375 

376 return [fieldList] 

377 

378 @staticmethod 

379 def _Field(name: str, items: list[list[nodes.Node]]) -> nodes.field: 

380 """ 

381 Create a field listing items separated by commas, or saying *none*. 

382 

383 :param name: The field's name. 

384 :param items: The items, each as the nodes of an item. 

385 :returns: The field. 

386 """ 

387 if len(items) == 0: 

388 body = nodes.paragraph("", "", nodes.emphasis(text="none")) 

389 else: 

390 body = nodes.paragraph() 

391 for index, item in enumerate(items): 

392 if index > 0: 

393 body += nodes.Text(", ") 

394 body.extend(item) 

395 

396 return nodes.field("", nodes.field_name(text=name), nodes.field_body("", body)) 

397 

398 

399@export 

400class Dependencies(PipelineReferenceDirective): 

401 """ 

402 The directive ``glci:dependencies``: what the current pipeline file uses, as a nested bullet list. 

403 

404 .. code-block:: ReST 

405 

406 .. glci:dependencies:: 

407 

408 * /ci/build.yml 

409 

410 * Python packages given by :glci:input:`Build.requirements`. 

411 

412 From the pipeline file, and the local files it includes and triggers, as far as they are known locally: 

413 

414 * the files ``include`` adds - a local file, another project's files, a URL, a template, a component, a job's 

415 artifact -, each once; a local file read from ``glci_repository_root`` with its own dependencies; 

416 * the child pipelines a trigger job runs, with the files of the child pipeline, and the other projects' pipelines a 

417 trigger job starts; 

418 * the images of the containers and service containers the jobs run in, ``default`` included. 

419 

420 The content is a bullet list of what a file can't tell, like packages installed by a script. It is merged into the 

421 derived list: an item whose text is the name of a derived item - an include as written, a trigger job as 

422 ``trigger <job>``, a container as ``image`` or ``service``, or the image - adds its nested list to that item, 

423 recursively. Any other item is appended to the list it is in. Content after the bullet list follows the list. 

424 """ 

425 

426 directiveName: str = "glci:dependencies" #: Name the directive is invoked by. 

427 

428 has_content = True #: The hand-written dependencies. 

429 required_arguments = 0 #: Number of required directive arguments. 

430 optional_arguments = 0 #: Number of optional arguments after the required ones. 

431 final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces. 

432 option_spec: dict[str, Any] = {} #: Mapping of option names to validator functions. 

433 

434 _keys: dict[int, set[str]] #: Names of each derived item, by the item's identity. 

435 

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

437 """ 

438 Create the list: the derived dependencies, merged with the hand-written ones. 

439 

440 :returns: The list and the content following it, a paragraph saying *none* if there are no dependencies, or 

441 nothing without a current pipeline. 

442 """ 

443 if (pipelineFile := self._CurrentPipeline()) is None: 443 ↛ 444line 443 didn't jump to line 444 because the condition on line 443 was never true

444 return [] 

445 

446 self._keys = {} 

447 dependencies = self._FileItems(pipelineFile, {pipelineFile.Path.resolve()}) 

448 dependencies["classes"].append("glci-dependencies") 

449 

450 others = [] 

451 for node in self.parse_content_to_nodes(): 

452 if isinstance(node, nodes.bullet_list): 452 ↛ 455line 452 didn't jump to line 455 because the condition on line 452 was always true

453 self._Merge(dependencies, node) 

454 else: 

455 others.append(node) 

456 

457 if len(dependencies) == 0: 

458 return [nodes.paragraph("", "", nodes.emphasis(text="none")), *others] 

459 

460 return [dependencies, *others] 

461 

462 def _Item(self, paragraph: nodes.paragraph, keys: set[str]) -> nodes.list_item: 

463 """ 

464 Create a derived item, known by the names a hand-written item may refer to it by. 

465 

466 :param paragraph: The item's text. 

467 :param keys: The names of the item. 

468 :returns: The item. 

469 """ 

470 item = nodes.list_item("", paragraph) 

471 self._keys[id(item)] = keys 

472 

473 return item 

474 

475 def _IncludeItem(self, include: Include) -> nodes.list_item: 

476 """ 

477 Create the item of an include. 

478 

479 A local file links to GitLab, if ``glci_project`` and ``glci_ref`` are configured; another project's files link 

480 to that project, a URL to itself, a component to its project. 

481 

482 :param include: The include. 

483 :returns: The item, known by the include as written. 

484 """ 

485 from pyTooling.GitLab.PipelineFile import IncludeKind 

486 

487 text = include.Location 

488 literal = nodes.literal(text, text) 

489 paragraph = nodes.paragraph("", f"{include.Kind.value} ") 

490 server = self.config.glci_server.rstrip("/") 

491 url = None 

492 if include.Kind is IncludeKind.Local and (root := self.env.get_domain("glci").RepositoryRoot) is not None: 

493 url = self._GitLabURL(root / text.lstrip("/")) 

494 elif include.Kind is IncludeKind.Project: 

495 url = f"{server}/{text}" 

496 elif include.Kind is IncludeKind.Remote: 

497 url = text 

498 elif include.Kind is IncludeKind.Component and "/" in (path := text.partition("@")[0]): 

499 url = f"{server}/{path.partition('/')[2].rpartition('/')[0]}" 

500 

501 paragraph += literal if url is None else nodes.reference(text, "", literal, refuri=url) 

502 if len(include.Files) > 0: 

503 paragraph += nodes.Text(f": {', '.join(include.Files)}") 

504 

505 return self._Item(paragraph, {text, text.partition("@")[0], *include.Files}) 

506 

507 def _ImageItem(self, kind: str, image: str) -> nodes.list_item: 

508 """ 

509 Create the item of a container's image. 

510 

511 :param kind: The kind of container, as ``image`` or ``service``. 

512 :param image: The image, as written. 

513 :returns: The item, known by the kind and the image. 

514 """ 

515 return self._Item(nodes.paragraph("", f"{kind} ", nodes.literal(image, image)), {kind, image}) 

516 

517 def _FileItems(self, pipelineFile: PipelineFile, visited: set[Path]) -> nodes.bullet_list: 

518 """ 

519 List a pipeline file's dependencies: its includes, the pipelines its trigger jobs start, and container images. 

520 

521 :param pipelineFile: The pipeline file. 

522 :param visited: The resolved paths of the files on the path to this one, which aren't expanded again. 

523 :returns: A bullet list, empty if the file uses nothing. 

524 """ 

525 resolver = self.env.get_domain("glci").Resolver 

526 bulletList = nodes.bullet_list() 

527 for include in {(include.Kind, include.Location): include for include in pipelineFile.Includes}.values(): 

528 bulletList += self._ResolvedItem(include, resolver, visited) 

529 

530 for job in pipelineFile.IterateJobs(): 

531 if (trigger := job.Trigger) is None: 

532 continue 

533 

534 paragraph = nodes.paragraph("", "trigger ", nodes.emphasis(text=job.Name)) 

535 if trigger.Project is not None: 

536 paragraph += (nodes.Text(" - project "), nodes.literal(trigger.Project, trigger.Project)) 

537 

538 item = self._Item(paragraph, {f"trigger {job.Name}", job.Name}) 

539 if len(childIncludes := trigger.Includes) > 0: 

540 item += nodes.bullet_list("", *(self._ResolvedItem(include, resolver, visited) for include in childIncludes)) 

541 

542 bulletList += item 

543 

544 images: dict[tuple[str, str], None] = {} 

545 if (default := pipelineFile.Default) is not None: 

546 if default.Image is not None: 546 ↛ 548line 546 didn't jump to line 548 because the condition on line 546 was always true

547 images[("image", default.Image)] = None 

548 images.update(dict.fromkeys(("service", service) for service in default.Services)) 

549 

550 for job in pipelineFile.IterateJobs(): 

551 if job.Image is not None: 

552 images[("image", job.Image)] = None 

553 images.update(dict.fromkeys(("service", service) for service in job.Services)) 

554 

555 bulletList.extend(self._ImageItem(kind, image) for kind, image in images) 

556 

557 return bulletList 

558 

559 def _ResolvedItem(self, include: Include, resolver: Any, visited: set[Path]) -> nodes.list_item: 

560 """ 

561 Create the item of an include, with the dependencies of the local file it names, if that is readable. 

562 

563 :param include: The include. 

564 :param resolver: The domain's resolver, or ``None`` if ``glci_repository_root`` isn't configured. 

565 :param visited: The resolved paths of the files on the path, which aren't expanded again. 

566 :returns: The item. 

567 """ 

568 item = self._IncludeItem(include) 

569 if resolver is None: 569 ↛ 570line 569 didn't jump to line 570 because the condition on line 569 was never true

570 return item 

571 

572 included = self._ResolveOrWarn(lambda: resolver.Resolve(include), lambda: None) 

573 if included is not None and (path := included.Path.resolve()) not in visited: 

574 if len(nested := self._FileItems(included, visited | {path})) > 0: 

575 item += nested 

576 

577 return item 

578 

579 def _Merge(self, derived: nodes.bullet_list, handwritten: nodes.bullet_list) -> None: 

580 """ 

581 Merge a hand-written list into a derived one. 

582 

583 A hand-written item whose text is a name of a derived item of this list adds its nested lists to that item, 

584 merged recursively; any other item is appended. 

585 

586 :param derived: The derived list. 

587 :param handwritten: The hand-written list. 

588 """ 

589 for item in list(handwritten.children): 

590 text = item[0].astext().strip() if len(item) > 0 and isinstance(item[0], nodes.paragraph) else None 

591 match = next( 

592 (child for child in derived.children if text is not None and text in self._keys.get(id(child), ())), None 

593 ) 

594 if match is None: 

595 derived += item 

596 continue 

597 

598 for node in item.children[1:]: 

599 nested = next((child for child in match.children if isinstance(child, nodes.bullet_list)), None) 

600 if isinstance(node, nodes.bullet_list) and nested is not None: 

601 self._Merge(nested, node) 

602 else: 

603 match += node 

604 

605 

606@export 

607class YAMLExcerpt(PipelineReferenceDirective): 

608 """ 

609 The directive ``glci:yaml``: the current pipeline file or a part of it, as a YAML code block. 

610 

611 .. code-block:: ReST 

612 

613 .. glci:yaml:: 

614 :section: inputs 

615 

616 Option ``:section:`` selects one of :data:`SECTIONS` - the ``inputs`` of the header's ``spec``, or a top-level key of 

617 the configuration; option ``:job:`` selects one job. Without either, the whole file is shown. 

618 

619 The lines are numbered as in the file, and the part is shifted left by the indentation of its first line. The caption 

620 names the file and the lines, and links to them on GitLab at ``glci_ref``, if ``glci_project``, ``glci_ref`` and 

621 ``glci_repository_root`` are configured. 

622 """ 

623 

624 directiveName: str = "glci:yaml" #: Name the directive is invoked by. 

625 

626 has_content = False #: A boolean; ``True`` if content is allowed. 

627 required_arguments = 0 #: Number of required directive arguments. 

628 optional_arguments = 0 #: Number of optional arguments after the required ones. 

629 final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces. 

630 option_spec: dict[str, Any] = { # type: ignore[misc] 

631 "caption": directives.unchanged_required, 

632 "job": strip, 

633 "name": strip, 

634 "section": strip, 

635 } #: Mapping of option names to validator functions. 

636 

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

638 """ 

639 Create the code block. 

640 

641 :returns: The code block in a captioned container, an error node for wrong options, or nothing without a current 

642 pipeline or when the part doesn't exist. 

643 """ 

644 if "section" in self.options and "job" in self.options: 

645 return [self.state.document.reporter.error( 

646 f"{self.directiveName}: Options ':section:' and ':job:' exclude each other.", line=self.lineno 

647 )] 

648 elif (section := self.options.get("section", None)) is not None and section not in SECTIONS: 

649 return [self.state.document.reporter.error( 

650 f"{self.directiveName}::section: '{section}' is not one of {', '.join(SECTIONS)}.", line=self.lineno 

651 )] 

652 

653 if (pipelineFile := self._CurrentPipeline()) is None: 653 ↛ 654line 653 didn't jump to line 654 because the condition on line 653 was never true

654 return [] 

655 

656 pipelineName = self.env.ref_context["glci:pipeline"] 

657 lines = pipelineFile.Path.read_text(encoding="utf-8").splitlines() 

658 if section is not None: 

659 if (line := self._SectionLine(pipelineFile, lines, section)) is None: 

660 _logger.warning( 

661 f"{self.directiveName}: Pipeline '{pipelineName}' has no {section}.", 

662 location=self.get_location(), type=WARNING_TYPE, subtype="drift" 

663 ) 

664 return [] 

665 

666 first, last = self._BlockOf(lines, line) 

667 elif (jobName := self.options.get("job", None)) is not None: 

668 job = pipelineFile.Jobs.get(jobName, None) or pipelineFile.HiddenJobs.get(jobName, None) 

669 if job is None: 

670 _logger.warning( 

671 f"{self.directiveName}: Pipeline '{pipelineName}' has no job '{jobName}'.", 

672 location=self.get_location(), type=WARNING_TYPE, subtype="drift" 

673 ) 

674 return [] 

675 

676 first, last = self._BlockOf(lines, job.Line) 

677 else: 

678 first = 1 

679 last = len(lines) 

680 

681 indentation = self._Indentation(lines[first - 1]) 

682 excerpt = [line[min(indentation, self._Indentation(line)):] for line in lines[first - 1:last]] 

683 

684 text = "\n".join(excerpt) 

685 literal = nodes.literal_block(text, text, language="yaml", linenos=True) 

686 literal["highlight_args"] = {"linenostart": first} 

687 self.set_source_info(literal) 

688 

689 if (caption := self.options.get("caption", None)) is None: 689 ↛ 701line 689 didn't jump to line 701 because the condition on line 689 was always true

690 fileName = pipelineFile.Path.name 

691 if first == 1 and last == len(lines): 

692 caption = fileName 

693 url = self._GitLabURL(pipelineFile.Path) 

694 else: 

695 caption = f"{fileName}, lines {first}-{last}" 

696 url = self._GitLabURL(pipelineFile.Path, first, last) 

697 

698 if url is not None: 

699 caption = f"`{caption} <{url}>`__" 

700 

701 container = container_wrapper(self, literal, caption) 

702 self.add_name(container) 

703 

704 return [container] 

705 

706 @classmethod 

707 def _SectionLine(cls, pipelineFile: PipelineFile, lines: list[str], section: str) -> Nullable[int]: 

708 """ 

709 Return the line of a section's key. 

710 

711 The inputs' key is the key holding the first input. A top-level key is searched in the configuration document, 

712 behind the header document's ``---``, if there is one. 

713 

714 :param pipelineFile: The pipeline file. 

715 :param lines: The lines of the file. 

716 :param section: One of :data:`SECTIONS`. 

717 :returns: The key's line, starting at 1, or ``None`` if the file hasn't the section. 

718 """ 

719 if section == "inputs": 

720 if len(pipelineFile.Inputs) == 0: 720 ↛ 721line 720 didn't jump to line 721 because the condition on line 720 was never true

721 return None 

722 

723 return cls._ParentOf(lines, next(iter(pipelineFile.Inputs.values())).Line) 

724 

725 start = 0 

726 if len(pipelineFile.Inputs) > 0 or pipelineFile.Description is not None: 726 ↛ 729line 726 didn't jump to line 729 because the condition on line 726 was always true

727 start = next((index + 1 for index, line in enumerate(lines) if line.rstrip() == "---"), 0) 

728 

729 for index in range(start, len(lines)): 

730 if lines[index].startswith(f"{section}:"): 

731 return index + 1 

732 

733 return None 

734 

735 @staticmethod 

736 def _Indentation(line: str) -> int: 

737 """ 

738 Return the indentation of a line of a YAML file. 

739 

740 :param line: The line. 

741 :returns: The number of spaces the line starts with. 

742 """ 

743 return len(line) - len(line.lstrip(" ")) 

744 

745 @staticmethod 

746 def _IsBlank(line: str) -> bool: 

747 """ 

748 Return whether a line is empty or a comment. 

749 

750 :param line: The line. 

751 :returns: ``True``, if the line holds nothing but whitespace or a comment. 

752 """ 

753 stripped = line.strip() 

754 return stripped == "" or stripped.startswith("#") 

755 

756 @classmethod 

757 def _ParentOf(cls, lines: list[str], line: int) -> int: 

758 """ 

759 Return the line of the key a line is nested in. 

760 

761 :param lines: The lines of the file. 

762 :param line: The nested line, starting at 1. 

763 :returns: The nearest line above it that is indented less, starting at 1, or ``1``. 

764 """ 

765 indentation = cls._Indentation(lines[line - 1]) 

766 for index in range(line - 2, -1, -1): 766 ↛ 771line 766 didn't jump to line 771 because the loop on line 766 didn't complete

767 text = lines[index] 

768 if not cls._IsBlank(text) and cls._Indentation(text) < indentation: 768 ↛ 766line 768 didn't jump to line 766 because the condition on line 768 was always true

769 return index + 1 

770 

771 return 1 

772 

773 @classmethod 

774 def _BlockOf(cls, lines: list[str], line: int) -> tuple[int, int]: 

775 """ 

776 Return the lines of a key and its value. 

777 

778 The block ends before the next line - not empty and not a comment - indented as much as the key or less. A comment 

779 indented as much as the key or less, and empty lines, at the end aren't part of it. 

780 

781 :param lines: The lines of the file. 

782 :param line: The key's line, starting at 1. 

783 :returns: The first and the last line of the block, starting at 1. 

784 """ 

785 indentation = cls._Indentation(lines[line - 1]) 

786 last = line 

787 for index in range(line, len(lines)): 

788 text = lines[index] 

789 if text.strip() == "": 

790 continue 

791 elif cls._Indentation(text) > indentation: 

792 last = index + 1 

793 elif not cls._IsBlank(text): 793 ↛ 787line 793 didn't jump to line 787 because the condition on line 793 was always true

794 break 

795 

796 return line, last 

797 

798 

799@export 

800class AutoInputs(PipelineReferenceDirective): 

801 """ 

802 The directive ``glci:autoinputs``: an entry for every input of the current pipeline file the document has no 

803 ``glci:input`` for. 

804 

805 .. code-block:: ReST 

806 

807 .. glci:autoinputs:: 

808 

809 An entry is created as a ``glci:input`` without content creates it: the facts of the pipeline file and its 

810 *Description*. Inputs documented by a ``glci:input`` later in the document are left out, too - the entries are 

811 created by :class:`AutoInputsTransform` when the whole document was parsed. 

812 """ 

813 

814 directiveName: str = "glci:autoinputs" #: Name the directive is invoked by. 

815 

816 has_content = False #: A boolean; ``True`` if content is allowed. 

817 required_arguments = 0 #: Number of required directive arguments. 

818 optional_arguments = 0 #: Number of optional arguments after the required ones. 

819 final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces. 

820 option_spec: dict[str, Any] = {} #: Mapping of option names to validator functions. 

821 

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

823 """ 

824 Create a placeholder, replaced by the entries once the document was parsed. 

825 

826 :returns: The placeholder, or nothing without a current pipeline. 

827 """ 

828 if (pipelineFile := self._CurrentPipeline()) is None: 

829 return [] 

830 

831 details = {"pipeline": self.env.ref_context["glci:pipeline"], "path": pipelineFile.Path} 

832 pending = nodes.pending(AutoInputsTransform, details) 

833 self.set_source_info(pending) 

834 self.state.document.note_pending(pending) 

835 

836 return [pending] 

837 

838 

839@export 

840class AutoInputsTransform(SphinxTransform): 

841 """ 

842 Replaces the placeholder of a ``glci:autoinputs`` by an entry for every input of its pipeline file the document has 

843 no ``glci:input`` for. 

844 

845 It runs after the document was parsed - so every ``glci:input`` has registered its entry - and before the entries are 

846 collected for the table of contents and the index. 

847 """ 

848 

849 default_priority = 400 #: Priority among the transforms: before the local table of contents and the smart quotes. 

850 

851 def apply(self, **kwargs: Any) -> None: 

852 """ 

853 Replace the placeholder by the entries. 

854 

855 :param kwargs: Unused. 

856 """ 

857 pending: nodes.pending = self.startnode 

858 pipelineName = pending.details["pipeline"] 

859 domain = self.env.get_domain("glci") 

860 pipelineFile = domain.LoadPipelineFile(pending.details["path"]) 

861 

862 entries = [] 

863 for name, parameter in pipelineFile.Inputs.items(): 

864 if domain.Objects.get(("input", f"{pipelineName}.{name}"), ("", ""))[0] != self.env.docname: 

865 entries.extend(InputDirective.CreateEntry( 

866 self.env, self.document, pipelineName, name, parameter, [], [], pending.source, pending.line 

867 )) 

868 

869 pending.replace_self(entries) 

870 

871 

872@export 

873def checkUndocumentedInputs(sphinx: Sphinx, doctree: nodes.document) -> None: 

874 """ 

875 Call-back for Sphinx' ``doctree-read`` event, warning about inputs the document has no entry for. 

876 

877 Every input of a pipeline file the document names by ``glci:pipeline`` needs an entry in the document, by a 

878 ``glci:input`` or a ``glci:autoinputs``; one without is a warning of type ``glci.drift`` at the ``glci:pipeline``. 

879 

880 :param sphinx: The Sphinx application. 

881 :param doctree: The document read. 

882 """ 

883 if (pipelines := sphinx.env.current_document.get("glci:pipelines", None)) is None: 

884 return 

885 

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

887 for pipelineName, path, location in pipelines: 

888 for name in domain.LoadPipelineFile(path).Inputs: 

889 if domain.Objects.get(("input", f"{pipelineName}.{name}"), ("", ""))[0] != sphinx.env.docname: 

890 _logger.warning( 

891 f"Input '{name}' of pipeline '{pipelineName}' has no entry: add a glci:input or a glci:autoinputs.", 

892 location=location, type=WARNING_TYPE, subtype="drift" 

893 )