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

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. 

32 

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: 

35 

36.. tree:: 

37 

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 

55 

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``. 

58 

59:class:`IncludeResolver` reads the local files a file includes, or a trigger job's child pipeline runs. 

60 

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 

66 

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 

71 

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 

75 

76from pyTooling.Common import getFullyQualifiedName, StringEnum 

77from pyTooling.Decorators import export, readonly 

78from pyTooling.MetaClasses import ExtendedType, abstractclass 

79 

80from pyTooling.GitLab import GitLabError 

81 

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 

88 

89 

90__all__ = ["ValueT", "RESERVED_KEYS", "DEFAULT_STAGES", "MAX_EXTENDS_DEPTH", "MAX_REFERENCE_DEPTH"] 

91 

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

94 

95ParentType = TypeVar("ParentType", bound="Base") 

96"""A type variable for the type of an element's parent.""" 

97 

98ParentTypes = Nullable[Union[type, tuple[type, ...]]] 

99"""The type of :attr:`Base._PARENT_TYPE`: ``None``, a class, or a tuple of classes.""" 

100 

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

103 

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

108 

109DEFAULT_STAGES = ("build", "test", "deploy") 

110"""Stages of a pipeline, whose configuration declares no ``stages``, between ``.pre`` and ``.post``.""" 

111 

112MAX_EXTENDS_DEPTH = 11 

113"""How many levels of ``extends`` GitLab resolves.""" 

114 

115MAX_REFERENCE_DEPTH = 10 

116"""How many levels of ``!reference`` tags GitLab resolves.""" 

117 

118 

119@export 

120class PipelineFileError(GitLabError): 

121 """ 

122 Base-exception of all exceptions raised by :mod:`pyTooling.GitLab.PipelineFile`. 

123 

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

127 

128 _path: Nullable[Path] #: Path to the configuration file. 

129 _line: Nullable[int] #: Line in the configuration file, starting at 1. 

130 

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. 

134 

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) 

140 

141 self._path = path 

142 self._line = line 

143 

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}'.") 

146 

147 @readonly 

148 def Path(self) -> Nullable[Path]: 

149 """ 

150 Read-only property to access the path to the configuration file (:attr:`_path`). 

151 

152 :returns: The path, or ``None`` if the problem isn't tied to a file. 

153 """ 

154 return self._path 

155 

156 @readonly 

157 def Line(self) -> Nullable[int]: 

158 """ 

159 Read-only property to access the line the problem was found at (:attr:`_line`). 

160 

161 :returns: The line, starting at 1, or ``None`` if the problem isn't tied to a line. 

162 """ 

163 return self._line 

164 

165 

166@export 

167class IncludeKind(StringEnum): 

168 """Where an included file comes from, as the key of an ``include`` entry names it.""" 

169 

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. 

176 

177 

178@export 

179class InputType(StringEnum): 

180 """The type of an input, as ``type`` names it.""" 

181 

182 String = "string" #: A string. 

183 Number = "number" #: A number. 

184 Boolean = "boolean" #: ``true`` or ``false``. 

185 Array = "array" #: A list of values. 

186 

187 

188@export 

189class TriggerStrategy(StringEnum): 

190 """How a trigger job's status follows the pipeline it starts, as ``trigger.strategy`` names it.""" 

191 

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. 

194 

195 

196@export 

197class NeedKind(StringEnum): 

198 """What an entry of ``needs`` refers to.""" 

199 

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. 

204 

205 

206@export 

207@abstractclass 

208class Base(Generic[ParentType], metaclass=ExtendedType, slots=True): 

209 """ 

210 Common behaviour of every element of a configuration file. 

211 

212 Every element knows the element containing it, the configuration file it belongs to, and the line it starts at. 

213 """ 

214 

215 _PARENT_TYPE: ClassVar[ParentTypes] = None #: Type a parent must have, or ``None`` when the element has no parent. 

216 

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. 

220 

221 def __init__(self, line: int, *, parent: Nullable[ParentType] = None) -> None: 

222 """ 

223 Initializes an element of a configuration file. 

224 

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 

242 

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 

248 

249 self._parent = parent 

250 self._pipelineFile = None if parent is None else parent._pipelineFile 

251 self._line = line 

252 

253 @property 

254 def Parent(self) -> Nullable[ParentType]: 

255 """ 

256 Property to access the containing element (:attr:`_parent`). 

257 

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. 

260 

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 

267 

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 

277 

278 self._parent = value 

279 self._pipelineFile = value._pipelineFile 

280 

281 @readonly 

282 def PipelineFile(self) -> Nullable[PipelineFile]: 

283 """ 

284 Read-only property to access the configuration file this element belongs to (:attr:`_pipelineFile`). 

285 

286 :returns: The configuration file, or ``None`` for an element outside one. 

287 """ 

288 return self._pipelineFile 

289 

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`). 

294 

295 :returns: The line, starting at 1. 

296 """ 

297 return self._line 

298 

299 @readonly 

300 def Location(self) -> str: 

301 """ 

302 Read-only property to return the place the element is written at, for a message. 

303 

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

308 

309 return f"{self._pipelineFile._path.name}:{self._line}" 

310 

311 @staticmethod 

312 def _KeyLine(mapping: CommentedMap, key: Any) -> int: 

313 """ 

314 Return the line a key of a mapping is written at. 

315 

316 A key brought in by a YAML merge key (``<<: *anchor``) is written at the anchor; its line is the mapping's line. 

317 

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 

326 

327 @staticmethod 

328 def _ItemLine(sequence: CommentedSeq, index: int) -> int: 

329 """ 

330 Return the line an item of a sequence is written at. 

331 

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 

337 

338 @staticmethod 

339 def _ToPython(value: Any) -> ValueT: 

340 """ 

341 Convert a value read by ``ruamel.yaml`` into plain Python types. 

342 

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. 

346 

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) 

363 

364 return value 

365 

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``. 

370 

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" 

377 

378 return str(value) 

379 

380 

381@export 

382class PipelineFile(Base[None]): 

383 """ 

384 A GitLab CI/CD configuration file. 

385 

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

389 

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``. 

400 

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. 

415 

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. 

418 

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) 

442 

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 

449 

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 

454 

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 

466 

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 

474 

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 

481 

482 self._variables[variable._name] = variable 

483 variable.Parent = self 

484 

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 

491 

492 (self._hiddenJobs if job.IsHidden else self._jobs)[job._name] = job 

493 job.Parent = self 

494 

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 

500 

501 self._workflow = workflow 

502 workflow.Parent = self 

503 

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 

510 

511 self._includes.append(include) 

512 include.Parent = self 

513 

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 

519 

520 self._default = default 

521 default.Parent = self 

522 

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 

529 

530 self._inputs[parameter._name] = parameter 

531 parameter.Parent = self 

532 

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 

538 

539 @readonly 

540 def Path(self) -> Path: 

541 """ 

542 Read-only property to access the path to the configuration file (:attr:`_path`). 

543 

544 :returns: The path. 

545 """ 

546 return self._path 

547 

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`). 

