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
« 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.
35Each of them reads the pipeline file of the preceding ``glci:pipeline``:
37.. code-block:: ReST
39 .. glci:pipeline:: Package
41 .. glci:parameter-table::
42 :kinds: inputs
44 .. glci:interface::
46 .. glci:dependencies::
48 * pip
50 .. glci:yaml::
51 :job: Package
53 .. glci:autoinputs::
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.
63When a document was read, an input of its pipeline file without an entry is a ``glci.drift`` warning
64(:func:`checkUndocumentedInputs`).
66.. seealso::
68 :mod:`pyTooling.GitLab.Sphinx`
69 |rarr| The domain ``glci``, its pipelines and parameter entries.
70"""
71from __future__ import annotations
73from pathlib import Path
74from typing import TYPE_CHECKING, Any, Callable, Optional as Nullable, TypeVar
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
84from pyTooling.Decorators import export
85from pyTooling.Sphinx import BaseDirective, SphinxExtensionError, strip
86from pyTooling.GitLab.Sphinx import NO_DEFAULT, WARNING_TYPE, InputDirective, formatValue
88if TYPE_CHECKING: # pragma: no cover
89 from pyTooling.GitLab.PipelineFile import Include, PipelineFile, ValueT
92__all__ = ["KINDS", "SECTIONS", "MAX_DEFAULT_LENGTH"]
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}
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")
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
108_logger = getLogger(__name__)
110_ResultType = TypeVar("_ResultType")
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 """
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.
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 )
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.
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
149 try:
150 relative = path.resolve().relative_to(root).as_posix()
151 except ValueError:
152 return None
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}"
160 return f"{url}#L{first}-{last}"
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.
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
176 return self.env.get_domain("glci").GetCurrentPipeline()
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.
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
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()
198@export
199class ParameterTable(PipelineReferenceDirective):
200 """
201 The directive ``glci:parameter-table``: summary tables of the current pipeline file's inputs and variables.
203 .. code-block:: ReST
205 .. glci:parameter-table::
206 :kinds: inputs
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.
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 """
217 directiveName: str = "glci:parameter-table" #: Name the directive is invoked by.
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.
227 def run(self) -> list[nodes.Node]:
228 """
229 Create the summary tables.
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)]
239 if (pipelineFile := self._CurrentPipeline()) is None:
240 return []
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
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())
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)
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))
273 tableBody += row
275 tables.append(tableGroup.parent)
277 return tables
279 def _ParseKinds(self) -> tuple[str, ...]:
280 """
281 Read option ``:kinds:``, a list of kinds separated by spaces or commas.
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)
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)}.")
295 return kinds
297 @staticmethod
298 def _DefaultParagraph(value: ValueT) -> nodes.paragraph:
299 """
300 Create the paragraph of a table cell showing a default.
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 ``…``.
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)
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]}…"
318 text = formatValue(value)
319 return nodes.paragraph("", "", nodes.literal(text, text))
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.
328 .. code-block:: ReST
330 .. glci:interface::
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``.
338 The files the pipeline file includes are listed by :class:`Dependencies`.
339 """
341 directiveName: str = "glci:interface" #: Name the directive is invoked by.
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.
349 def run(self) -> list[nodes.Node]:
350 """
351 Create the field list.
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 []
358 pipelineName = self.env.ref_context["glci:pipeline"]
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])
376 return [fieldList]
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*.
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)
396 return nodes.field("", nodes.field_name(text=name), nodes.field_body("", body))
399@export
400class Dependencies(PipelineReferenceDirective):
401 """
402 The directive ``glci:dependencies``: what the current pipeline file uses, as a nested bullet list.
404 .. code-block:: ReST
406 .. glci:dependencies::
408 * /ci/build.yml
410 * Python packages given by :glci:input:`Build.requirements`.
412 From the pipeline file, and the local files it includes and triggers, as far as they are known locally:
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.
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 """
426 directiveName: str = "glci:dependencies" #: Name the directive is invoked by.
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.
434 _keys: dict[int, set[str]] #: Names of each derived item, by the item's identity.
436 def run(self) -> list[nodes.Node]:
437 """
438 Create the list: the derived dependencies, merged with the hand-written ones.
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 []
446 self._keys = {}
447 dependencies = self._FileItems(pipelineFile, {pipelineFile.Path.resolve()})
448 dependencies["classes"].append("glci-dependencies")
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)
457 if len(dependencies) == 0:
458 return [nodes.paragraph("", "", nodes.emphasis(text="none")), *others]
460 return [dependencies, *others]
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.
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
473 return item
475 def _IncludeItem(self, include: Include) -> nodes.list_item:
476 """
477 Create the item of an include.
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.
482 :param include: The include.
483 :returns: The item, known by the include as written.
484 """
485 from pyTooling.GitLab.PipelineFile import IncludeKind
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]}"
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)}")
505 return self._Item(paragraph, {text, text.partition("@")[0], *include.Files})
507 def _ImageItem(self, kind: str, image: str) -> nodes.list_item:
508 """
509 Create the item of a container's image.
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})
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.
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)
530 for job in pipelineFile.IterateJobs():
531 if (trigger := job.Trigger) is None:
532 continue
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))
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))
542 bulletList += item
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))
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))
555 bulletList.extend(self._ImageItem(kind, image) for kind, image in images)
557 return bulletList
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.
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
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
577 return item
579 def _Merge(self, derived: nodes.bullet_list, handwritten: nodes.bullet_list) -> None:
580 """
581 Merge a hand-written list into a derived one.
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.
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
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
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.
611 .. code-block:: ReST
613 .. glci:yaml::
614 :section: inputs
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.
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 """
624 directiveName: str = "glci:yaml" #: Name the directive is invoked by.
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.
637 def run(self) -> list[nodes.Node]:
638 """
639 Create the code block.
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 )]
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 []
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 []
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 []
676 first, last = self._BlockOf(lines, job.Line)
677 else:
678 first = 1
679 last = len(lines)
681 indentation = self._Indentation(lines[first - 1])
682 excerpt = [line[min(indentation, self._Indentation(line)):] for line in lines[first - 1:last]]
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)
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)
698 if url is not None:
699 caption = f"`{caption} <{url}>`__"
701 container = container_wrapper(self, literal, caption)
702 self.add_name(container)
704 return [container]
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.
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.
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
723 return cls._ParentOf(lines, next(iter(pipelineFile.Inputs.values())).Line)
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)
729 for index in range(start, len(lines)):
730 if lines[index].startswith(f"{section}:"):
731 return index + 1
733 return None
735 @staticmethod
736 def _Indentation(line: str) -> int:
737 """
738 Return the indentation of a line of a YAML file.
740 :param line: The line.
741 :returns: The number of spaces the line starts with.
742 """
743 return len(line) - len(line.lstrip(" "))
745 @staticmethod
746 def _IsBlank(line: str) -> bool:
747 """
748 Return whether a line is empty or a comment.
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("#")
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.
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
771 return 1
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.
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.
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
796 return line, last
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.
805 .. code-block:: ReST
807 .. glci:autoinputs::
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 """
814 directiveName: str = "glci:autoinputs" #: Name the directive is invoked by.
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.
822 def run(self) -> list[nodes.Node]:
823 """
824 Create a placeholder, replaced by the entries once the document was parsed.
826 :returns: The placeholder, or nothing without a current pipeline.
827 """
828 if (pipelineFile := self._CurrentPipeline()) is None:
829 return []
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)
836 return [pending]
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.
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 """
849 default_priority = 400 #: Priority among the transforms: before the local table of contents and the smart quotes.
851 def apply(self, **kwargs: Any) -> None:
852 """
853 Replace the placeholder by the entries.
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"])
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 ))
869 pending.replace_self(entries)
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.
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``.
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
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 )