Coverage for pyTooling/GitLab/Sphinx/__init__.py: 97%

324 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 domain ``glci`` documenting GitLab CI/CD pipeline files from their YAML files. 

33 

34The facts of a pipeline file's parameters - an input's type, whether it is required, its default, its options - are 

35read from the file by :mod:`pyTooling.GitLab.PipelineFile`, so a page states them without copying them. What the file 

36can't say stays hand-written, as the content of a directive: 

37 

38.. code-block:: rst 

39 

40 .. glci:pipeline:: Package 

41 :file: ../templates/Package/template.yml 

42 

43 Package 

44 ####### 

45 

46 .. glci:input:: package_name 

47 

48 :Example: ``myPackage`` 

49 

50.. rubric:: What it registers 

51 

52* Directives 

53 

54 * ``glci:pipeline`` 

55 * ``glci:input`` 

56 * ``glci:variable`` 

57 

58* Roles 

59 

60 * ``:glci:pipeline:`` 

61 * ``:glci:input:`` 

62 * ``:glci:variable:`` 

63 

64* Configuration values, as listed in :attr:`GitLabCIDomain.configValues` 

65 

66 * ``glci_server`` 

67 * ``glci_project`` 

68 * ``glci_repository_root`` 

69 * ``glci_pipeline_directory`` 

70 * ``glci_ref`` 

71 * ``glci_label_prefix`` 

72 

73The pipeline files are read with ``ruamel.yaml`` when a directive runs, not when this module is imported. 

74 

75.. seealso:: 

76 

77 :mod:`pyTooling.GitLab.PipelineFile` 

78 |rarr| The model of a pipeline file the domain reads. 

79 :mod:`pyTooling.GitLab.Sphinx.Graph` 

80 |rarr| The ``glci:pipeline-graph`` directive, drawing a pipeline file's jobs and their dependencies. 

81 :mod:`pyTooling.GitLab.Sphinx.Reference` 

82 |rarr| The directives summarizing a pipeline file: its parameters, its interface, its dependencies, its YAML. 

83""" 

84from __future__ import annotations 

85 

86from pathlib import Path 

87from typing import TYPE_CHECKING, Any, ClassVar, Iterable, Optional as Nullable, Union 

88 

89from docutils import nodes 

90from docutils.nodes import Element, Node, fully_normalize_name 

91from docutils.parsers.rst import directives 

92from sphinx import addnodes 

93from sphinx.application import Sphinx 

94from sphinx.builders import Builder 

95from sphinx.domains import Domain, ObjType 

96from sphinx.environment import BuildEnvironment 

97from sphinx.roles import XRefRole 

98from sphinx.util.logging import getLogger 

99from sphinx.util.nodes import make_id, make_refnode 

100 

101from pyTooling.Common import getFullyQualifiedName 

102from pyTooling.Decorators import export, readonly 

103from pyTooling.Sphinx import BaseDirective 

104 

105if TYPE_CHECKING: # pragma: no cover 

106 from pyTooling.GitLab.PipelineFile import IncludeResolver, Input, PipelineFile, ValueT, Variable 

107 

108 

109__all__ = ["NO_DEFAULT", "WARNING_TYPE", "LEADING_FIELDS", "FILE_SUFFIXES"] 

110 

111#: The text of the field *Default Value* when a parameter has no default. 

112NO_DEFAULT = "— — — —" 

113 

114#: The type of the warnings this domain emits; the drift warnings have the subtype ``drift``, so 

115#: ``suppress_warnings = ["glci.drift"]`` silences them. 

116WARNING_TYPE = "glci" 

117 

118#: The fields a *Description* taken from the pipeline file follows. 

119LEADING_FIELDS = ("Type", "Required", "Default Value", "Possible Values", "Pattern") 

120 

121#: The file names a ``glci:pipeline`` without ``:file:`` tries in ``glci_pipeline_directory``, in this order; ``{name}`` 

122#: is the pipeline's name. The last is a CI/CD component's layout. 

123FILE_SUFFIXES = ("{name}.yml", "{name}.yaml", "{name}/template.yml", "{name}/template.yaml") 

124 

125_logger = getLogger(__name__) 

126 

127 

128@export 

129def formatValue(value: ValueT) -> str: 

130 """ 

131 Format a value read from a pipeline file the way the file writes it. 

132 

133 A string is quoted as YAML quotes it - ``'3.14'`` -, a boolean is ``true`` or ``false``, a number is written as it is, 

134 and a list is written in YAML's flow style - ``['a', 'b']``. 

135 

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

137 :returns: The value as text, or :data:`NO_DEFAULT` for ``None``. 