552 

553 :returns: The inputs, by name, in file order. 

554 """ 

555 return self._inputs 

556 

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`). 

561 

562 :returns: The description, or ``None`` if the file gives none. 

563 """ 

564 return self._description 

565 

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`). 

570 

571 :returns: The stages, in the order they run, or ``None`` if the file has no ``stages`` key. 

572 """ 

573 return self._stages 

574 

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. 

579 

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``. 

582 

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

587 

588 @readonly 

589 def Variables(self) -> dict[str, Variable]: 

590 """ 

591 Read-only property to access the default variables (:attr:`_variables`). 

592 

593 :returns: The variables, by name, in file order. 

594 """ 

595 return self._variables 

596 

597 @readonly 

598 def Jobs(self) -> dict[str, Job]: 

599 """ 

600 Read-only property to access the jobs (:attr:`_jobs`). 

601 

602 :returns: The jobs, by name, in file order. 

603 """ 

604 return self._jobs 

605 

606 @readonly 

607 def HiddenJobs(self) -> dict[str, Job]: 

608 """ 

609 Read-only property to access the hidden jobs (:attr:`_hiddenJobs`). 

610 

611 :returns: The hidden jobs, by name, in file order. 

612 """ 

613 return self._hiddenJobs 

614 

615 @readonly 

616 def Workflow(self) -> Nullable[Workflow]: 

617 """ 

618 Read-only property to access the ``workflow`` key (:attr:`_workflow`). 

619 

620 :returns: The workflow, or ``None`` if the file has no ``workflow`` key. 

621 """ 

622 return self._workflow 

623 

624 @readonly 

625 def Includes(self) -> list[Include]: 

626 """ 

627 Read-only property to access the entries of ``include`` (:attr:`_includes`). 

628 

629 :returns: The entries, in file order. 

630 """ 

631 return self._includes 

632 

633 @readonly 

634 def Default(self) -> Nullable[Default]: 

635 """ 

636 Read-only property to access the ``default`` key (:attr:`_default`). 

637 

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 

641 

642 @readonly 

643 def JobCount(self) -> int: 

644 """ 

645 Read-only property to return the number of jobs, hidden jobs not counted. 

646 

647 :returns: Number of jobs. 

648 """ 

649 return len(self._jobs) 

650 

651 def ContainsJob(self, name: str) -> bool: 

652 """ 

653 Check whether the file has a job of that name. 

654 

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 

659 

660 def IterateJobs(self) -> Iterator[Job]: 

661 """ 

662 Iterate the jobs, hidden jobs not included. 

663 

664 :returns: An iterator over the jobs, in file order. 

665 """ 

666 return iter(self._jobs.values()) 

667 

668 def IterateHiddenJobs(self) -> Iterator[Job]: 

669 """ 

670 Iterate the hidden jobs. 

671 

672 :returns: An iterator over the hidden jobs, in file order. 

673 """ 

674 return iter(self._hiddenJobs.values()) 

675 

676 def __str__(self) -> str: 

677 """ 

678 Return the file's name. 

679 

680 :returns: Name of the configuration file. 

681 """ 

682 return self._path.name 

683 

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. 

687 

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`: 

690 

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. 

696 

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. 

700 

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 

715 

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 

724 

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. 

728 

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 [] 

737 

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 

746 

747 files.append(child) 

748 

749 return files 

750 

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. 

759 

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 

775 

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) 

789 

790 elements[job._name] = element 

791 

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) 

797 

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]) 

807 

808 pipeline = DefinedPipeline(self) 

809 addElements(self, pipeline, 0, (self, )) 

810 pipeline.Validate() 

811 

812 return pipeline 

813 

814 @classmethod 

815 def FromFile(cls, path: Path, resolver: Nullable[IncludeResolver] = None) -> Self: 

816 """ 

817 Read a configuration file. 

818 

819 A file may begin with a header document holding the ``spec`` key, separated from the configuration by ``---``. 

820 

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. 

823 

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 

849 

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 

854 

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(), )) 

860 

861 pipelineFile = cls._Parse(document, path, header, included, incomplete) 

862 pipelineFile._Validate() 

863 

864 return pipelineFile 

865 

866 @staticmethod 

867 def _Load(path: Path) -> tuple[Nullable[CommentedMap], CommentedMap]: 

868 """ 

869 Read the documents of a configuration file. 

870 

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) 

880 

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 

885 

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 

892 

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) 

900 

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 

908 

909 return header, document 

910 

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. 

922 

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 

948 

949 stages = [Base._ToText(stage) for stage in stageList] 

950 

951 variables = None 

952 if "variables" in document: 

953 variables = Variable._FromYAMLMapping(document["variables"], None, path, Base._KeyLine(document, "variables")) 

954 

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)) 

958 

959 workflow = None 

960 if "workflow" in document: 

961 workflow = Workflow._FromYAML(document["workflow"], path, Base._KeyLine(document, "workflow")) 

962 

963 includes = None 

964 if "include" in document: 

965 includes = Include._FromYAMLEntries(document["include"], path, Base._KeyLine(document, "include")) 

966 

967 default = Default._FromYAML(document, path) 

968 

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 

977 

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

980 

981 if (description := spec.get("description", None)) is not None: 

982 description = Base._ToText(description) 

983 

984 return cls(path, stages, variables, jobs, workflow, includes, default, inputs, description) 

985 

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. 

996 

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. 

1001 

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. 

1007 

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]] = {} 

1026 

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. 

1030 

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 

1045 

1046 result.lc.line = line 

1047 result.lc.col = 0 

1048 return result 

1049 

1050 def resolve(value: Any, owner: Nullable[str], depth: int) -> Any: 

1051 """ 

1052 Nested function for recursion, resolving the ``!reference`` tags in a value. 

1053 

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 ) 

1069 

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 ) 

1078 

1079 unresolved.setdefault(owner, []).append(f"!reference [{segments}]") 

1080 return None 

1081 

1082 target = target[segment] 

1083 

1084 if segmentList[0] in foreign: 

1085 target = relocate(target, value.lc.line) 

1086 

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]) 

1103 

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 

1113 

1114 return value 

1115 

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. 

1119 

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 

1131 

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]) 

1135 

1136 def copy(mapping: CommentedMap) -> CommentedMap: 

1137 """ 

1138 Nested function copying a mapping with the lines of its keys. 

1139 

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]) 

1149 

1150 return result 

1151 

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) 

1156 

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 

1165 

1166 mappings[name] = resolve(job, name, 0) 

1167 

1168 defaults = CommentedMap() 

1169 if isinstance(document.get("default", None), CommentedMap): 

1170 defaults = copy(resolve(document["default"], None, 0)) 

1171 

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]) 

1176 

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. 

1180 

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 ) 

1207 

