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
« 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.
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:
38.. code-block:: rst
40 .. glci:pipeline:: Package
41 :file: ../templates/Package/template.yml
43 Package
44 #######
46 .. glci:input:: package_name
48 :Example: ``myPackage``
50.. rubric:: What it registers
52* Directives
54 * ``glci:pipeline``
55 * ``glci:input``
56 * ``glci:variable``
58* Roles
60 * ``:glci:pipeline:``
61 * ``:glci:input:``
62 * ``:glci:variable:``
64* Configuration values, as listed in :attr:`GitLabCIDomain.configValues`
66 * ``glci_server``
67 * ``glci_project``
68 * ``glci_repository_root``
69 * ``glci_pipeline_directory``
70 * ``glci_ref``
71 * ``glci_label_prefix``
73The pipeline files are read with ``ruamel.yaml`` when a directive runs, not when this module is imported.
75.. seealso::
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
86from pathlib import Path
87from typing import TYPE_CHECKING, Any, ClassVar, Iterable, Optional as Nullable, Union
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
101from pyTooling.Common import getFullyQualifiedName
102from pyTooling.Decorators import export, readonly
103from pyTooling.Sphinx import BaseDirective
105if TYPE_CHECKING: # pragma: no cover
106 from pyTooling.GitLab.PipelineFile import IncludeResolver, Input, PipelineFile, ValueT, Variable
109__all__ = ["NO_DEFAULT", "WARNING_TYPE", "LEADING_FIELDS", "FILE_SUFFIXES"]
111#: The text of the field *Default Value* when a parameter has no default.
112NO_DEFAULT = "— — — —"
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"
118#: The fields a *Description* taken from the pipeline file follows.
119LEADING_FIELDS = ("Type", "Required", "Default Value", "Possible Values", "Pattern")
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")
125_logger = getLogger(__name__)
128@export
129def formatValue(value: ValueT) -> str:
130 """
131 Format a value read from a pipeline file the way the file writes it.
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']``.
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())}}}"
151 return str(value)
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.
159 .. code-block:: rst
161 .. glci:pipeline:: Package
162 :file: ../templates/Package/template.yml
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.
168 The directive writes no visible output. Placed above a page's title, its target is the title, as a label is.
169 """
171 directiveName: str = "glci:pipeline" #: Name the directive is invoked by.
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.
177 def run(self) -> list[Node]:
178 """
179 Register the pipeline, read its file, and make it the current pipeline.
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")
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])
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)
207 self.env.ref_context["glci:pipeline"] = name
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))
216 self.set_source_info(target)
217 self.state.document.note_explicit_target(target)
218 domain.NoteObject("pipeline", name, nodeID, target)
220 return [addnodes.index(entries=[("single", f"GitLab CI/CD pipeline; {name}", nodeID, "", None)]), target]
222 def _Load(self, domain: GitLabCIDomain, name: str, path: Path) -> None:
223 """
224 Read the pipeline file and make it the current one.
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.
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
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}"
246 _logger.warning(f"{self.directiveName} '{name}': {ex}", location=location, type=WARNING_TYPE, subtype="pipeline")
247 return
249 self.env.current_document["glci:pipeline-file"] = path
250 self.env.current_document.setdefault("glci:pipelines", []).append((name, path, self.get_location()))
253@export
254class ParameterDirective(BaseDirective):
255 """
256 Base-class of the directives documenting one parameter of the current pipeline.
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.
263 A hand-written field repeating a fact of the file is a warning, and the file's value is shown.
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 """
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.
274 has_content = True #: The hand-written fields and text.
275 required_arguments = 1 #: The parameter's name.
277 def run(self) -> list[Node]:
278 """
279 Create the parameter's entry and register it.
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 []
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 )
300 facts = self.FACT_FIELDS if parameter is None else [field[0].astext() for field in self._FactFields(parameter)]
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:]
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
320 fields.append(field)
322 source, line = self.get_source_info()
323 return self.CreateEntry(self.env, self.state.document, pipelineName, name, parameter, fields, content, source, line)
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.
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)
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))
365 section.source = source
366 section.line = line
367 document.note_explicit_target(section)
369 if parameter is not None:
370 fields = [*cls._FactFields(parameter), *fields]
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()))
378 if len(fields) > 0:
379 section += nodes.field_list("", *fields)
380 section.extend(content)
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 ]
389 @staticmethod
390 def _Field(name: str, *body: Node) -> nodes.field:
391 """
392 Create a field of a field list.
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))
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.
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))
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.
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)
426 return cls._Field(name, paragraph)
428 @classmethod
429 def _DefaultField(cls, value: ValueT) -> nodes.field:
430 """
431 Create the field *Default Value*.
433 A multi-line string becomes a literal block, keeping its line breaks; no default becomes :data:`NO_DEFAULT`.
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"))
443 return cls._LiteralField("Default Value", (formatValue(value), ))
445 @classmethod
446 def _FactFields(cls, parameter: Union[Input, Variable]) -> list[nodes.field]:
447 """
448 Create the fields taken from the pipeline file.
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 []
456@export
457class InputDirective(ParameterDirective):
458 """
459 The directive ``glci:input``: an input of the current pipeline file's ``spec:inputs``.
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 """
465 directiveName: str = "glci:input" #: Name the directive is invoked by.
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.
471 #: The fields the file may state.
472 FACT_FIELDS = ("Type", "Required", "Default Value", "Possible Values", "Pattern")
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.
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)))
490 if parameter.Regex is not None:
491 fields.append(cls._LiteralField("Pattern", (parameter.Regex, )))
493 return fields
496@export
497class VariableDirective(ParameterDirective):
498 """
499 The directive ``glci:variable``: a default variable of the current pipeline file, declared in its ``variables``.
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 """
506 directiveName: str = "glci:variable" #: Name the directive is invoked by.
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.
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.
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)))
525 return fields
528@export
529class GitLabCIXRefRole(XRefRole):
530 """
531 The roles ``:glci:pipeline:``, ``:glci:input:`` and ``:glci:variable:``.
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 """
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 ``~``.
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]
560 return title, target
563@export
564class GitLabCIDomain(Domain):
565 """
566 The Sphinx domain ``glci``, documenting GitLab CI/CD pipeline files.
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 """
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.
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.
583 directives: ClassVar[dict[str, type]] = { # type: ignore[misc]
584 "pipeline": PipelineDirective,
585 "input": InputDirective,
586 "variable": VariableDirective,
587 } #: The directives, by name.
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.
595 initial_data: ClassVar[dict[str, Any]] = { # type: ignore[misc]
596 "objects": {},
597 } #: The domain's data: ``objects`` maps (type, name) to (document, anchor).
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 }
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.
630 def __init__(self, env: BuildEnvironment) -> None:
631 """
632 Initializes the domain.
634 :param env: The build environment.
635 """
636 super().__init__(env)
638 self._files = {}
639 self._resolver = None
641 @readonly
642 def Objects(self) -> dict[tuple[str, str], tuple[str, str]]:
643 """
644 Read-only property to return the documented objects.
646 :returns: A mapping of (object type, name) to (document, anchor).
647 """
648 return self.data["objects"]
650 @readonly
651 def RepositoryRoot(self) -> Nullable[Path]:
652 """
653 Read-only property to return the documented repository's root directory.
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
660 return (Path(self.env.srcdir) / directory).resolve()
662 @readonly
663 def PipelineDirectory(self) -> Nullable[Path]:
664 """
665 Read-only property to return the directory holding the pipeline files.
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
672 return (Path(self.env.srcdir) / directory).resolve()
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.
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
685 self._resolver = IncludeResolver(root)
687 return self._resolver
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.
693 The local files it includes are read by :attr:`Resolver`, so ``extends`` and ``!reference`` reach the jobs they
694 define.
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
709 resolved = path.resolve()
710 if (known := self._files.get(resolved, None)) is not None:
711 return known
713 from pyTooling.GitLab.PipelineFile import PipelineFile
715 self._files[resolved] = PipelineFile.FromFile(path, self.Resolver)
716 return self._files[resolved]
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``.
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
728 return self.LoadPipelineFile(path)
730 def ResolvePipeline(self, name: str) -> Nullable[tuple[str, str]]:
731 """
732 Return where a pipeline is documented.
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
746 return self.data["objects"].get(("pipeline", name), None)
748 def NoteObject(self, objectType: str, name: str, nodeID: str, location: Nullable[Node] = None) -> None:
749 """
750 Register a documented object.
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
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
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 )
788 objects[(objectType, name)] = (self.env.docname, nodeID)
790 def clear_doc(self, docname: str) -> None:
791 """
792 Remove the objects of a document, before it is read again.
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]
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.
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)
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.
826 A parameter's name without a pipeline is looked up in the pipeline current where the role was written.
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}"
840 if (location := self.data["objects"].get((typ, target), None)) is None:
841 return None
843 return make_refnode(builder, fromdocname, location[0], location[1], contnode, target)
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.
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))
870 return results
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.
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
882@export
883def setup(sphinx: Sphinx) -> dict[str, Any]:
884 """
885 Register the domain ``glci``, its directives and its configuration values with Sphinx.
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.
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
898 sphinx.setup_extension("pyTooling.Sphinx")
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)
908 for configName, (default, rebuild, types) in GitLabCIDomain.configValues.items():
909 sphinx.add_config_value(f"{GitLabCIDomain.name}_{configName}", default, rebuild, types)
911 sphinx.connect("doctree-read", checkUndocumentedInputs)
912 sphinx.connect("doctree-resolved", resolveLinks)
914 return {"version": __version__, "parallel_read_safe": True, "parallel_write_safe": True}