138 """ 

139 if value is None: 

140 return NO_DEFAULT 

141 elif isinstance(value, bool): 

142 return "true" if value else "false" 

143 elif isinstance(value, str): 

144 escaped = value.replace("'", "''") 

145 return f"'{escaped}'" 

146 elif isinstance(value, list): 

147 return f"[{', '.join(formatValue(item) for item in value)}]" 

148 elif isinstance(value, dict): 

149 return f"{{{', '.join(f'{key}: {formatValue(item)}' for key, item in value.items())}}}" 

150 

151 return str(value) 

152 

153 

154@export 

155class PipelineDirective(BaseDirective): 

156 """ 

157 The directive ``glci:pipeline``: a pipeline file's target, its index entry, and the current pipeline of the document. 

158 

159 .. code-block:: rst 

160 

161 .. glci:pipeline:: Package 

162 :file: ../templates/Package/template.yml 

163 

164 The argument is the pipeline's name. Without ``:file:``, the file is looked up in ``glci_pipeline_directory`` by the 

165 names of :data:`FILE_SUFFIXES`. Every ``glci:input`` and ``glci:variable`` following it in the document belongs to 

166 this pipeline. 

167 

168 The directive writes no visible output. Placed above a page's title, its target is the title, as a label is. 

169 """ 

170 

171 directiveName: str = "glci:pipeline" #: Name the directive is invoked by. 

172 

173 has_content = False #: The directive has no content. 

174 required_arguments = 1 #: The pipeline's name. 

175 option_spec = {"file": directives.unchanged_required} #: Path to the pipeline file, relative to the document. 

176 

177 def run(self) -> list[Node]: 

178 """ 

179 Register the pipeline, read its file, and make it the current pipeline. 

180 

181 :returns: An index node and the pipeline's target. 

182 """ 

183 name = self.arguments[0].strip() 

184 domain: GitLabCIDomain = self.env.get_domain("glci") 

185 

186 path = None 

187 if "file" in self.options: 

188 path = Path(self.env.relfn2path(self.options["file"], self.env.docname)[1]) 

189 elif (directory := domain.PipelineDirectory) is not None: 

190 candidates = [directory / suffix.format(name=name) for suffix in FILE_SUFFIXES] 

191 path = next((candidate for candidate in candidates if candidate.exists()), candidates[0]) 

192 

193 if path is None: 

194 _logger.warning( 

195 f"{self.directiveName} '{name}' has no file: give option ':file:' or set 'glci_pipeline_directory'.", 

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

197 ) 

198 elif not path.exists(): 

199 _logger.warning( 

200 f"{self.directiveName} '{name}': file '{path}' doesn't exist.", 

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

202 ) 

203 else: 

204 self.env.note_dependency(str(path)) 

205 self._Load(domain, name, path) 

206 

207 self.env.ref_context["glci:pipeline"] = name 

208 

209 nodeID = make_id(self.env, self.state.document, "glci-pipeline", name) 

210 target = nodes.target("", "", ids=[nodeID]) 

211 if (prefix := self.config.glci_label_prefix) is not None: 

212 label = f"{prefix}/{name}" 

213 target["ids"].append(nodes.make_id(label)) 

214 target["names"].append(fully_normalize_name(label)) 

215 

216 self.set_source_info(target) 

217 self.state.document.note_explicit_target(target) 

218 domain.NoteObject("pipeline", name, nodeID, target) 

219 

220 return [addnodes.index(entries=[("single", f"GitLab CI/CD pipeline; {name}", nodeID, "", None)]), target] 

221 

222 def _Load(self, domain: GitLabCIDomain, name: str, path: Path) -> None: 

223 """ 

224 Read the pipeline file and make it the current one. 

225 

226 A file that isn't a well-formed configuration is reported as a warning at the place in the file, and the document 

227 has no current pipeline model then. A pipeline read is added to the document's list ``glci:pipelines`` of (name, 

228 path, location of the directive), which is checked when the document was read. 

229 

230 :param domain: The domain. 

231 :param name: The pipeline's name, as given as argument. 

232 :param path: Path to the pipeline file. 

233 """ 

234 from pyTooling.GitLab.PipelineFile import PipelineFileError 

235 

236 try: 

237 domain.LoadPipelineFile(path) 

238 except PipelineFileError as ex: 

239 if ex.Path is None: 239 ↛ 240line 239 didn't jump to line 240 because the condition on line 239 was never true

240 location = self.get_location() 

241 elif ex.Line is None: 241 ↛ 242line 241 didn't jump to line 242 because the condition on line 241 was never true

242 location = str(ex.Path) 

243 else: 

244 location = f"{ex.Path}:{ex.Line}" 

245 

246 _logger.warning(f"{self.directiveName} '{name}': {ex}", location=location, type=WARNING_TYPE, subtype="pipeline") 

247 return 

248 

249 self.env.current_document["glci:pipeline-file"] = path 

250 self.env.current_document.setdefault("glci:pipelines", []).append((name, path, self.get_location())) 

251 

252 

253@export 

254class ParameterDirective(BaseDirective): 

255 """ 

256 Base-class of the directives documenting one parameter of the current pipeline. 

257 

258 The entry is a section titled by the parameter's name, holding a field list: first the fields read from the pipeline 