1208 unresolved.setdefault(chain[0] if len(chain) > 0 else name, []).append(f"extends {base}") 

1209 continue 

1210 

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) 

1214 

1215 merge(result, baseMapping) 

1216 

1217 merge(result, own) 

1218 return result 

1219 

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]) 

1230 

1231 resolved[name] = (mapping, unresolved.get(name, [])) 

1232 

1233 return resolved 

1234 

1235 def _Validate(self) -> None: 

1236 """ 

1237 Validate the configuration read from a file. 

1238 

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() 

1248 

1249 def _ValidateStages(self) -> None: 

1250 """ 

1251 Validate that every job is in a stage of the pipeline. 

1252 

1253 A stage interpolating an input, as ``$[[ inputs.stage ]]``, is known when the file is included only. 

1254 

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 

1266 

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. 

1270 

1271 A job's name interpolating an input is known when the file is included only. 

1272 

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 

1279 

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 

1283 

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 

1289 

1290 def _ValidateAcyclic(self) -> None: 

1291 """ 

1292 Validate that the jobs don't need each other in a cycle. 

1293 

1294 :raises PipelineFileError: If the jobs need each other in a cycle. 

1295 """ 

1296 finished: set[str] = set() 

1297 stack: list[str] = [] 

1298 

1299 def visit(job: Job) -> None: 

1300 """ 

1301 Nested function for recursion. 

1302 

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) 

1311 

1312 stack.append(job._name) 

1313 for needed in job.NeededJobs: 

1314 visit(needed) 

1315 stack.pop() 

1316 finished.add(job._name) 

1317 

1318 for job in self._jobs.values(): 

1319 visit(job) 

1320 

1321 

1322@export 

1323class Job(Base[PipelineFile]): 

1324 """ 

1325 A job of a configuration file, or a hidden job. 

1326 

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

1330 

1331 _PARENT_TYPE: ClassVar[ParentTypes] = PipelineFile #: A job is contained in a configuration file. 

1332 

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. 

1353 

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. 

1382 

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. 

1385 

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) 

1428 

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.") 

1441 

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 

1446 

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 

1451 

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 

1460 

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 

1467 

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 

1489 

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 = {} 

1502 

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 

1510 

1511 self._needs.append(need) 

1512 need.Parent = self 

1513 

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 

1520 

1521 self._variables[variable._name] = variable 

1522 variable.Parent = self 

1523 

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 

1530 

1531 self._rules.append(rule) 

1532 rule.Parent = self 

1533 

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 

1540 

1541 self._inputs[parameter._name] = parameter 

1542 parameter.Parent = self 

1543 

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 

1552 

1553 if parallel is not None: 

1554 self._parallel = parallel 

1555 parallel.Parent = self 

1556 

1557 if trigger is not None: 

1558 self._trigger = trigger 

1559 trigger.Parent = self 

1560 

1561 if parent is not None: 

1562 (parent._hiddenJobs if self.IsHidden else parent._jobs)[name] = self 

1563 

1564 @Base.Parent.setter 

1565 def Parent(self, value: PipelineFile) -> None: 

1566 Base.Parent.fset(self, value) 

1567 

1568 for need in () if self._needs is None else self._needs: 

1569 need.Parent = self 

1570 

1571 for variable in self._variables.values(): 

1572 variable.Parent = self 

1573 

1574 for rule in self._rules: 

1575 rule.Parent = self 

1576 

1577 for parameter in self._inputs.values(): 

1578 parameter.Parent = self 

1579 

1580 for element in (self._parallel, self._trigger): 

1581 if element is not None: 

1582 element.Parent = self 

1583 

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`). 

1588 

1589 :returns: Name of the job. 

1590 """ 

1591 return self._name 

1592 

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. 

1597 

1598 :returns: ``True``, if the job's name starts with a dot. 

1599 """ 

1600 return self._name.startswith(".") 

1601 

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`). 

1606 

1607 The job's keys are the merge of theirs and its own already. 

1608 

1609 :returns: The names, in the order ``extends`` lists them. 

1610 """ 

1611 return self._extends 

1612 

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`). 

1617 

1618 A file including other files may extend or refer to their jobs; then the job lacks what they contribute. 

1619 

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 

1624 

1625 @readonly 

1626 def Stage(self) -> str: 

1627 """ 

1628 Read-only property to access the stage the job runs in (:attr:`_stage`). 

1629 

1630 :returns: The stage, ``test`` for a job that doesn't name one. 

1631 """ 

1632 return self._stage 

1633 

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`). 

1638 

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) 

1643 

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. 

1648 

1649 An optional need of a job the file doesn't have is skipped, as are needs of other pipelines. 

1650 

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 () 

1655 

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) 

1658 

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`). 

1663 

1664 :returns: The rules, in the order GitLab evaluates them. 

1665 """ 

1666 return self._rules 

1667 

1668 @readonly 

1669 def Inputs(self) -> dict[str, Input]: 

1670 """ 

1671 Read-only property to access the inputs of the job (:attr:`_inputs`). 

1672 

1673 :returns: The inputs, by name, in file order. 

1674 """ 

1675 return self._inputs 

1676 

1677 @readonly 

1678 def Parallel(self) -> Nullable[Parallel]: 

1679 """ 

1680 Read-only property to access the job's ``parallel`` (:attr:`_parallel`). 

1681 

1682 :returns: How often the job runs, or its matrix, or ``None`` for a job running once. 

1683 """ 

1684 return self._parallel 

1685 

1686 @readonly 

1687 def Trigger(self) -> Nullable[Trigger]: 

1688 """ 

1689 Read-only property to access the pipeline the job starts (:attr:`_trigger`). 

1690 

1691 :returns: The trigger, or ``None`` for a job running a script. 

1692 """ 

1693 return self._trigger 

1694 

1695 @readonly 

1696 def When(self) -> Nullable[RuleOutcome]: 

1697 """ 

1698 Read-only property to access when the job runs (:attr:`_when`). 

1699 

1700 :returns: The ``when`` key, or ``None`` if the job doesn't say - GitLab runs it on success then. 

1701 """ 

1702 return self._when 

1703 

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`). 

1708 

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 

1712 

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`). 

1717 

1718 :returns: The exit codes of ``allow_failure.exit_codes``, in file order. 

1719 """ 

1720 return self._allowedExitCodes 

1721 

1722 @readonly 

1723 def Script(self) -> tuple[str, ...]: 

1724 """ 

1725 Read-only property to access the lines of the job's ``script`` (:attr:`_script`). 

1726 

1727 :returns: The lines, nested lists flattened. 

1728 """ 

1729 return self._script 

1730 

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`). 

1735 

1736 :returns: The lines, nested lists flattened. 

1737 """ 

1738 return self._beforeScript 

1739 

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`). 

1744 

1745 :returns: The lines, nested lists flattened. 

1746 """ 

1747 return self._afterScript 

1748 

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`). 

1753 

