Coverage for pyTooling/GitLab/PipelineFile.py: 98%
1974 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"""
31A data model of a GitLab CI/CD configuration file.
33A configuration file - the project's :file:`.gitlab-ci.yml`, a file it includes, or the file of a child pipeline - is
34read once into objects:
36.. tree::
38 - :class:`PipelineFile` | a configuration file, e.g. :file:`.gitlab-ci.yml`
39 - :class:`Input` | an input of the header's ``spec:inputs``
40 - :class:`Workflow` | the ``workflow`` key
41 - :class:`Rule` | a rule deciding whether a pipeline is created
42 - :class:`Include` | an entry of ``include``
43 - :class:`Rule` | a rule deciding whether the file is included
44 - :class:`Default` | the ``default`` key
45 - :class:`Variable` | a default variable, declared in the top-level ``variables``
46 - :class:`Job` | a job or a hidden job, in file order
47 - :class:`Need` | an entry of the job's ``needs``
48 - :class:`Parallel` | the matrix instances needed
49 - :class:`Rule` | a rule deciding whether the job is added to a pipeline
50 - :class:`Variable` | a variable of the job
51 - :class:`Input` | an input of the job
52 - :class:`Parallel` | the job's ``parallel``: how often it runs, or its matrix
53 - :class:`Trigger` | the child pipeline or other project's pipeline the job starts
54 - :class:`Include` | a file of the child pipeline
56Every element knows its parent, the file it belongs to and the line it starts at, so a consumer can name the place a
57finding comes from, as ``.gitlab-ci.yml:42``.
59:class:`IncludeResolver` reads the local files a file includes, or a trigger job's child pipeline runs.
61The model is independent of :mod:`pyTooling.GitLab`, which models a pipeline *run* as the REST API reports it.
62:meth:`PipelineFile.ToPipeline` builds the pipeline a file defines as a :mod:`pyTooling.CI` model, whose elements link
63back to the jobs they were built from.
64"""
65from __future__ import annotations
67from itertools import product
68from pathlib import Path
69from typing import Any, ClassVar, Generic, Hashable, Iterable, Iterator, Mapping, Optional as Nullable
70from typing import Self, Sequence, TypeVar, Union
72from pyTooling.CI import DependencyMixin, Job as CIJob, JobGroup, Matrix as CIMatrix
73from pyTooling.CI import MatrixJob as CIMatrixJob, MatrixWorkflow as CIMatrixWorkflow
74from pyTooling.CI import Pipeline as CIPipeline, Rule as CIRule, RuleOutcome, Workflow as CIWorkflow
76from pyTooling.Common import getFullyQualifiedName, StringEnum
77from pyTooling.Decorators import export, readonly
78from pyTooling.MetaClasses import ExtendedType, abstractclass
80from pyTooling.GitLab import GitLabError
82from ruamel.yaml import YAML, YAMLError
83from ruamel.yaml.comments import CommentedMap, CommentedSeq
84from ruamel.yaml.scalarbool import ScalarBoolean
85from ruamel.yaml.scalarfloat import ScalarFloat
86from ruamel.yaml.scalarint import ScalarInt
87from ruamel.yaml.scalarstring import ScalarString
90__all__ = ["ValueT", "RESERVED_KEYS", "DEFAULT_STAGES", "MAX_EXTENDS_DEPTH", "MAX_REFERENCE_DEPTH"]
92ValueT = Union[str, bool, int, float, None, list["ValueT"], dict[str, "ValueT"]]
93"""A value read from a configuration file, converted to plain Python types."""
95ParentType = TypeVar("ParentType", bound="Base")
96"""A type variable for the type of an element's parent."""
98ParentTypes = Nullable[Union[type, tuple[type, ...]]]
99"""The type of :attr:`Base._PARENT_TYPE`: ``None``, a class, or a tuple of classes."""
101DefinitionType = TypeVar("DefinitionType", bound="Base")
102"""A type variable for the type of the file's element an element of :mod:`pyTooling.CI` is built from."""
104RESERVED_KEYS = frozenset((
105 "default", "include", "stages", "variables", "workflow", "image", "services", "cache", "before_script", "after_script"
106))
107"""Top-level keys of a configuration file, which declare no job."""
109DEFAULT_STAGES = ("build", "test", "deploy")
110"""Stages of a pipeline, whose configuration declares no ``stages``, between ``.pre`` and ``.post``."""
112MAX_EXTENDS_DEPTH = 11
113"""How many levels of ``extends`` GitLab resolves."""
115MAX_REFERENCE_DEPTH = 10
116"""How many levels of ``!reference`` tags GitLab resolves."""
119@export
120class PipelineFileError(GitLabError):
121 """
122 Base-exception of all exceptions raised by :mod:`pyTooling.GitLab.PipelineFile`.
124 The exception is raised for a file that is not a well-formed configuration. It carries the file and the line the
125 problem was found at in :attr:`Path` and :attr:`Line`, and names both in a note.
126 """
128 _path: Nullable[Path] #: Path to the configuration file.
129 _line: Nullable[int] #: Line in the configuration file, starting at 1.
131 def __init__(self, message: str, path: Nullable[Path] = None, line: Nullable[int] = None) -> None:
132 """
133 Initializes a configuration error and names the place it was found at in a note.
135 :param message: The exception's message.
136 :param path: Optional, path to the configuration file. Default: ``None``.
137 :param line: Optional, line in the configuration file, starting at 1. Default: ``None``.
138 """
139 super().__init__(message)
141 self._path = path
142 self._line = line
144 if path is not None: 144 ↛ exitline 144 didn't return from function '__init__' because the condition on line 144 was always true
145 self.add_note(f"In '{path}'." if line is None else f"In '{path}:{line}'.")
147 @readonly
148 def Path(self) -> Nullable[Path]:
149 """
150 Read-only property to access the path to the configuration file (:attr:`_path`).
152 :returns: The path, or ``None`` if the problem isn't tied to a file.
153 """
154 return self._path
156 @readonly
157 def Line(self) -> Nullable[int]:
158 """
159 Read-only property to access the line the problem was found at (:attr:`_line`).
161 :returns: The line, starting at 1, or ``None`` if the problem isn't tied to a line.
162 """
163 return self._line
166@export
167class IncludeKind(StringEnum):
168 """Where an included file comes from, as the key of an ``include`` entry names it."""
170 Local = "local" #: A file of the same repository.
171 Project = "project" #: Files of another project.
172 Remote = "remote" #: A file at a URL.
173 Template = "template" #: A CI/CD template shipped with GitLab.
174 Component = "component" #: A CI/CD component.
175 Artifact = "artifact" #: A file a job of the pipeline created, for a dynamic child pipeline.
178@export
179class InputType(StringEnum):
180 """The type of an input, as ``type`` names it."""
182 String = "string" #: A string.
183 Number = "number" #: A number.
184 Boolean = "boolean" #: ``true`` or ``false``.
185 Array = "array" #: A list of values.
188@export
189class TriggerStrategy(StringEnum):
190 """How a trigger job's status follows the pipeline it starts, as ``trigger.strategy`` names it."""
192 Depend = "depend" #: The trigger job waits for the pipeline, and reports failed, success or running.
193 Mirror = "mirror" #: The trigger job waits for the pipeline, and mirrors its status exactly.
196@export
197class NeedKind(StringEnum):
198 """What an entry of ``needs`` refers to."""
200 Job = "job" #: A job of the same pipeline.
201 PipelineJob = "pipeline-job" #: A job of another pipeline in the same parent-child hierarchy.
202 ProjectJob = "project-job" #: A job of another project's pipeline, whose artifacts are downloaded.
203 PipelineStatus = "pipeline-status" #: The status of another project's pipeline, which the job mirrors.
206@export
207@abstractclass
208class Base(Generic[ParentType], metaclass=ExtendedType, slots=True):
209 """
210 Common behaviour of every element of a configuration file.
212 Every element knows the element containing it, the configuration file it belongs to, and the line it starts at.
213 """
215 _PARENT_TYPE: ClassVar[ParentTypes] = None #: Type a parent must have, or ``None`` when the element has no parent.
217 _parent: Nullable[ParentType] #: Reference to the containing element.
218 _pipelineFile: Nullable[PipelineFile] #: Reference to the configuration file this element belongs to.
219 _line: int #: Line the element starts at in its file, starting at 1.
221 def __init__(self, line: int, *, parent: Nullable[ParentType] = None) -> None:
222 """
223 Initializes an element of a configuration file.
225 :param line: Line the element starts at in the configuration file, starting at 1.
226 :param parent: Optional, reference to the containing element. Default: ``None``.
227 :raises ValueError: If parameter 'line' is ``None``.
228 :raises TypeError: If parameter 'line' is not of type :class:`int`.
229 :raises ValueError: If parameter 'line' is not positive.
230 :raises TypeError: If parameter 'parent' is not of the type this class declares in :attr:`_PARENT_TYPE`.
231 """
232 if line is None:
233 raise ValueError("Parameter 'line' is None.")
234 elif not isinstance(line, int) or isinstance(line, bool):
235 ex = TypeError("Parameter 'line' is not of type 'int'.")
236 ex.add_note(f"Got type '{getFullyQualifiedName(line)}'.")
237 raise ex
238 elif line < 1:
239 ex = ValueError("Parameter 'line' is not positive.")
240 ex.add_note(f"Got value '{line}'.")
241 raise ex
243 if parent is not None and not isinstance(parent, self._PARENT_TYPE):
244 parentTypes = self._PARENT_TYPE if isinstance(self._PARENT_TYPE, tuple) else (self._PARENT_TYPE, )
245 ex = TypeError(f"Parameter 'parent' is not of type {' or '.join(f'{t.__name__!r}' for t in parentTypes)}.")
246 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
247 raise ex
249 self._parent = parent
250 self._pipelineFile = None if parent is None else parent._pipelineFile
251 self._line = line
253 @property
254 def Parent(self) -> Nullable[ParentType]:
255 """
256 Property to access the containing element (:attr:`_parent`).
258 Assigning a parent attaches an element constructed before it: the element takes the parent's configuration file,
259 and so do the elements it contains.
261 :returns: The containing element, or ``None`` for a :class:`PipelineFile`.
262 :raises ValueError: If ``None`` is assigned.
263 :raises TypeError: If a parent is assigned to a :class:`PipelineFile`, which has no parent.
264 :raises TypeError: If an assigned value is not of the type this class declares in :attr:`_PARENT_TYPE`.
265 """
266 return self._parent
268 @Parent.setter
269 def Parent(self, value: ParentType) -> None:
270 if value is None:
271 raise ValueError("Parameter 'value' is None.")
272 elif not isinstance(value, self._PARENT_TYPE):
273 parentTypes = self._PARENT_TYPE if isinstance(self._PARENT_TYPE, tuple) else (self._PARENT_TYPE, )
274 ex = TypeError(f"Parameter 'value' is not of type {' or '.join(f'{t.__name__!r}' for t in parentTypes)}.")
275 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
276 raise ex
278 self._parent = value
279 self._pipelineFile = value._pipelineFile
281 @readonly
282 def PipelineFile(self) -> Nullable[PipelineFile]:
283 """
284 Read-only property to access the configuration file this element belongs to (:attr:`_pipelineFile`).
286 :returns: The configuration file, or ``None`` for an element outside one.
287 """
288 return self._pipelineFile
290 @readonly
291 def Line(self) -> int:
292 """
293 Read-only property to access the line the element starts at in its file (:attr:`_line`).
295 :returns: The line, starting at 1.
296 """
297 return self._line
299 @readonly
300 def Location(self) -> str:
301 """
302 Read-only property to return the place the element is written at, for a message.
304 :returns: The file's name and the line, as ``.gitlab-ci.yml:42``, or ``line 42`` for an element outside a file.
305 """
306 if self._pipelineFile is None:
307 return f"line {self._line}"
309 return f"{self._pipelineFile._path.name}:{self._line}"
311 @staticmethod
312 def _KeyLine(mapping: CommentedMap, key: Any) -> int:
313 """
314 Return the line a key of a mapping is written at.
316 A key brought in by a YAML merge key (``<<: *anchor``) is written at the anchor; its line is the mapping's line.
318 :param mapping: The mapping read from the file.
319 :param key: The key.
320 :returns: The line, starting at 1.
321 """
322 try:
323 return mapping.lc.key(key)[0] + 1
324 except KeyError:
325 return mapping.lc.line + 1
327 @staticmethod
328 def _ItemLine(sequence: CommentedSeq, index: int) -> int:
329 """
330 Return the line an item of a sequence is written at.
332 :param sequence: The sequence read from the file.
333 :param index: Position of the item, starting at 0.
334 :returns: The line, starting at 1.
335 """
336 return sequence.lc.item(index)[0] + 1
338 @staticmethod
339 def _ToPython(value: Any) -> ValueT:
340 """
341 Convert a value read by ``ruamel.yaml`` into plain Python types.
343 The round-trip loader returns its own types for mappings, lists, block scalars, anchored booleans, and numbers
344 written in another notation than a plain decimal. They derive from the Python types, but keep what they were
345 read with. Every other value is a Python type already.
347 :param value: The value read from the file.
348 :returns: The value as :class:`dict`, :class:`list`, :class:`str`, :class:`bool`, :class:`int`,
349 :class:`float` or ``None``.
350 """
351 if isinstance(value, CommentedMap):
352 return {str(key): Base._ToPython(item) for key, item in value.items()}
353 elif isinstance(value, CommentedSeq):
354 return [Base._ToPython(item) for item in value]
355 elif isinstance(value, ScalarBoolean): 355 ↛ 356line 355 didn't jump to line 356 because the condition on line 355 was never true
356 return bool(value)
357 elif isinstance(value, ScalarInt): 357 ↛ 358line 357 didn't jump to line 358 because the condition on line 357 was never true
358 return int(value)
359 elif isinstance(value, ScalarFloat):
360 return float(value)
361 elif isinstance(value, ScalarString):
362 return str(value)
364 return value
366 @staticmethod
367 def _ToText(value: Any) -> str:
368 """
369 Convert a scalar read from the file into the text GitLab uses, so ``true`` stays ``true`` and not ``True``.
371 :param value: The scalar.
372 :returns: The text.
373 """
374 value = Base._ToPython(value)
375 if isinstance(value, bool):
376 return "true" if value else "false"
378 return str(value)
381@export
382class PipelineFile(Base[None]):
383 """
384 A GitLab CI/CD configuration file.
386 A top-level key names a job, unless it is a global keyword (:data:`RESERVED_KEYS`). A job whose name starts with a
387 dot is a hidden job: GitLab doesn't run it, other jobs extend it.
388 """
390 _path: Path #: Path to the configuration file.
391 _stages: Nullable[tuple[str, ...]] #: Stages, as the ``stages`` key declares them.
392 _variables: dict[str, Variable] #: Default variables, by name, in file order.
393 _jobs: dict[str, Job] #: Jobs, by name, in file order.
394 _hiddenJobs: dict[str, Job] #: Hidden jobs, by name, in file order.
395 _workflow: Nullable[Workflow] #: The ``workflow`` key.
396 _includes: list[Include] #: Entries of ``include``, in file order.
397 _default: Nullable[Default] #: The ``default`` key.
398 _inputs: dict[str, Input] #: Inputs of the header's ``spec:inputs``, by name, in file order.
399 _description: Nullable[str] #: Description of the header's ``spec:description``.
401 def __init__(
402 self,
403 path: Path,
404 stages: Nullable[Iterable[str]] = None,
405 variables: Nullable[Iterable[Variable]] = None,
406 jobs: Nullable[Iterable[Job]] = None,
407 workflow: Nullable[Workflow] = None,
408 includes: Nullable[Iterable[Include]] = None,
409 default: Nullable[Default] = None,
410 inputs: Nullable[Iterable[Input]] = None,
411 description: Nullable[str] = None
412 ) -> None:
413 """
414 Initializes a configuration file.
416 A variable, job, workflow, include, default or input is attached by passing it, or by constructing it with the
417 configuration file as parent. Use :meth:`FromFile` to read a configuration file.
419 :param path: Path to the configuration file.
420 :param stages: Optional, stages as the ``stages`` key declares them. Default: ``None``, for a file without a
421 ``stages`` key.
422 :param variables: Optional, default variables, which are attached to the file. Default: ``None``.
423 :param jobs: Optional, jobs and hidden jobs, which are attached to the file. Default: ``None``.
424 :param workflow: Optional, the ``workflow`` key, which is attached to the file. Default: ``None``.
425 :param includes: Optional, entries of ``include``, which are attached to the file. Default: ``None``.
426 :param default: Optional, the ``default`` key, which is attached to the file. Default: ``None``.
427 :param inputs: Optional, inputs of the header's ``spec:inputs``, which are attached to the file. Default:
428 ``None``.
429 :param description: Optional, description of the header's ``spec:description``. Default: ``None``.
430 :raises ValueError: If parameter 'path' is ``None``.
431 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`.
432 :raises TypeError: If an element of parameter 'stages' is not of type :class:`str`.
433 :raises TypeError: If an element of parameter 'variables' is not of type :class:`Variable`.
434 :raises TypeError: If an element of parameter 'jobs' is not of type :class:`Job`.
435 :raises TypeError: If parameter 'workflow' is not of type :class:`Workflow`.
436 :raises TypeError: If an element of parameter 'includes' is not of type :class:`Include`.
437 :raises TypeError: If parameter 'default' is not of type :class:`Default`.
438 :raises TypeError: If an element of parameter 'inputs' is not of type :class:`Input`.
439 :raises TypeError: If parameter 'description' is not of type :class:`str`.
440 """
441 super().__init__(1)
443 if path is None:
444 raise ValueError("Parameter 'path' is None.")
445 elif not isinstance(path, Path):
446 ex = TypeError("Parameter 'path' is not of type 'Path'.")
447 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
448 raise ex
450 if description is not None and not isinstance(description, str):
451 ex = TypeError("Parameter 'description' is not of type 'str'.")
452 ex.add_note(f"Got type '{getFullyQualifiedName(description)}'.")
453 raise ex
455 self._pipelineFile = self
456 self._path = path
457 self._stages = None
458 self._variables = {}
459 self._jobs = {}
460 self._hiddenJobs = {}
461 self._workflow = None
462 self._includes = []
463 self._default = None
464 self._inputs = {}
465 self._description = description
467 if stages is not None:
468 self._stages = tuple(stages)
469 for stage in self._stages:
470 if not isinstance(stage, str):
471 ex = TypeError("An element of parameter 'stages' is not of type 'str'.")
472 ex.add_note(f"Got type '{getFullyQualifiedName(stage)}'.")
473 raise ex
475 if variables is not None:
476 for variable in variables:
477 if not isinstance(variable, Variable):
478 ex = TypeError("An element of parameter 'variables' is not of type 'Variable'.")
479 ex.add_note(f"Got type '{getFullyQualifiedName(variable)}'.")
480 raise ex
482 self._variables[variable._name] = variable
483 variable.Parent = self
485 if jobs is not None:
486 for job in jobs:
487 if not isinstance(job, Job):
488 ex = TypeError("An element of parameter 'jobs' is not of type 'Job'.")
489 ex.add_note(f"Got type '{getFullyQualifiedName(job)}'.")
490 raise ex
492 (self._hiddenJobs if job.IsHidden else self._jobs)[job._name] = job
493 job.Parent = self
495 if workflow is not None:
496 if not isinstance(workflow, Workflow):
497 ex = TypeError("Parameter 'workflow' is not of type 'Workflow'.")
498 ex.add_note(f"Got type '{getFullyQualifiedName(workflow)}'.")
499 raise ex
501 self._workflow = workflow
502 workflow.Parent = self
504 if includes is not None:
505 for include in includes:
506 if not isinstance(include, Include):
507 ex = TypeError("An element of parameter 'includes' is not of type 'Include'.")
508 ex.add_note(f"Got type '{getFullyQualifiedName(include)}'.")
509 raise ex
511 self._includes.append(include)
512 include.Parent = self
514 if default is not None:
515 if not isinstance(default, Default):
516 ex = TypeError("Parameter 'default' is not of type 'Default'.")
517 ex.add_note(f"Got type '{getFullyQualifiedName(default)}'.")
518 raise ex
520 self._default = default
521 default.Parent = self
523 if inputs is not None:
524 for parameter in inputs:
525 if not isinstance(parameter, Input):
526 ex = TypeError("An element of parameter 'inputs' is not of type 'Input'.")
527 ex.add_note(f"Got type '{getFullyQualifiedName(parameter)}'.")
528 raise ex
530 self._inputs[parameter._name] = parameter
531 parameter.Parent = self
533 @Base.Parent.setter
534 def Parent(self, value: None) -> None:
535 ex = TypeError(f"A '{getFullyQualifiedName(self)}' has no parent.")
536 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
537 raise ex
539 @readonly
540 def Path(self) -> Path:
541 """
542 Read-only property to access the path to the configuration file (:attr:`_path`).
544 :returns: The path.
545 """
546 return self._path
548 @readonly
549 def Inputs(self) -> dict[str, Input]:
550 """
551 Read-only property to access the inputs of the header's ``spec:inputs`` (:attr:`_inputs`).
553 :returns: The inputs, by name, in file order.
554 """
555 return self._inputs
557 @readonly
558 def Description(self) -> Nullable[str]:
559 """
560 Read-only property to access the description of the header's ``spec:description`` (:attr:`_description`).
562 :returns: The description, or ``None`` if the file gives none.
563 """
564 return self._description
566 @readonly
567 def DeclaredStages(self) -> Nullable[tuple[str, ...]]:
568 """
569 Read-only property to access the stages as the ``stages`` key declares them (:attr:`_stages`).
571 :returns: The stages, in the order they run, or ``None`` if the file has no ``stages`` key.
572 """
573 return self._stages
575 @readonly
576 def Stages(self) -> tuple[str, ...]:
577 """
578 Read-only property to return the stages of the pipeline, in the order they run.
580 The stages are those of :attr:`DeclaredStages`, or :data:`DEFAULT_STAGES` without a ``stages`` key, and always
581 begin with ``.pre`` and end with ``.post``.
583 :returns: The stages.
584 """
585 stages = DEFAULT_STAGES if self._stages is None else self._stages
586 return (".pre", *(stage for stage in stages if stage not in (".pre", ".post")), ".post")
588 @readonly
589 def Variables(self) -> dict[str, Variable]:
590 """
591 Read-only property to access the default variables (:attr:`_variables`).
593 :returns: The variables, by name, in file order.
594 """
595 return self._variables
597 @readonly
598 def Jobs(self) -> dict[str, Job]:
599 """
600 Read-only property to access the jobs (:attr:`_jobs`).
602 :returns: The jobs, by name, in file order.
603 """
604 return self._jobs
606 @readonly
607 def HiddenJobs(self) -> dict[str, Job]:
608 """
609 Read-only property to access the hidden jobs (:attr:`_hiddenJobs`).
611 :returns: The hidden jobs, by name, in file order.
612 """
613 return self._hiddenJobs
615 @readonly
616 def Workflow(self) -> Nullable[Workflow]:
617 """
618 Read-only property to access the ``workflow`` key (:attr:`_workflow`).
620 :returns: The workflow, or ``None`` if the file has no ``workflow`` key.
621 """
622 return self._workflow
624 @readonly
625 def Includes(self) -> list[Include]:
626 """
627 Read-only property to access the entries of ``include`` (:attr:`_includes`).
629 :returns: The entries, in file order.
630 """
631 return self._includes
633 @readonly
634 def Default(self) -> Nullable[Default]:
635 """
636 Read-only property to access the ``default`` key (:attr:`_default`).
638 :returns: The default, or ``None`` if the file has neither a ``default`` key nor a global keyword it stands for.
639 """
640 return self._default
642 @readonly
643 def JobCount(self) -> int:
644 """
645 Read-only property to return the number of jobs, hidden jobs not counted.
647 :returns: Number of jobs.
648 """
649 return len(self._jobs)
651 def ContainsJob(self, name: str) -> bool:
652 """
653 Check whether the file has a job of that name.
655 :param name: Name of the job, the key it is declared under.
656 :returns: ``True``, if the file has a job of that name. A hidden job isn't a job.
657 """
658 return name in self._jobs
660 def IterateJobs(self) -> Iterator[Job]:
661 """
662 Iterate the jobs, hidden jobs not included.
664 :returns: An iterator over the jobs, in file order.
665 """
666 return iter(self._jobs.values())
668 def IterateHiddenJobs(self) -> Iterator[Job]:
669 """
670 Iterate the hidden jobs.
672 :returns: An iterator over the hidden jobs, in file order.
673 """
674 return iter(self._hiddenJobs.values())
676 def __str__(self) -> str:
677 """
678 Return the file's name.
680 :returns: Name of the configuration file.
681 """
682 return self._path.name
684 def ToPipeline(self, resolver: Nullable[IncludeResolver] = None, depth: Nullable[int] = None) -> DefinedPipeline:
685 """
686 Build the service-independent model of the pipeline this file defines.
688 Every job - hidden jobs aside - becomes an element of :mod:`pyTooling.CI`, named by its key and linked to the job
689 by :attr:`~DefinitionMixin.Definition`:
691 * a job running a script becomes a :class:`DefinedJob`;
692 * a trigger job becomes a :class:`DefinedWorkflow`, holding the elements of its child pipeline, if the resolver
693 reads the child pipeline's files and the depth allows it;
694 * a job with ``parallel`` becomes a :class:`DefinedMatrix`, holding a :class:`DefinedMatrixJob` or a
695 :class:`DefinedMatrixWorkflow` per instance.
697 The ``needs`` of a job become the element's :attr:`~pyTooling.CI.DependencyMixin.Needs`; a job without ``needs``
698 needs every element of the nearest earlier stage holding elements. A need of some instances of a matrix needs the
699 whole matrix. So :meth:`~pyTooling.CI.Workflow.ToGraph` converts the result into a graph.
701 :param resolver: Optional, the resolver reading the files of the child pipelines. Without it, child
702 pipelines are not expanded. Default: ``None``.
703 :param depth: Optional, how many levels of child pipelines to expand; ``0`` expands none, ``None``
704 every level. Default: ``None``.
705 :returns: The pipeline.
706 :raises TypeError: If parameter 'resolver' is not of type :class:`IncludeResolver`.
707 :raises TypeError: If parameter 'depth' is not of type :class:`int`.
708 :raises ValueError: If parameter 'depth' is negative.
709 :raises PipelineFileError: If a child pipeline's file is not a well-formed configuration, or runs itself.
710 """
711 if resolver is not None and not isinstance(resolver, IncludeResolver):
712 ex = TypeError("Parameter 'resolver' is not of type 'IncludeResolver'.")
713 ex.add_note(f"Got type '{getFullyQualifiedName(resolver)}'.")
714 raise ex
716 if depth is not None and (not isinstance(depth, int) or isinstance(depth, bool)):
717 ex = TypeError("Parameter 'depth' is not of type 'int'.")
718 ex.add_note(f"Got type '{getFullyQualifiedName(depth)}'.")
719 raise ex
720 elif depth is not None and depth < 0:
721 ex = ValueError("Parameter 'depth' is negative.")
722 ex.add_note(f"Got value '{depth}'.")
723 raise ex
725 def childFiles(job: Job, level: int, callers: tuple[PipelineFile, ...]) -> list[PipelineFile]:
726 """
727 Nested function reading the files of a trigger job's child pipeline.
729 :param job: The trigger job.
730 :param level: How many levels of child pipelines are expanded above this one.
731 :param callers: The files expanded above this one, this one last.
732 :returns: The files the resolver reads, if the depth allows expanding them.
733 :raises PipelineFileError: If a child pipeline runs a file expanded above it.
734 """
735 if resolver is None or job._trigger is None or (depth is not None and level >= depth):
736 return []
738 files = []
739 for include in job._trigger._includes:
740 if (child := resolver.Resolve(include)) is None: 740 ↛ 741line 740 didn't jump to line 741 because the condition on line 740 was never true
741 continue
742 elif any(caller._path.resolve() == child._path.resolve() for caller in callers):
743 ex = PipelineFileError(f"Child pipeline '{child._path.name}' runs itself.", callers[-1]._path, include._line)
744 ex.add_note(f"Runs: {' -> '.join(caller._path.name for caller in (*callers, child))}.")
745 raise ex
747 files.append(child)
749 return files
751 def addElements(
752 pipelineFile: PipelineFile,
753 group: CIWorkflow,
754 level: int,
755 callers: tuple[PipelineFile, ...]
756 ) -> None:
757 """
758 Nested function for recursion, adding the elements of a file's jobs to a group.
760 :param pipelineFile: The file whose jobs become elements.
761 :param group: The group the elements are added to.
762 :param level: How many levels of child pipelines are expanded above this one.
763 :param callers: The files expanded above this one, this one last.
764 :raises PipelineFileError: If a child pipeline runs a file expanded above it.
765 """
766 elements: dict[str, DependencyMixin] = {}
767 for job in pipelineFile._jobs.values():
768 children = childFiles(job, level, callers)
769 if job._parallel is not None:
770 element = DefinedMatrix(job, parent=group)
771 if job._parallel._count is not None:
772 dimensions = [{"CI_NODE_INDEX": str(index)} for index in range(1, job._parallel._count + 1)]
773 else:
774 dimensions = job._parallel.Combinations
776 for dimension in dimensions:
777 if job._trigger is None: 777 ↛ 780line 777 didn't jump to line 780 because the condition on line 777 was always true
778 DefinedMatrixJob(job, dimension, parent=element)
779 else:
780 instance = DefinedMatrixWorkflow(job, dimension, childFiles=children, parent=element)
781 for child in children:
782 addElements(child, instance, level + 1, (*callers, child))
783 elif job._trigger is not None:
784 element = DefinedWorkflow(job, childFiles=children, parent=group)
785 for child in children:
786 addElements(child, element, level + 1, (*callers, child))
787 else:
788 element = DefinedJob(job, parent=group)
790 elements[job._name] = element
792 stages = pipelineFile.Stages
793 byStage: dict[int, list[str]] = {}
794 for job in pipelineFile._jobs.values():
795 if job._stage in stages: 795 ↛ 794line 795 didn't jump to line 794 because the condition on line 795 was always true
796 byStage.setdefault(stages.index(job._stage), []).append(job._name)
798 for job in pipelineFile._jobs.values():
799 if job._needs is not None:
800 for need in job._needs:
801 if need._kind is NeedKind.Job and need._job in elements: 801 ↛ 800line 801 didn't jump to line 800 because the condition on line 801 was always true
802 elements[job._name].AddNeed(elements[need._job])
803 elif job._stage in stages: 803 ↛ 798line 803 didn't jump to line 798 because the condition on line 803 was always true
804 earlier = [index for index in byStage if index < stages.index(job._stage)]
805 for name in byStage[max(earlier)] if len(earlier) > 0 else ():
806 elements[job._name].AddNeed(elements[name])
808 pipeline = DefinedPipeline(self)
809 addElements(self, pipeline, 0, (self, ))
810 pipeline.Validate()
812 return pipeline
814 @classmethod
815 def FromFile(cls, path: Path, resolver: Nullable[IncludeResolver] = None) -> Self:
816 """
817 Read a configuration file.
819 A file may begin with a header document holding the ``spec`` key, separated from the configuration by ``---``.
821 With a resolver, the local files the file includes are read too, so a job may extend their jobs and a
822 ``!reference`` tag refer to their keys. The included jobs aren't jobs of this file.
824 :param path: Path to the configuration file.
825 :param resolver: Optional, the resolver reading the local files the file includes. Default: ``None``.
826 :returns: The configuration file, with its variables and jobs attached.
827 :raises ValueError: If parameter 'path' is ``None``.
828 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`.
829 :raises TypeError: If parameter 'resolver' is not of type :class:`IncludeResolver`.
830 :raises PipelineFileError: If the file doesn't exist.
831 :raises PipelineFileError: If the file can't be read.
832 :raises PipelineFileError: If the file is not a YAML document.
833 :raises PipelineFileError: If the file holds more than two documents, or its first of two documents has no
834 ``spec`` key.
835 :raises PipelineFileError: If the configuration is not a mapping.
836 :raises PipelineFileError: If a key holds a value of the wrong kind.
837 :raises PipelineFileError: If an input is malformed, or its default doesn't match its type or options.
838 :raises PipelineFileError: If a job is in a stage the pipeline doesn't have. |br|
839 The note lists the stages.
840 :raises PipelineFileError: If a job needs a job the file doesn't have, or the jobs need each other in a cycle. |br|
841 For an unknown job, the note lists the file's jobs.
842 """
843 if path is None:
844 raise ValueError("Parameter 'path' is None.")
845 elif not isinstance(path, Path):
846 ex = TypeError("Parameter 'path' is not of type 'Path'.")
847 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
848 raise ex
850 if resolver is not None and not isinstance(resolver, IncludeResolver):
851 ex = TypeError("Parameter 'resolver' is not of type 'IncludeResolver'.")
852 ex.add_note(f"Got type '{getFullyQualifiedName(resolver)}'.")
853 raise ex
855 header, document = cls._Load(path)
856 included: dict[str, Any] = {}
857 incomplete = "include" in document
858 if resolver is not None:
859 included, incomplete = resolver._Included(document, path, (path.resolve(), ))
861 pipelineFile = cls._Parse(document, path, header, included, incomplete)
862 pipelineFile._Validate()
864 return pipelineFile
866 @staticmethod
867 def _Load(path: Path) -> tuple[Nullable[CommentedMap], CommentedMap]:
868 """
869 Read the documents of a configuration file.
871 :param path: Path to the configuration file.
872 :returns: The header document, or ``None``, and the configuration.
873 :raises PipelineFileError: If the file doesn't exist, can't be read, or is not a YAML document.
874 :raises PipelineFileError: If the file holds more than two documents, or its first of two documents has no
875 ``spec`` key.
876 :raises PipelineFileError: If the configuration is not a mapping.
877 """
878 if not path.exists():
879 raise PipelineFileError("Configuration file doesn't exist.", path) from FileNotFoundError(path)
881 try:
882 content = path.read_text(encoding="utf-8")
883 except OSError as cause:
884 raise PipelineFileError("Configuration file can't be read.", path) from cause
886 try:
887 documents = list(YAML(typ="rt").load_all(content))
888 except YAMLError as cause:
889 mark = getattr(cause, "problem_mark", None)
890 line = None if mark is None else mark.line + 1
891 raise PipelineFileError("Configuration file is not a YAML document.", path, line) from cause
893 header = None
894 if len(documents) > 2:
895 raise PipelineFileError("Configuration file holds more than two documents.", path)
896 elif len(documents) == 2:
897 header = documents[0]
898 if not isinstance(header, CommentedMap) or "spec" not in header:
899 raise PipelineFileError("Header document of configuration file has no 'spec' key.", path, 1)
901 document = documents[-1] if len(documents) > 0 else None
902 if document is None:
903 document = CommentedMap()
904 elif not isinstance(document, CommentedMap):
905 ex = PipelineFileError("Configuration is not a mapping.", path)
906 ex.add_note(f"Got type '{getFullyQualifiedName(document)}'.")
907 raise ex
909 return header, document
911 @classmethod
912 def _Parse(
913 cls,
914 document: CommentedMap,
915 path: Path,
916 header: Nullable[CommentedMap] = None,
917 included: Nullable[Mapping[str, Any]] = None,
918 incomplete: Nullable[bool] = None
919 ) -> Self:
920 """
921 Build a configuration file and the elements it contains from the documents read from it.
923 :param document: The configuration, a mapping.
924 :param path: Path to the configuration file.
925 :param header: Optional, the header document, a mapping with key ``spec``. Default: ``None``.
926 :param included: Optional, the top-level keys of the included files read. Default: ``None``.
927 :param incomplete: Optional, ``True``, if the file includes files that weren't read. Default: ``None``,
928 for ``True`` if the file includes files.
929 :returns: The configuration file, with its variables, jobs, workflow, includes and default
930 attached.
931 :raises PipelineFileError: If key ``spec`` or ``spec:inputs`` of the header is not a mapping, or an input is
932 malformed.
933 :raises PipelineFileError: If key ``stages`` is not a list.
934 :raises PipelineFileError: If key ``variables``, ``workflow`` or a job is not a mapping.
935 :raises PipelineFileError: If a job extends a job, or a ``!reference`` tag refers to a key, the file doesn't have,
936 though it includes no files.
937 :raises PipelineFileError: If jobs extend each other in a cycle, or ``extends`` or ``!reference`` tags nest too
938 deep.
939 :raises PipelineFileError: If a key of a variable, a job, the workflow or an include holds a value of the wrong
940 kind.
941 """
942 stages = None
943 if (stageList := document.get("stages", None)) is not None:
944 if not isinstance(stageList, CommentedSeq):
945 ex = PipelineFileError("Key 'stages' is not a list.", path, Base._KeyLine(document, "stages"))
946 ex.add_note(f"Got type '{getFullyQualifiedName(stageList)}'.")
947 raise ex
949 stages = [Base._ToText(stage) for stage in stageList]
951 variables = None
952 if "variables" in document:
953 variables = Variable._FromYAMLMapping(document["variables"], None, path, Base._KeyLine(document, "variables"))
955 jobs = []
956 for name, (mapping, unresolved) in cls._ResolveJobs(document, path, included, incomplete).items():
957 jobs.append(Job._FromYAML(name, mapping, path, Base._KeyLine(document, name), unresolved))
959 workflow = None
960 if "workflow" in document:
961 workflow = Workflow._FromYAML(document["workflow"], path, Base._KeyLine(document, "workflow"))
963 includes = None
964 if "include" in document:
965 includes = Include._FromYAMLEntries(document["include"], path, Base._KeyLine(document, "include"))
967 default = Default._FromYAML(document, path)
969 inputs = None
970 description = None
971 if header is not None:
972 spec = header["spec"]
973 if not isinstance(spec, CommentedMap):
974 ex = PipelineFileError("Key 'spec' of the header is not a mapping.", path, Base._KeyLine(header, "spec"))
975 ex.add_note(f"Got type '{getFullyQualifiedName(spec)}'.")
976 raise ex
978 if "inputs" in spec: 978 ↛ 981line 978 didn't jump to line 981 because the condition on line 978 was always true
979 inputs = Input._FromYAMLMapping(spec["inputs"], None, path, Base._KeyLine(spec, "inputs"))
981 if (description := spec.get("description", None)) is not None:
982 description = Base._ToText(description)
984 return cls(path, stages, variables, jobs, workflow, includes, default, inputs, description)
986 @classmethod
987 def _ResolveJobs(
988 cls,
989 document: CommentedMap,
990 path: Path,
991 included: Nullable[Mapping[str, Any]] = None,
992 incomplete: Nullable[bool] = None
993 ) -> dict[str, tuple[CommentedMap, list[str]]]:
994 """
995 Resolve the ``!reference`` tags, the ``extends`` and the ``default`` of every job, as GitLab merges them.
997 A ``!reference`` tag is replaced by the value it refers to; one in a list of a list's items is spliced into it.
998 A job's mapping is the deep merge of the jobs it extends, in order, and of its own keys: mappings merge, other
999 values - lists included - replace, and ``null`` removes a key. A job then gets every key of ``default``, and of
1000 the global keywords standing for it, which it doesn't set, unless ``inherit.default`` says otherwise.
1002 A job may extend the jobs, and a reference refer to the keys, of the included files read. Every key keeps the line
1003 it is written at; a key an included file contributes gets the line of the ``extends`` key or the reference
1004 bringing it in. What a job refers to, but neither the file nor an included file has, is an error - unless the file
1005 includes files that weren't read, which may have it: then it is skipped and reported in the job's ``unresolved``
1006 list.
1008 :param document: The configuration.
1009 :param path: Path to the configuration file.
1010 :param included: Optional, the top-level keys of the included files read. Default: ``None``.
1011 :param incomplete: Optional, ``True``, if the file includes files that weren't read. Default: ``None``,
1012 for ``True`` if the file includes files.
1013 :returns: Every job's merged mapping, and what it refers to but the file doesn't have, by name, in
1014 file order.
1015 :raises PipelineFileError: If a job is not a mapping.
1016 :raises PipelineFileError: If a job extends a job, or a ``!reference`` tag refers to a key, the file doesn't have,
1017 though it includes no files.
1018 :raises PipelineFileError: If jobs extend each other in a cycle, or ``extends`` or ``!reference`` tags nest too
1019 deep.
1020 """
1021 included = {} if included is None else dict(included)
1022 hasIncludes = "include" in document if incomplete is None else incomplete
1023 scope = included | dict(document)
1024 foreign = {name for name in included if name not in document}
1025 unresolved: dict[str, list[str]] = {}
1027 def relocate(value: Any, line: int) -> Any:
1028 """
1029 Nested function for recursion, copying a value of an included file with every line set to one of this file.
1031 :param value: The value.
1032 :param line: The line, starting at 0.
1033 :returns: The copy.
1034 """
1035 if isinstance(value, CommentedMap):
1036 result = CommentedMap((key, relocate(item, line)) for key, item in value.items())
1037 for key in result:
1038 result.lc.add_kv_line_col(key, [line, 0, line, 0])
1039 elif isinstance(value, CommentedSeq):
1040 result = CommentedSeq(relocate(item, line) for item in value)
1041 for index in range(len(result)):
1042 result.lc.add_kv_line_col(index, [line, 0, line, 0])
1043 else:
1044 return value
1046 result.lc.line = line
1047 result.lc.col = 0
1048 return result
1050 def resolve(value: Any, owner: Nullable[str], depth: int) -> Any:
1051 """
1052 Nested function for recursion, resolving the ``!reference`` tags in a value.
1054 :param value: The value.
1055 :param owner: Name of the job the value belongs to, or ``None`` for ``default``.
1056 :param depth: How many references were followed to reach the value.
1057 :returns: The resolved value, or ``None`` for a reference of a job the file can't resolve.
1058 :raises PipelineFileError: If a reference can't be resolved, though the file includes no files, or it is in
1059 ``default``.
1060 :raises PipelineFileError: If references nest deeper than :data:`MAX_REFERENCE_DEPTH` levels.
1061 """
1062 if isinstance(value, CommentedSeq) and value.tag.value == "!reference":
1063 segments = ", ".join(str(segment) for segment in value)
1064 where = "the default" if owner is None else f"job '{owner}'"
1065 if depth >= MAX_REFERENCE_DEPTH:
1066 raise PipelineFileError(
1067 f"Reference [{segments}] of {where} nests more than {MAX_REFERENCE_DEPTH} levels.", path, value.lc.line + 1
1068 )
1070 segmentList = [str(segment) for segment in value]
1071 target: Any = scope
1072 for segment in segmentList:
1073 if not isinstance(target, Mapping) or segment not in target:
1074 if not hasIncludes or owner is None:
1075 raise PipelineFileError(
1076 f"Reference [{segments}] of {where} refers to a key the file doesn't have.", path, value.lc.line + 1
1077 )
1079 unresolved.setdefault(owner, []).append(f"!reference [{segments}]")
1080 return None
1082 target = target[segment]
1084 if segmentList[0] in foreign:
1085 target = relocate(target, value.lc.line)
1087 return resolve(target, owner, depth + 1)
1088 elif isinstance(value, CommentedSeq):
1089 items = CommentedSeq()
1090 items.lc.line = value.lc.line
1091 for index, item in enumerate(value):
1092 isReference = isinstance(item, CommentedSeq) and item.tag.value == "!reference"
1093 resolved = resolve(item, owner, depth)
1094 if isReference and resolved is None:
1095 continue
1096 elif isReference and isinstance(resolved, CommentedSeq):
1097 for position, element in enumerate(resolved):
1098 items.append(element)
1099 items.lc.add_kv_line_col(len(items) - 1, resolved.lc.data[position])
1100 else:
1101 items.append(resolved)
1102 items.lc.add_kv_line_col(len(items) - 1, value.lc.data[index])
1104 return items
1105 elif isinstance(value, CommentedMap):
1106 for key in list(value):
1107 isReference = isinstance(value[key], CommentedSeq) and value[key].tag.value == "!reference"
1108 resolved = resolve(value[key], owner, depth)
1109 if resolved is None and isReference: 1109 ↛ 1110line 1109 didn't jump to line 1110 because the condition on line 1109 was never true
1110 del value[key]
1111 else:
1112 value[key] = resolved
1114 return value
1116 def merge(target: CommentedMap, source: CommentedMap) -> None:
1117 """
1118 Nested function for recursion, merging a mapping deeply into another, keeping each key's line.
1120 :param target: The mapping merged into.
1121 :param source: The mapping merged.
1122 """
1123 for key, value in source.items():
1124 if value is None:
1125 target.pop(key, None)
1126 continue
1127 elif isinstance(value, CommentedMap) and isinstance(target.get(key, None), CommentedMap):
1128 merged = copy(target[key])
1129 merge(merged, value)
1130 value = merged
1132 target[key] = value
1133 if key in (source.lc.data or {}): 1133 ↛ 1123line 1133 didn't jump to line 1123 because the condition on line 1133 was always true
1134 target.lc.add_kv_line_col(key, source.lc.data[key])
1136 def copy(mapping: CommentedMap) -> CommentedMap:
1137 """
1138 Nested function copying a mapping with the lines of its keys.
1140 :param mapping: The mapping.
1141 :returns: The copy.
1142 """
1143 result = CommentedMap(mapping)
1144 result.lc.line = mapping.lc.line
1145 result.lc.col = mapping.lc.col
1146 for key in mapping:
1147 if key in (mapping.lc.data or {}): 1147 ↛ 1146line 1147 didn't jump to line 1146 because the condition on line 1147 was always true
1148 result.lc.add_kv_line_col(key, mapping.lc.data[key])
1150 return result
1152 mappings: dict[str, CommentedMap] = {}
1153 for name in foreign:
1154 if name not in RESERVED_KEYS and isinstance(included[name], CommentedMap): 1154 ↛ 1153line 1154 didn't jump to line 1153 because the condition on line 1154 was always true
1155 mappings[name] = resolve(included[name], name, 0)
1157 for name, job in document.items():
1158 name = str(name)
1159 if name in RESERVED_KEYS:
1160 continue
1161 elif not isinstance(job, CommentedMap):
1162 ex = PipelineFileError(f"Job '{name}' is not a mapping.", path, Base._KeyLine(document, name))
1163 ex.add_note(f"Got type '{getFullyQualifiedName(job)}'.")
1164 raise ex
1166 mappings[name] = resolve(job, name, 0)
1168 defaults = CommentedMap()
1169 if isinstance(document.get("default", None), CommentedMap):
1170 defaults = copy(resolve(document["default"], None, 0))
1172 for key in Default._KEYS:
1173 if key in document and key not in defaults:
1174 defaults[key] = document[key]
1175 defaults.lc.add_kv_line_col(key, document.lc.data[key])
1177 def extended(name: str, chain: tuple[str, ...]) -> CommentedMap:
1178 """
1179 Nested function for recursion, merging the jobs a job extends and its own keys.
1181 :param name: Name of the job.
1182 :param chain: Names of the jobs extending this one, outermost first.
1183 :returns: The merged mapping.
1184 :raises PipelineFileError: If a job extends a job the file doesn't have, though it includes no files.
1185 :raises PipelineFileError: If jobs extend each other in a cycle, or nest deeper than
1186 :data:`MAX_EXTENDS_DEPTH` levels.
1187 """
1188 own = mappings[name]
1189 names = own.get("extends", ())
1190 names = [names] if isinstance(names, str) else list(names)
1191 result = CommentedMap()
1192 result.lc.line = own.lc.line
1193 result.lc.col = own.lc.col
1194 for base in (str(base) for base in names):
1195 if base in chain or base == name:
1196 cycle = " -> ".join((*chain, name, base))
1197 raise PipelineFileError(f"Jobs extend each other in a cycle: {cycle}.", path, Base._KeyLine(document, name))
1198 elif len(chain) >= MAX_EXTENDS_DEPTH:
1199 raise PipelineFileError(
1200 f"Job '{name}' extends more than {MAX_EXTENDS_DEPTH} levels.", path, Base._KeyLine(document, name)
1201 )
1202 elif base not in mappings:
1203 if not hasIncludes:
1204 raise PipelineFileError(
1205 f"Job '{name}' extends job '{base}', which the file doesn't have.", path, Base._KeyLine(own, "extends")
1206 )
1208 unresolved.setdefault(chain[0] if len(chain) > 0 else name, []).append(f"extends {base}")
1209 continue
1211 baseMapping = extended(base, (*chain, name))
1212 if base in foreign and name not in foreign:
1213 baseMapping = relocate(baseMapping, Base._KeyLine(own, "extends") - 1)
1215 merge(result, baseMapping)
1217 merge(result, own)
1218 return result
1220 resolved = {}
1221 for name in (name for name in mappings if name not in foreign):
1222 mapping = extended(name, ())
1223 inherit = mapping.get("inherit", None)
1224 inherited = inherit.get("default", True) if isinstance(inherit, CommentedMap) else True
1225 if not name.startswith(".") and inherited is not False:
1226 for key, value in defaults.items():
1227 if key not in mapping and (inherited is True or key in inherited):
1228 mapping[key] = value
1229 mapping.lc.add_kv_line_col(key, defaults.lc.data[key])
1231 resolved[name] = (mapping, unresolved.get(name, []))
1233 return resolved
1235 def _Validate(self) -> None:
1236 """
1237 Validate the configuration read from a file.
1239 :raises PipelineFileError: If a job is in a stage the pipeline doesn't have. |br|
1240 The note lists the stages.
1241 :raises PipelineFileError: If a job needs a job the file doesn't have. |br|
1242 The note lists the file's jobs.
1243 :raises PipelineFileError: If the jobs need each other in a cycle.
1244 """
1245 self._ValidateStages()
1246 self._ValidateNeeds()
1247 self._ValidateAcyclic()
1249 def _ValidateStages(self) -> None:
1250 """
1251 Validate that every job is in a stage of the pipeline.
1253 A stage interpolating an input, as ``$[[ inputs.stage ]]``, is known when the file is included only.
1255 :raises PipelineFileError: If a job is in a stage the pipeline doesn't have. |br|
1256 The note lists the stages.
1257 """
1258 stages = self.Stages
1259 for job in self._jobs.values():
1260 if job._stage not in stages and "$[[" not in job._stage:
1261 ex = PipelineFileError(
1262 f"Job '{job._name}' is in stage '{job._stage}', which isn't declared.", self._path, job._line
1263 )
1264 ex.add_note(f"Stages: {', '.join(stages)}.")
1265 raise ex
1267 def _ValidateNeeds(self) -> None:
1268 """
1269 Validate that every job needs only jobs of the file, unless the need is optional or refers to another pipeline.
1271 A job's name interpolating an input is known when the file is included only.
1273 :raises PipelineFileError: If a job needs a job the file doesn't have. |br|
1274 The note lists the file's jobs.
1275 """
1276 for job in self._jobs.values():
1277 if job._needs is None:
1278 continue
1280 for need in job._needs:
1281 if need._kind is not NeedKind.Job or need._optional or need._job in self._jobs or "$[[" in need._job:
1282 continue
1284 ex = PipelineFileError(
1285 f"Job '{job._name}' needs job '{need._job}', which the file doesn't have.", self._path, need._line
1286 )
1287 ex.add_note(f"Jobs: {', '.join(self._jobs)}.")
1288 raise ex
1290 def _ValidateAcyclic(self) -> None:
1291 """
1292 Validate that the jobs don't need each other in a cycle.
1294 :raises PipelineFileError: If the jobs need each other in a cycle.
1295 """
1296 finished: set[str] = set()
1297 stack: list[str] = []
1299 def visit(job: Job) -> None:
1300 """
1301 Nested function for recursion.
1303 :param job: The job whose needs are followed.
1304 :raises PipelineFileError: If the job is reached again while its needs are followed.
1305 """
1306 if job._name in finished:
1307 return
1308 elif job._name in stack:
1309 cycle = stack[stack.index(job._name):] + [job._name]
1310 raise PipelineFileError(f"Jobs need each other in a cycle: {' -> '.join(cycle)}.", self._path, job._line)
1312 stack.append(job._name)
1313 for needed in job.NeededJobs:
1314 visit(needed)
1315 stack.pop()
1316 finished.add(job._name)
1318 for job in self._jobs.values():
1319 visit(job)
1322@export
1323class Job(Base[PipelineFile]):
1324 """
1325 A job of a configuration file, or a hidden job.
1327 A job runs :attr:`Script` in :attr:`Stage`, after the jobs of the earlier stages - or, with a ``needs`` key, after
1328 the jobs in :attr:`Needs`. A hidden job, whose name starts with a dot, isn't run; other jobs extend it.
1329 """
1331 _PARENT_TYPE: ClassVar[ParentTypes] = PipelineFile #: A job is contained in a configuration file.
1333 _name: str #: Name of the job, the key it is declared under.
1334 _stage: str #: Stage the job runs in.
1335 _needs: Nullable[list[Need]] #: Entries of the job's ``needs``, or ``None`` without a ``needs`` key.
1336 _rules: list[Rule] #: Rules deciding whether the job is added to a pipeline.
1337 _when: Nullable[RuleOutcome] #: When the job runs.
1338 _allowFailure: Nullable[bool] #: ``True``, if the pipeline continues when the job fails.
1339 _allowedExitCodes: tuple[int, ...] #: Exit codes, with which the job may fail.
1340 _script: tuple[str, ...] #: Lines of the job's ``script``.
1341 _beforeScript: tuple[str, ...] #: Lines of the job's ``before_script``.
1342 _afterScript: tuple[str, ...] #: Lines of the job's ``after_script``.
1343 _image: Nullable[str] #: Image of the container the job runs in.
1344 _services: tuple[str, ...] #: Images of the service containers.
1345 _tags: tuple[str, ...] #: Tags selecting the runner.
1346 _environment: Nullable[str] #: Name of the environment the job deploys to.
1347 _variables: dict[str, Variable] #: Variables of the job, by name, in file order.
1348 _inputs: dict[str, Input] #: Inputs of the job, by name, in file order.
1349 _parallel: Nullable[Parallel] #: The job's ``parallel``.
1350 _trigger: Nullable[Trigger] #: The pipeline the job starts.
1351 _extends: tuple[str, ...] #: Names of the jobs this job extends.
1352 _unresolved: tuple[str, ...] #: What the job refers to, but its file doesn't have.
1354 def __init__(
1355 self,
1356 name: str,
1357 line: int,
1358 stage: str = "test",
1359 needs: Nullable[Iterable[Need]] = None,
1360 when: Nullable[RuleOutcome] = None,
1361 allowFailure: Nullable[bool] = None,
1362 allowedExitCodes: Nullable[Iterable[int]] = None,
1363 script: Nullable[Iterable[str]] = None,
1364 beforeScript: Nullable[Iterable[str]] = None,
1365 afterScript: Nullable[Iterable[str]] = None,
1366 image: Nullable[str] = None,
1367 services: Nullable[Iterable[str]] = None,
1368 tags: Nullable[Iterable[str]] = None,
1369 environment: Nullable[str] = None,
1370 variables: Nullable[Iterable[Variable]] = None,
1371 rules: Nullable[Iterable[Rule]] = None,
1372 inputs: Nullable[Iterable[Input]] = None,
1373 parallel: Nullable[Parallel] = None,
1374 trigger: Nullable[Trigger] = None,
1375 extends: Nullable[Iterable[str]] = None,
1376 unresolved: Nullable[Iterable[str]] = None,
1377 *,
1378 parent: Nullable[PipelineFile] = None
1379 ) -> None:
1380 """
1381 Initializes a job.
1383 A need, a variable, a rule, an input, a parallel or a trigger is attached by passing it, or by constructing it with
1384 the job as parent.
1386 :param name: Name of the job, the key it is declared under.
1387 :param line: Line the job's name is written at, starting at 1.
1388 :param stage: Optional, stage the job runs in. Default: ``test``.
1389 :param needs: Optional, entries of the job's ``needs``, which are attached to the job; an empty
1390 iterable for ``needs: []``. Default: ``None``, for a job without a ``needs`` key.
1391 :param when: Optional, when the job runs. Default: ``None``, for a job that doesn't say.
1392 :param allowFailure: Optional, ``True``, if the pipeline continues when the job fails. Default: ``None``.
1393 :param allowedExitCodes: Optional, exit codes, with which the job may fail. Default: ``None``.
1394 :param script: Optional, lines of the job's ``script``. Default: ``None``.
1395 :param beforeScript: Optional, lines of the job's ``before_script``. Default: ``None``.
1396 :param afterScript: Optional, lines of the job's ``after_script``. Default: ``None``.
1397 :param image: Optional, image of the container the job runs in. Default: ``None``.
1398 :param services: Optional, images of the service containers. Default: ``None``.
1399 :param tags: Optional, tags selecting the runner. Default: ``None``.
1400 :param environment: Optional, name of the environment the job deploys to. Default: ``None``.
1401 :param variables: Optional, variables of the job, which are attached to the job. Default: ``None``.
1402 :param rules: Optional, rules deciding whether the job is added to a pipeline, which are attached to
1403 the job. Default: ``None``.
1404 :param inputs: Optional, inputs of the job, which are attached to the job. Default: ``None``.
1405 :param parallel: Optional, the job's ``parallel``, which is attached to the job. Default: ``None``.
1406 :param trigger: Optional, the pipeline the job starts, which is attached to the job. Default: ``None``.
1407 :param extends: Optional, names of the jobs this job extends. Default: ``None``.
1408 :param unresolved: Optional, what the job refers to with ``extends`` or a ``!reference`` tag, but its file
1409 doesn't have. Default: ``None``.
1410 :param parent: Optional, reference to the configuration file containing the job. Default: ``None``.
1411 :raises ValueError: If parameter 'name' or 'stage' is ``None``.
1412 :raises TypeError: If parameter 'name' or 'stage' is not of type :class:`str`.
1413 :raises ValueError: If parameter 'name' or 'stage' is empty.
1414 :raises TypeError: If parameter 'when' is not of type :class:`~pyTooling.CI.RuleOutcome`.
1415 :raises TypeError: If parameter 'allowFailure' is not of type :class:`bool`.
1416 :raises TypeError: If parameter 'image' or 'environment' is not of type :class:`str`.
1417 :raises TypeError: If an element of parameter 'allowedExitCodes' is not of type :class:`int`.
1418 :raises TypeError: If an element of parameter 'script', 'beforeScript', 'afterScript', 'services', 'tags',
1419 'extends' or 'unresolved' is not of type :class:`str`.
1420 :raises TypeError: If an element of parameter 'needs' is not of type :class:`Need`.
1421 :raises TypeError: If an element of parameter 'variables' is not of type :class:`Variable`.
1422 :raises TypeError: If an element of parameter 'rules' is not of type :class:`Rule`.
1423 :raises TypeError: If an element of parameter 'inputs' is not of type :class:`Input`.
1424 :raises TypeError: If parameter 'parallel' is not of type :class:`Parallel`.
1425 :raises TypeError: If parameter 'trigger' is not of type :class:`Trigger`.
1426 """
1427 super().__init__(line, parent=parent)
1429 for parameterName, text in (
1430 ("name", name),
1431 ("stage", stage)
1432 ):
1433 if text is None:
1434 raise ValueError(f"Parameter '{parameterName}' is None.")
1435 elif not isinstance(text, str):
1436 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
1437 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
1438 raise ex
1439 elif text == "":
1440 raise ValueError(f"Parameter '{parameterName}' is empty.")
1442 if when is not None and not isinstance(when, RuleOutcome):
1443 ex = TypeError("Parameter 'when' is not of type 'RuleOutcome'.")
1444 ex.add_note(f"Got type '{getFullyQualifiedName(when)}'.")
1445 raise ex
1447 if allowFailure is not None and not isinstance(allowFailure, bool):
1448 ex = TypeError("Parameter 'allowFailure' is not of type 'bool'.")
1449 ex.add_note(f"Got type '{getFullyQualifiedName(allowFailure)}'.")
1450 raise ex
1452 for parameterName, text in (
1453 ("image", image),
1454 ("environment", environment)
1455 ):
1456 if text is not None and not isinstance(text, str):
1457 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
1458 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
1459 raise ex
1461 self._allowedExitCodes = () if allowedExitCodes is None else tuple(allowedExitCodes)
1462 for code in self._allowedExitCodes:
1463 if not isinstance(code, int) or isinstance(code, bool):
1464 ex = TypeError("An element of parameter 'allowedExitCodes' is not of type 'int'.")
1465 ex.add_note(f"Got type '{getFullyQualifiedName(code)}'.")
1466 raise ex
1468 self._script = () if script is None else tuple(script)
1469 self._beforeScript = () if beforeScript is None else tuple(beforeScript)
1470 self._afterScript = () if afterScript is None else tuple(afterScript)
1471 self._services = () if services is None else tuple(services)
1472 self._tags = () if tags is None else tuple(tags)
1473 self._extends = () if extends is None else tuple(extends)
1474 self._unresolved = () if unresolved is None else tuple(unresolved)
1475 for parameterName, texts in (
1476 ("script", self._script),
1477 ("beforeScript", self._beforeScript),
1478 ("afterScript", self._afterScript),
1479 ("services", self._services),
1480 ("tags", self._tags),
1481 ("extends", self._extends),
1482 ("unresolved", self._unresolved)
1483 ):
1484 for text in texts:
1485 if not isinstance(text, str):
1486 ex = TypeError(f"An element of parameter '{parameterName}' is not of type 'str'.")
1487 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
1488 raise ex
1490 self._name = name
1491 self._stage = stage
1492 self._needs = None
1493 self._rules = []
1494 self._inputs = {}
1495 self._parallel = None
1496 self._trigger = None
1497 self._when = when
1498 self._allowFailure = allowFailure
1499 self._image = image
1500 self._environment = environment
1501 self._variables = {}
1503 if needs is not None:
1504 self._needs = []
1505 for need in needs:
1506 if not isinstance(need, Need):
1507 ex = TypeError("An element of parameter 'needs' is not of type 'Need'.")
1508 ex.add_note(f"Got type '{getFullyQualifiedName(need)}'.")
1509 raise ex
1511 self._needs.append(need)
1512 need.Parent = self
1514 if variables is not None:
1515 for variable in variables:
1516 if not isinstance(variable, Variable):
1517 ex = TypeError("An element of parameter 'variables' is not of type 'Variable'.")
1518 ex.add_note(f"Got type '{getFullyQualifiedName(variable)}'.")
1519 raise ex
1521 self._variables[variable._name] = variable
1522 variable.Parent = self
1524 if rules is not None:
1525 for rule in rules:
1526 if not isinstance(rule, Rule):
1527 ex = TypeError("An element of parameter 'rules' is not of type 'Rule'.")
1528 ex.add_note(f"Got type '{getFullyQualifiedName(rule)}'.")
1529 raise ex
1531 self._rules.append(rule)
1532 rule.Parent = self
1534 if inputs is not None:
1535 for parameter in inputs:
1536 if not isinstance(parameter, Input):
1537 ex = TypeError("An element of parameter 'inputs' is not of type 'Input'.")
1538 ex.add_note(f"Got type '{getFullyQualifiedName(parameter)}'.")
1539 raise ex
1541 self._inputs[parameter._name] = parameter
1542 parameter.Parent = self
1544 for parameterName, element, elementClass in (
1545 ("parallel", parallel, Parallel),
1546 ("trigger", trigger, Trigger)
1547 ):
1548 if element is not None and not isinstance(element, elementClass):
1549 ex = TypeError(f"Parameter '{parameterName}' is not of type '{elementClass.__name__}'.")
1550 ex.add_note(f"Got type '{getFullyQualifiedName(element)}'.")
1551 raise ex
1553 if parallel is not None:
1554 self._parallel = parallel
1555 parallel.Parent = self
1557 if trigger is not None:
1558 self._trigger = trigger
1559 trigger.Parent = self
1561 if parent is not None:
1562 (parent._hiddenJobs if self.IsHidden else parent._jobs)[name] = self
1564 @Base.Parent.setter
1565 def Parent(self, value: PipelineFile) -> None:
1566 Base.Parent.fset(self, value)
1568 for need in () if self._needs is None else self._needs:
1569 need.Parent = self
1571 for variable in self._variables.values():
1572 variable.Parent = self
1574 for rule in self._rules:
1575 rule.Parent = self
1577 for parameter in self._inputs.values():
1578 parameter.Parent = self
1580 for element in (self._parallel, self._trigger):
1581 if element is not None:
1582 element.Parent = self
1584 @readonly
1585 def Name(self) -> str:
1586 """
1587 Read-only property to access the job's name, the key it is declared under (:attr:`_name`).
1589 :returns: Name of the job.
1590 """
1591 return self._name
1593 @readonly
1594 def IsHidden(self) -> bool:
1595 """
1596 Read-only property to return whether the job is a hidden job, which GitLab doesn't run.
1598 :returns: ``True``, if the job's name starts with a dot.
1599 """
1600 return self._name.startswith(".")
1602 @readonly
1603 def Extends(self) -> tuple[str, ...]:
1604 """
1605 Read-only property to access the names of the jobs this job extends (:attr:`_extends`).
1607 The job's keys are the merge of theirs and its own already.
1609 :returns: The names, in the order ``extends`` lists them.
1610 """
1611 return self._extends
1613 @readonly
1614 def Unresolved(self) -> tuple[str, ...]:
1615 """
1616 Read-only property to access what the job refers to, but its file doesn't have (:attr:`_unresolved`).
1618 A file including other files may extend or refer to their jobs; then the job lacks what they contribute.
1620 :returns: The jobs it extends and the ``!reference`` tags, as ``extends .base`` and ``!reference [.setup,
1621 script]``, or an empty tuple if the job is complete.
1622 """
1623 return self._unresolved
1625 @readonly
1626 def Stage(self) -> str:
1627 """
1628 Read-only property to access the stage the job runs in (:attr:`_stage`).
1630 :returns: The stage, ``test`` for a job that doesn't name one.
1631 """
1632 return self._stage
1634 @readonly
1635 def Needs(self) -> Nullable[tuple[Need, ...]]:
1636 """
1637 Read-only property to return the entries of the job's ``needs`` (:attr:`_needs`).
1639 :returns: The entries, in file order; an empty tuple for ``needs: []``; or ``None`` for a job without a ``needs``
1640 key, which runs after the jobs of the earlier stages.
1641 """
1642 return None if self._needs is None else tuple(self._needs)
1644 @readonly
1645 def NeededJobs(self) -> tuple[Job, ...]:
1646 """
1647 Read-only property to return the jobs of the same file this job needs, as its ``needs`` names them.
1649 An optional need of a job the file doesn't have is skipped, as are needs of other pipelines.
1651 :returns: The jobs, in the order the ``needs`` key lists them.
1652 """
1653 if self._needs is None or self._pipelineFile is None:
1654 return ()
1656 jobs = self._pipelineFile._jobs
1657 return tuple(jobs[need._job] for need in self._needs if need._kind is NeedKind.Job and need._job in jobs)
1659 @readonly
1660 def Rules(self) -> list[Rule]:
1661 """
1662 Read-only property to access the rules deciding whether the job is added to a pipeline (:attr:`_rules`).
1664 :returns: The rules, in the order GitLab evaluates them.
1665 """
1666 return self._rules
1668 @readonly
1669 def Inputs(self) -> dict[str, Input]:
1670 """
1671 Read-only property to access the inputs of the job (:attr:`_inputs`).
1673 :returns: The inputs, by name, in file order.
1674 """
1675 return self._inputs
1677 @readonly
1678 def Parallel(self) -> Nullable[Parallel]:
1679 """
1680 Read-only property to access the job's ``parallel`` (:attr:`_parallel`).
1682 :returns: How often the job runs, or its matrix, or ``None`` for a job running once.
1683 """
1684 return self._parallel
1686 @readonly
1687 def Trigger(self) -> Nullable[Trigger]:
1688 """
1689 Read-only property to access the pipeline the job starts (:attr:`_trigger`).
1691 :returns: The trigger, or ``None`` for a job running a script.
1692 """
1693 return self._trigger
1695 @readonly
1696 def When(self) -> Nullable[RuleOutcome]:
1697 """
1698 Read-only property to access when the job runs (:attr:`_when`).
1700 :returns: The ``when`` key, or ``None`` if the job doesn't say - GitLab runs it on success then.
1701 """
1702 return self._when
1704 @readonly
1705 def AllowFailure(self) -> Nullable[bool]:
1706 """
1707 Read-only property to access whether the pipeline continues when the job fails (:attr:`_allowFailure`).
1709 :returns: The ``allow_failure`` key, ``True`` for a mapping of exit codes, or ``None`` if the job doesn't say.
1710 """
1711 return self._allowFailure
1713 @readonly
1714 def AllowedExitCodes(self) -> tuple[int, ...]:
1715 """
1716 Read-only property to access the exit codes, with which the job may fail (:attr:`_allowedExitCodes`).
1718 :returns: The exit codes of ``allow_failure.exit_codes``, in file order.
1719 """
1720 return self._allowedExitCodes
1722 @readonly
1723 def Script(self) -> tuple[str, ...]:
1724 """
1725 Read-only property to access the lines of the job's ``script`` (:attr:`_script`).
1727 :returns: The lines, nested lists flattened.
1728 """
1729 return self._script
1731 @readonly
1732 def BeforeScript(self) -> tuple[str, ...]:
1733 """
1734 Read-only property to access the lines of the job's ``before_script`` (:attr:`_beforeScript`).
1736 :returns: The lines, nested lists flattened.
1737 """
1738 return self._beforeScript
1740 @readonly
1741 def AfterScript(self) -> tuple[str, ...]:
1742 """
1743 Read-only property to access the lines of the job's ``after_script`` (:attr:`_afterScript`).
1745 :returns: The lines, nested lists flattened.
1746 """
1747 return self._afterScript
1749 @readonly
1750 def Image(self) -> Nullable[str]:
1751 """
1752 Read-only property to access the image of the container the job runs in (:attr:`_image`).
1754 :returns: The image, as ``python:3.14``, or ``None`` if the job names none.
1755 """
1756 return self._image
1758 @readonly
1759 def Services(self) -> tuple[str, ...]:
1760 """
1761 Read-only property to access the images of the service containers (:attr:`_services`).
1763 :returns: The images, in file order.
1764 """
1765 return self._services
1767 @readonly
1768 def Tags(self) -> tuple[str, ...]:
1769 """
1770 Read-only property to access the tags selecting the runner (:attr:`_tags`).
1772 :returns: The tags, in file order.
1773 """
1774 return self._tags
1776 @readonly
1777 def Environment(self) -> Nullable[str]:
1778 """
1779 Read-only property to access the name of the environment the job deploys to (:attr:`_environment`).
1781 :returns: The environment's name, or ``None`` if the job deploys to none.
1782 """
1783 return self._environment
1785 @readonly
1786 def Variables(self) -> dict[str, Variable]:
1787 """
1788 Read-only property to access the variables of the job (:attr:`_variables`).
1790 :returns: The variables, by name, in file order.
1791 """
1792 return self._variables
1794 def __str__(self) -> str:
1795 """
1796 Return the job's name.
1798 :returns: Name of the job.
1799 """
1800 return self._name
1802 @staticmethod
1803 def _Lines(value: Any, key: str, owner: str, mapping: CommentedMap, path: Path) -> list[str]:
1804 """
1805 Read a script key: a line, or a list of lines and nested lists of lines.
1807 :param value: The key's value.
1808 :param key: The key, for a message.
1809 :param owner: The element holding the key, for a message, as ``job 'build'``.
1810 :param mapping: The job's mapping, for the key's line.
1811 :param path: Path to the configuration file.
1812 :returns: The lines, nested lists flattened.
1813 :raises PipelineFileError: If the value is a mapping, or a list holds a mapping.
1814 """
1815 if isinstance(value, CommentedSeq):
1816 lines = []
1817 for item in value:
1818 lines.extend(Job._Lines(item, key, owner, mapping, path))
1820 return lines
1821 elif isinstance(value, CommentedMap):
1822 ex = PipelineFileError(f"Key '{key}' of {owner} is not a script.", path, Base._KeyLine(mapping, key))
1823 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
1824 raise ex
1826 return [] if value is None else [Base._ToText(value)]
1828 @staticmethod
1829 def _Name(value: Any) -> Nullable[str]:
1830 """
1831 Read a key naming something by a string or by the ``name`` of a mapping, as ``image`` and ``environment`` do.
1833 :param value: The key's value.
1834 :returns: The name, or ``None``.
1835 """
1836 if isinstance(value, CommentedMap):
1837 value = value.get("name", None)
1839 return None if value is None else Base._ToText(value)
1841 @classmethod
1842 def _FromYAML(
1843 cls,
1844 name: str,
1845 mapping: CommentedMap,
1846 path: Path,
1847 line: int,
1848 unresolved: Nullable[Iterable[str]] = None
1849 ) -> Self:
1850 """
1851 Build a job and the elements it contains from its mapping in the configuration file.
1853 :param name: Name of the job.
1854 :param mapping: The job's mapping, merged with the jobs it extends and the default.
1855 :param path: Path to the configuration file.
1856 :param line: Line the job's name is written at, starting at 1.
1857 :param unresolved: Optional, what the job refers to, but the file doesn't have. Default: ``None``.
1858 :returns: The job.
1859 :raises PipelineFileError: If key ``when`` is not a value GitLab knows. |br|
1860 The note lists the allowed values.
1861 :raises PipelineFileError: If a key of the job holds a value of the wrong kind.
1862 :raises PipelineFileError: If an entry of key ``needs`` is malformed.
1863 """
1864 needs = None
1865 if "needs" in mapping:
1866 needs = Need._FromYAMLEntries(mapping["needs"], name, path, Base._KeyLine(mapping, "needs"))
1868 when = None
1869 if (whenValue := mapping.get("when", None)) is not None:
1870 try:
1871 when = RuleOutcome.Parse(Base._ToText(whenValue))
1872 except ValueError as cause:
1873 ex = PipelineFileError(
1874 f"Key 'when' of job '{name}' is not a known value.", path, Base._KeyLine(mapping, "when")
1875 )
1876 ex.add_note(f"Got '{whenValue}'.")
1877 ex.add_note(f"Allowed values: {', '.join(member.value for member in RuleOutcome)}.")
1878 raise ex from cause
1880 allowFailure = Base._ToPython(mapping.get("allow_failure", None))
1881 exitCodes = None
1882 if isinstance(allowFailure, dict):
1883 exitCodes = allowFailure.get("exit_codes", ())
1884 exitCodes = [exitCodes] if isinstance(exitCodes, int) else exitCodes
1885 allowFailure = True
1887 if allowFailure is not None and not isinstance(allowFailure, bool):
1888 ex = PipelineFileError(
1889 f"Key 'allow_failure' of job '{name}' is not a boolean.", path, Base._KeyLine(mapping, "allow_failure")
1890 )
1891 ex.add_note(f"Got '{allowFailure}'.")
1892 raise ex
1894 services = None
1895 if (serviceList := mapping.get("services", None)) is not None:
1896 if not isinstance(serviceList, CommentedSeq):
1897 ex = PipelineFileError(
1898 f"Key 'services' of job '{name}' is not a list.", path, Base._KeyLine(mapping, "services")
1899 )
1900 ex.add_note(f"Got type '{getFullyQualifiedName(serviceList)}'.")
1901 raise ex
1903 services = [service for item in serviceList if (service := cls._Name(item)) is not None]
1905 tags = None
1906 if (tagList := mapping.get("tags", None)) is not None:
1907 if not isinstance(tagList, CommentedSeq):
1908 ex = PipelineFileError(f"Key 'tags' of job '{name}' is not a list.", path, Base._KeyLine(mapping, "tags"))
1909 ex.add_note(f"Got type '{getFullyQualifiedName(tagList)}'.")
1910 raise ex
1912 tags = [Base._ToText(tag) for tag in tagList]
1914 variables = None
1915 if "variables" in mapping:
1916 keyLine = Base._KeyLine(mapping, "variables")
1917 variables = Variable._FromYAMLMapping(mapping["variables"], f"job '{name}'", path, keyLine)
1919 rules = None
1920 if "rules" in mapping:
1921 rules = Rule._FromYAMLEntries(mapping["rules"], f"job '{name}'", path, Base._KeyLine(mapping, "rules"))
1923 inputs = None
1924 if "inputs" in mapping:
1925 inputs = Input._FromYAMLMapping(mapping["inputs"], name, path, Base._KeyLine(mapping, "inputs"))
1927 parallel = None
1928 if "parallel" in mapping:
1929 parallel = Parallel._FromYAML(mapping["parallel"], f"job '{name}'", path, Base._KeyLine(mapping, "parallel"))
1931 trigger = None
1932 if "trigger" in mapping:
1933 trigger = Trigger._FromYAML(mapping["trigger"], name, path, Base._KeyLine(mapping, "trigger"))
1935 extends = mapping.get("extends", ())
1936 extends = [extends] if isinstance(extends, str) else extends
1937 stage = mapping.get("stage", None)
1939 return cls(
1940 name, line,
1941 stage="test" if stage is None else Base._ToText(stage),
1942 needs=needs,
1943 when=when,
1944 allowFailure=allowFailure,
1945 allowedExitCodes=exitCodes,
1946 script=cls._Lines(mapping.get("script", None), "script", f"job '{name}'", mapping, path),
1947 beforeScript=cls._Lines(mapping.get("before_script", None), "before_script", f"job '{name}'", mapping, path),
1948 afterScript=cls._Lines(mapping.get("after_script", None), "after_script", f"job '{name}'", mapping, path),
1949 image=cls._Name(mapping.get("image", None)),
1950 services=services,
1951 tags=tags,
1952 environment=cls._Name(mapping.get("environment", None)),
1953 variables=variables,
1954 rules=rules,
1955 inputs=inputs,
1956 parallel=parallel,
1957 trigger=trigger,
1958 extends=[Base._ToText(base) for base in extends],
1959 unresolved=unresolved
1960 )
1963@export
1964class Variable(Base[Union[PipelineFile, Job]]):
1965 """
1966 A CI/CD variable, declared in a ``variables`` key.
1968 A default variable - declared in the top-level ``variables`` - may carry a :attr:`Description`, which makes GitLab
1969 prefill it in the form running a pipeline by hand, and :attr:`Options` to select its value from.
1970 """
1972 _PARENT_TYPE: ClassVar[ParentTypes] = (PipelineFile, Job) #: A variable is declared by a configuration file or a job.
1974 _name: str #: Name of the variable.
1975 _value: Nullable[str] #: Value of the variable.
1976 _description: Nullable[str] #: Description of the variable, shown when running a pipeline by hand.
1977 _options: tuple[str, ...] #: Values to select from, when running a pipeline by hand.
1978 _expand: Nullable[bool] #: ``False``, if variables in the value are not expanded.
1980 _KEYS: ClassVar[tuple[str, ...]] = ("value", "description", "options", "expand") #: Keys of a declaration as mapping.
1982 def __init__(
1983 self,
1984 name: str,
1985 line: int,
1986 value: Nullable[str] = None,
1987 description: Nullable[str] = None,
1988 options: Nullable[Iterable[str]] = None,
1989 expand: Nullable[bool] = None,
1990 *,
1991 parent: Nullable[Union[PipelineFile, Job]] = None
1992 ) -> None:
1993 """
1994 Initializes a variable.
1996 :param name: Name of the variable.
1997 :param line: Line the variable's name is written at, starting at 1.
1998 :param value: Optional, value of the variable. Default: ``None``.
1999 :param description: Optional, description of the variable. Default: ``None``.
2000 :param options: Optional, values to select from when running a pipeline by hand. Default: ``None``.
2001 :param expand: Optional, ``False``, if variables in the value are not expanded. Default: ``None``, for a
2002 variable that doesn't say.
2003 :param parent: Optional, reference to the configuration file or the job declaring the variable. Default:
2004 ``None``.
2005 :raises ValueError: If parameter 'name' is ``None``.
2006 :raises TypeError: If parameter 'name' is not of type :class:`str`.
2007 :raises ValueError: If parameter 'name' is empty.
2008 :raises TypeError: If parameter 'value' is not of type :class:`str`.
2009 :raises TypeError: If parameter 'description' is not of type :class:`str`.
2010 :raises TypeError: If an element of parameter 'options' is not of type :class:`str`.
2011 :raises TypeError: If parameter 'expand' is not of type :class:`bool`.
2012 """
2013 super().__init__(line, parent=parent)
2015 if name is None:
2016 raise ValueError("Parameter 'name' is None.")
2017 elif not isinstance(name, str):
2018 ex = TypeError("Parameter 'name' is not of type 'str'.")
2019 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
2020 raise ex
2021 elif name == "":
2022 raise ValueError("Parameter 'name' is empty.")
2024 for parameterName, text in (
2025 ("value", value),
2026 ("description", description)
2027 ):
2028 if text is not None and not isinstance(text, str):
2029 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
2030 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
2031 raise ex
2033 self._options = () if options is None else tuple(options)
2034 for option in self._options:
2035 if not isinstance(option, str):
2036 ex = TypeError("An element of parameter 'options' is not of type 'str'.")
2037 ex.add_note(f"Got type '{getFullyQualifiedName(option)}'.")
2038 raise ex
2040 if expand is not None and not isinstance(expand, bool):
2041 ex = TypeError("Parameter 'expand' is not of type 'bool'.")
2042 ex.add_note(f"Got type '{getFullyQualifiedName(expand)}'.")
2043 raise ex
2045 self._name = name
2046 self._value = value
2047 self._description = description
2048 self._expand = expand
2050 if parent is not None:
2051 parent._variables[name] = self
2053 @readonly
2054 def Name(self) -> str:
2055 """
2056 Read-only property to access the variable's name (:attr:`_name`).
2058 :returns: Name of the variable.
2059 """
2060 return self._name
2062 @readonly
2063 def Value(self) -> Nullable[str]:
2064 """
2065 Read-only property to access the variable's value (:attr:`_value`).
2067 A value written as a number or a boolean is its text, as ``3.14`` or ``true``.
2069 :returns: The value, or ``None`` if the declaration gives none.
2070 """
2071 return self._value
2073 @readonly
2074 def Description(self) -> Nullable[str]:
2075 """
2076 Read-only property to access the variable's description (:attr:`_description`).
2078 :returns: The description, or ``None`` if the declaration gives none.
2079 """
2080 return self._description
2082 @readonly
2083 def Options(self) -> tuple[str, ...]:
2084 """
2085 Read-only property to access the values to select from, when running a pipeline by hand (:attr:`_options`).
2087 :returns: The values, in the order the ``options`` key lists them.
2088 """
2089 return self._options
2091 @readonly
2092 def Expand(self) -> Nullable[bool]:
2093 """
2094 Read-only property to access whether variables in the value are expanded (:attr:`_expand`).
2096 :returns: The ``expand`` key, or ``None`` if the declaration gives none - GitLab expands then.
2097 """
2098 return self._expand
2100 def __str__(self) -> str:
2101 """
2102 Return the variable's name.
2104 :returns: Name of the variable.
2105 """
2106 return self._name
2108 @classmethod
2109 def _FromYAML(cls, name: str, declaration: Any, path: Path, line: int) -> Self:
2110 """
2111 Read a variable's declaration: a value, or a mapping of the keys in :attr:`_KEYS`.
2113 :param name: Name of the variable.
2114 :param declaration: The declaration.
2115 :param path: Path to the configuration file.
2116 :param line: Line the variable's name is written at, starting at 1.
2117 :returns: The variable.
2118 :raises PipelineFileError: If the declaration is neither a value nor a mapping.
2119 :raises PipelineFileError: If the mapping has a key GitLab doesn't know for a variable. |br|
2120 The note lists the allowed keys.
2121 :raises PipelineFileError: If key ``options`` is not a list of values, or key ``expand`` not a boolean.
2122 """
2123 if declaration is None:
2124 return cls(name, line)
2125 elif isinstance(declaration, CommentedSeq):
2126 ex = PipelineFileError(f"Variable '{name}' is neither a value nor a mapping.", path, line)
2127 ex.add_note(f"Got type '{getFullyQualifiedName(declaration)}'.")
2128 raise ex
2129 elif not isinstance(declaration, CommentedMap):
2130 return cls(name, line, Base._ToText(declaration))
2132 for key in declaration:
2133 if key not in cls._KEYS:
2134 ex = PipelineFileError(f"Variable '{name}' has unknown key '{key}'.", path, Base._KeyLine(declaration, key))
2135 ex.add_note(f"Allowed keys: {', '.join(cls._KEYS)}.")
2136 raise ex
2138 options = None
2139 if (optionList := declaration.get("options", None)) is not None:
2140 isList = isinstance(optionList, CommentedSeq)
2141 if not isList or any(isinstance(item, (CommentedMap, CommentedSeq)) for item in optionList):
2142 ex = PipelineFileError(
2143 f"Key 'options' of variable '{name}' is not a list of values.", path, Base._KeyLine(declaration, "options")
2144 )
2145 ex.add_note(f"Got '{Base._ToPython(optionList)}'.")
2146 raise ex
2148 options = [Base._ToText(option) for option in optionList]
2150 expand = Base._ToPython(declaration.get("expand", None))
2151 if expand is not None and not isinstance(expand, bool):
2152 ex = PipelineFileError(
2153 f"Key 'expand' of variable '{name}' is not a boolean.", path, Base._KeyLine(declaration, "expand")
2154 )
2155 ex.add_note(f"Got '{expand}'.")
2156 raise ex
2158 value = declaration.get("value", None)
2159 description = declaration.get("description", None)
2161 return cls(
2162 name, line,
2163 value=None if value is None else Base._ToText(value),
2164 description=None if description is None else str(description),
2165 options=options,
2166 expand=expand
2167 )
2169 @classmethod
2170 def _FromYAMLMapping(cls, mapping: Any, owner: Nullable[str], path: Path, line: int) -> list[Self]:
2171 """
2172 Read the variables of a ``variables`` key.
2174 :param mapping: The key's value.
2175 :param owner: The element declaring the variables, for a message, as ``job 'build'``, or ``None``
2176 for the top-level ``variables``.
2177 :param path: Path to the configuration file.
2178 :param line: Line the ``variables`` key is written at, starting at 1.
2179 :returns: The variables, in file order.
2180 :raises PipelineFileError: If the value is not a mapping.
2181 :raises PipelineFileError: If a variable is malformed.
2182 """
2183 if not isinstance(mapping, CommentedMap):
2184 ex = PipelineFileError(
2185 "Key 'variables' is not a mapping." if owner is None else f"Key 'variables' of {owner} is not a mapping.",
2186 path, line
2187 )
2188 ex.add_note(f"Got type '{getFullyQualifiedName(mapping)}'.")
2189 raise ex
2191 return [cls._FromYAML(str(name), value, path, Base._KeyLine(mapping, name)) for name, value in mapping.items()]
2194@export
2195class Need(Base[Job]):
2196 """
2197 An entry of a job's ``needs``.
2199 An entry names a job of the same pipeline, as a string or as ``job`` of a mapping; a job of another pipeline of
2200 the parent-child hierarchy, with ``pipeline`` and ``job``; a job of another project's pipeline, with ``project``,
2201 ``job`` and ``ref``; or, with ``pipeline`` only, another project's pipeline, whose status the job mirrors.
2202 :attr:`Kind` says which.
2203 """
2205 _PARENT_TYPE: ClassVar[ParentTypes] = Job #: A need is an entry of a job's ``needs``.
2207 _kind: NeedKind #: What the entry refers to.
2208 _job: Nullable[str] #: Name of the needed job.
2209 _artifacts: bool #: ``True``, if the needed job's artifacts are downloaded.
2210 _optional: bool #: ``True``, if the needed job may be missing in the pipeline.
2211 _pipeline: Nullable[str] #: The other pipeline: its ID, or the project whose pipeline's status is mirrored.
2212 _project: Nullable[str] #: Path of the other project.
2213 _ref: Nullable[str] #: Reference of the other project, whose latest pipeline's artifacts are downloaded.
2214 _parallel: Nullable[Parallel] #: The matrix instances of the needed job, which are needed.
2216 _KEYS: ClassVar[tuple[str, ...]] = (
2217 "job", "artifacts", "optional", "pipeline", "project", "ref", "parallel"
2218 ) #: Keys of an entry as mapping.
2220 def __init__(
2221 self,
2222 job: Nullable[str],
2223 line: int,
2224 kind: NeedKind = NeedKind.Job,
2225 artifacts: bool = True,
2226 optional: bool = False,
2227 pipeline: Nullable[str] = None,
2228 project: Nullable[str] = None,
2229 ref: Nullable[str] = None,
2230 parallel: Nullable[Parallel] = None,
2231 *,
2232 parent: Nullable[Job] = None
2233 ) -> None:
2234 """
2235 Initializes an entry of a job's ``needs``.
2237 :param job: Name of the needed job, or ``None`` for a need of kind :attr:`NeedKind.PipelineStatus`.
2238 :param line: Line the entry is written at, starting at 1.
2239 :param kind: Optional, what the entry refers to. Default: a job of the same pipeline.
2240 :param artifacts: Optional, ``True``, if the needed job's artifacts are downloaded. Default: ``True``.
2241 :param optional: Optional, ``True``, if the needed job may be missing in the pipeline. Default: ``False``.
2242 :param pipeline: Optional, the other pipeline: its ID, or the project whose pipeline's status is mirrored.
2243 Default: ``None``.
2244 :param project: Optional, path of the other project. Default: ``None``.
2245 :param ref: Optional, reference of the other project. Default: ``None``.
2246 :param parallel: Optional, the matrix instances of the needed job, which are needed, which is attached to
2247 the need. Default: ``None``, for every instance.
2248 :param parent: Optional, reference to the job whose ``needs`` holds the entry. Default: ``None``.
2249 :raises ValueError: If parameter 'kind' is ``None``.
2250 :raises TypeError: If parameter 'kind' is not of type :class:`NeedKind`.
2251 :raises TypeError: If parameter 'job', 'pipeline', 'project' or 'ref' is not of type :class:`str`.
2252 :raises TypeError: If parameter 'artifacts' or 'optional' is not of type :class:`bool`.
2253 :raises ValueError: If parameter 'job' is ``None``, but the kind names a job.
2254 :raises ValueError: If parameter 'pipeline' is ``None``, but the kind refers to another pipeline.
2255 :raises ValueError: If parameter 'project' or 'ref' is ``None``, but the kind refers to another project's job.
2256 :raises TypeError: If parameter 'parallel' is not of type :class:`Parallel`.
2257 """
2258 super().__init__(line, parent=parent)
2260 if kind is None:
2261 raise ValueError("Parameter 'kind' is None.")
2262 elif not isinstance(kind, NeedKind):
2263 ex = TypeError("Parameter 'kind' is not of type 'NeedKind'.")
2264 ex.add_note(f"Got type '{getFullyQualifiedName(kind)}'.")
2265 raise ex
2267 for parameterName, text in (
2268 ("job", job),
2269 ("pipeline", pipeline),
2270 ("project", project),
2271 ("ref", ref)
2272 ):
2273 if text is not None and not isinstance(text, str):
2274 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
2275 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
2276 raise ex
2278 for parameterName, flag in (
2279 ("artifacts", artifacts),
2280 ("optional", optional)
2281 ):
2282 if not isinstance(flag, bool):
2283 ex = TypeError(f"Parameter '{parameterName}' is not of type 'bool'.")
2284 ex.add_note(f"Got type '{getFullyQualifiedName(flag)}'.")
2285 raise ex
2287 for parameterName, text, kinds in (
2288 ("job", job, (NeedKind.Job, NeedKind.PipelineJob, NeedKind.ProjectJob)),
2289 ("pipeline", pipeline, (NeedKind.PipelineJob, NeedKind.PipelineStatus)),
2290 ("project", project, (NeedKind.ProjectJob, )),
2291 ("ref", ref, (NeedKind.ProjectJob, ))
2292 ):
2293 if text is None and kind in kinds:
2294 ex = ValueError(f"Parameter '{parameterName}' is None.")
2295 ex.add_note(f"A need of kind '{kind.value}' needs it.")
2296 raise ex
2298 self._kind = kind
2299 self._job = job
2300 self._artifacts = artifacts
2301 self._optional = optional
2302 self._pipeline = pipeline
2303 self._project = project
2304 self._ref = ref
2305 self._parallel = None
2307 if parallel is not None:
2308 if not isinstance(parallel, Parallel):
2309 ex = TypeError("Parameter 'parallel' is not of type 'Parallel'.")
2310 ex.add_note(f"Got type '{getFullyQualifiedName(parallel)}'.")
2311 raise ex
2313 self._parallel = parallel
2314 parallel.Parent = self
2316 if parent is not None:
2317 if parent._needs is None: 2317 ↛ 2320line 2317 didn't jump to line 2320 because the condition on line 2317 was always true
2318 parent._needs = []
2320 parent._needs.append(self)
2322 @Base.Parent.setter
2323 def Parent(self, value: Job) -> None:
2324 Base.Parent.fset(self, value)
2326 if self._parallel is not None:
2327 self._parallel.Parent = self
2329 @readonly
2330 def Parallel(self) -> Nullable[Parallel]:
2331 """
2332 Read-only property to access the matrix instances of the needed job, which are needed (:attr:`_parallel`).
2334 :returns: The instances' matrix, or ``None`` if every instance is needed.
2335 """
2336 return self._parallel
2338 @readonly
2339 def Kind(self) -> NeedKind:
2340 """
2341 Read-only property to access what the entry refers to (:attr:`_kind`).
2343 :returns: The kind of the need.
2344 """
2345 return self._kind
2347 @readonly
2348 def JobName(self) -> Nullable[str]:
2349 """
2350 Read-only property to access the name of the needed job (:attr:`_job`).
2352 :returns: Name of the job, or ``None`` for a need mirroring another pipeline's status.
2353 """
2354 return self._job
2356 @readonly
2357 def NeededJob(self) -> Nullable[Job]:
2358 """
2359 Read-only property to return the needed job of the same file.
2361 :returns: The job, or ``None`` for a need of another pipeline, or of a job the file doesn't have.
2362 """
2363 if self._kind is not NeedKind.Job or self._pipelineFile is None:
2364 return None
2366 return self._pipelineFile._jobs.get(self._job, None)
2368 @readonly
2369 def Artifacts(self) -> bool:
2370 """
2371 Read-only property to access whether the needed job's artifacts are downloaded (:attr:`_artifacts`).
2373 :returns: ``True``, if the artifacts are downloaded.
2374 """
2375 return self._artifacts
2377 @readonly
2378 def IsOptional(self) -> bool:
2379 """
2380 Read-only property to access whether the needed job may be missing in the pipeline (:attr:`_optional`).
2382 :returns: ``True``, if the need is optional.
2383 """
2384 return self._optional
2386 @readonly
2387 def Pipeline(self) -> Nullable[str]:
2388 """
2389 Read-only property to access the other pipeline (:attr:`_pipeline`).
2391 :returns: The pipeline's ID, as ``$PARENT_PIPELINE_ID``, or the project whose pipeline's status is mirrored, or
2392 ``None`` for a need of the same pipeline or of another project's job.
2393 """
2394 return self._pipeline
2396 @readonly
2397 def Project(self) -> Nullable[str]:
2398 """
2399 Read-only property to access the path of the other project (:attr:`_project`).
2401 :returns: The project's path, or ``None`` for a need of a job of the same project.
2402 """
2403 return self._project
2405 @readonly
2406 def Ref(self) -> Nullable[str]:
2407 """
2408 Read-only property to access the reference of the other project (:attr:`_ref`).
2410 :returns: The reference, or ``None`` for a need of a job of the same project.
2411 """
2412 return self._ref
2414 def __str__(self) -> str:
2415 """
2416 Return the needed job's name, or the pipeline whose status is mirrored.
2418 :returns: The job's name, prefixed by the project for another project's job.
2419 """
2420 if self._kind is NeedKind.PipelineStatus:
2421 return f"pipeline {self._pipeline}"
2422 elif self._kind is NeedKind.ProjectJob:
2423 return f"{self._project}: {self._job}"
2425 return self._job
2427 @classmethod
2428 def _FromYAML(cls, entry: Any, jobName: str, path: Path, line: int) -> Self:
2429 """
2430 Read an entry of a job's ``needs``: a job's name, or a mapping of the keys in :attr:`_KEYS`.
2432 :param entry: The entry.
2433 :param jobName: Name of the job, whose ``needs`` holds the entry, for a message.
2434 :param path: Path to the configuration file.
2435 :param line: Line the entry is written at, starting at 1.
2436 :returns: The need.
2437 :raises PipelineFileError: If the entry is neither a job's name nor a mapping.
2438 :raises PipelineFileError: If the mapping has a key GitLab doesn't know for a need. |br|
2439 The note lists the allowed keys.
2440 :raises PipelineFileError: If key ``artifacts`` or ``optional`` is not a boolean.
2441 :raises PipelineFileError: If the mapping lacks a key its kind needs: ``job``, or ``ref`` with ``project``.
2442 """
2443 if isinstance(entry, CommentedSeq) or entry is None:
2444 ex = PipelineFileError(f"Need of job '{jobName}' is neither a job's name nor a mapping.", path, line)
2445 ex.add_note(f"Got type '{getFullyQualifiedName(entry)}'.")
2446 raise ex
2447 elif not isinstance(entry, CommentedMap):
2448 return cls(Base._ToText(entry), line)
2450 for key in entry:
2451 if key not in cls._KEYS:
2452 ex = PipelineFileError(f"Need of job '{jobName}' has unknown key '{key}'.", path, Base._KeyLine(entry, key))
2453 ex.add_note(f"Allowed keys: {', '.join(cls._KEYS)}.")
2454 raise ex
2456 flags = {}
2457 for key, default in (
2458 ("artifacts", True),
2459 ("optional", False)
2460 ):
2461 flags[key] = Base._ToPython(entry.get(key, default))
2462 if not isinstance(flags[key], bool):
2463 ex = PipelineFileError(
2464 f"Key '{key}' of a need of job '{jobName}' is not a boolean.", path, Base._KeyLine(entry, key)
2465 )
2466 ex.add_note(f"Got '{flags[key]}'.")
2467 raise ex
2469 texts = {}
2470 for key in ("job", "pipeline", "project", "ref"):
2471 texts[key] = None if entry.get(key, None) is None else Base._ToText(entry[key])
2473 if texts["project"] is not None:
2474 kind = NeedKind.ProjectJob
2475 elif texts["pipeline"] is not None:
2476 kind = NeedKind.PipelineStatus if texts["job"] is None else NeedKind.PipelineJob
2477 else:
2478 kind = NeedKind.Job
2480 if texts["job"] is None and kind is not NeedKind.PipelineStatus:
2481 raise PipelineFileError(f"Need of job '{jobName}' has no 'job' key.", path, line)
2482 elif texts["ref"] is None and kind is NeedKind.ProjectJob:
2483 raise PipelineFileError(f"Need of job '{jobName}' with key 'project' has no 'ref' key.", path, line)
2485 parallel = None
2486 if "parallel" in entry:
2487 keyLine = Base._KeyLine(entry, "parallel")
2488 parallel = Parallel._FromYAML(entry["parallel"], f"a need of job '{jobName}'", path, keyLine)
2489 if parallel._count is not None:
2490 raise PipelineFileError(f"Key 'parallel' of a need of job '{jobName}' has no 'matrix' key.", path, line)
2492 return cls(
2493 texts["job"], line, kind, flags["artifacts"], flags["optional"],
2494 texts["pipeline"], texts["project"], texts["ref"], parallel
2495 )
2497 @classmethod
2498 def _FromYAMLEntries(cls, value: Any, jobName: str, path: Path, line: int) -> list[Self]:
2499 """
2500 Read a job's ``needs``: a list of entries, or a single mapping, as ``pipeline: other/project``.
2502 :param value: The key's value.
2503 :param jobName: Name of the job, for a message.
2504 :param path: Path to the configuration file.
2505 :param line: Line the ``needs`` key is written at, starting at 1.
2506 :returns: The needs, in file order.
2507 :raises PipelineFileError: If the value is neither a list nor a mapping.
2508 :raises PipelineFileError: If an entry is malformed.
2509 """
2510 if isinstance(value, CommentedMap):
2511 return [cls._FromYAML(value, jobName, path, line)]
2512 elif not isinstance(value, CommentedSeq):
2513 ex = PipelineFileError(f"Key 'needs' of job '{jobName}' is neither a list nor a mapping.", path, line)
2514 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
2515 raise ex
2517 return [cls._FromYAML(entry, jobName, path, Base._ItemLine(value, index)) for index, entry in enumerate(value)]
2520@export
2521class Workflow(Base[PipelineFile]):
2522 """The ``workflow`` key of a configuration file: the pipeline's name and the rules deciding whether it is created."""
2524 _PARENT_TYPE: ClassVar[ParentTypes] = PipelineFile #: A workflow belongs to a configuration file.
2526 _name: Nullable[str] #: Name of the pipeline.
2527 _rules: list[Rule] #: Rules deciding whether a pipeline is created.
2529 def __init__(
2530 self,
2531 line: int,
2532 name: Nullable[str] = None,
2533 rules: Nullable[Iterable[Rule]] = None,
2534 *,
2535 parent: Nullable[PipelineFile] = None
2536 ) -> None:
2537 """
2538 Initializes the ``workflow`` key.
2540 :param line: Line the ``workflow`` key is written at, starting at 1.
2541 :param name: Optional, name of the pipeline. Default: ``None``.
2542 :param rules: Optional, rules deciding whether a pipeline is created, which are attached to the workflow.
2543 Default: ``None``.
2544 :param parent: Optional, reference to the configuration file. Default: ``None``.
2545 :raises TypeError: If parameter 'name' is not of type :class:`str`.
2546 :raises TypeError: If an element of parameter 'rules' is not of type :class:`Rule`.
2547 """
2548 super().__init__(line, parent=parent)
2550 if name is not None and not isinstance(name, str):
2551 ex = TypeError("Parameter 'name' is not of type 'str'.")
2552 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
2553 raise ex
2555 self._name = name
2556 self._rules = []
2558 if rules is not None:
2559 for rule in rules:
2560 if not isinstance(rule, Rule):
2561 ex = TypeError("An element of parameter 'rules' is not of type 'Rule'.")
2562 ex.add_note(f"Got type '{getFullyQualifiedName(rule)}'.")
2563 raise ex
2565 self._rules.append(rule)
2566 rule.Parent = self
2568 if parent is not None:
2569 parent._workflow = self
2571 @Base.Parent.setter
2572 def Parent(self, value: PipelineFile) -> None:
2573 Base.Parent.fset(self, value)
2575 for rule in self._rules:
2576 rule.Parent = self
2578 @readonly
2579 def Name(self) -> Nullable[str]:
2580 """
2581 Read-only property to access the pipeline's name (:attr:`_name`).
2583 :returns: The ``name`` key, as written, or ``None`` if the workflow gives none.
2584 """
2585 return self._name
2587 @readonly
2588 def Rules(self) -> list[Rule]:
2589 """
2590 Read-only property to access the rules deciding whether a pipeline is created (:attr:`_rules`).
2592 :returns: The rules, in the order GitLab evaluates them.
2593 """
2594 return self._rules
2596 @classmethod
2597 def _FromYAML(cls, mapping: Any, path: Path, line: int) -> Self:
2598 """
2599 Read the ``workflow`` key.
2601 :param mapping: The key's value.
2602 :param path: Path to the configuration file.
2603 :param line: Line the ``workflow`` key is written at, starting at 1.
2604 :returns: The workflow.
2605 :raises PipelineFileError: If the value is not a mapping.
2606 :raises PipelineFileError: If a rule is malformed.
2607 """
2608 if not isinstance(mapping, CommentedMap):
2609 ex = PipelineFileError("Key 'workflow' is not a mapping.", path, line)
2610 ex.add_note(f"Got type '{getFullyQualifiedName(mapping)}'.")
2611 raise ex
2613 rules = None
2614 if "rules" in mapping:
2615 rules = Rule._FromYAMLEntries(mapping["rules"], "the workflow", path, Base._KeyLine(mapping, "rules"))
2617 name = mapping.get("name", None)
2618 return cls(line, None if name is None else Base._ToText(name), rules)
2621@export
2622class Trigger(Base[Job]):
2623 """
2624 A job's ``trigger``: the child pipeline, or the other project's pipeline, the job starts.
2626 A child pipeline runs the configuration of the files in :attr:`Includes`; another project's pipeline runs that
2627 project's configuration on :attr:`Branch`.
2628 """
2630 _PARENT_TYPE: ClassVar[ParentTypes] = Job #: A trigger belongs to a job.
2632 _project: Nullable[str] #: Path of the other project, whose pipeline is started.
2633 _branch: Nullable[str] #: Branch of the other project.
2634 _strategy: Nullable[TriggerStrategy] #: How the job's status follows the started pipeline.
2635 _includes: list[Include] #: Files of the child pipeline's configuration.
2637 def __init__(
2638 self,
2639 line: int,
2640 project: Nullable[str] = None,
2641 branch: Nullable[str] = None,
2642 strategy: Nullable[TriggerStrategy] = None,
2643 includes: Nullable[Iterable[Include]] = None,
2644 *,
2645 parent: Nullable[Job] = None
2646 ) -> None:
2647 """
2648 Initializes a job's ``trigger``.
2650 :param line: Line the ``trigger`` key is written at, starting at 1.
2651 :param project: Optional, path of the other project, whose pipeline is started. Default: ``None``.
2652 :param branch: Optional, branch of the other project. Default: ``None``, for its default branch.
2653 :param strategy: Optional, how the job's status follows the started pipeline. Default: ``None``, for a job
2654 succeeding as soon as the pipeline is created.
2655 :param includes: Optional, files of the child pipeline's configuration, which are attached to the trigger.
2656 Default: ``None``.
2657 :param parent: Optional, reference to the job. Default: ``None``.
2658 :raises TypeError: If parameter 'project' or 'branch' is not of type :class:`str`.
2659 :raises TypeError: If parameter 'strategy' is not of type :class:`TriggerStrategy`.
2660 :raises TypeError: If an element of parameter 'includes' is not of type :class:`Include`.
2661 """
2662 super().__init__(line, parent=parent)
2664 for parameterName, text in (
2665 ("project", project),
2666 ("branch", branch)
2667 ):
2668 if text is not None and not isinstance(text, str):
2669 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
2670 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
2671 raise ex
2673 if strategy is not None and not isinstance(strategy, TriggerStrategy):
2674 ex = TypeError("Parameter 'strategy' is not of type 'TriggerStrategy'.")
2675 ex.add_note(f"Got type '{getFullyQualifiedName(strategy)}'.")
2676 raise ex
2678 self._project = project
2679 self._branch = branch
2680 self._strategy = strategy
2681 self._includes = []
2683 if includes is not None:
2684 for include in includes:
2685 if not isinstance(include, Include):
2686 ex = TypeError("An element of parameter 'includes' is not of type 'Include'.")
2687 ex.add_note(f"Got type '{getFullyQualifiedName(include)}'.")
2688 raise ex
2690 self._includes.append(include)
2691 include.Parent = self
2693 if parent is not None:
2694 parent._trigger = self
2696 @Base.Parent.setter
2697 def Parent(self, value: Job) -> None:
2698 Base.Parent.fset(self, value)
2700 for include in self._includes:
2701 include.Parent = self
2703 @readonly
2704 def Project(self) -> Nullable[str]:
2705 """
2706 Read-only property to access the path of the other project, whose pipeline is started (:attr:`_project`).
2708 :returns: The project's path, or ``None`` for a child pipeline.
2709 """
2710 return self._project
2712 @readonly
2713 def Branch(self) -> Nullable[str]:
2714 """
2715 Read-only property to access the branch of the other project (:attr:`_branch`).
2717 :returns: The branch, or ``None`` for the project's default branch.
2718 """
2719 return self._branch
2721 @readonly
2722 def Strategy(self) -> Nullable[TriggerStrategy]:
2723 """
2724 Read-only property to access how the job's status follows the started pipeline (:attr:`_strategy`).
2726 :returns: The strategy, or ``None`` if the trigger doesn't say.
2727 """
2728 return self._strategy
2730 @readonly
2731 def Includes(self) -> list[Include]:
2732 """
2733 Read-only property to access the files of the child pipeline's configuration (:attr:`_includes`).
2735 :returns: The files, in file order; empty for another project's pipeline.
2736 """
2737 return self._includes
2739 @readonly
2740 def IsChildPipeline(self) -> bool:
2741 """
2742 Read-only property to return whether the job starts a child pipeline.
2744 :returns: ``True``, if the trigger includes files; ``False`` for another project's pipeline.
2745 """
2746 return len(self._includes) > 0
2748 @classmethod
2749 def _FromYAML(cls, value: Any, jobName: str, path: Path, line: int) -> Self:
2750 """
2751 Read a job's ``trigger``: another project's path, or a mapping with ``include`` or ``project``.
2753 :param value: The key's value.
2754 :param jobName: Name of the job, for a message.
2755 :param path: Path to the configuration file.
2756 :param line: Line the ``trigger`` key is written at, starting at 1.
2757 :returns: The trigger.
2758 :raises PipelineFileError: If the value is neither a project's path nor a mapping with ``include`` or
2759 ``project``.
2760 :raises PipelineFileError: If key ``strategy`` is not a value GitLab knows. |br|
2761 The note lists the allowed values.
2762 :raises PipelineFileError: If an include is malformed.
2763 """
2764 if isinstance(value, (str, ScalarString)):
2765 return cls(line, project=Base._ToText(value))
2766 elif not isinstance(value, CommentedMap) or ("include" not in value and "project" not in value):
2767 ex = PipelineFileError(
2768 f"Key 'trigger' of job '{jobName}' is neither a project nor a mapping with 'include' or 'project'.", path, line
2769 )
2770 ex.add_note(f"Got '{Base._ToPython(value)}'.")
2771 raise ex
2773 strategy = None
2774 if (strategyValue := value.get("strategy", None)) is not None:
2775 try:
2776 strategy = TriggerStrategy.Parse(Base._ToText(strategyValue))
2777 except ValueError as cause:
2778 ex = PipelineFileError(
2779 f"Key 'strategy' of the trigger of job '{jobName}' is not a known value.",
2780 path, Base._KeyLine(value, "strategy")
2781 )
2782 ex.add_note(f"Got '{strategyValue}'.")
2783 ex.add_note(f"Allowed values: {', '.join(member.value for member in TriggerStrategy)}.")
2784 raise ex from cause
2786 includes = None
2787 if "include" in value:
2788 includes = Include._FromYAMLEntries(value["include"], path, Base._KeyLine(value, "include"))
2790 project = value.get("project", None)
2791 branch = value.get("branch", None)
2792 return cls(
2793 line,
2794 project=None if project is None else Base._ToText(project),
2795 branch=None if branch is None else Base._ToText(branch),
2796 strategy=strategy,
2797 includes=includes
2798 )
2801@export
2802class Include(Base[Union[PipelineFile, Trigger]]):
2803 """
2804 An entry of ``include``, which adds the configuration of other files.
2806 :attr:`Kind` says where the files come from, :attr:`Location` names them: a path of the same repository, a URL, a
2807 template's name, a component's reference, the path of another project, whose files are in :attr:`Files`, or the
2808 artifact of the job :attr:`ArtifactJob`.
2809 """
2811 _PARENT_TYPE: ClassVar[ParentTypes] = (PipelineFile, Trigger) #: Included by a configuration file or a trigger.
2813 _kind: IncludeKind #: Where the included files come from.
2814 _location: str #: Path, URL, template's name, component's reference, or project's path.
2815 _ref: Nullable[str] #: Reference of the project the files are read from.
2816 _files: tuple[str, ...] #: Paths of the files of another project.
2817 _job: Nullable[str] #: Name of the job, whose artifact is included.
2818 _inputs: dict[str, ValueT] #: Inputs passed to the included files, by name.
2819 _rules: list[Rule] #: Rules deciding whether the files are included.
2821 _KINDS: ClassVar[tuple[str, ...]] = tuple(member.value for member in IncludeKind) #: Keys naming an include's kind.
2823 def __init__(
2824 self,
2825 kind: IncludeKind,
2826 location: str,
2827 line: int,
2828 files: Nullable[Iterable[str]] = None,
2829 ref: Nullable[str] = None,
2830 inputs: Nullable[Mapping[str, ValueT]] = None,
2831 rules: Nullable[Iterable[Rule]] = None,
2832 job: Nullable[str] = None,
2833 *,
2834 parent: Nullable[Union[PipelineFile, Trigger]] = None
2835 ) -> None:
2836 """
2837 Initializes an entry of ``include``.
2839 :param kind: Where the included files come from.
2840 :param location: Path, URL, template's name, component's reference, or - for another project - its path.
2841 :param line: Line the entry is written at, starting at 1.
2842 :param files: Optional, paths of the files of another project. Default: ``None``.
2843 :param ref: Optional, reference of the project the files are read from. Default: ``None``.
2844 :param inputs: Optional, inputs passed to the included files, by name. Default: ``None``.
2845 :param rules: Optional, rules deciding whether the files are included, which are attached to the entry.
2846 Default: ``None``.
2847 :param job: Optional, name of the job, whose artifact is included. Default: ``None``.
2848 :param parent: Optional, reference to the configuration file or the trigger. Default: ``None``.
2849 :raises ValueError: If parameter 'kind' or 'location' is ``None``.
2850 :raises TypeError: If parameter 'kind' is not of type :class:`IncludeKind`.
2851 :raises TypeError: If parameter 'location', 'ref' or 'job' is not of type :class:`str`.
2852 :raises ValueError: If parameter 'location' is empty.
2853 :raises TypeError: If an element of parameter 'files' is not of type :class:`str`.
2854 :raises TypeError: If an element of parameter 'rules' is not of type :class:`Rule`.
2855 """
2856 super().__init__(line, parent=parent)
2858 if kind is None:
2859 raise ValueError("Parameter 'kind' is None.")
2860 elif not isinstance(kind, IncludeKind):
2861 ex = TypeError("Parameter 'kind' is not of type 'IncludeKind'.")
2862 ex.add_note(f"Got type '{getFullyQualifiedName(kind)}'.")
2863 raise ex
2865 if location is None:
2866 raise ValueError("Parameter 'location' is None.")
2867 elif not isinstance(location, str):
2868 ex = TypeError("Parameter 'location' is not of type 'str'.")
2869 ex.add_note(f"Got type '{getFullyQualifiedName(location)}'.")
2870 raise ex
2871 elif location == "":
2872 raise ValueError("Parameter 'location' is empty.")
2874 for parameterName, text in (
2875 ("ref", ref),
2876 ("job", job)
2877 ):
2878 if text is not None and not isinstance(text, str):
2879 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
2880 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
2881 raise ex
2883 self._files = () if files is None else tuple(files)
2884 for file in self._files:
2885 if not isinstance(file, str):
2886 ex = TypeError("An element of parameter 'files' is not of type 'str'.")
2887 ex.add_note(f"Got type '{getFullyQualifiedName(file)}'.")
2888 raise ex
2890 self._kind = kind
2891 self._location = location
2892 self._ref = ref
2893 self._job = job
2894 self._inputs = {} if inputs is None else dict(inputs)
2895 self._rules = []
2897 if rules is not None:
2898 for rule in rules:
2899 if not isinstance(rule, Rule):
2900 ex = TypeError("An element of parameter 'rules' is not of type 'Rule'.")
2901 ex.add_note(f"Got type '{getFullyQualifiedName(rule)}'.")
2902 raise ex
2904 self._rules.append(rule)
2905 rule.Parent = self
2907 if parent is not None:
2908 parent._includes.append(self)
2910 @Base.Parent.setter
2911 def Parent(self, value: PipelineFile) -> None:
2912 Base.Parent.fset(self, value)
2914 for rule in self._rules:
2915 rule.Parent = self
2917 @readonly
2918 def Kind(self) -> IncludeKind:
2919 """
2920 Read-only property to access where the included files come from (:attr:`_kind`).
2922 :returns: The kind of the include.
2923 """
2924 return self._kind
2926 @readonly
2927 def Location(self) -> str:
2928 """
2929 Read-only property to access what the entry includes (:attr:`_location`).
2931 :returns: A path, a URL, a template's name, a component's reference, or another project's path, as written.
2932 """
2933 return self._location
2935 @readonly
2936 def Ref(self) -> Nullable[str]:
2937 """
2938 Read-only property to access the reference of the project the files are read from (:attr:`_ref`).
2940 :returns: The ``ref`` key, or ``None`` - the project's default branch then.
2941 """
2942 return self._ref
2944 @readonly
2945 def Files(self) -> tuple[str, ...]:
2946 """
2947 Read-only property to access the paths of the files of another project (:attr:`_files`).
2949 :returns: The paths of ``file``, in file order.
2950 """
2951 return self._files
2953 @readonly
2954 def ArtifactJob(self) -> Nullable[str]:
2955 """
2956 Read-only property to access the name of the job, whose artifact is included (:attr:`_job`).
2958 :returns: The ``job`` key of an artifact's include, or ``None``.
2959 """
2960 return self._job
2962 @readonly
2963 def Inputs(self) -> dict[str, ValueT]:
2964 """
2965 Read-only property to access the inputs passed to the included files (:attr:`_inputs`).
2967 :returns: The inputs' values, by name, with the types they are written with.
2968 """
2969 return self._inputs
2971 @readonly
2972 def Rules(self) -> list[Rule]:
2973 """
2974 Read-only property to access the rules deciding whether the files are included (:attr:`_rules`).
2976 :returns: The rules, in the order GitLab evaluates them.
2977 """
2978 return self._rules
2980 def __str__(self) -> str:
2981 """
2982 Return the include's kind and location.
2984 :returns: The kind and the location, as ``local: /ci/build.yml``.
2985 """
2986 return f"{self._kind.value}: {self._location}"
2988 @classmethod
2989 def _FromYAML(cls, entry: Any, path: Path, line: int) -> Self:
2990 """
2991 Read an entry of ``include``: a path or a URL, or a mapping with one key of :attr:`_KINDS`.
2993 :param entry: The entry.
2994 :param path: Path to the configuration file.
2995 :param line: Line the entry is written at, starting at 1.
2996 :returns: The include.
2997 :raises PipelineFileError: If the entry is neither a string nor a mapping.
2998 :raises PipelineFileError: If the mapping has none, or more than one, of the keys of :attr:`_KINDS`.
2999 :raises PipelineFileError: If key ``file`` of another project's include is missing, or not a path or a list.
3000 :raises PipelineFileError: If key ``inputs`` is not a mapping.
3001 :raises PipelineFileError: If a rule is malformed.
3002 """
3003 if isinstance(entry, CommentedSeq) or entry is None:
3004 ex = PipelineFileError("Include is neither a path nor a mapping.", path, line)
3005 ex.add_note(f"Got type '{getFullyQualifiedName(entry)}'.")
3006 raise ex
3007 elif not isinstance(entry, CommentedMap):
3008 location = Base._ToText(entry)
3009 kind = IncludeKind.Remote if location.startswith(("https://", "http://")) else IncludeKind.Local
3010 return cls(kind, location, line)
3012 kinds = [key for key in cls._KINDS if key in entry]
3013 if len(kinds) != 1:
3014 count = "none" if len(kinds) == 0 else "more than one"
3015 ex = PipelineFileError(f"Include has {count} of its kinds' keys.", path, line)
3016 ex.add_note(f"Kinds: {', '.join(cls._KINDS)}.")
3017 raise ex
3019 kind = IncludeKind(kinds[0])
3020 files = None
3021 if kind is IncludeKind.Project:
3022 if (fileValue := entry.get("file", None)) is None:
3023 raise PipelineFileError("Include of another project has no 'file' key.", path, line)
3024 elif isinstance(fileValue, CommentedMap):
3025 ex = PipelineFileError("Key 'file' of an include is neither a path nor a list.", path, line)
3026 ex.add_note(f"Got type '{getFullyQualifiedName(fileValue)}'.")
3027 raise ex
3029 fileList = fileValue if isinstance(fileValue, CommentedSeq) else [fileValue]
3030 files = [Base._ToText(file) for file in fileList]
3032 inputs = entry.get("inputs", None)
3033 if inputs is not None and not isinstance(inputs, CommentedMap):
3034 ex = PipelineFileError("Key 'inputs' of an include is not a mapping.", path, Base._KeyLine(entry, "inputs"))
3035 ex.add_note(f"Got type '{getFullyQualifiedName(inputs)}'.")
3036 raise ex
3038 rules = None
3039 if "rules" in entry:
3040 rules = Rule._FromYAMLEntries(entry["rules"], "an include", path, Base._KeyLine(entry, "rules"))
3042 ref = entry.get("ref", None)
3043 job = entry.get("job", None)
3044 return cls(
3045 kind, Base._ToText(entry[kinds[0]]), line,
3046 files=files,
3047 ref=None if ref is None else Base._ToText(ref),
3048 inputs=None if inputs is None else Base._ToPython(inputs),
3049 rules=rules,
3050 job=None if job is None else Base._ToText(job)
3051 )
3053 @classmethod
3054 def _FromYAMLEntries(cls, value: Any, path: Path, line: int) -> list[Self]:
3055 """
3056 Read the ``include`` key: an entry, or a list of entries.
3058 :param value: The key's value.
3059 :param path: Path to the configuration file.
3060 :param line: Line the ``include`` key is written at, starting at 1.
3061 :returns: The includes, in file order.
3062 :raises PipelineFileError: If an entry is malformed.
3063 """
3064 if not isinstance(value, CommentedSeq):
3065 return [cls._FromYAML(value, path, line)]
3067 return [cls._FromYAML(entry, path, Base._ItemLine(value, index)) for index, entry in enumerate(value)]
3070@export
3071class Rule(Base[Union[Job, Workflow, Include]]):
3072 """
3073 A rule of ``rules``, deciding whether a job is added to a pipeline, a pipeline is created, or a file is included.
3075 GitLab evaluates the rules in order; the first one matching decides. A rule matches if its :attr:`Condition`, its
3076 :attr:`Changes` and its :attr:`Exists` match, as far as it has them.
3077 """
3079 _PARENT_TYPE: ClassVar[ParentTypes] = (Job, Workflow, Include) #: A rule belongs to a job, a workflow or an include.
3081 _condition: Nullable[str] #: Expression of the rule's ``if``.
3082 _changes: tuple[str, ...] #: Paths of the rule's ``changes``.
3083 _compareTo: Nullable[str] #: Reference ``changes`` is compared to.
3084 _exists: tuple[str, ...] #: Paths of the rule's ``exists``.
3085 _when: Nullable[RuleOutcome] #: When the job runs, if the rule matches.
3086 _allowFailure: Nullable[bool] #: ``True``, if the pipeline continues when the job fails, if the rule matches.
3087 _variables: dict[str, str] #: Variables set, if the rule matches, by name.
3089 def __init__(
3090 self,
3091 line: int,
3092 condition: Nullable[str] = None,
3093 changes: Nullable[Iterable[str]] = None,
3094 compareTo: Nullable[str] = None,
3095 exists: Nullable[Iterable[str]] = None,
3096 when: Nullable[RuleOutcome] = None,
3097 allowFailure: Nullable[bool] = None,
3098 variables: Nullable[Mapping[str, str]] = None,
3099 *,
3100 parent: Nullable[Union[Job, Workflow, Include]] = None
3101 ) -> None:
3102 """
3103 Initializes a rule.
3105 :param line: Line the rule is written at, starting at 1.
3106 :param condition: Optional, expression of the rule's ``if``. Default: ``None``.
3107 :param changes: Optional, paths of the rule's ``changes``. Default: ``None``.
3108 :param compareTo: Optional, reference ``changes`` is compared to. Default: ``None``.
3109 :param exists: Optional, paths of the rule's ``exists``. Default: ``None``.
3110 :param when: Optional, when the job runs, if the rule matches. Default: ``None``.
3111 :param allowFailure: Optional, ``True``, if the pipeline continues when the job fails. Default: ``None``.
3112 :param variables: Optional, variables set, if the rule matches, by name. Default: ``None``.
3113 :param parent: Optional, reference to the job, workflow or include the rule belongs to. Default: ``None``.
3114 :raises TypeError: If parameter 'condition' or 'compareTo' is not of type :class:`str`.
3115 :raises TypeError: If an element of parameter 'changes' or 'exists' is not of type :class:`str`.
3116 :raises TypeError: If parameter 'when' is not of type :class:`~pyTooling.CI.RuleOutcome`.
3117 :raises TypeError: If parameter 'allowFailure' is not of type :class:`bool`.
3118 """
3119 super().__init__(line, parent=parent)
3121 for parameterName, text in (
3122 ("condition", condition),
3123 ("compareTo", compareTo)
3124 ):
3125 if text is not None and not isinstance(text, str):
3126 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
3127 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
3128 raise ex
3130 self._changes = () if changes is None else tuple(changes)
3131 self._exists = () if exists is None else tuple(exists)
3132 for parameterName, paths in (
3133 ("changes", self._changes),
3134 ("exists", self._exists)
3135 ):
3136 for path in paths:
3137 if not isinstance(path, str):
3138 ex = TypeError(f"An element of parameter '{parameterName}' is not of type 'str'.")
3139 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
3140 raise ex
3142 if when is not None and not isinstance(when, RuleOutcome):
3143 ex = TypeError("Parameter 'when' is not of type 'RuleOutcome'.")
3144 ex.add_note(f"Got type '{getFullyQualifiedName(when)}'.")
3145 raise ex
3147 if allowFailure is not None and not isinstance(allowFailure, bool):
3148 ex = TypeError("Parameter 'allowFailure' is not of type 'bool'.")
3149 ex.add_note(f"Got type '{getFullyQualifiedName(allowFailure)}'.")
3150 raise ex
3152 self._condition = condition
3153 self._compareTo = compareTo
3154 self._when = when
3155 self._allowFailure = allowFailure
3156 self._variables = {} if variables is None else dict(variables)
3158 if parent is not None:
3159 parent._rules.append(self)
3161 @readonly
3162 def Condition(self) -> Nullable[str]:
3163 """
3164 Read-only property to access the expression of the rule's ``if`` (:attr:`_condition`).
3166 :returns: The expression, as written, or ``None`` if the rule has no ``if``.
3167 """
3168 return self._condition
3170 @readonly
3171 def Changes(self) -> tuple[str, ...]:
3172 """
3173 Read-only property to access the paths of the rule's ``changes`` (:attr:`_changes`).
3175 :returns: The paths, as written in ``changes`` or ``changes.paths``.
3176 """
3177 return self._changes
3179 @readonly
3180 def CompareTo(self) -> Nullable[str]:
3181 """
3182 Read-only property to access the reference ``changes`` is compared to (:attr:`_compareTo`).
3184 :returns: The reference of ``changes.compare_to``, or ``None``.
3185 """
3186 return self._compareTo
3188 @readonly
3189 def Exists(self) -> tuple[str, ...]:
3190 """
3191 Read-only property to access the paths of the rule's ``exists`` (:attr:`_exists`).
3193 :returns: The paths, as written in ``exists`` or ``exists.paths``.
3194 """
3195 return self._exists
3197 @readonly
3198 def When(self) -> Nullable[RuleOutcome]:
3199 """
3200 Read-only property to access when the job runs, if the rule matches (:attr:`_when`).
3202 :returns: The rule's ``when``, or ``None`` if the rule doesn't say.
3203 """
3204 return self._when
3206 @readonly
3207 def AllowFailure(self) -> Nullable[bool]:
3208 """
3209 Read-only property to access whether the pipeline continues when the job fails (:attr:`_allowFailure`).
3211 :returns: The rule's ``allow_failure``, or ``None`` if the rule doesn't say.
3212 """
3213 return self._allowFailure
3215 @readonly
3216 def Variables(self) -> dict[str, str]:
3217 """
3218 Read-only property to access the variables set, if the rule matches (:attr:`_variables`).
3220 :returns: The variables' values, by name.
3221 """
3222 return self._variables
3224 @staticmethod
3225 def _Paths(value: Any, key: str, owner: str, entry: CommentedMap, path: Path) -> tuple[list[str], Nullable[str]]:
3226 """
3227 Read the paths of ``changes`` or ``exists``: a list, or a mapping with ``paths``.
3229 :param value: The key's value.
3230 :param key: The key, for a message.
3231 :param owner: The element the rule belongs to, for a message.
3232 :param entry: The rule's mapping, for the key's line.
3233 :param path: Path to the configuration file.
3234 :returns: The paths, and the reference of ``compare_to``.
3235 :raises PipelineFileError: If the value is neither a list nor a mapping with a list ``paths``.
3236 """
3237 compareTo = None
3238 if isinstance(value, CommentedMap):
3239 compareTo = value.get("compare_to", None)
3240 value = value.get("paths", None)
3242 if not isinstance(value, CommentedSeq):
3243 ex = PipelineFileError(
3244 f"Key '{key}' of a rule of {owner} is not a list of paths.", path, Base._KeyLine(entry, key)
3245 )
3246 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
3247 raise ex
3249 return [Base._ToText(item) for item in value], None if compareTo is None else Base._ToText(compareTo)
3251 @classmethod
3252 def _FromYAMLEntries(cls, value: Any, owner: str, path: Path, line: int) -> list[Self]:
3253 """
3254 Read a ``rules`` key: a list of rules, each a mapping.
3256 :param value: The key's value.
3257 :param owner: The element the rules belong to, for a message, as ``job 'build'``.
3258 :param path: Path to the configuration file.
3259 :param line: Line the ``rules`` key is written at, starting at 1.
3260 :returns: The rules, in file order.
3261 :raises PipelineFileError: If the value is not a list, or a rule is not a mapping.
3262 :raises PipelineFileError: If key ``when`` of a rule is not a value GitLab knows. |br|
3263 The note lists the allowed values.
3264 :raises PipelineFileError: If a key of a rule holds a value of the wrong kind.
3265 """
3266 if not isinstance(value, CommentedSeq):
3267 ex = PipelineFileError(f"Key 'rules' of {owner} is not a list.", path, line)
3268 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
3269 raise ex
3271 rules = []
3272 for index, entry in enumerate(value):
3273 entryLine = Base._ItemLine(value, index)
3274 if not isinstance(entry, CommentedMap):
3275 ex = PipelineFileError(f"Rule of {owner} is not a mapping.", path, entryLine)
3276 ex.add_note(f"Got type '{getFullyQualifiedName(entry)}'.")
3277 raise ex
3279 changes = None
3280 compareTo = None
3281 if "changes" in entry:
3282 changes, compareTo = cls._Paths(entry["changes"], "changes", owner, entry, path)
3284 exists = None
3285 if "exists" in entry:
3286 exists, _ = cls._Paths(entry["exists"], "exists", owner, entry, path)
3288 when = None
3289 if (whenValue := entry.get("when", None)) is not None:
3290 try:
3291 when = RuleOutcome.Parse(Base._ToText(whenValue))
3292 except ValueError as cause:
3293 ex = PipelineFileError(f"Key 'when' of a rule of {owner} is not a known value.", path, entryLine)
3294 ex.add_note(f"Got '{whenValue}'.")
3295 ex.add_note(f"Allowed values: {', '.join(member.value for member in RuleOutcome)}.")
3296 raise ex from cause
3298 allowFailure = Base._ToPython(entry.get("allow_failure", None))
3299 if allowFailure is not None and not isinstance(allowFailure, bool):
3300 ex = PipelineFileError(f"Key 'allow_failure' of a rule of {owner} is not a boolean.", path, entryLine)
3301 ex.add_note(f"Got '{allowFailure}'.")
3302 raise ex
3304 variables = entry.get("variables", None)
3305 if variables is not None and not isinstance(variables, CommentedMap):
3306 ex = PipelineFileError(f"Key 'variables' of a rule of {owner} is not a mapping.", path, entryLine)
3307 ex.add_note(f"Got type '{getFullyQualifiedName(variables)}'.")
3308 raise ex
3310 condition = entry.get("if", None)
3311 rules.append(cls(
3312 entryLine,
3313 condition=None if condition is None else Base._ToText(condition),
3314 changes=changes,
3315 compareTo=compareTo,
3316 exists=exists,
3317 when=when,
3318 allowFailure=allowFailure,
3319 variables=None if variables is None else {str(k): Base._ToText(v) for k, v in variables.items()}
3320 ))
3322 return rules
3325@export
3326class Default(Base[PipelineFile]):
3327 """
3328 The ``default`` key of a configuration file: keys every job gets, unless it sets them itself.
3330 The global keywords ``image``, ``services``, ``before_script`` and ``after_script`` - deprecated forms of
3331 ``default`` - are read into it too, unless ``default`` sets them.
3332 """
3334 _PARENT_TYPE: ClassVar[ParentTypes] = PipelineFile #: A default belongs to a configuration file.
3336 _image: Nullable[str] #: Image of the container a job runs in.
3337 _services: tuple[str, ...] #: Images of the service containers.
3338 _tags: tuple[str, ...] #: Tags selecting the runner.
3339 _beforeScript: tuple[str, ...] #: Lines of ``before_script``.
3340 _afterScript: tuple[str, ...] #: Lines of ``after_script``.
3342 _KEYS: ClassVar[tuple[str, ...]] = ("image", "services", "before_script", "after_script") #: The global keywords.
3344 def __init__(
3345 self,
3346 line: int,
3347 image: Nullable[str] = None,
3348 services: Nullable[Iterable[str]] = None,
3349 tags: Nullable[Iterable[str]] = None,
3350 beforeScript: Nullable[Iterable[str]] = None,
3351 afterScript: Nullable[Iterable[str]] = None,
3352 *,
3353 parent: Nullable[PipelineFile] = None
3354 ) -> None:
3355 """
3356 Initializes the ``default`` key.
3358 :param line: Line the ``default`` key, or the first global keyword, is written at, starting at 1.
3359 :param image: Optional, image of the container a job runs in. Default: ``None``.
3360 :param services: Optional, images of the service containers. Default: ``None``.
3361 :param tags: Optional, tags selecting the runner. Default: ``None``.
3362 :param beforeScript: Optional, lines of ``before_script``. Default: ``None``.
3363 :param afterScript: Optional, lines of ``after_script``. Default: ``None``.
3364 :param parent: Optional, reference to the configuration file. Default: ``None``.
3365 :raises TypeError: If parameter 'image' is not of type :class:`str`.
3366 :raises TypeError: If an element of parameter 'services', 'tags', 'beforeScript' or 'afterScript' is not of
3367 type :class:`str`.
3368 """
3369 super().__init__(line, parent=parent)
3371 if image is not None and not isinstance(image, str):
3372 ex = TypeError("Parameter 'image' is not of type 'str'.")
3373 ex.add_note(f"Got type '{getFullyQualifiedName(image)}'.")
3374 raise ex
3376 self._services = () if services is None else tuple(services)
3377 self._tags = () if tags is None else tuple(tags)
3378 self._beforeScript = () if beforeScript is None else tuple(beforeScript)
3379 self._afterScript = () if afterScript is None else tuple(afterScript)
3380 for parameterName, texts in (
3381 ("services", self._services),
3382 ("tags", self._tags),
3383 ("beforeScript", self._beforeScript),
3384 ("afterScript", self._afterScript)
3385 ):
3386 for text in texts:
3387 if not isinstance(text, str):
3388 ex = TypeError(f"An element of parameter '{parameterName}' is not of type 'str'.")
3389 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
3390 raise ex
3392 self._image = image
3394 if parent is not None:
3395 parent._default = self
3397 @readonly
3398 def Image(self) -> Nullable[str]:
3399 """
3400 Read-only property to access the image of the container a job runs in (:attr:`_image`).
3402 :returns: The image, or ``None`` if the default names none.
3403 """
3404 return self._image
3406 @readonly
3407 def Services(self) -> tuple[str, ...]:
3408 """
3409 Read-only property to access the images of the service containers (:attr:`_services`).
3411 :returns: The images, in file order.
3412 """
3413 return self._services
3415 @readonly
3416 def Tags(self) -> tuple[str, ...]:
3417 """
3418 Read-only property to access the tags selecting the runner (:attr:`_tags`).
3420 :returns: The tags, in file order.
3421 """
3422 return self._tags
3424 @readonly
3425 def BeforeScript(self) -> tuple[str, ...]:
3426 """
3427 Read-only property to access the lines of ``before_script`` (:attr:`_beforeScript`).
3429 :returns: The lines, nested lists flattened.
3430 """
3431 return self._beforeScript
3433 @readonly
3434 def AfterScript(self) -> tuple[str, ...]:
3435 """
3436 Read-only property to access the lines of ``after_script`` (:attr:`_afterScript`).
3438 :returns: The lines, nested lists flattened.
3439 """
3440 return self._afterScript
3442 @classmethod
3443 def _FromYAML(cls, document: CommentedMap, path: Path) -> Nullable[Self]:
3444 """
3445 Read the ``default`` key and the global keywords standing for it.
3447 :param document: The configuration.
3448 :param path: Path to the configuration file.
3449 :returns: The default, or ``None`` if the configuration has neither.
3450 :raises PipelineFileError: If key ``default`` is not a mapping.
3451 :raises PipelineFileError: If a key holds a value of the wrong kind.
3452 """
3453 mapping = CommentedMap()
3454 line = None
3455 if "default" in document:
3456 line = Base._KeyLine(document, "default")
3457 if not isinstance(document["default"], CommentedMap):
3458 ex = PipelineFileError("Key 'default' is not a mapping.", path, line)
3459 ex.add_note(f"Got type '{getFullyQualifiedName(document['default'])}'.")
3460 raise ex
3462 mapping = document["default"]
3464 globalKeys = {key: document[key] for key in cls._KEYS if key in document and key not in mapping}
3465 if line is None and len(globalKeys) == 0:
3466 return None
3467 elif line is None:
3468 line = min(Base._KeyLine(document, key) for key in globalKeys)
3470 def value(key: str) -> Any:
3471 """
3472 Nested function returning a key of ``default``, or else the global keyword.
3474 :param key: The key.
3475 :returns: The key's value, or ``None``.
3476 """
3477 return mapping[key] if key in mapping else globalKeys.get(key, None)
3479 owner = mapping if "default" in document else document
3480 services = None
3481 if (serviceList := value("services")) is not None:
3482 if not isinstance(serviceList, CommentedSeq):
3483 ex = PipelineFileError("Key 'services' of the default is not a list.", path, line)
3484 ex.add_note(f"Got type '{getFullyQualifiedName(serviceList)}'.")
3485 raise ex
3487 services = [service for item in serviceList if (service := Job._Name(item)) is not None]
3489 tags = None
3490 if (tagList := value("tags")) is not None:
3491 if not isinstance(tagList, CommentedSeq):
3492 ex = PipelineFileError("Key 'tags' of the default is not a list.", path, line)
3493 ex.add_note(f"Got type '{getFullyQualifiedName(tagList)}'.")
3494 raise ex
3496 tags = [Base._ToText(tag) for tag in tagList]
3498 return cls(
3499 line,
3500 image=Job._Name(value("image")),
3501 services=services,
3502 tags=tags,
3503 beforeScript=Job._Lines(value("before_script"), "before_script", "the default", owner, path),
3504 afterScript=Job._Lines(value("after_script"), "after_script", "the default", owner, path)
3505 )
3508@export
3509class Parallel(Base[Union[Job, Need]]):
3510 """
3511 A ``parallel`` key: how often a job runs, or the matrix of variables its instances run with.
3513 A job runs :attr:`Count` times, or once per combination of :attr:`Matrix`: an entry maps variables to values, and
3514 contributes the product of their lists. A need's ``parallel`` names the matrix instances it needs.
3515 """
3517 _PARENT_TYPE: ClassVar[ParentTypes] = (Job, Need) #: A parallel belongs to a job or a need.
3519 _count: Nullable[int] #: How often the job runs.
3520 _matrix: tuple[dict[str, tuple[str, ...]], ...] #: Entries of the matrix: variables to their values.
3522 def __init__(
3523 self,
3524 line: int,
3525 count: Nullable[int] = None,
3526 matrix: Nullable[Iterable[Mapping[str, Iterable[str]]]] = None,
3527 *,
3528 parent: Nullable[Union[Job, Need]] = None
3529 ) -> None:
3530 """
3531 Initializes a ``parallel`` key.
3533 :param line: Line the ``parallel`` key is written at, starting at 1.
3534 :param count: Optional, how often the job runs. Default: ``None``, for a matrix.
3535 :param matrix: Optional, entries of the matrix, each mapping variables to their values. Default: ``None``,
3536 for a count.
3537 :param parent: Optional, reference to the job or the need. Default: ``None``.
3538 :raises ValueError: If both or neither of parameters 'count' and 'matrix' are given.
3539 :raises TypeError: If parameter 'count' is not of type :class:`int`.
3540 :raises ValueError: If parameter 'count' is out of range 1..200.
3541 :raises TypeError: If an entry of parameter 'matrix' is not a mapping, or a variable's values are not strings.
3542 """
3543 super().__init__(line, parent=parent)
3545 if (count is None) == (matrix is None):
3546 raise ValueError("Parameters 'count' and 'matrix' are both given, or both None.")
3547 elif count is not None and (not isinstance(count, int) or isinstance(count, bool)):
3548 ex = TypeError("Parameter 'count' is not of type 'int'.")
3549 ex.add_note(f"Got type '{getFullyQualifiedName(count)}'.")
3550 raise ex
3551 elif count is not None and not (1 <= count <= 200):
3552 ex = ValueError("Parameter 'count' is out of range 1..200.")
3553 ex.add_note(f"Got value '{count}'.")
3554 raise ex
3556 entries = []
3557 for entry in () if matrix is None else matrix:
3558 if not isinstance(entry, Mapping):
3559 ex = TypeError("An entry of parameter 'matrix' is not a mapping.")
3560 ex.add_note(f"Got type '{getFullyQualifiedName(entry)}'.")
3561 raise ex
3563 variables = {}
3564 for variable, values in entry.items():
3565 variables[variable] = tuple(values)
3566 if not isinstance(variable, str) or any(not isinstance(value, str) for value in variables[variable]):
3567 ex = TypeError(f"Variable '{variable}' of parameter 'matrix' has values not of type 'str'.")
3568 ex.add_note(f"Got '{variables[variable]}'.")
3569 raise ex
3571 entries.append(variables)
3573 self._count = count
3574 self._matrix = tuple(entries)
3576 if parent is not None:
3577 parent._parallel = self
3579 @readonly
3580 def Count(self) -> Nullable[int]:
3581 """
3582 Read-only property to access how often the job runs (:attr:`_count`).
3584 :returns: The count of ``parallel: N``, or ``None`` for a matrix.
3585 """
3586 return self._count
3588 @readonly
3589 def Matrix(self) -> tuple[dict[str, tuple[str, ...]], ...]:
3590 """
3591 Read-only property to access the entries of the matrix (:attr:`_matrix`).
3593 :returns: The entries, each mapping variables to their values, in file order; empty for a count.
3594 """
3595 return self._matrix
3597 @readonly
3598 def IsMatrix(self) -> bool:
3599 """
3600 Read-only property to return whether the key is a matrix.
3602 :returns: ``True`` for ``parallel: matrix``, ``False`` for ``parallel: N``.
3603 """
3604 return self._count is None
3606 @readonly
3607 def Combinations(self) -> list[dict[str, str]]:
3608 """
3609 Read-only property to return the combinations of the matrix, one per instance.
3611 :returns: The combinations, each mapping the variables to a value, in the order GitLab creates the instances.
3612 """
3613 combinations = []
3614 for entry in self._matrix:
3615 for values in product(*entry.values()):
3616 combinations.append(dict(zip(entry, values)))
3618 return combinations
3620 @readonly
3621 def InstanceNames(self) -> list[str]:
3622 """
3623 Read-only property to return the names GitLab gives the instances.
3625 :returns: The names, as ``test 1/3`` for a count, or ``deploy: [aws, app1]`` for a matrix.
3626 """
3627 if isinstance(self._parent, Need):
3628 name = self._parent._job
3629 else:
3630 name = "" if self._parent is None else self._parent._name
3632 if self._count is not None:
3633 return [self._InstanceName(name, self._count, {"CI_NODE_INDEX": index}) for index in range(1, self._count + 1)]
3635 return [self._InstanceName(name, None, combination) for combination in self.Combinations]
3637 @staticmethod
3638 def _InstanceName(name: str, count: Nullable[int], dimensions: Mapping[str, Any]) -> str:
3639 """
3640 Return the name GitLab gives an instance.
3642 :param name: Name of the job.
3643 :param count: The count of ``parallel: N``, or ``None`` for a matrix.
3644 :param dimensions: The instance's variables, ``CI_NODE_INDEX`` for ``parallel: N``.
3645 :returns: The name, as ``test 1/3`` for a count, or ``deploy: [aws, app1]`` for a matrix.
3646 """
3647 if count is not None:
3648 return f"{name} {dimensions['CI_NODE_INDEX']}/{count}"
3650 return f"{name}: [{', '.join(str(value) for value in dimensions.values())}]"
3652 @classmethod
3653 def _FromYAML(cls, value: Any, owner: str, path: Path, line: int) -> Self:
3654 """
3655 Read a ``parallel`` key: a count, or a mapping with key ``matrix``.
3657 :param value: The key's value.
3658 :param owner: The element holding the key, for a message, as ``job 'test'``.
3659 :param path: Path to the configuration file.
3660 :param line: Line the ``parallel`` key is written at, starting at 1.
3661 :returns: The parallel.
3662 :raises PipelineFileError: If the value is neither a count nor a mapping with key ``matrix``.
3663 :raises PipelineFileError: If the count is out of range 1..200.
3664 :raises PipelineFileError: If the matrix is not a list of mappings of variables to a value or a list of values.
3665 """
3666 count = Base._ToPython(value)
3667 if isinstance(count, int) and not isinstance(count, bool):
3668 if not (1 <= count <= 200):
3669 ex = PipelineFileError(f"Key 'parallel' of {owner} is out of range 1..200.", path, line)
3670 ex.add_note(f"Got '{count}'.")
3671 raise ex
3673 return cls(line, count)
3674 elif not isinstance(value, CommentedMap) or "matrix" not in value:
3675 ex = PipelineFileError(f"Key 'parallel' of {owner} is neither a count nor a mapping with 'matrix'.", path, line)
3676 ex.add_note(f"Got '{count}'.")
3677 raise ex
3679 entries = value["matrix"]
3680 if not isinstance(entries, CommentedSeq) or any(not isinstance(entry, CommentedMap) for entry in entries):
3681 ex = PipelineFileError(f"Key 'parallel.matrix' of {owner} is not a list of mappings.", path, line)
3682 ex.add_note(f"Got '{Base._ToPython(entries)}'.")
3683 raise ex
3685 matrix = []
3686 for entry in entries:
3687 variables = {}
3688 for variable, values in entry.items():
3689 values = values if isinstance(values, CommentedSeq) else [values]
3690 if any(isinstance(item, (CommentedMap, CommentedSeq)) for item in values):
3691 ex = PipelineFileError(
3692 f"Variable '{variable}' of the matrix of {owner} is neither a value nor a list of values.",
3693 path, Base._KeyLine(entry, variable)
3694 )
3695 ex.add_note(f"Got '{Base._ToPython(values)}'.")
3696 raise ex
3698 variables[str(variable)] = [Base._ToText(item) for item in values]
3700 matrix.append(variables)
3702 return cls(line, matrix=matrix)
3705@export
3706class Input(Base[Union[PipelineFile, Job]]):
3707 """
3708 An input of the header's ``spec:inputs``, or of a job's ``inputs``.
3710 An input of a file is a parameter of its configuration, which ``include:inputs`` or ``trigger:include:inputs`` set, or
3711 the form running a pipeline by hand; it is mandatory without a default. An input of a job is set when the job is
3712 run or retried by hand, and always has a default.
3713 """
3715 _PARENT_TYPE: ClassVar[ParentTypes] = (PipelineFile, Job) #: An input belongs to a configuration file or a job.
3717 _name: str #: Name of the input.
3718 _type: InputType #: Type of the input.
3719 _required: bool #: ``True``, if the input has no default.
3720 _default: ValueT #: Value of the input, if it isn't set.
3721 _description: Nullable[str] #: Description of the input.
3722 _options: tuple[ValueT, ...] #: Values the input may have.
3723 _regex: Nullable[str] #: Regular expression the input's value must match.
3724 _rules: tuple[dict[str, ValueT], ...] #: Conditional options and defaults of ``rules``.
3726 _KEYS: ClassVar[tuple[str, ...]] = (
3727 "default", "type", "description", "options", "regex", "rules"
3728 ) #: Keys of an input as mapping.
3730 def __init__(
3731 self,
3732 name: str,
3733 line: int,
3734 inputType: InputType = InputType.String,
3735 required: bool = True,
3736 default: ValueT = None,
3737 description: Nullable[str] = None,
3738 options: Nullable[Iterable[ValueT]] = None,
3739 regex: Nullable[str] = None,
3740 rules: Nullable[Iterable[Mapping[str, ValueT]]] = None,
3741 *,
3742 parent: Nullable[Union[PipelineFile, Job]] = None
3743 ) -> None:
3744 """
3745 Initializes an input.
3747 :param name: Name of the input.
3748 :param line: Line the input's name is written at, starting at 1.
3749 :param inputType: Optional, type of the input. Default: :attr:`InputType.String`.
3750 :param required: Optional, ``True``, if the input has no default. Default: ``True``.
3751 :param default: Optional, value of the input, if it isn't set. Default: ``None``.
3752 :param description: Optional, description of the input. Default: ``None``.
3753 :param options: Optional, values the input may have. Default: ``None``.
3754 :param regex: Optional, regular expression the input's value must match. Default: ``None``.
3755 :param rules: Optional, conditional options and defaults, each a mapping of ``if``, ``options`` and
3756 ``default``. Default: ``None``.
3757 :param parent: Optional, reference to the configuration file or the job. Default: ``None``.
3758 :raises ValueError: If parameter 'name' is ``None``.
3759 :raises TypeError: If parameter 'name' is not of type :class:`str`.
3760 :raises ValueError: If parameter 'name' is empty.
3761 :raises TypeError: If parameter 'inputType' is not of type :class:`InputType`.
3762 :raises TypeError: If parameter 'required' is not of type :class:`bool`.
3763 :raises TypeError: If parameter 'description' or 'regex' is not of type :class:`str`.
3764 """
3765 super().__init__(line, parent=parent)
3767 if name is None:
3768 raise ValueError("Parameter 'name' is None.")
3769 elif not isinstance(name, str):
3770 ex = TypeError("Parameter 'name' is not of type 'str'.")
3771 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
3772 raise ex
3773 elif name == "":
3774 raise ValueError("Parameter 'name' is empty.")
3776 if not isinstance(inputType, InputType):
3777 ex = TypeError("Parameter 'inputType' is not of type 'InputType'.")
3778 ex.add_note(f"Got type '{getFullyQualifiedName(inputType)}'.")
3779 raise ex
3781 if not isinstance(required, bool):
3782 ex = TypeError("Parameter 'required' is not of type 'bool'.")
3783 ex.add_note(f"Got type '{getFullyQualifiedName(required)}'.")
3784 raise ex
3786 for parameterName, text in (
3787 ("description", description),
3788 ("regex", regex)
3789 ):
3790 if text is not None and not isinstance(text, str):
3791 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
3792 ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
3793 raise ex
3795 self._name = name
3796 self._type = inputType
3797 self._required = required
3798 self._default = default
3799 self._description = description
3800 self._options = () if options is None else tuple(options)
3801 self._regex = regex
3802 self._rules = () if rules is None else tuple(dict(rule) for rule in rules)
3804 if parent is not None:
3805 parent._inputs[name] = self
3807 @readonly
3808 def Name(self) -> str:
3809 """
3810 Read-only property to access the input's name (:attr:`_name`).
3812 :returns: Name of the input.
3813 """
3814 return self._name
3816 @readonly
3817 def Type(self) -> InputType:
3818 """
3819 Read-only property to access the input's type (:attr:`_type`).
3821 :returns: The ``type`` key, :attr:`InputType.String` if the input doesn't say.
3822 """
3823 return self._type
3825 @readonly
3826 def Required(self) -> bool:
3827 """
3828 Read-only property to access whether the input has to be set (:attr:`_required`).
3830 :returns: ``True``, if the input has no ``default`` key.
3831 """
3832 return self._required
3834 @readonly
3835 def Default(self) -> ValueT:
3836 """
3837 Read-only property to access the input's value, if it isn't set (:attr:`_default`).
3839 :returns: The default, with the type it is written with, or ``None`` if the input has none.
3840 """
3841 return self._default
3843 @readonly
3844 def Description(self) -> Nullable[str]:
3845 """
3846 Read-only property to access the input's description (:attr:`_description`).
3848 :returns: The description, or ``None`` if the input gives none.
3849 """
3850 return self._description
3852 @readonly
3853 def Options(self) -> tuple[ValueT, ...]:
3854 """
3855 Read-only property to access the values the input may have (:attr:`_options`).
3857 :returns: The options, in file order; empty if the input may have any value.
3858 """
3859 return self._options
3861 @readonly
3862 def Regex(self) -> Nullable[str]:
3863 """
3864 Read-only property to access the regular expression the input's value must match (:attr:`_regex`).
3866 :returns: The expression, as written, or ``None``.
3867 """
3868 return self._regex
3870 @readonly
3871 def Rules(self) -> tuple[dict[str, ValueT], ...]:
3872 """
3873 Read-only property to access the input's conditional options and defaults (:attr:`_rules`).
3875 :returns: The rules, each mapping ``if``, ``options`` and ``default``, in the order GitLab evaluates them.
3876 """
3877 return self._rules
3879 def __str__(self) -> str:
3880 """
3881 Return the input's name.
3883 :returns: Name of the input.
3884 """
3885 return self._name
3887 @classmethod
3888 def _FromYAML(cls, name: str, declaration: Any, jobName: Nullable[str], path: Path, line: int) -> Self:
3889 """
3890 Read an input's declaration: empty, or a mapping of the keys in :attr:`_KEYS`.
3892 :param name: Name of the input.
3893 :param declaration: The declaration.
3894 :param jobName: Name of the job declaring the input, or ``None`` for ``spec:inputs``.
3895 :param path: Path to the configuration file.
3896 :param line: Line the input's name is written at, starting at 1.
3897 :returns: The input.
3898 :raises PipelineFileError: If the declaration is neither empty nor a mapping.
3899 :raises PipelineFileError: If the mapping has a key GitLab doesn't know for an input. |br|
3900 The note lists the allowed keys.
3901 :raises PipelineFileError: If key ``type`` is not an input type. |br|
3902 The note lists the allowed values.
3903 :raises PipelineFileError: If an input of a job has no default.
3904 :raises PipelineFileError: If the default doesn't match the type, or isn't one of the options.
3905 :raises PipelineFileError: If key ``options`` or ``rules`` is not a list, or ``regex`` is used with a type other
3906 than ``string``.
3907 """
3908 subject = f"input '{name}'" if jobName is None else f"input '{name}' of job '{jobName}'"
3909 if declaration is None:
3910 declaration = CommentedMap()
3911 elif not isinstance(declaration, CommentedMap):
3912 ex = PipelineFileError(f"I{subject[1:]} is not a mapping.", path, line)
3913 ex.add_note(f"Got type '{getFullyQualifiedName(declaration)}'.")
3914 raise ex
3916 for key in declaration:
3917 if key not in cls._KEYS:
3918 ex = PipelineFileError(f"I{subject[1:]} has unknown key '{key}'.", path, Base._KeyLine(declaration, key))
3919 ex.add_note(f"Allowed keys: {', '.join(cls._KEYS)}.")
3920 raise ex
3922 try:
3923 inputType = InputType.Parse(Base._ToText(declaration.get("type", "string")))
3924 except ValueError as cause:
3925 ex = PipelineFileError(f"Key 'type' of {subject} is not an input type.", path, line)
3926 ex.add_note(f"Got '{declaration['type']}'.")
3927 ex.add_note(f"Allowed values: {', '.join(member.value for member in InputType)}.")
3928 raise ex from cause
3930 required = "default" not in declaration
3931 if required and jobName is not None:
3932 raise PipelineFileError(f"I{subject[1:]} has no default.", path, line)
3934 for key in ("options", "rules"):
3935 if key in declaration and not isinstance(declaration[key], CommentedSeq):
3936 ex = PipelineFileError(
3937 f"Key '{key}' of {subject} is not a list.", path, Base._KeyLine(declaration, key)
3938 )
3939 ex.add_note(f"Got type '{getFullyQualifiedName(declaration[key])}'.")
3940 raise ex
3942 default = Base._ToPython(declaration.get("default", None))
3943 options = Base._ToPython(declaration.get("options", []))
3944 types = {
3945 InputType.String: (str, ),
3946 InputType.Number: (int, float),
3947 InputType.Boolean: (bool, ),
3948 InputType.Array: (list, )
3949 }[inputType]
3950 if default is not None and (not isinstance(default, types) or (isinstance(default, bool) and bool not in types)):
3951 ex = PipelineFileError(f"Default of {subject} is not of type '{inputType.value}'.", path, line)
3952 ex.add_note(f"Got '{default}'.")
3953 raise ex
3954 elif default is not None and len(options) > 0 and default not in options:
3955 ex = PipelineFileError(f"Default of {subject} is not one of its options.", path, line)
3956 ex.add_note(f"Options: {', '.join(str(option) for option in options)}.")
3957 raise ex
3958 elif "regex" in declaration and inputType is not InputType.String:
3959 raise PipelineFileError(f"Key 'regex' of {subject} needs type 'string'.", path, line)
3961 description = declaration.get("description", None)
3962 regex = declaration.get("regex", None)
3963 return cls(
3964 name, line, inputType, required, default,
3965 description=None if description is None else Base._ToText(description),
3966 options=options,
3967 regex=None if regex is None else Base._ToText(regex),
3968 rules=Base._ToPython(declaration.get("rules", []))
3969 )
3971 @classmethod
3972 def _FromYAMLMapping(cls, mapping: Any, jobName: Nullable[str], path: Path, line: int) -> list[Self]:
3973 """
3974 Read the inputs of ``spec:inputs`` or of a job's ``inputs``.
3976 :param mapping: The key's value.
3977 :param jobName: Name of the job declaring the inputs, or ``None`` for ``spec:inputs``.
3978 :param path: Path to the configuration file.
3979 :param line: Line the key is written at, starting at 1.
3980 :returns: The inputs, in file order.
3981 :raises PipelineFileError: If the value is not a mapping.
3982 :raises PipelineFileError: If an input is malformed.
3983 """
3984 if not isinstance(mapping, CommentedMap):
3985 message = "Key 'spec:inputs'" if jobName is None else f"Key 'inputs' of job '{jobName}'"
3986 ex = PipelineFileError(f"{message} is not a mapping.", path, line)
3987 ex.add_note(f"Got type '{getFullyQualifiedName(mapping)}'.")
3988 raise ex
3990 return [
3991 cls._FromYAML(str(name), value, jobName, path, Base._KeyLine(mapping, name)) for name, value in mapping.items()
3992 ]
3995@export
3996class IncludeResolver(metaclass=ExtendedType, slots=True):
3997 """
3998 Reads the local files a configuration includes, or a trigger job's child pipeline runs, from a repository's directory.
4000 A local include's path, as ``/ci/build.yml`` or ``ci/build.yml``, is relative to the repository's root. Includes of
4001 another kind - another project's files, a URL, a template, a component, an artifact - aren't read.
4003 Every file is read once; asking for it again returns the same :class:`PipelineFile`. A file read is read with this
4004 resolver, so its own local includes are read too.
4005 """
4007 _root: Path #: The repository's root directory.
4008 _files: dict[Path, PipelineFile] #: Files already read, by resolved path.
4009 _documents: dict[Path, CommentedMap] #: Configurations of included files already read, by resolved path.
4011 def __init__(self, root: Path) -> None:
4012 """
4013 Initializes a resolver.
4015 :param root: The repository's root directory.
4016 :raises ValueError: If parameter 'root' is ``None``.
4017 :raises TypeError: If parameter 'root' is not of type :class:`~pathlib.Path`.
4018 """
4019 if root is None:
4020 raise ValueError("Parameter 'root' is None.")
4021 elif not isinstance(root, Path):
4022 ex = TypeError("Parameter 'root' is not of type 'Path'.")
4023 ex.add_note(f"Got type '{getFullyQualifiedName(root)}'.")
4024 raise ex
4026 self._root = root
4027 self._files = {}
4028 self._documents = {}
4030 @readonly
4031 def Root(self) -> Path:
4032 """
4033 Read-only property to access the repository's root directory (:attr:`_root`).
4035 :returns: The directory.
4036 """
4037 return self._root
4039 def Resolve(self, include: Include) -> Nullable[PipelineFile]:
4040 """
4041 Read the file a local include names.
4043 :param include: The include.
4044 :returns: The file, or ``None`` for an include of another kind, or of a file that doesn't exist.
4045 :raises ValueError: If parameter 'include' is ``None``.
4046 :raises TypeError: If parameter 'include' is not of type :class:`Include`.
4047 :raises PipelineFileError: If the file is not a well-formed configuration.
4048 """
4049 if include is None:
4050 raise ValueError("Parameter 'include' is None.")
4051 elif not isinstance(include, Include):
4052 ex = TypeError("Parameter 'include' is not of type 'Include'.")
4053 ex.add_note(f"Got type '{getFullyQualifiedName(include)}'.")
4054 raise ex
4056 if include._kind is not IncludeKind.Local:
4057 return None
4059 path = (self._root / include._location.lstrip("/")).resolve()
4060 if (known := self._files.get(path, None)) is not None:
4061 return known
4062 elif not path.exists():
4063 return None
4065 self._files[path] = PipelineFile.FromFile(path, self)
4066 return self._files[path]
4068 def _Included(
4069 self,
4070 document: CommentedMap,
4071 path: Path,
4072 visited: tuple[Path, ...]
4073 ) -> tuple[dict[str, Any], bool]:
4074 """
4075 Read the local files a configuration includes, and those they include in turn.
4077 A file included again, by itself or another, is read once.
4079 :param document: The configuration.
4080 :param path: Path to the configuration file.
4081 :param visited: Resolved paths of the files read on the way to this one, this one last.
4082 :returns: The top-level keys of the included files, a later file's key replacing an earlier one's;
4083 and ``True``, if an include couldn't be read.
4084 :raises PipelineFileError: If an include is malformed, or an included file is not a well-formed configuration.
4085 """
4086 keys: dict[str, Any] = {}
4087 incomplete = False
4088 if "include" not in document:
4089 return keys, incomplete
4091 for include in Include._FromYAMLEntries(document["include"], path, Base._KeyLine(document, "include")):
4092 if include._kind is not IncludeKind.Local:
4093 incomplete = True
4094 continue
4096 includedPath = (self._root / include._location.lstrip("/")).resolve()
4097 if includedPath in visited: 4097 ↛ 4098line 4097 didn't jump to line 4098 because the condition on line 4097 was never true
4098 continue
4099 elif not includedPath.exists(): 4099 ↛ 4100line 4099 didn't jump to line 4100 because the condition on line 4099 was never true
4100 incomplete = True
4101 continue
4102 elif includedPath not in self._documents: 4102 ↛ 4105line 4102 didn't jump to line 4105 because the condition on line 4102 was always true
4103 self._documents[includedPath] = PipelineFile._Load(includedPath)[1]
4105 nested, nestedIncomplete = self._Included(self._documents[includedPath], includedPath, (*visited, includedPath))
4106 incomplete = incomplete or nestedIncomplete
4107 keys.update(nested)
4108 keys.update((str(key), value) for key, value in self._documents[includedPath].items())
4110 return keys, incomplete
4113@export
4114class DefinitionMixin(Generic[DefinitionType], metaclass=ExtendedType, mixin=True, expects=("_DEFINITION_TYPE",)):
4115 """
4116 Mixin-class for an element of :mod:`pyTooling.CI` built from a configuration file, linking it to its definition.
4118 :meth:`PipelineFile.ToPipeline` builds the elements, so a consumer of the generic model still reaches the facts only
4119 the file has: the line an element is written at, its stage, its trigger.
4120 """
4122 _definition: DefinitionType #: The element of the configuration file this element was built from.
4124 @classmethod
4125 def _CheckDefinition(cls, definition: DefinitionType) -> None:
4126 """
4127 Check a definition before the element is built from it.
4129 The host class names the element by its definition, so it checks the definition before calling
4130 ``super().__init__()``.
4132 :param definition: The element of the configuration file the element is built from.
4133 :raises ValueError: If parameter 'definition' is ``None``.
4134 :raises TypeError: If parameter 'definition' is not of the type the host class declares in
4135 :attr:`_DEFINITION_TYPE`.
4136 """
4137 if definition is None:
4138 raise ValueError("Parameter 'definition' is None.")
4139 elif not isinstance(definition, cls._DEFINITION_TYPE):
4140 ex = TypeError(f"Parameter 'definition' is not of type '{cls._DEFINITION_TYPE.__name__}'.")
4141 ex.add_note(f"Got type '{getFullyQualifiedName(definition)}'.")
4142 raise ex
4144 @staticmethod
4145 def _Rules(rules: Sequence[Rule], when: Nullable[RuleOutcome], default: RuleOutcome) -> list[CIRule]:
4146 """
4147 Return the :mod:`pyTooling.CI` rules of an element built from a configuration file.
4149 Each of the file's rules becomes a rule deciding the rule's ``when``, else the element's, else ``default``. Its
4150 ``changes``, ``compare_to``, ``exists``, ``allow_failure`` and ``variables`` are the rule's key-value-pairs, as
4151 far as it has them. An element with ``when``, but without rules, gets one rule without condition. The rules are
4152 built anew for every element, as the instances of a matrix share one definition.
4154 :param rules: The rules of the file's element, in the order GitLab evaluates them.
4155 :param when: The ``when`` of the file's element, or ``None``.
4156 :param default: What a rule decides, if neither the rule nor the element say.
4157 :returns: The rules, empty for an element with neither rules nor ``when``.
4158 """
4159 if len(rules) == 0:
4160 return [] if when is None else [CIRule(None, when)]
4162 ciRules = []
4163 for rule in rules:
4164 keyValuePairs: dict[Hashable, Any] = {}
4165 if len(rule._changes) > 0:
4166 keyValuePairs["changes"] = rule._changes
4168 if rule._compareTo is not None:
4169 keyValuePairs["compare_to"] = rule._compareTo
4171 if len(rule._exists) > 0:
4172 keyValuePairs["exists"] = rule._exists
4174 if rule._allowFailure is not None:
4175 keyValuePairs["allow_failure"] = rule._allowFailure
4177 if len(rule._variables) > 0:
4178 keyValuePairs["variables"] = dict(rule._variables)
4180 if rule._when is not None:
4181 outcome = rule._when
4182 elif when is not None:
4183 outcome = when
4184 else:
4185 outcome = default
4187 ciRules.append(CIRule(rule._condition, outcome, keyValuePairs=keyValuePairs))
4189 return ciRules
4191 def __init__(self, definition: DefinitionType) -> None:
4192 """
4193 Initializes the link of an element to its definition, which :meth:`_CheckDefinition` checked.
4195 :param definition: The element of the configuration file this element is built from.
4196 """
4197 self._definition = definition
4199 @readonly
4200 def Definition(self) -> DefinitionType:
4201 """
4202 Read-only property to access the file's element this element was built from (:attr:`_definition`).
4204 :returns: The :class:`PipelineFile` of a pipeline, or the :class:`Job` of any other element.
4205 """
4206 return self._definition
4209@export
4210class ChildPipelineMixin(metaclass=ExtendedType, mixin=True):
4211 """Mixin-class for a trigger job built from a configuration file, holding the files its child pipeline runs."""
4213 _childFiles: tuple[PipelineFile, ...] #: The files the child pipeline's elements were built from.
4215 def __init__(self, childFiles: Nullable[Iterable[PipelineFile]] = None) -> None:
4216 """
4217 Initializes the child pipeline's files.
4219 :param childFiles: Optional, the files the child pipeline's elements are built from. Default: ``None``.
4220 :raises TypeError: If an element of parameter 'childFiles' is not of type :class:`PipelineFile`.
4221 """
4222 self._childFiles = () if childFiles is None else tuple(childFiles)
4223 for childFile in self._childFiles:
4224 if not isinstance(childFile, PipelineFile):
4225 ex = TypeError("An element of parameter 'childFiles' is not of type 'PipelineFile'.")
4226 ex.add_note(f"Got type '{getFullyQualifiedName(childFile)}'.")
4227 raise ex
4229 @readonly
4230 def ChildFiles(self) -> tuple[PipelineFile, ...]:
4231 """
4232 Read-only property to access the files the child pipeline was expanded from (:attr:`_childFiles`).
4234 :returns: The files, empty if the child pipeline wasn't expanded - its files aren't at hand, the depth was used
4235 up, or the job starts another project's pipeline.
4236 """
4237 return self._childFiles
4240@export
4241class DefinedPipeline(CIPipeline, DefinitionMixin[PipelineFile]):
4242 """The pipeline a configuration file defines, as :meth:`PipelineFile.ToPipeline` builds it."""
4244 _DEFINITION_TYPE: ClassVar[type] = PipelineFile #: A pipeline is built from a configuration file.
4246 def __init__(self, definition: PipelineFile, *, keyValuePairs: Nullable[Mapping[Hashable, Any]] = None) -> None:
4247 """
4248 Initializes a pipeline built from a configuration file, named by ``workflow:name``, or else by the file's name.
4250 :param definition: The configuration file.
4251 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
4252 """
4253 self._CheckDefinition(definition)
4255 workflow = definition._workflow
4256 name = definition._path.name if workflow is None or workflow._name is None else workflow._name
4257 rules = [] if workflow is None else self._Rules(workflow._rules, None, RuleOutcome.Always)
4258 super().__init__(name, rules=rules, keyValuePairs=keyValuePairs)
4259 DefinitionMixin.__init__(self, definition)
4262@export
4263class DefinedWorkflow(CIWorkflow, ChildPipelineMixin, DefinitionMixin[Job]):
4264 """A trigger job, as :meth:`PipelineFile.ToPipeline` builds it: the pipeline it starts, as a workflow."""
4266 _DEFINITION_TYPE: ClassVar[type] = Job #: A workflow is built from a trigger job.
4268 def __init__(
4269 self,
4270 definition: Job,
4271 *,
4272 childFiles: Nullable[Iterable[PipelineFile]] = None,
4273 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
4274 parent: Nullable[CIWorkflow] = None
4275 ) -> None:
4276 """
4277 Initializes a workflow built from a trigger job, named by the job's key.
4279 The other project, or the child pipeline's files, are the workflow's :attr:`~pyTooling.CI.Workflow.Reference`,
4280 the job's rules the workflow's :attr:`~pyTooling.CI.RulesMixin.Rules`.
4282 :param definition: The trigger job.
4283 :param childFiles: Optional, the files the child pipeline's elements are built from. Default: ``None``.
4284 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
4285 :param parent: Optional, reference to the workflow containing the trigger job. Default: ``None``.
4286 :raises ValueError: If parameter 'definition' starts no pipeline.
4287 """
4288 self._CheckDefinition(definition)
4290 if definition._trigger is None:
4291 ex = ValueError("Parameter 'definition' starts no pipeline.")
4292 ex.add_note(f"Got job '{definition._name}'.")
4293 raise ex
4295 super().__init__(
4296 definition._name, reference=DefinedWorkflow._Reference(definition),
4297 rules=self._Rules(definition._rules, definition._when, RuleOutcome.DEFAULT), keyValuePairs=keyValuePairs,
4298 parent=parent
4299 )
4300 ChildPipelineMixin.__init__(self, childFiles)
4301 DefinitionMixin.__init__(self, definition)
4303 @staticmethod
4304 def _Reference(definition: Job) -> str:
4305 """
4306 Return what a trigger job starts, for :attr:`~pyTooling.CI.Workflow.Reference`.
4308 :param definition: The trigger job.
4309 :returns: The other project's path, or the child pipeline's files, as ``local: /ci/child.yml``.
4310 """
4311 trigger = definition._trigger
4312 if trigger._project is not None:
4313 return trigger._project
4315 return ", ".join(str(include) for include in trigger._includes)
4318@export
4319class DefinedMatrix(CIMatrix, DefinitionMixin[Job]):
4320 """A job with ``parallel``, as :meth:`PipelineFile.ToPipeline` builds it: a matrix of its instances."""
4322 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix is built from a job with ``parallel``.
4324 def __init__(
4325 self,
4326 definition: Job,
4327 *,
4328 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
4329 parent: Nullable[CIWorkflow] = None
4330 ) -> None:
4331 """
4332 Initializes a matrix built from a job with ``parallel``, named by the job's key.
4334 :param definition: The job.
4335 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
4336 :param parent: Optional, reference to the workflow containing the matrix. Default: ``None``.
4337 :raises ValueError: If parameter 'definition' has no ``parallel``.
4338 """
4339 self._CheckDefinition(definition)
4341 if definition._parallel is None:
4342 ex = ValueError("Parameter 'definition' has no 'parallel'.")
4343 ex.add_note(f"Got job '{definition._name}'.")
4344 raise ex
4346 super().__init__(
4347 definition._name, rules=self._Rules(definition._rules, definition._when, RuleOutcome.DEFAULT),
4348 keyValuePairs=keyValuePairs, parent=parent
4349 )
4350 DefinitionMixin.__init__(self, definition)
4353@export
4354class DefinedMatrixWorkflow(CIMatrixWorkflow, ChildPipelineMixin, DefinitionMixin[Job]):
4355 """One instance of a trigger job with ``parallel``, as :meth:`PipelineFile.ToPipeline` builds it."""
4357 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix instance is built from the job with ``parallel``.
4359 def __init__(
4360 self,
4361 definition: Job,
4362 dimensions: Mapping[str, Any],
4363 *,
4364 childFiles: Nullable[Iterable[PipelineFile]] = None,
4365 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
4366 parent: Nullable[CIMatrix] = None
4367 ) -> None:
4368 """
4369 Initializes one instance of a trigger job with ``parallel``, named by the job's key.
4371 :param definition: The trigger job.
4372 :param dimensions: The variables this instance runs with, as ``{"PROVIDER": "aws"}``, or
4373 ``{"CI_NODE_INDEX": "2"}`` for ``parallel: N``.
4374 :param childFiles: Optional, the files the child pipeline's elements are built from. Default: ``None``.
4375 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
4376 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
4377 :raises ValueError: If parameter 'definition' starts no pipeline.
4378 :raises ValueError: If parameter 'dimensions' is ``None``.
4379 """
4380 self._CheckDefinition(definition)
4382 if definition._trigger is None:
4383 ex = ValueError("Parameter 'definition' starts no pipeline.")
4384 ex.add_note(f"Got job '{definition._name}'.")
4385 raise ex
4386 elif dimensions is None:
4387 raise ValueError("Parameter 'dimensions' is None.")
4389 super().__init__(
4390 definition._name, dimensions, reference=DefinedWorkflow._Reference(definition),
4391 rules=self._Rules(definition._rules, definition._when, RuleOutcome.DEFAULT), keyValuePairs=keyValuePairs,
4392 parent=parent
4393 )
4394 ChildPipelineMixin.__init__(self, childFiles)
4395 DefinitionMixin.__init__(self, definition)
4397 def __str__(self) -> str:
4398 """
4399 Return the name GitLab gives the instance.
4401 :returns: The name, as ``test 1/3`` for ``parallel: N``, or ``test: [3.14, linux]`` for a matrix.
4402 """
4403 return Parallel._InstanceName(self._name, self._definition._parallel._count, self._dimensions)
4406@export
4407class DefinedJob(CIJob, DefinitionMixin[Job]):
4408 """A job running a script, as :meth:`PipelineFile.ToPipeline` builds it."""
4410 _DEFINITION_TYPE: ClassVar[type] = Job #: A job is built from its job in the configuration file.
4412 def __init__(
4413 self,
4414 definition: Job,
4415 *,
4416 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
4417 parent: Nullable[JobGroup] = None
4418 ) -> None:
4419 """
4420 Initializes a job built from its job in the configuration file, named by the job's key.
4422 :param definition: The job.
4423 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
4424 :param parent: Optional, reference to the group containing the job. Default: ``None``.
4425 """
4426 self._CheckDefinition(definition)
4428 super().__init__(
4429 definition._name, rules=self._Rules(definition._rules, definition._when, RuleOutcome.DEFAULT),
4430 keyValuePairs=keyValuePairs, parent=parent
4431 )
4432 DefinitionMixin.__init__(self, definition)
4435@export
4436class DefinedMatrixJob(CIMatrixJob, DefinitionMixin[Job]):
4437 """One instance of a job with ``parallel``, as :meth:`PipelineFile.ToPipeline` builds it."""
4439 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix instance is built from the job with ``parallel``.
4441 def __init__(
4442 self,
4443 definition: Job,
4444 dimensions: Mapping[str, Any],
4445 *,
4446 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
4447 parent: Nullable[CIMatrix] = None
4448 ) -> None:
4449 """
4450 Initializes one instance of a job with ``parallel``, named by the job's key.
4452 :param definition: The job.
4453 :param dimensions: The variables this instance runs with, as ``{"PROVIDER": "aws"}``, or
4454 ``{"CI_NODE_INDEX": "2"}`` for ``parallel: N``.
4455 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
4456 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
4457 :raises ValueError: If parameter 'dimensions' is ``None``.
4458 """
4459 self._CheckDefinition(definition)
4461 if dimensions is None:
4462 raise ValueError("Parameter 'dimensions' is None.")
4464 super().__init__(
4465 definition._name, dimensions, rules=self._Rules(definition._rules, definition._when, RuleOutcome.DEFAULT),
4466 keyValuePairs=keyValuePairs, parent=parent
4467 )
4468 DefinitionMixin.__init__(self, definition)
4470 def __str__(self) -> str:
4471 """
4472 Return the name GitLab gives the instance.
4474 :returns: The name, as ``test 1/3`` for ``parallel: N``, or ``test: [3.14, linux]`` for a matrix.
4475 """
4476 return Parallel._InstanceName(self._name, self._definition._parallel._count, self._dimensions)