259 file (:meth:`_FactFields`), then the fields of the directive's content, in the order written. Without a hand-written 

260 *Description*, the pipeline file's ``description`` is used, placed behind the :data:`LEADING_FIELDS`. Content after 

261 the field list follows it. 

262 

263 A hand-written field repeating a fact of the file is a warning, and the file's value is shown. 

264 

265 Besides its anchor ``glci-<type>-<Pipeline>.<name>``, the section carries the anchor of its label and - unless the 

266 document uses it already - the anchor docutils derives from a title, as a hand-written section has. 

267 """ 

268 

269 OBJECT_TYPE: ClassVar[str] #: The domain's object type, as ``input``. 

270 LABEL_KIND: ClassVar[str] #: The kind in a label, as ``Input`` in ``PIPELINE/Package/Input/name``. 

271 COLLECTION: ClassVar[str] #: The pipeline file's property holding the parameters, as ``Inputs``. 

272 FACT_FIELDS: ClassVar[tuple[str, ...]] #: The fields the pipeline file may state. 

273 

274 has_content = True #: The hand-written fields and text. 

275 required_arguments = 1 #: The parameter's name. 

276 

277 def run(self) -> list[Node]: 

278 """ 

279 Create the parameter's entry and register it. 

280 

281 :returns: An index node and the entry's section, or nothing outside a ``glci:pipeline``. 

282 """ 

283 name = self.arguments[0].strip() 

284 if (pipelineName := self.env.ref_context.get("glci:pipeline", None)) is None: 

285 _logger.warning( 

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

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

288 ) 

289 return [] 

290 

291 domain: GitLabCIDomain = self.env.get_domain("glci") 

292 parameter = None 

293 if (pipelineFile := domain.GetCurrentPipeline()) is not None: 293 ↛ 300line 293 didn't jump to line 300 because the condition on line 293 was always true

294 if (parameter := getattr(pipelineFile, self.COLLECTION).get(name, None)) is None: 

295 _logger.warning( 

296 f"Pipeline '{pipelineName}' has no {self.OBJECT_TYPE} '{name}' ({pipelineFile.Path.name}).", 

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

298 ) 

299 

300 facts = self.FACT_FIELDS if parameter is None else [field[0].astext() for field in self._FactFields(parameter)] 

301 

302 content = self.parse_content_to_nodes() 

303 handwritten = [] 

304 if len(content) > 0 and isinstance(content[0], nodes.field_list): 

305 handwritten = list(content[0].children) 

306 content = content[1:] 

307 

308 fields = [] 

309 for field in handwritten: 

310 fieldName = field[0].astext().strip() 

311 if fieldName in facts: 

312 _logger.warning( 

313 f"{self.directiveName} '{pipelineName}.{name}': field '{fieldName}' is taken from the pipeline file; " 

314 "remove it.", 

315 location=field, type=WARNING_TYPE, subtype="drift" 

316 ) 

317 if parameter is not None: 317 ↛ 320line 317 didn't jump to line 320 because the condition on line 317 was always true

318 continue 

319 

320 fields.append(field) 

321 

322 source, line = self.get_source_info() 

323 return self.CreateEntry(self.env, self.state.document, pipelineName, name, parameter, fields, content, source, line) 

324 

325 @classmethod 

326 def CreateEntry( 

327 cls, 

328 env: BuildEnvironment, 

329 document: nodes.document, 

330 pipelineName: str, 

331 name: str, 

332 parameter: Nullable[Union[Input, Variable]], 

333 fields: list[nodes.field], 

334 content: list[Node], 

335 source: str, 

336 line: Nullable[int] 

337 ) -> list[Node]: 

338 """ 

339 Create a parameter's entry and register it in the domain. 

340 

341 :param env: The build environment. 

342 :param document: The document the entry is placed in. 

343 :param pipelineName: The pipeline's name. 

344 :param name: The parameter's name. 

345 :param parameter: The parameter, as read from the pipeline file, or ``None`` if the pipeline file wasn't read or 

346 hasn't this parameter. 

347 :param fields: The hand-written fields, without those repeating a fact of the pipeline file. 

348 :param content: The nodes following the field list. 

349 :param source: The source file the entry is reported at. 

350 :param line: The line the entry is reported at, or ``None``. 

351 :returns: An index node and the entry's section. 