1754 :returns: The image, as ``python:3.14``, or ``None`` if the job names none. 

1755 """ 

1756 return self._image 

1757 

1758 @readonly 

1759 def Services(self) -> tuple[str, ...]: 

1760 """ 

1761 Read-only property to access the images of the service containers (:attr:`_services`). 

1762 

1763 :returns: The images, in file order. 

1764 """ 

1765 return self._services 

1766 

1767 @readonly 

1768 def Tags(self) -> tuple[str, ...]: 

1769 """ 

1770 Read-only property to access the tags selecting the runner (:attr:`_tags`). 

1771 

1772 :returns: The tags, in file order. 

1773 """ 

1774 return self._tags 

1775 

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`). 

1780 

1781 :returns: The environment's name, or ``None`` if the job deploys to none. 

1782 """ 

1783 return self._environment 

1784 

1785 @readonly 

1786 def Variables(self) -> dict[str, Variable]: 

1787 """ 

1788 Read-only property to access the variables of the job (:attr:`_variables`). 

1789 

1790 :returns: The variables, by name, in file order. 

1791 """ 

1792 return self._variables 

1793 

1794 def __str__(self) -> str: 

1795 """ 

1796 Return the job's name. 

1797 

1798 :returns: Name of the job. 

1799 """ 

1800 return self._name 

1801 

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. 

1806 

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)) 

1819 

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 

1825 

1826 return [] if value is None else [Base._ToText(value)] 

1827 

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. 

1832 

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) 

1838 

1839 return None if value is None else Base._ToText(value) 

1840 

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. 

1852 

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

1867 

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 

1879 

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 

1886 

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 

1893 

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 

1902 

1903 services = [service for item in serviceList if (service := cls._Name(item)) is not None] 

1904 

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 

1911 

1912 tags = [Base._ToText(tag) for tag in tagList] 

1913 

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) 

1918 

1919 rules = None 

1920 if "rules" in mapping: 

1921 rules = Rule._FromYAMLEntries(mapping["rules"], f"job '{name}'", path, Base._KeyLine(mapping, "rules")) 

1922 

1923 inputs = None 

1924 if "inputs" in mapping: 

1925 inputs = Input._FromYAMLMapping(mapping["inputs"], name, path, Base._KeyLine(mapping, "inputs")) 

1926 

1927 parallel = None 

1928 if "parallel" in mapping: 

1929 parallel = Parallel._FromYAML(mapping["parallel"], f"job '{name}'", path, Base._KeyLine(mapping, "parallel")) 

1930 

1931 trigger = None 

1932 if "trigger" in mapping: 

1933 trigger = Trigger._FromYAML(mapping["trigger"], name, path, Base._KeyLine(mapping, "trigger")) 

1934 

1935 extends = mapping.get("extends", ()) 

1936 extends = [extends] if isinstance(extends, str) else extends 

1937 stage = mapping.get("stage", None) 

1938 

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 ) 

1961 

1962 

1963@export 

1964class Variable(Base[Union[PipelineFile, Job]]): 

1965 """ 

1966 A CI/CD variable, declared in a ``variables`` key. 

1967 

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

1971 

1972 _PARENT_TYPE: ClassVar[ParentTypes] = (PipelineFile, Job) #: A variable is declared by a configuration file or a job. 

1973 

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. 

1979 

1980 _KEYS: ClassVar[tuple[str, ...]] = ("value", "description", "options", "expand") #: Keys of a declaration as mapping. 

1981 

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. 

1995 

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) 

2014 

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.") 

2023 

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 

2032 

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 

2039 

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 

2044 

2045 self._name = name 

2046 self._value = value 

2047 self._description = description 

2048 self._expand = expand 

2049 

2050 if parent is not None: 

2051 parent._variables[name] = self 

2052 

2053 @readonly 

2054 def Name(self) -> str: 

2055 """ 

2056 Read-only property to access the variable's name (:attr:`_name`). 

2057 

2058 :returns: Name of the variable. 

2059 """ 

2060 return self._name 

2061 

2062 @readonly 

2063 def Value(self) -> Nullable[str]: 

2064 """ 

2065 Read-only property to access the variable's value (:attr:`_value`). 

2066 

2067 A value written as a number or a boolean is its text, as ``3.14`` or ``true``. 

2068 

2069 :returns: The value, or ``None`` if the declaration gives none. 

2070 """ 

2071 return self._value 

2072 

2073 @readonly 

2074 def Description(self) -> Nullable[str]: 

2075 """ 

2076 Read-only property to access the variable's description (:attr:`_description`). 

2077 

2078 :returns: The description, or ``None`` if the declaration gives none. 

2079 """ 

2080 return self._description 

2081 

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`). 

2086 

2087 :returns: The values, in the order the ``options`` key lists them. 

2088 """ 

2089 return self._options 

2090 

2091 @readonly 

2092 def Expand(self) -> Nullable[bool]: 

2093 """ 

2094 Read-only property to access whether variables in the value are expanded (:attr:`_expand`). 

2095 

2096 :returns: The ``expand`` key, or ``None`` if the declaration gives none - GitLab expands then. 

2097 """ 

2098 return self._expand 

2099 

2100 def __str__(self) -> str: 

2101 """ 

2102 Return the variable's name. 

2103 

2104 :returns: Name of the variable. 

2105 """ 

2106 return self._name 

2107 

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`. 

2112 

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)) 

2131 

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 

2137 

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 

2147 

2148 options = [Base._ToText(option) for option in optionList] 

2149 

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 

2157 

2158 value = declaration.get("value", None) 

2159 description = declaration.get("description", None) 

2160 

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 ) 

2168 

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. 

2173 

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 

2190 

2191 return [cls._FromYAML(str(name), value, path, Base._KeyLine(mapping, name)) for name, value in mapping.items()] 

2192 

2193 

2194@export 

2195class Need(Base[Job]): 

2196 """ 

2197 An entry of a job's ``needs``. 

2198 

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

2204 

2205 _PARENT_TYPE: ClassVar[ParentTypes] = Job #: A need is an entry of a job's ``needs``. 

2206 

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. 

2215 

2216 _KEYS: ClassVar[tuple[str, ...]] = ( 

2217 "job", "artifacts", "optional", "pipeline", "project", "ref", "parallel" 

2218 ) #: Keys of an entry as mapping. 

2219 

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``. 

2236 

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) 

2259 

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 

2266 

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 

2277 

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 

2286 

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 

2297 

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 

2306 

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 

2312 

2313 self._parallel = parallel 

2314 parallel.Parent = self 

2315 

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 = [] 

2319 

2320 parent._needs.append(self) 

2321 

2322 @Base.Parent.setter 

2323 def Parent(self, value: Job) -> None: 

2324 Base.Parent.fset(self, value) 

2325 

2326 if self._parallel is not None: 

2327 self._parallel.Parent = self 

2328 

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`). 

2333 

2334 :returns: The instances' matrix, or ``None`` if every instance is needed. 

2335 """ 

2336 return self._parallel 

2337 

2338 @readonly 

2339 def Kind(self) -> NeedKind: 

2340 """ 

2341 Read-only property to access what the entry refers to (:attr:`_kind`). 

2342 

2343 :returns: The kind of the need. 

2344 """ 

2345 return self._kind 

2346 

2347 @readonly 

2348 def JobName(self) -> Nullable[str]: 

2349 """ 

2350 Read-only property to access the name of the needed job (:attr:`_job`). 

2351 

2352 :returns: Name of the job, or ``None`` for a need mirroring another pipeline's status. 

2353 """ 

2354 return self._job 

2355 

2356 @readonly 

2357 def NeededJob(self) -> Nullable[Job]: 

2358 """ 

2359 Read-only property to return the needed job of the same file. 

2360 

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 

2365 

2366 return self._pipelineFile._jobs.get(self._job, None) 

2367 

2368 @readonly 

2369 def Artifacts(self) -> bool: 

2370 """ 

2371 Read-only property to access whether the needed job's artifacts are downloaded (:attr:`_artifacts`). 

2372 

2373 :returns: ``True``, if the artifacts are downloaded. 

2374 """ 

2375 return self._artifacts 

2376 

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`). 

2381 

2382 :returns: ``True``, if the need is optional. 

2383 """ 

2384 return self._optional 

2385 

2386 @readonly 

2387 def Pipeline(self) -> Nullable[str]: 

2388 """ 

2389 Read-only property to access the other pipeline (:attr:`_pipeline`). 

2390 

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 

2395 

2396 @readonly 

2397 def Project(self) -> Nullable[str]: 

2398 """ 

2399 Read-only property to access the path of the other project (:attr:`_project`). 

2400 

2401 :returns: The project's path, or ``None`` for a need of a job of the same project. 

2402 """ 

2403 return self._project 

2404 

2405 @readonly 

2406 def Ref(self) -> Nullable[str]: 

2407 """ 

2408 Read-only property to access the reference of the other project (:attr:`_ref`). 

2409 

2410 :returns: The reference, or ``None`` for a need of a job of the same project. 

2411 """ 

2412 return self._ref 

2413 

2414 def __str__(self) -> str: 

2415 """ 

2416 Return the needed job's name, or the pipeline whose status is mirrored. 

2417 

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

2424 

2425 return self._job 

2426 

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`. 

2431 

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) 

2449 

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 

2455 

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 

2468 

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]) 

2472 

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 

2479 

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) 

2484 

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) 

2491 

2492 return cls( 

2493 texts["job"], line, kind, flags["artifacts"], flags["optional"], 

2494 texts["pipeline"], texts["project"], texts["ref"], parallel 

2495 ) 

2496 

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``. 

2501 

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 

2516 

2517 return [cls._FromYAML(entry, jobName, path, Base._ItemLine(value, index)) for index, entry in enumerate(value)] 

2518 

2519 

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

2523 

2524 _PARENT_TYPE: ClassVar[ParentTypes] = PipelineFile #: A workflow belongs to a configuration file. 

2525 

2526 _name: Nullable[str] #: Name of the pipeline. 

2527 _rules: list[Rule] #: Rules deciding whether a pipeline is created. 

2528 

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. 

2539 

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) 

2549 

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 

2554 

2555 self._name = name 

2556 self._rules = [] 

2557 

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 

2564 

2565 self._rules.append(rule) 

2566 rule.Parent = self 

2567 

2568 if parent is not None: 

2569 parent._workflow = self 

2570 

2571 @Base.Parent.setter 

2572 def Parent(self, value: PipelineFile) -> None: 

2573 Base.Parent.fset(self, value) 

2574 

2575 for rule in self._rules: 

2576 rule.Parent = self 

2577 

2578 @readonly 

2579 def Name(self) -> Nullable[str]: 

2580 """ 

2581 Read-only property to access the pipeline's name (:attr:`_name`). 

2582 

2583 :returns: The ``name`` key, as written, or ``None`` if the workflow gives none. 

2584 """ 

2585 return self._name 

2586 

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`). 

2591 

2592 :returns: The rules, in the order GitLab evaluates them. 

2593 """ 

2594 return self._rules 

2595 

2596 @classmethod 

2597 def _FromYAML(cls, mapping: Any, path: Path, line: int) -> Self: 

2598 """ 

2599 Read the ``workflow`` key. 

2600 

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 

2612 

2613 rules = None 

2614 if "rules" in mapping: 

2615 rules = Rule._FromYAMLEntries(mapping["rules"], "the workflow", path, Base._KeyLine(mapping, "rules")) 

2616 

2617 name = mapping.get("name", None) 

2618 return cls(line, None if name is None else Base._ToText(name), rules) 

2619 

2620 

2621@export 

2622class Trigger(Base[Job]): 

2623 """ 

2624 A job's ``trigger``: the child pipeline, or the other project's pipeline, the job starts. 

2625 

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

2629 

2630 _PARENT_TYPE: ClassVar[ParentTypes] = Job #: A trigger belongs to a job. 

2631 

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. 

2636 

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``. 

2649 

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) 

2663 

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 

2672 

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 

2677 

2678 self._project = project 

2679 self._branch = branch 

2680 self._strategy = strategy 

2681 self._includes = [] 

2682 

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 

2689 

2690 self._includes.append(include) 

2691 include.Parent = self 

2692 

2693 if parent is not None: 

2694 parent._trigger = self 

2695 

2696 @Base.Parent.setter 

2697 def Parent(self, value: Job) -> None: 

2698 Base.Parent.fset(self, value) 

2699 

2700 for include in self._includes: 

2701 include.Parent = self 

2702 

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`). 

2707 

2708 :returns: The project's path, or ``None`` for a child pipeline. 

2709 """ 

2710 return self._project 

2711 

2712 @readonly 

2713 def Branch(self) -> Nullable[str]: 

2714 """ 

2715 Read-only property to access the branch of the other project (:attr:`_branch`). 

2716 

2717 :returns: The branch, or ``None`` for the project's default branch. 

2718 """ 

2719 return self._branch 

2720 

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`). 

2725 

2726 :returns: The strategy, or ``None`` if the trigger doesn't say. 

2727 """ 

2728 return self._strategy 