352 """ 

353 fullName = f"{pipelineName}.{name}" 

354 nodeID = make_id(env, document, f"glci-{cls.OBJECT_TYPE}", fullName) 

355 section = nodes.section("", nodes.title(name, name), ids=[nodeID]) 

356 if (titleID := nodes.make_id(name)) not in document.ids: 356 ↛ 360line 356 didn't jump to line 360 because the condition on line 356 was always true

357 section["ids"].append(titleID) 

358 

359 # The label's anchor comes last: docutils links a name to a node's last anchor, so a link keeps its target. 

360 if (prefix := env.config.glci_label_prefix) is not None: 360 ↛ 365line 360 didn't jump to line 365 because the condition on line 360 was always true

361 label = f"{prefix}/{pipelineName}/{cls.LABEL_KIND}/{name}" 

362 section["ids"].append(nodes.make_id(label)) 

363 section["names"].append(fully_normalize_name(label)) 

364 

365 section.source = source 

366 section.line = line 

367 document.note_explicit_target(section) 

368 

369 if parameter is not None: 

370 fields = [*cls._FactFields(parameter), *fields] 

371 

372 fieldNames = [field[0].astext().strip() for field in fields] 

373 if "Description" not in fieldNames and parameter is not None and parameter.Description: 

374 leading = [index + 1 for index, fieldName in enumerate(fieldNames) if fieldName in LEADING_FIELDS] 

375 position = max(leading, default=0) 

376 fields.insert(position, cls._TextField("Description", parameter.Description.strip())) 

377 

378 if len(fields) > 0: 

379 section += nodes.field_list("", *fields) 

380 section.extend(content) 

381 

382 domain: GitLabCIDomain = env.get_domain("glci") 

383 domain.NoteObject(cls.OBJECT_TYPE, fullName, nodeID, section) 

384 return [ 

385 addnodes.index(entries=[("single", f"{name} ({cls.OBJECT_TYPE} of {pipelineName})", nodeID, "", None)]), 

386 section 

387 ] 

388 

389 @staticmethod 

390 def _Field(name: str, *body: Node) -> nodes.field: 

391 """ 

392 Create a field of a field list. 

393 

394 :param name: Name of the field, as ``Type``. 

395 :param body: The nodes of the field's body. 

396 :returns: The field. 

397 """ 

398 return nodes.field("", nodes.field_name(name, name), nodes.field_body("", *body)) 

399 

400 @classmethod 

401 def _TextField(cls, name: str, text: str) -> nodes.field: 

402 """ 

403 Create a field of a field list, whose body is a line of text. 

404 

405 :param name: Name of the field, as ``Required``. 

406 :param text: Text of the field's body. 

407 :returns: The field. 

408 """ 

409 return cls._Field(name, nodes.paragraph(text, text)) 

410 

411 @classmethod 

412 def _LiteralField(cls, name: str, texts: Iterable[str]) -> nodes.field: 

413 """ 

414 Create a field of a field list, whose body is a line of literals separated by commas. 

415 

416 :param name: Name of the field, as ``Possible Values``. 

417 :param texts: The literals' texts. 

418 :returns: The field. 

419 """ 

420 paragraph = nodes.paragraph() 

421 for index, text in enumerate(texts): 

422 if index > 0: 

423 paragraph += nodes.Text(", ") 

424 paragraph += nodes.literal(text, text) 

425 

426 return cls._Field(name, paragraph) 

427 

428 @classmethod 

429 def _DefaultField(cls, value: ValueT) -> nodes.field: 

430 """ 

431 Create the field *Default Value*. 

432 

433 A multi-line string becomes a literal block, keeping its line breaks; no default becomes :data:`NO_DEFAULT`. 

434 

435 :param value: The default value. 

436 :returns: The field. 

437 """ 

438 if value is None: 

439 return cls._TextField("Default Value", NO_DEFAULT) 

440 elif isinstance(value, str) and "\n" in value: 

441 return cls._Field("Default Value", nodes.literal_block(value, value, language="text")) 

442 

443 return cls._LiteralField("Default Value", (formatValue(value), )) 

444 

445 @classmethod 

446 def _FactFields(cls, parameter: Union[Input, Variable]) -> list[nodes.field]: 

447 """ 

448 Create the fields taken from the pipeline file. 

449 

450 :param parameter: The parameter, as read from the pipeline file. 

451 :returns: The fields the file states for this parameter, in the order of :attr:`FACT_FIELDS`. 

452 """ 

453 return [] 

454 

455 

456@export 

457class InputDirective(ParameterDirective): 

458 """ 

459 The directive ``glci:input``: an input of the current pipeline file's ``spec:inputs``. 

460 

461 The fields *Type*, *Required* and *Default Value* are taken from the pipeline file, and - where the file states them - 

462 *Possible Values* (``options``) and *Pattern* (``regex``). 

463 """ 

464 

465 directiveName: str = "glci:input" #: Name the directive is invoked by. 

466 

467 OBJECT_TYPE = "input" #: The domain's object type. 

468 LABEL_KIND = "Input" #: The kind in a label. 

469 COLLECTION = "Inputs" #: The pipeline file's property holding the parameters. 

470 

471 #: The fields the file may state. 

472 FACT_FIELDS = ("Type", "Required", "Default Value", "Possible Values", "Pattern") 

473 

474 @classmethod 

475 def _FactFields(cls, parameter: Input) -> list[nodes.field]: 

476 """ 

477 Create the fields *Type*, *Required*, *Default Value*, and *Possible Values* and *Pattern* if the input has them. 