2729 

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`). 

2734 

2735 :returns: The files, in file order; empty for another project's pipeline. 

2736 """ 

2737 return self._includes 

2738 

2739 @readonly 

2740 def IsChildPipeline(self) -> bool: 

2741 """ 

2742 Read-only property to return whether the job starts a child pipeline. 

2743 

2744 :returns: ``True``, if the trigger includes files; ``False`` for another project's pipeline. 

2745 """ 

2746 return len(self._includes) > 0 

2747 

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``. 

2752 

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 

2772 

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 

2785 

2786 includes = None 

2787 if "include" in value: 

2788 includes = Include._FromYAMLEntries(value["include"], path, Base._KeyLine(value, "include")) 

2789 

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 ) 

2799 

2800 

2801@export 

2802class Include(Base[Union[PipelineFile, Trigger]]): 

2803 """ 

2804 An entry of ``include``, which adds the configuration of other files. 

2805 

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

2810 

2811 _PARENT_TYPE: ClassVar[ParentTypes] = (PipelineFile, Trigger) #: Included by a configuration file or a trigger. 

2812 

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. 

2820 

2821 _KINDS: ClassVar[tuple[str, ...]] = tuple(member.value for member in IncludeKind) #: Keys naming an include's kind. 

2822 

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``. 

2838 

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) 

2857 

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 

2864 

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.") 

2873 

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 

2882 

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 

2889 

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 = [] 

2896 

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 

2903 

2904 self._rules.append(rule) 

2905 rule.Parent = self 

2906 

2907 if parent is not None: 

2908 parent._includes.append(self) 

2909 

2910 @Base.Parent.setter 

2911 def Parent(self, value: PipelineFile) -> None: 

2912 Base.Parent.fset(self, value) 

2913 

2914 for rule in self._rules: 

2915 rule.Parent = self 

2916 

2917 @readonly 

2918 def Kind(self) -> IncludeKind: 

2919 """ 

2920 Read-only property to access where the included files come from (:attr:`_kind`). 

2921 

2922 :returns: The kind of the include. 

2923 """ 

2924 return self._kind 

2925 

2926 @readonly 

2927 def Location(self) -> str: 

2928 """ 

2929 Read-only property to access what the entry includes (:attr:`_location`). 

2930 

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 

2934 

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`). 

2939 

2940 :returns: The ``ref`` key, or ``None`` - the project's default branch then. 

2941 """ 

2942 return self._ref 

2943 

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`). 

2948 

2949 :returns: The paths of ``file``, in file order. 

2950 """ 

2951 return self._files 

2952 

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`). 

2957 

2958 :returns: The ``job`` key of an artifact's include, or ``None``. 

2959 """ 

2960 return self._job 

2961 

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`). 

2966 

2967 :returns: The inputs' values, by name, with the types they are written with. 

2968 """ 

2969 return self._inputs 

2970 

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`). 

2975 

2976 :returns: The rules, in the order GitLab evaluates them. 

2977 """ 

2978 return self._rules 

2979 

2980 def __str__(self) -> str: 

2981 """ 

2982 Return the include's kind and location. 

2983 

2984 :returns: The kind and the location, as ``local: /ci/build.yml``. 

2985 """ 

2986 return f"{self._kind.value}: {self._location}" 

2987 

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`. 

2992 

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) 

3011 

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 

3018 

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 

3028 

3029 fileList = fileValue if isinstance(fileValue, CommentedSeq) else [fileValue] 

3030 files = [Base._ToText(file) for file in fileList] 

3031 

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 

3037 

3038 rules = None 

3039 if "rules" in entry: 

3040 rules = Rule._FromYAMLEntries(entry["rules"], "an include", path, Base._KeyLine(entry, "rules")) 

3041 

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 ) 

3052 

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. 

3057 

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)] 

3066 

3067 return [cls._FromYAML(entry, path, Base._ItemLine(value, index)) for index, entry in enumerate(value)] 

3068 

3069 

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. 

3074 

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

3078 

3079 _PARENT_TYPE: ClassVar[ParentTypes] = (Job, Workflow, Include) #: A rule belongs to a job, a workflow or an include. 

3080 

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. 

3088 

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. 

3104 

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) 

3120 

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 

3129 

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 

3141 

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 

3146 

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 

3151 

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) 

3157 

3158 if parent is not None: 

3159 parent._rules.append(self) 

3160 

3161 @readonly 

3162 def Condition(self) -> Nullable[str]: 

3163 """ 

3164 Read-only property to access the expression of the rule's ``if`` (:attr:`_condition`). 

3165 

3166 :returns: The expression, as written, or ``None`` if the rule has no ``if``. 

3167 """ 

3168 return self._condition 

3169 

3170 @readonly 

3171 def Changes(self) -> tuple[str, ...]: 

3172 """ 

3173 Read-only property to access the paths of the rule's ``changes`` (:attr:`_changes`). 

3174 

3175 :returns: The paths, as written in ``changes`` or ``changes.paths``. 

3176 """ 

3177 return self._changes 

3178 

3179 @readonly 

3180 def CompareTo(self) -> Nullable[str]: 

3181 """ 

3182 Read-only property to access the reference ``changes`` is compared to (:attr:`_compareTo`). 

3183 

3184 :returns: The reference of ``changes.compare_to``, or ``None``. 

3185 """ 

3186 return self._compareTo 

3187 

3188 @readonly 

3189 def Exists(self) -> tuple[str, ...]: 

3190 """ 

3191 Read-only property to access the paths of the rule's ``exists`` (:attr:`_exists`). 

3192 

3193 :returns: The paths, as written in ``exists`` or ``exists.paths``. 

3194 """ 

3195 return self._exists 

3196 

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`). 

3201 

3202 :returns: The rule's ``when``, or ``None`` if the rule doesn't say. 

3203 """ 

3204 return self._when 

3205 

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`). 

3210 

3211 :returns: The rule's ``allow_failure``, or ``None`` if the rule doesn't say. 

3212 """ 

3213 return self._allowFailure 

3214 

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`). 

3219 

3220 :returns: The variables' values, by name. 

3221 """ 

3222 return self._variables 

3223 

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``. 

3228 

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) 

3241 

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 

3248 

3249 return [Base._ToText(item) for item in value], None if compareTo is None else Base._ToText(compareTo) 

3250 

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. 

3255 

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 

3270 

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 

3278 

3279 changes = None 

3280 compareTo = None 

3281 if "changes" in entry: 

3282 changes, compareTo = cls._Paths(entry["changes"], "changes", owner, entry, path) 

3283 

3284 exists = None 

3285 if "exists" in entry: 

3286 exists, _ = cls._Paths(entry["exists"], "exists", owner, entry, path) 

3287 

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 

3297 

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 

3303 

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 

3309 

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 )) 

3321 

3322 return rules 

3323 

3324 

3325@export 

3326class Default(Base[PipelineFile]): 

3327 """ 

3328 The ``default`` key of a configuration file: keys every job gets, unless it sets them itself. 

3329 

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

3333 

3334 _PARENT_TYPE: ClassVar[ParentTypes] = PipelineFile #: A default belongs to a configuration file. 

3335 

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``. 

3341 

3342 _KEYS: ClassVar[tuple[str, ...]] = ("image", "services", "before_script", "after_script") #: The global keywords. 

3343 

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. 

3357 

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) 

3370 

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 

3375 

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 

3391 

3392 self._image = image 

3393 

3394 if parent is not None: 

3395 parent._default = self 

3396 

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`). 

3401 

3402 :returns: The image, or ``None`` if the default names none. 