478 

479 :param parameter: The input, as read from the pipeline file. 

480 :returns: The fields. 

481 """ 

482 fields = [ 

483 cls._TextField("Type", parameter.Type.value), 

484 cls._TextField("Required", "yes" if parameter.Required else "no"), 

485 cls._DefaultField(parameter.Default) 

486 ] 

487 if len(parameter.Options) > 0: 

488 fields.append(cls._LiteralField("Possible Values", (formatValue(option) for option in parameter.Options))) 

489 

490 if parameter.Regex is not None: 

491 fields.append(cls._LiteralField("Pattern", (parameter.Regex, ))) 

492 

493 return fields 

494 

495 

496@export 

497class VariableDirective(ParameterDirective): 

498 """ 

499 The directive ``glci:variable``: a default variable of the current pipeline file, declared in its ``variables``. 

500 

501 The fields *Type* - a variable is a string -, *Default Value* (``value``) and - where the file states them - *Possible 

502 Values* (``options``) are taken from the pipeline file. A variable with a ``description`` is prefilled in the form 

503 running a pipeline by hand. 

504 """ 

505 

506 directiveName: str = "glci:variable" #: Name the directive is invoked by. 

507 

508 OBJECT_TYPE = "variable" #: The domain's object type. 

509 LABEL_KIND = "Variable" #: The kind in a label. 

510 COLLECTION = "Variables" #: The pipeline file's property holding the parameters. 

511 FACT_FIELDS = ("Type", "Default Value", "Possible Values") #: The fields the file may state. 

512 

513 @classmethod 

514 def _FactFields(cls, parameter: Variable) -> list[nodes.field]: 

515 """ 

516 Create the fields *Type* and *Default Value*, and *Possible Values* if the variable has options. 

517 

518 :param parameter: The variable, as read from the pipeline file. 

519 :returns: The fields. 

520 """ 

521 fields = [cls._TextField("Type", "string"), cls._DefaultField(parameter.Value)] 

522 if len(parameter.Options) > 0: 

523 fields.append(cls._LiteralField("Possible Values", (formatValue(option) for option in parameter.Options))) 

524 

525 return fields 

526 

527 

528@export 

529class GitLabCIXRefRole(XRefRole): 

530 """ 

531 The roles ``:glci:pipeline:``, ``:glci:input:`` and ``:glci:variable:``. 

532 

533 A parameter is named as ``<Pipeline>.<name>``; inside a ``glci:pipeline``, the name alone refers to the current 

534 pipeline's parameter. A leading ``~`` shows only the part behind the last dot. 

535 """ 

536 

537 def process_link( 

538 self, 

539 env: BuildEnvironment, 

540 refnode: Element, 

541 has_explicit_title: bool, 

542 title: str, 

543 target: str 

544 ) -> tuple[str, str]: 

545 """ 

546 Remember the current pipeline on the reference, and shorten the title for a leading ``~``. 

547 

548 :param env: The build environment. 

549 :param refnode: The reference node. 

550 :param has_explicit_title: ``True``, if the role was written with a title, as ``text <target>``. 

551 :param title: The title. 

552 :param target: The target. 

553 :returns: The title and the target. 

554 """ 

555 refnode["glci:pipeline"] = env.ref_context.get("glci:pipeline", None) 

556 if not has_explicit_title and target.startswith("~"): 

557 target = target[1:] 

558 title = target.rpartition(".")[2] 

559 

560 return title, target 

561 

562 

563@export 

564class GitLabCIDomain(Domain): 

565 """ 

566 The Sphinx domain ``glci``, documenting GitLab CI/CD pipeline files. 

567 

568 Its objects are keyed by type and name - ``("pipeline", "Package")``, ``("input", "Package.package_name")`` - and 

569 located by document and anchor. Directives of other modules reach the domain by ``self.env.get_domain("glci")``, and 

570 use :meth:`GetCurrentPipeline`, :meth:`LoadPipelineFile`, :attr:`Resolver` and :meth:`ResolvePipeline`. 