3403 """ 

3404 return self._image 

3405 

3406 @readonly 

3407 def Services(self) -> tuple[str, ...]: 

3408 """ 

3409 Read-only property to access the images of the service containers (:attr:`_services`). 

3410 

3411 :returns: The images, in file order. 

3412 """ 

3413 return self._services 

3414 

3415 @readonly 

3416 def Tags(self) -> tuple[str, ...]: 

3417 """ 

3418 Read-only property to access the tags selecting the runner (:attr:`_tags`). 

3419 

3420 :returns: The tags, in file order. 

3421 """ 

3422 return self._tags 

3423 

3424 @readonly 

3425 def BeforeScript(self) -> tuple[str, ...]: 

3426 """ 

3427 Read-only property to access the lines of ``before_script`` (:attr:`_beforeScript`). 

3428 

3429 :returns: The lines, nested lists flattened. 

3430 """ 

3431 return self._beforeScript 

3432 

3433 @readonly 

3434 def AfterScript(self) -> tuple[str, ...]: 

3435 """ 

3436 Read-only property to access the lines of ``after_script`` (:attr:`_afterScript`). 

3437 

3438 :returns: The lines, nested lists flattened. 

3439 """ 

3440 return self._afterScript 

3441 

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. 

3446 

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 

3461 

3462 mapping = document["default"] 

3463 

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) 

3469 

3470 def value(key: str) -> Any: 

3471 """ 

3472 Nested function returning a key of ``default``, or else the global keyword. 

3473 

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) 

3478 

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 

3486 

3487 services = [service for item in serviceList if (service := Job._Name(item)) is not None] 

3488 

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 

3495 

3496 tags = [Base._ToText(tag) for tag in tagList] 

3497 

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 ) 

3506 

3507 

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. 

3512 

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

3516 

3517 _PARENT_TYPE: ClassVar[ParentTypes] = (Job, Need) #: A parallel belongs to a job or a need. 

3518 

3519 _count: Nullable[int] #: How often the job runs. 

3520 _matrix: tuple[dict[str, tuple[str, ...]], ...] #: Entries of the matrix: variables to their values. 

3521 

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. 

3532 

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) 

3544 

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 

3555 

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 

3562 

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 

3570 

3571 entries.append(variables) 

3572 

3573 self._count = count 

3574 self._matrix = tuple(entries) 

3575 

3576 if parent is not None: 

3577 parent._parallel = self 

3578 

3579 @readonly 

3580 def Count(self) -> Nullable[int]: 

3581 """ 

3582 Read-only property to access how often the job runs (:attr:`_count`). 

3583 

3584 :returns: The count of ``parallel: N``, or ``None`` for a matrix. 

3585 """ 

3586 return self._count 

3587 

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`). 

3592 

3593 :returns: The entries, each mapping variables to their values, in file order; empty for a count. 

3594 """ 

3595 return self._matrix 

3596 

3597 @readonly 

3598 def IsMatrix(self) -> bool: 

3599 """ 

3600 Read-only property to return whether the key is a matrix. 

3601 

3602 :returns: ``True`` for ``parallel: matrix``, ``False`` for ``parallel: N``. 

3603 """ 

3604 return self._count is None 

3605 

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. 

3610 

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))) 

3617 

3618 return combinations 

3619 

3620 @readonly 

3621 def InstanceNames(self) -> list[str]: 

3622 """ 

3623 Read-only property to return the names GitLab gives the instances. 

3624 

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 

3631 

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)] 

3634 

3635 return [self._InstanceName(name, None, combination) for combination in self.Combinations] 

3636 

3637 @staticmethod 

3638 def _InstanceName(name: str, count: Nullable[int], dimensions: Mapping[str, Any]) -> str: 

3639 """ 

3640 Return the name GitLab gives an instance. 

3641 

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

3649 

3650 return f"{name}: [{', '.join(str(value) for value in dimensions.values())}]" 

3651 

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``. 

3656 

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 

3672 

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 

3678 

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 

3684 

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 

3697 

3698 variables[str(variable)] = [Base._ToText(item) for item in values] 

3699 

3700 matrix.append(variables) 

3701 

3702 return cls(line, matrix=matrix) 

3703 

3704 

3705@export 

3706class Input(Base[Union[PipelineFile, Job]]): 

3707 """ 

3708 An input of the header's ``spec:inputs``, or of a job's ``inputs``. 

3709 

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

3714 

3715 _PARENT_TYPE: ClassVar[ParentTypes] = (PipelineFile, Job) #: An input belongs to a configuration file or a job. 

3716 

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``. 

3725 

3726 _KEYS: ClassVar[tuple[str, ...]] = ( 

3727 "default", "type", "description", "options", "regex", "rules" 

3728 ) #: Keys of an input as mapping. 

3729 

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. 

3746 

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) 

3766 

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.") 

3775 

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 

3780 

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 

3785 

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 

3794 

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) 

3803 

3804 if parent is not None: 

3805 parent._inputs[name] = self 

3806 

3807 @readonly 

3808 def Name(self) -> str: 

3809 """ 

3810 Read-only property to access the input's name (:attr:`_name`). 

3811 

3812 :returns: Name of the input. 

3813 """ 

3814 return self._name 

3815 

3816 @readonly 

3817 def Type(self) -> InputType: 

3818 """ 

3819 Read-only property to access the input's type (:attr:`_type`). 

3820 

3821 :returns: The ``type`` key, :attr:`InputType.String` if the input doesn't say. 

3822 """ 

3823 return self._type 

3824 

3825 @readonly 

3826 def Required(self) -> bool: 

3827 """ 

3828 Read-only property to access whether the input has to be set (:attr:`_required`). 

3829 

3830 :returns: ``True``, if the input has no ``default`` key. 

3831 """ 

3832 return self._required 

3833 

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`). 

3838 

3839 :returns: The default, with the type it is written with, or ``None`` if the input has none. 

3840 """ 

3841 return self._default 

3842 

3843 @readonly 

3844 def Description(self) -> Nullable[str]: 

3845 """ 

3846 Read-only property to access the input's description (:attr:`_description`). 

3847 

3848 :returns: The description, or ``None`` if the input gives none. 

3849 """ 

3850 return self._description 

3851 

3852 @readonly 

3853 def Options(self) -> tuple[ValueT, ...]: 

3854 """ 

3855 Read-only property to access the values the input may have (:attr:`_options`). 

3856 

3857 :returns: The options, in file order; empty if the input may have any value. 

3858 """ 

3859 return self._options 

3860 

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`). 

3865 

3866 :returns: The expression, as written, or ``None``. 

3867 """ 

3868 return self._regex 

3869 

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`). 

3874 

3875 :returns: The rules, each mapping ``if``, ``options`` and ``default``, in the order GitLab evaluates them. 

3876 """ 

3877 return self._rules 

3878 

3879 def __str__(self) -> str: 

3880 """ 

3881 Return the input's name. 

3882 

3883 :returns: Name of the input. 

3884 """ 

3885 return self._name 

3886 

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`. 