571 """ 

572 

573 name = "glci" #: Name of the domain, the prefix of its directives and roles. 

574 label = "GitLab CI/CD" #: Name of the domain, as displayed. 

575 data_version = 1 #: Version of the data layout; a change discards pickled environments. 

576 

577 object_types: ClassVar[dict[str, ObjType]] = { # type: ignore[misc] 

578 "pipeline": ObjType("pipeline", "pipeline"), 

579 "input": ObjType("input", "input"), 

580 "variable": ObjType("variable", "variable"), 

581 } #: The object types, each referenced by the role of the same name. 

582 

583 directives: ClassVar[dict[str, type]] = { # type: ignore[misc] 

584 "pipeline": PipelineDirective, 

585 "input": InputDirective, 

586 "variable": VariableDirective, 

587 } #: The directives, by name. 

588 

589 roles: ClassVar[dict[str, XRefRole]] = { # type: ignore[misc] 

590 "pipeline": GitLabCIXRefRole(innernodeclass=nodes.literal, warn_dangling=True), 

591 "input": GitLabCIXRefRole(innernodeclass=nodes.literal, warn_dangling=True), 

592 "variable": GitLabCIXRefRole(innernodeclass=nodes.literal, warn_dangling=True), 

593 } #: The roles, by name. 

594 

595 initial_data: ClassVar[dict[str, Any]] = { # type: ignore[misc] 

596 "objects": {}, 

597 } #: The domain's data: ``objects`` maps (type, name) to (document, anchor). 

598 

599 #: The configuration values the domain adds to :file:`conf.py`, as ``name: (default, rebuild, types)``. Each is 

600 #: registered with the domain's name as prefix, e.g. ``glci_project``. 

601 #: 

602 #: ``server`` 

603 #: The URL of the GitLab instance links point to, ``https://gitlab.com`` by default, or a self-managed instance's, 

604 #: as ``https://gitlab.example.com``. 

605 #: ``project`` 

606 #: The documented project, as ``group/project`` or ``group/subgroup/project``. 

607 #: ``repository_root`` 

608 #: The documented repository's root directory, relative to the Sphinx source directory, as ``..``. A local 

609 #: ``include`` and a child pipeline's file are read from it. 

610 #: ``pipeline_directory`` 

611 #: The directory holding the pipeline files, relative to the Sphinx source directory, as ``../templates``. A 

612 #: ``glci:pipeline`` without ``:file:`` reads its file from it; without it, from ``repository_root``. 

613 #: ``ref`` 

614 #: The ref - a branch or tag - of the documented project the documentation describes, as ``v1``, or ``None``. 

615 #: ``label_prefix`` 

616 #: The root of the ``:ref:`` labels the directives register besides their domain targets, as 

617 #: ``PIPELINE/Package/Input/package_name``, or ``None`` for none. 

618 configValues: ClassVar[dict[str, tuple[Any, str, Any]]] = { 

619 "server": ("https://gitlab.com", "env", str), 

620 "project": (None, "env", (str, type(None))), 

621 "repository_root": (None, "env", (str, type(None))), 

622 "pipeline_directory": (None, "env", (str, type(None))), 

623 "ref": (None, "env", (str, type(None))), 

624 "label_prefix": ("PIPELINE", "env", (str, type(None))), 

625 } 

626 

627 _files: dict[Path, PipelineFile] #: Pipeline files already read, by resolved path. 

628 _resolver: Nullable[IncludeResolver] #: Resolver reading included files and child pipelines, created when needed. 

629 

630 def __init__(self, env: BuildEnvironment) -> None: 

631 """ 

632 Initializes the domain. 

633 

634 :param env: The build environment. 

635 """ 

636 super().__init__(env) 

637 

638 self._files = {} 

639 self._resolver = None 

640 

641 @readonly 

642 def Objects(self) -> dict[tuple[str, str], tuple[str, str]]: 

643 """ 

644 Read-only property to return the documented objects. 

645 

646 :returns: A mapping of (object type, name) to (document, anchor). 

647 """ 

648 return self.data["objects"] 

649 

650 @readonly 

651 def RepositoryRoot(self) -> Nullable[Path]: 

652 """ 

653 Read-only property to return the documented repository's root directory. 

654 

655 :returns: ``glci_repository_root`` resolved against the Sphinx source directory, or ``None`` if it isn't set. 

656 """ 

657 if (directory := self.env.config.glci_repository_root) is None: 

658 return None 

659 

660 return (Path(self.env.srcdir) / directory).resolve() 

661 

662 @readonly 

663 def PipelineDirectory(self) -> Nullable[Path]: 

664 """ 

665 Read-only property to return the directory holding the pipeline files. 

666 

667 :returns: ``glci_pipeline_directory`` resolved against the Sphinx source directory, else :attr:`RepositoryRoot`. 

668 """ 

669 if (directory := self.env.config.glci_pipeline_directory) is None: 

670 return self.RepositoryRoot 

671 

672 return (Path(self.env.srcdir) / directory).resolve() 

673 

674 @readonly 

675 def Resolver(self) -> Nullable[IncludeResolver]: 

676 """ 

677 Read-only property to access the resolver reading local includes and child pipeline files (:attr:`_resolver`), 

678 created when first needed. 

679 

680 :returns: The resolver reading from :attr:`RepositoryRoot`, or ``None`` if ``glci_repository_root`` isn't set. 

681 """ 

682 if self._resolver is None and (root := self.RepositoryRoot) is not None: 

683 from pyTooling.GitLab.PipelineFile import IncludeResolver 

684 

685 self._resolver = IncludeResolver(root) 

686 

687 return self._resolver 

688 

689 def LoadPipelineFile(self, path: Path) -> PipelineFile: 

690 """ 

691 Read a pipeline file, once per build and process - a parallel build reads a file once in every process needing it. 

692 

693 The local files it includes are read by :attr:`Resolver`, so ``extends`` and ``!reference`` reach the jobs they 

694 define. 

695 

696 :param path: Path to the pipeline file. 

697 :returns: The pipeline file's model. 

698 :raises ValueError: If parameter 'path' is ``None``. 

699 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`. 

700 :raises PipelineFileError: If the file doesn't exist or is not a well-formed configuration. 

701 """ 

702 if path is None: 

703 raise ValueError("Parameter 'path' is None.") 

704 elif not isinstance(path, Path): 

705 ex = TypeError("Parameter 'path' is not of type 'Path'.") 

706 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.") 

707 raise ex 

708 

709 resolved = path.resolve() 

710 if (known := self._files.get(resolved, None)) is not None: 

711 return known 

712 

713 from pyTooling.GitLab.PipelineFile import PipelineFile 

714 

715 self._files[resolved] = PipelineFile.FromFile(path, self.Resolver) 

716 return self._files[resolved] 

717 

718 def GetCurrentPipeline(self) -> Nullable[PipelineFile]: 

719 """ 

720 Return the model of the current document's pipeline file, as set by the last ``glci:pipeline``. 

721 

722 :returns: The pipeline file, or ``None`` if the document has no ``glci:pipeline``, or its file couldn't be read - 

723 which the ``glci:pipeline`` directive reported already. 

724 """ 

725 if (path := self.env.current_document.get("glci:pipeline-file", None)) is None: 725 ↛ 726line 725 didn't jump to line 726 because the condition on line 725 was never true

726 return None 

727 

728 return self.LoadPipelineFile(path) 

729 

730 def ResolvePipeline(self, name: str) -> Nullable[tuple[str, str]]: 

731 """ 

732 Return where a pipeline is documented. 

733 

734 :param name: The pipeline's name, as its ``glci:pipeline`` names it. 

735 :returns: The document and the anchor of its ``glci:pipeline``, or ``None`` if it isn't documented. 

736 :raises ValueError: If parameter 'name' is ``None``. 

737 :raises TypeError: If parameter 'name' is not of type :class:`str`. 

738 """ 

739 if name is None: 

740 raise ValueError("Parameter 'name' is None.") 

741 elif not isinstance(name, str): 

742 ex = TypeError("Parameter 'name' is not of type 'str'.") 

743 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.") 

744 raise ex 

745 

746 return self.data["objects"].get(("pipeline", name), None) 

747 

748 def NoteObject(self, objectType: str, name: str, nodeID: str, location: Nullable[Node] = None) -> None: 

749 """ 

750 Register a documented object. 

751 

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

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

754 :param nodeID: The anchor of the object's node. 

755 :param location: Optional, the node a duplicate is reported at. Default: ``None``. 

756 :raises ValueError: If parameter 'objectType' is ``None``. 

757 :raises TypeError: If parameter 'objectType' is not of type :class:`str`. 

758 :raises ValueError: If parameter 'name' is ``None``. 

759 :raises TypeError: If parameter 'name' is not of type :class:`str`. 

760 :raises ValueError: If parameter 'nodeID' is ``None``. 

761 :raises TypeError: If parameter 'nodeID' is not of type :class:`str`. 

762 :raises TypeError: If parameter 'location' is not of type :class:`~docutils.nodes.Node`. 

763 """ 

764 for parameterName, value in ( 

765 ("objectType", objectType), 

766 ("name", name), 

767 ("nodeID", nodeID) 

768 ): 

769 if value is None: 

770 raise ValueError(f"Parameter '{parameterName}' is None.") 

771 elif not isinstance(value, str): 

772 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.") 

773 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.") 

774 raise ex 

775 

776 if location is not None and not isinstance(location, Node): 

777 ex = TypeError("Parameter 'location' is not of type 'Node'.") 

778 ex.add_note(f"Got type '{getFullyQualifiedName(location)}'.") 

779 raise ex 

780 

781 objects = self.data["objects"] 

782 if (known := objects.get((objectType, name), None)) is not None: 

783 _logger.warning( 

784 f"Duplicate description of glci:{objectType} '{name}', other instance in '{known[0]}'.", 

785 location=location, type=WARNING_TYPE, subtype="duplicate" 

786 ) 

787 

788 objects[(objectType, name)] = (self.env.docname, nodeID) 

789 

790 def clear_doc(self, docname: str) -> None: 

791 """ 

792 Remove the objects of a document, before it is read again. 

793 

794 :param docname: The document. 

795 """ 

796 objects = self.data["objects"] 

797 for key in [key for key, (document, _) in objects.items() if document == docname]: 

798 del objects[key] 

799 

800 def merge_domaindata(self, docnames: Iterable[str], otherdata: dict[str, Any]) -> None: 

801 """ 

802 Merge the objects a parallel reader collected for its documents. 

803 

804 :param docnames: The documents the other reader read. 

805 :param otherdata: The other reader's data. 

806 """ 

807 documents = set(docnames) 

808 objects = self.data["objects"] 

809 for key, (document, nodeID) in otherdata["objects"].items(): 

810 if document in documents: 

811 objects[key] = (document, nodeID) 

812 

813 def resolve_xref( 

814 self, 

815 env: BuildEnvironment, 

816 fromdocname: str, 

817 builder: Builder, 

818 typ: str, 

819 target: str, 

820 node: addnodes.pending_xref, 

821 contnode: Element 

822 ) -> Nullable[nodes.reference]: 

823 """ 

824 Resolve a reference by one of the domain's roles. 

825 

826 A parameter's name without a pipeline is looked up in the pipeline current where the role was written. 

827 

828 :param env: The build environment. 

829 :param fromdocname: The document containing the reference. 

830 :param builder: The builder. 

831 :param typ: The role's name, as ``input``. 

832 :param target: The target, as ``Package.package_name`` or ``package_name``. 

833 :param node: The pending reference. 

834 :param contnode: The node rendering the reference's title. 

835 :returns: The reference, or ``None`` if the target isn't documented. 

836 """ 

837 if typ != "pipeline" and "." not in target and (pipelineName := node.get("glci:pipeline", None)) is not None: 

838 target = f"{pipelineName}.{target}" 

839 

840 if (location := self.data["objects"].get((typ, target), None)) is None: 

841 return None 

842 

843 return make_refnode(builder, fromdocname, location[0], location[1], contnode, target) 

844 

845 def resolve_any_xref( 

846 self, 

847 env: BuildEnvironment, 

848 fromdocname: str, 

849 builder: Builder, 

850 target: str, 

851 node: addnodes.pending_xref, 

852 contnode: Element 

853 ) -> list[tuple[str, nodes.reference]]: 

854 """ 

855 Resolve a reference by the ``:any:`` role, trying every object type. 

856 

857 :param env: The build environment. 

858 :param fromdocname: The document containing the reference. 

859 :param builder: The builder. 

860 :param target: The target. 

861 :param node: The pending reference. 

862 :param contnode: The node rendering the reference's title. 

863 :returns: A pair of role and reference per object type the target resolves for. 

864 """ 

865 results = [] 

866 for objectType in self.object_types: 

867 if (reference := self.resolve_xref(env, fromdocname, builder, objectType, target, node, contnode)) is not None: 

868 results.append((f"glci:{objectType}", reference)) 

869 

870 return results 

871 

872 def get_objects(self) -> Iterable[tuple[str, str, str, str, str, int]]: 

873 """ 

874 Iterate the documented objects, for the search index and the inventory. 

875 

876 :returns: An iterator of (name, display name, type, document, anchor, priority). 

877 """ 

878 for (objectType, name), (document, nodeID) in self.data["objects"].items(): 

879 yield name, name, objectType, document, nodeID, 1 

880 

881 

882@export 

883def setup(sphinx: Sphinx) -> dict[str, Any]: 

884 """ 

885 Register the domain ``glci``, its directives and its configuration values with Sphinx. 

886 

887 The directives derive from :class:`~pyTooling.Sphinx.BaseDirective` and draw graphs with 

888 :mod:`sphinx.ext.graphviz`, so the extension :mod:`pyTooling.Sphinx` is set up first. 

889 

890 :param sphinx: The Sphinx application to register with. 

891 :returns: The extension's metadata. 

892 """ 

893 from pyTooling.GitLab import __version__ 

894 from pyTooling.GitLab.Sphinx.Graph import PipelineGraph, resolveLinks 

895 from pyTooling.GitLab.Sphinx.Reference import AutoInputs, Dependencies, Interface, ParameterTable, YAMLExcerpt 

896 from pyTooling.GitLab.Sphinx.Reference import checkUndocumentedInputs 

897 

898 sphinx.setup_extension("pyTooling.Sphinx") 

899 

900 sphinx.add_domain(GitLabCIDomain) 

901 sphinx.add_directive_to_domain("glci", "pipeline-graph", PipelineGraph) 

902 sphinx.add_directive_to_domain("glci", "parameter-table", ParameterTable) 

903 sphinx.add_directive_to_domain("glci", "interface", Interface) 

904 sphinx.add_directive_to_domain("glci", "dependencies", Dependencies) 

905 sphinx.add_directive_to_domain("glci", "yaml", YAMLExcerpt) 

906 sphinx.add_directive_to_domain("glci", "autoinputs", AutoInputs) 

907 

908 for configName, (default, rebuild, types) in GitLabCIDomain.configValues.items(): 

909 sphinx.add_config_value(f"{GitLabCIDomain.name}_{configName}", default, rebuild, types) 

910 

911 sphinx.connect("doctree-read", checkUndocumentedInputs) 

912 sphinx.connect("doctree-resolved", resolveLinks) 

913 

914 return {"version": __version__, "parallel_read_safe": True, "parallel_write_safe": True}