3891 

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 

3915 

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 

3921 

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 

3929 

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) 

3933 

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 

3941 

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) 

3960 

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 ) 

3970 

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``. 

3975 

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 

3989 

3990 return [ 

3991 cls._FromYAML(str(name), value, jobName, path, Base._KeyLine(mapping, name)) for name, value in mapping.items() 

3992 ] 

3993 

3994 

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. 

3999 

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. 

4002 

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

4006 

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. 

4010 

4011 def __init__(self, root: Path) -> None: 

4012 """ 

4013 Initializes a resolver. 

4014 

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 

4025 

4026 self._root = root 

4027 self._files = {} 

4028 self._documents = {} 

4029 

4030 @readonly 

4031 def Root(self) -> Path: 

4032 """ 

4033 Read-only property to access the repository's root directory (:attr:`_root`). 

4034 

4035 :returns: The directory. 

4036 """ 

4037 return self._root 

4038 

4039 def Resolve(self, include: Include) -> Nullable[PipelineFile]: 

4040 """ 

4041 Read the file a local include names. 

4042 

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 

4055 

4056 if include._kind is not IncludeKind.Local: 

4057 return None 

4058 

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 

4064 

4065 self._files[path] = PipelineFile.FromFile(path, self) 

4066 return self._files[path] 

4067 

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. 

4076 

4077 A file included again, by itself or another, is read once. 

4078 

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 

4090 

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 

4095 

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] 

4104 

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()) 

4109 

4110 return keys, incomplete 

4111 

4112 

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. 

4117 

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

4121 

4122 _definition: DefinitionType #: The element of the configuration file this element was built from. 

4123 

4124 @classmethod 

4125 def _CheckDefinition(cls, definition: DefinitionType) -> None: 

4126 """ 

4127 Check a definition before the element is built from it. 

4128 

4129 The host class names the element by its definition, so it checks the definition before calling 

4130 ``super().__init__()``. 

4131 

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 

4143 

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. 

4148 

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. 

4153 

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)] 

4161 

4162 ciRules = [] 

4163 for rule in rules: 

4164 keyValuePairs: dict[Hashable, Any] = {} 

4165 if len(rule._changes) > 0: 

4166 keyValuePairs["changes"] = rule._changes 

4167 

4168 if rule._compareTo is not None: 

4169 keyValuePairs["compare_to"] = rule._compareTo 

4170 

4171 if len(rule._exists) > 0: 

4172 keyValuePairs["exists"] = rule._exists 

4173 

4174 if rule._allowFailure is not None: 

4175 keyValuePairs["allow_failure"] = rule._allowFailure 

4176 

4177 if len(rule._variables) > 0: 

4178 keyValuePairs["variables"] = dict(rule._variables) 

4179 

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 

4186 

4187 ciRules.append(CIRule(rule._condition, outcome, keyValuePairs=keyValuePairs)) 

4188 

4189 return ciRules 

4190 

4191 def __init__(self, definition: DefinitionType) -> None: 

4192 """ 

4193 Initializes the link of an element to its definition, which :meth:`_CheckDefinition` checked. 

4194 

4195 :param definition: The element of the configuration file this element is built from. 

4196 """ 

4197 self._definition = definition 

4198 

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`). 

4203 

4204 :returns: The :class:`PipelineFile` of a pipeline, or the :class:`Job` of any other element. 

4205 """ 

4206 return self._definition 

4207 

4208 

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

4212 

4213 _childFiles: tuple[PipelineFile, ...] #: The files the child pipeline's elements were built from. 

4214 

4215 def __init__(self, childFiles: Nullable[Iterable[PipelineFile]] = None) -> None: 

4216 """ 

4217 Initializes the child pipeline's files. 

4218 

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 

4228 

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`). 

4233 

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 

4238 

4239 

4240@export 

4241class DefinedPipeline(CIPipeline, DefinitionMixin[PipelineFile]): 

4242 """The pipeline a configuration file defines, as :meth:`PipelineFile.ToPipeline` builds it.""" 

4243 

4244 _DEFINITION_TYPE: ClassVar[type] = PipelineFile #: A pipeline is built from a configuration file. 

4245 

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. 

4249 

4250 :param definition: The configuration file. 

4251 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

4252 """ 

4253 self._CheckDefinition(definition) 

4254 

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) 

4260 

4261 

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

4265 

4266 _DEFINITION_TYPE: ClassVar[type] = Job #: A workflow is built from a trigger job. 

4267 

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. 

4278 

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`. 

4281 

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) 

4289 

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 

4294 

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) 

4302 

4303 @staticmethod 

4304 def _Reference(definition: Job) -> str: 

4305 """ 

4306 Return what a trigger job starts, for :attr:`~pyTooling.CI.Workflow.Reference`. 

4307 

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 

4314 

4315 return ", ".join(str(include) for include in trigger._includes) 

4316 

4317 

4318@export 

4319class DefinedMatrix(CIMatrix, DefinitionMixin[Job]): 

4320 """A job with ``parallel``, as :meth:`PipelineFile.ToPipeline` builds it: a matrix of its instances.""" 

4321 

4322 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix is built from a job with ``parallel``. 

4323 

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. 

4333 

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) 

4340 

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 

4345 

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) 

4351 

4352 

4353@export 

4354class DefinedMatrixWorkflow(CIMatrixWorkflow, ChildPipelineMixin, DefinitionMixin[Job]): 

4355 """One instance of a trigger job with ``parallel``, as :meth:`PipelineFile.ToPipeline` builds it.""" 

4356 

4357 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix instance is built from the job with ``parallel``. 

4358 

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. 

4370 

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) 

4381 

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.") 

4388 

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) 

4396 

4397 def __str__(self) -> str: 

4398 """ 

4399 Return the name GitLab gives the instance. 

4400 

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) 

4404 

4405 

4406@export 

4407class DefinedJob(CIJob, DefinitionMixin[Job]): 

4408 """A job running a script, as :meth:`PipelineFile.ToPipeline` builds it.""" 

4409 

4410 _DEFINITION_TYPE: ClassVar[type] = Job #: A job is built from its job in the configuration file. 

4411 

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. 

4421 

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) 

4427 

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) 

4433 

4434 

4435@export 

4436class DefinedMatrixJob(CIMatrixJob, DefinitionMixin[Job]): 

4437 """One instance of a job with ``parallel``, as :meth:`PipelineFile.ToPipeline` builds it.""" 

4438 

4439 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix instance is built from the job with ``parallel``. 

4440 

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. 

4451 

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) 

4460 

4461 if dimensions is None: 

4462 raise ValueError("Parameter 'dimensions' is None.") 

4463 

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) 

4469 

4470 def __str__(self) -> str: 

4471 """ 

4472 Return the name GitLab gives the instance. 

4473 

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)