Coverage for pyTooling/CI/__init__.py: 98%

490 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-09-29 07:08 +0000

1# ==================================================================================================================== # 

2# _____ _ _ ____ ___ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___|_ _| # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` || | | | # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| || |___ | | # 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____|___| # 

7# |_| |___/ |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany # 

15# # 

16# Licensed under the Apache License, Version 2.0 (the "License"); # 

17# you may not use this file except in compliance with the License. # 

18# You may obtain a copy of the License at # 

19# # 

20# http://www.apache.org/licenses/LICENSE-2.0 # 

21# # 

22# Unless required by applicable law or agreed to in writing, software # 

23# distributed under the License is distributed on an "AS IS" BASIS, # 

24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

25# See the License for the specific language governing permissions and # 

26# limitations under the License. # 

27# # 

28# SPDX-License-Identifier: Apache-2.0 # 

29# ==================================================================================================================== # 

30# 

31""" 

32Data models of continuous integration services, and the service-independent model of a pipeline they derive from. 

33 

34A model reads a service's REST payloads or files into objects with a parent-child relation, so a consumer works with 

35named attributes and typed enumerations instead of nested dictionaries and magic strings. The models carry no 

36dependency on what is done with them - rendering, tracing or reporting are consumers of a model, not part of it. 

37 

38The service-independent pipeline: 

39 

40.. code-block:: text 

41 

42 PipelineGroup the pipelines started for one commit 

43 +-- Pipeline a pipeline 

44 +-- Workflow a called workflow or a child pipeline, grouping the elements it contains 

45 | +-- ... the same elements a pipeline contains 

46 +-- Matrix a matrix, grouping the instances it produced 

47 | +-- MatrixJob one job instance 

48 | +-- MatrixWorkflow one instance of a called workflow 

49 +-- Job a job 

50 +-- Step a step of that job 

51 

52Every element knows its parent and the pipeline it belongs to. The elements one level below a workflow - its jobs, 

53matrices and called workflows - can **need** each other: :meth:`DependencyMixin.AddNeed` links two siblings, and 

54:meth:`JobGroup.Validate` checks a completed pipeline for cycles. :meth:`Workflow.ToGraph` converts it into a 

55:class:`~pyTooling.Graph.Graph`. 

56 

57A service's model derives from these classes and adds what only that service has: identifiers, URLs, its own status 

58values, the interface of a reusable workflow. 

59 

60.. hint:: 

61 

62 See :ref:`high-level help <CI/Pipeline>` for the mapping of GitHub Actions and GitLab CI onto this model. 

63 

64.. seealso:: 

65 

66 :mod:`pyTooling.CI.GitHub` 

67 |rarr| The model of a GitHub Actions workflow run. 

68 :mod:`pyTooling.REST` 

69 |rarr| The client a model's payloads are read with. 

70""" 

71from __future__ import annotations 

72 

73from datetime import datetime 

74from typing import Any, ClassVar, Iterable, Iterator, Mapping, Optional as Nullable 

75 

76from pyTooling.Common import getFullyQualifiedName, StringEnum 

77from pyTooling.Decorators import export, readonly 

78from pyTooling.Exceptions import ToolingException 

79from pyTooling.Graph import Graph, Subgraph, Vertex 

80from pyTooling.MetaClasses import ExtendedType, ThisClass, abstractclass 

81from pyTooling.REST import JSONObject 

82 

83 

84@export 

85class CIError(ToolingException): 

86 """Base-exception of all exceptions raised by :mod:`pyTooling.CI` and the models of the CI services.""" 

87 

88 

89@export 

90class PipelineError(CIError): 

91 """Base-exception of all exceptions raised by the service-independent pipeline model.""" 

92 

93 

94@export 

95class NeedDependencyError(PipelineError): 

96 """The exception raised for a need - a dependency - two elements of a pipeline can't have.""" 

97 

98 

99@export 

100class NeedDependencyCycleError(NeedDependencyError): 

101 """The exception raised for a need which would close a cycle of dependencies.""" 

102 

103 

104@export 

105class Outcome(StringEnum): 

106 """ 

107 How an element of a pipeline ended, in terms every CI service has. 

108 

109 The members and values are those of OpenTelemetry's semantic conventions for CI/CD 

110 (:class:`pyTooling.Tracing.CI.Result`). A service's model maps its own values onto these, e.g. GitHub's 

111 ``startup_failure`` onto :attr:`Error`. 

112 """ 

113 

114 Success = "success" #: It succeeded. 

115 Failure = "failure" #: It failed. 

116 Timeout = "timeout" #: It was stopped by a timeout. 

117 Skip = "skip" #: It was skipped, because a condition excluded it. 

118 Cancellation = "cancellation" #: It was cancelled before it finished. 

119 Error = "error" #: It ended for another reason, e.g. the service couldn't start it. 

120 

121 @classmethod 

122 def Combine(cls, outcomes: Iterable[Nullable[Outcome]]) -> Nullable[Outcome]: 

123 """ 

124 Combine the outcomes of the elements of a group into the group's outcome. 

125 

126 The worst outcome wins, so one failed job makes a group's outcome a failure. A group whose elements were all 

127 skipped is skipped; a skipped element beside a successful one doesn't change the success. The order is: 

128 

129 #. :attr:`Failure` 

130 #. :attr:`Timeout` 

131 #. :attr:`Error` 

132 #. :attr:`Cancellation` 

133 #. :attr:`Success` 

134 #. :attr:`Skip` 

135 

136 :param outcomes: The elements' outcomes. 

137 :returns: The combined outcome, or ``None`` if there is none, or one of them is ``None``. 

138 """ 

139 found = set(outcomes) 

140 for outcome in (None, cls.Failure, cls.Timeout, cls.Error, cls.Cancellation, cls.Success, cls.Skip): 

141 if outcome in found: 

142 return outcome 

143 

144 return None 

145 

146 

147@export 

148@abstractclass 

149class Base(metaclass=ExtendedType, slots=True): 

150 """ 

151 Common behaviour of every element of a pipeline. 

152 

153 Every element has a name and a position in the tree, and from a run the times a service reports and an 

154 :class:`Outcome`. 

155 """ 

156 

157 _PARENT_TYPE: ClassVar[Nullable[type]] = None #: Type a parent must have, or ``None`` when it has no parent. 

158 

159 _name: str #: Name of the element. 

160 _parent: Nullable[Base] #: Reference to the containing element. 

161 _pipeline: Nullable[Pipeline] #: Reference to the pipeline this element belongs to. 

162 _createdAt: Nullable[datetime] #: Time the element was created. 

163 _startedAt: Nullable[datetime] #: Time the element started running. 

164 _completedAt: Nullable[datetime] #: Time the element completed. 

165 _outcome: Nullable[Outcome] #: How the element ended. 

166 

167 def __init__( 

168 self, 

169 name: str, 

170 *, 

171 createdAt: Nullable[datetime] = None, 

172 startedAt: Nullable[datetime] = None, 

173 completedAt: Nullable[datetime] = None, 

174 outcome: Nullable[Outcome] = None, 

175 parent: Nullable[Base] = None 

176 ) -> None: 

177 """ 

178 Initializes an element of a pipeline. 

179 

180 The element is added to its parent's elements last, so a class deriving from this one checks its own parameters 

181 before it calls this initializer. 

182 

183 :param name: Name of the element. 

184 :param createdAt: Optional, time the element was created. Default: ``None``. 

185 :param startedAt: Optional, time the element started running. Default: ``None``. 

186 :param completedAt: Optional, time the element completed. Default: ``None``. 

187 :param outcome: Optional, how the element ended. Default: ``None``. 

188 :param parent: Optional, reference to the containing element. Default: ``None``. 

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

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

191 :raises ValueError: If parameter 'name' is empty. 

192 :raises TypeError: If parameter 'createdAt' is not of type :class:`~datetime.datetime`. 

193 :raises TypeError: If parameter 'startedAt' is not of type :class:`~datetime.datetime`. 

194 :raises TypeError: If parameter 'completedAt' is not of type :class:`~datetime.datetime`. 

195 :raises TypeError: If parameter 'outcome' is not of type :class:`Outcome`. 

196 :raises TypeError: If parameter 'parent' is given for a class declaring no :attr:`_PARENT_TYPE`. 

197 :raises TypeError: If parameter 'parent' is not of the type this class declares in :attr:`_PARENT_TYPE`. 

198 """ 

199 if name is None: 

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

201 elif not isinstance(name, str): 

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

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

204 raise ex 

205 elif name == "": 

206 raise ValueError("Parameter 'name' is empty.") 

207 

208 for parameterName, timestamp in (("createdAt", createdAt), ("startedAt", startedAt), ("completedAt", completedAt)): 

209 if timestamp is not None and not isinstance(timestamp, datetime): 

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

211 ex.add_note(f"Got type '{getFullyQualifiedName(timestamp)}'.") 

212 raise ex 

213 

214 if outcome is not None and not isinstance(outcome, Outcome): 

215 ex = TypeError("Parameter 'outcome' is not of type 'Outcome'.") 

216 ex.add_note(f"Got type '{getFullyQualifiedName(outcome)}'.") 

217 raise ex 

218 

219 if parent is not None: 

220 if self._PARENT_TYPE is None: 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true

221 ex = TypeError(f"A '{getFullyQualifiedName(self)}' has no parent.") 

222 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.") 

223 raise ex 

224 elif not isinstance(parent, self._PARENT_TYPE): 

225 ex = TypeError(f"Parameter 'parent' is not of type '{self._PARENT_TYPE.__name__}'.") 

226 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.") 

227 raise ex 

228 

229 self._name = name 

230 self._parent = parent 

231 self._pipeline = None if parent is None else parent._pipeline 

232 self._createdAt = createdAt 

233 self._startedAt = startedAt 

234 self._completedAt = completedAt 

235 self._outcome = outcome 

236 

237 if parent is not None: 

238 parent._AddElement(self) 

239 

240 @readonly 

241 def Name(self) -> str: 

242 """ 

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

244 

245 :returns: Name of the element. 

246 """ 

247 return self._name 

248 

249 @readonly 

250 def Parent(self) -> Nullable[Base]: 

251 """ 

252 Read-only property to access the containing element (:attr:`_parent`). 

253 

254 :returns: The containing element, or ``None`` for a :class:`PipelineGroup` and an element not yet placed. 

255 """ 

256 return self._parent 

257 

258 @readonly 

259 def Pipeline(self) -> Nullable[Pipeline]: 

260 """ 

261 Read-only property to access the pipeline this element belongs to (:attr:`_pipeline`). 

262 

263 :returns: The pipeline, or ``None`` for an element outside one. 

264 """ 

265 return self._pipeline 

266 

267 @readonly 

268 def CreatedAt(self) -> Nullable[datetime]: 

269 """ 

270 Read-only property to access the time the element was created (:attr:`_createdAt`). 

271 

272 :returns: The time, or ``None`` if none was reported. 

273 """ 

274 return self._createdAt 

275 

276 @readonly 

277 def StartedAt(self) -> Nullable[datetime]: 

278 """ 

279 Read-only property to access the time the element started running (:attr:`_startedAt`). 

280 

281 :returns: The time, or ``None`` while it hasn't started. 

282 """ 

283 return self._startedAt 

284 

285 @readonly 

286 def CompletedAt(self) -> Nullable[datetime]: 

287 """ 

288 Read-only property to access the time the element completed (:attr:`_completedAt`). 

289 

290 :returns: The time, or ``None`` while it hasn't completed. 

291 """ 

292 return self._completedAt 

293 

294 @readonly 

295 def Outcome(self) -> Nullable[Outcome]: 

296 """ 

297 Read-only property to access how the element ended (:attr:`_outcome`). 

298 

299 :returns: The outcome, or ``None`` while the element hasn't ended. 

300 """ 

301 return self._outcome 

302 

303 @readonly 

304 def Duration(self) -> Nullable[float]: 

305 """ 

306 Read-only property to return how long the element ran. 

307 

308 The times are read through :attr:`StartedAt` and :attr:`CompletedAt`, so a group deriving its times from what 

309 it holds reports a duration too. 

310 

311 :returns: Seconds from starting to completing, or ``None`` while either time is unknown. 

312 """ 

313 if self.StartedAt is None or self.CompletedAt is None: 

314 return None 

315 

316 return (self.CompletedAt - self.StartedAt).total_seconds() 

317 

318 @staticmethod 

319 def _Earliest(times: Iterable[Nullable[datetime]]) -> Nullable[datetime]: 

320 """ 

321 Return the earliest of the times that are known. 

322 

323 :param times: The times, of which some may be unknown. 

324 :returns: The earliest time, or ``None`` if none is known. 

325 """ 

326 known = [time for time in times if time is not None] 

327 

328 return min(known) if len(known) > 0 else None 

329 

330 @staticmethod 

331 def _Latest(times: Iterable[Nullable[datetime]]) -> Nullable[datetime]: 

332 """ 

333 Return the latest of the times, once all of them are known. 

334 

335 :param times: The times, of which some may be unknown. 

336 :returns: The latest time, or ``None`` if one of them is unknown, or there is none. 

337 """ 

338 known = [] 

339 for time in times: 

340 if time is None: 

341 return None 

342 

343 known.append(time) 

344 

345 return max(known) if len(known) > 0 else None 

346 

347 def __str__(self) -> str: 

348 """ 

349 Return a string representation of the element. 

350 

351 :returns: The element's name. 

352 """ 

353 return self._name 

354 

355 

356@export 

357class QualifiedNameMixin(metaclass=ExtendedType, mixin=True, expects=("_parent",)): 

358 """ 

359 Mixin-class for elements named by the workflows containing them. 

360 """ 

361 

362 @readonly 

363 def QualifiedName(self) -> str: 

364 """ 

365 Read-only property to return the element's name, prefixed by the names of the workflows containing it. 

366 

367 Every element is named by :func:`str`, so a :class:`MatrixJob` or a :class:`MatrixWorkflow` carries the 

368 values of the dimensions it was produced for. The walk ends at the :class:`Pipeline`, and passes through a 

369 :class:`Matrix` without naming it - its instances carry its name already. 

370 

371 :returns: The name, with every calling workflow in front of it, separated by ``' / '``. 

372 """ 

373 names = [str(self)] 

374 element = self 

375 while (element := element._parent) is not None and not isinstance(element, Pipeline): 

376 if isinstance(element, Workflow): 

377 names.append(str(element)) 

378 

379 return " / ".join(reversed(names)) 

380 

381 

382@export 

383class ConditionMixin(metaclass=ExtendedType, mixin=True): 

384 """ 

385 Mixin-class for elements a definition can give a condition: workflows, matrices, jobs and steps. 

386 

387 The condition is kept as written - a GitHub ``if:`` expression or a GitLab ``rules:if`` - and not evaluated. 

388 """ 

389 

390 _condition: Nullable[str] #: Condition under which the element runs, as written. 

391 

392 def __init__(self, condition: Nullable[str] = None) -> None: 

393 """ 

394 Initializes the condition of an element. 

395 

396 :param condition: Optional, condition under which the element runs, as written. Default: ``None``. 

397 :raises TypeError: If parameter 'condition' is not of type :class:`str`. 

398 """ 

399 if condition is not None and not isinstance(condition, str): 

400 ex = TypeError("Parameter 'condition' is not of type 'str'.") 

401 ex.add_note(f"Got type '{getFullyQualifiedName(condition)}'.") 

402 raise ex 

403 

404 self._condition = condition 

405 

406 @readonly 

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

408 """ 

409 Read-only property to access the condition under which the element runs (:attr:`_condition`). 

410 

411 :returns: The condition, or ``None`` if the element has none, or it isn't known. 

412 """ 

413 return self._condition 

414 

415 

416@export 

417class DependencyMixin(metaclass=ExtendedType, mixin=True, expects=("_parent",)): 

418 """ 

419 Mixin-class for elements that can need their siblings: jobs, matrices and called workflows. 

420 

421 An element needing another one starts after it. A need is always a **sibling** - an element of the same group. A 

422 group needing another group needs everything that group contains. Whether the needs of a pipeline form cycles is 

423 checked once it is completely built, by :meth:`JobGroup.Validate` or :meth:`PipelineGroup.Validate`. 

424 

425 The mixin compares the elements' parents, so the ``expects`` contract requires :attr:`Base._parent` from whichever 

426 class it ends up in. 

427 """ 

428 

429 _needs: list[DependencyMixin] #: Elements this element needs, in the order they were added. 

430 _dependents: list[DependencyMixin] #: Elements needing this element, in the order they were added. 

431 

432 def __init__( 

433 self, 

434 needs: Nullable[Iterable[DependencyMixin]] = None, 

435 dependents: Nullable[Iterable[DependencyMixin]] = None 

436 ) -> None: 

437 """ 

438 Initializes an element's dependencies. 

439 

440 The element must be contained in its group already, as a need is a sibling. 

441 

442 :param needs: Optional, siblings this element needs. Default: ``None``. 

443 :param dependents: Optional, siblings needing this element. Default: ``None``. 

444 :raises TypeError: If parameter 'needs' or 'dependents' is not iterable. 

445 :raises ValueError: If an element of parameter 'needs' or 'dependents' is ``None``. 

446 :raises TypeError: If an element of parameter 'needs' or 'dependents' is not of type 

447 :class:`DependencyMixin`. 

448 :raises NeedDependencyError: If an element of parameter 'needs' or 'dependents' isn't contained in the same 

449 group. 

450 """ 

451 for name, elements in (("needs", needs), ("dependents", dependents)): 

452 if elements is not None and not isinstance(elements, Iterable): 

453 ex = TypeError(f"Parameter '{name}' is not iterable.") 

454 ex.add_note(f"Got type '{getFullyQualifiedName(elements)}'.") 

455 raise ex 

456 

457 self._needs = [] 

458 self._dependents = [] 

459 

460 if needs is not None: 

461 for need in needs: 

462 self.AddNeed(need) 

463 if dependents is not None: 

464 for dependent in dependents: 

465 if dependent is None: 

466 raise ValueError("An element of parameter 'dependents' is None.") 

467 elif not isinstance(dependent, DependencyMixin): 

468 ex = TypeError("An element of parameter 'dependents' is not of type 'DependencyMixin'.") 

469 ex.add_note(f"Got type '{getFullyQualifiedName(dependent)}'.") 

470 raise ex 

471 

472 dependent.AddNeed(self) 

473 

474 @readonly 

475 def Needs(self) -> list[DependencyMixin]: 

476 """ 

477 Read-only property to access the elements this element needs (:attr:`_needs`). 

478 

479 :returns: The needed elements, in the order they were added. 

480 """ 

481 return self._needs 

482 

483 @readonly 

484 def Dependents(self) -> list[DependencyMixin]: 

485 """ 

486 Read-only property to access the elements needing this element (:attr:`_dependents`). 

487 

488 :returns: The dependent elements, in the order they were added. 

489 """ 

490 return self._dependents 

491 

492 def AddNeed(self, need: DependencyMixin) -> None: 

493 """ 

494 Add an element this element needs, and this element as its dependent. 

495 

496 :param need: The element this element needs. 

497 :raises ValueError: If parameter 'need' is ``None``. 

498 :raises TypeError: If parameter 'need' is not of type :class:`DependencyMixin`. 

499 :raises NeedDependencyCycleError: If parameter 'need' is this element. 

500 :raises NeedDependencyError: If parameter 'need' isn't contained in the same group as this element. 

501 :raises NeedDependencyError: If this element needs parameter 'need' already. 

502 """ 

503 if need is None: 

504 raise ValueError("Parameter 'need' is None.") 

505 elif not isinstance(need, DependencyMixin): 

506 ex = TypeError("Parameter 'need' is not of type 'DependencyMixin'.") 

507 ex.add_note(f"Got type '{getFullyQualifiedName(need)}'.") 

508 raise ex 

509 elif need is self: 

510 raise NeedDependencyCycleError(f"'{self}' can't need itself.") 

511 elif self._parent is None or need._parent is not self._parent: 

512 ex = NeedDependencyError(f"'{self}' can't need '{need}', which isn't contained in the same group.") 

513 ex.add_note(f"'{self}' is contained in '{self._parent}', '{need}' in '{need._parent}'.") 

514 raise ex 

515 elif need in self._needs: 

516 raise NeedDependencyError(f"'{self}' needs '{need}' already.") 

517 

518 self._needs.append(need) 

519 need._dependents.append(self) 

520 

521 @staticmethod 

522 def _FindCycle(elements: Iterable[DependencyMixin]) -> Nullable[list[DependencyMixin]]: 

523 """ 

524 Find a cycle in the needs of sibling elements. 

525 

526 The elements are searched depth-first along their needs; an element reached again while it is on the current 

527 path closes a cycle. Every element and need is visited once. 

528 

529 :param elements: The elements of one group. 

530 :returns: The cycle from its first element back to it, e.g. ``[A, D, C, A]``, or ``None``. 

531 """ 

532 onPath = set() 

533 finished = set() 

534 for start in elements: 

535 if start in finished: 535 ↛ 536line 535 didn't jump to line 536 because the condition on line 535 was never true

536 continue 

537 

538 path = [start] 

539 stack = [iter(start._needs)] 

540 onPath.add(start) 

541 while len(stack) > 0: 

542 need = next(stack[-1], None) 

543 if need is None: 

544 element = path.pop() 

545 stack.pop() 

546 onPath.discard(element) 

547 finished.add(element) 

548 elif need in onPath: 

549 return path[path.index(need):] + [need] 

550 elif need not in finished: 

551 path.append(need) 

552 stack.append(iter(need._needs)) 

553 onPath.add(need) 

554 

555 return None 

556 

557 

558@export 

559class MatrixInstanceMixin(metaclass=ExtendedType, mixin=True): 

560 """ 

561 Mixin-class for a job a matrix produced, carrying the matrix' dimensions it ran with. 

562 

563 A dimension is a variable of the matrix; the instance carries its name and the value it had for this instance. A 

564 service's matrix instance derives from that service's job class and mixes this in, as :class:`MatrixJob` does with 

565 :class:`Job`. 

566 """ 

567 

568 _dimensions: dict[str, Any] #: The matrix' dimensions this instance ran with, as name and value. 

569 

570 def __init__(self, dimensions: Nullable[Mapping[str, Any]] = None) -> None: 

571 """ 

572 Initializes the dimensions of a matrix instance. 

573 

574 :param dimensions: Optional, the dimensions' names and values this instance ran with. Default: ``None``. 

575 :raises TypeError: If parameter 'dimensions' is not a mapping. 

576 :raises TypeError: If a key of parameter 'dimensions' is not of type :class:`str`. 

577 """ 

578 self._dimensions = {} 

579 if dimensions is None: 

580 return 

581 elif not isinstance(dimensions, Mapping): 

582 ex = TypeError("Parameter 'dimensions' is not a mapping ('dict', ...).") 

583 ex.add_note(f"Got type '{getFullyQualifiedName(dimensions)}'.") 

584 raise ex 

585 

586 for name, value in dimensions.items(): 

587 if not isinstance(name, str): 

588 ex = TypeError("A key of parameter 'dimensions' is not of type 'str'.") 

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

590 raise ex 

591 

592 self._dimensions[name] = value 

593 

594 @readonly 

595 def Dimensions(self) -> dict[str, Any]: 

596 """ 

597 Read-only property to access the dimensions this instance ran with (:attr:`_dimensions`). 

598 

599 :returns: The dimensions' names and values, in the matrix' order. 

600 """ 

601 return self._dimensions 

602 

603 

604@export 

605class PipelineGroup(Base): 

606 """ 

607 The pipelines started for one commit, and the top of the tree. 

608 

609 The group has no times of its own and derives them from its pipelines. 

610 """ 

611 

612 _PARENT_TYPE: ClassVar[Nullable[type]] = None #: A pipeline group is the top of the tree and has no parent. 

613 

614 _pipelines: list[Pipeline] #: Pipelines of the group. 

615 

616 def __init__(self, name: str, pipelines: Nullable[Iterable[Pipeline]] = None) -> None: 

617 """ 

618 Initializes a group of pipelines. 

619 

620 :param name: Name of the group, e.g. the commit every pipeline of the group was started on. 

621 :param pipelines: Optional, the pipelines, which are attached to the group. Default: ``None``. 

622 :raises TypeError: If an element of parameter 'pipelines' is not of type :class:`Pipeline`. 

623 """ 

624 super().__init__(name) 

625 

626 self._pipelines = [] 

627 if pipelines is None: 

628 return 

629 

630 for pipeline in pipelines: 

631 if not isinstance(pipeline, Pipeline): 

632 ex = TypeError("An element of parameter 'pipelines' is not of type 'Pipeline'.") 

633 ex.add_note(f"Got type '{getFullyQualifiedName(pipeline)}'.") 

634 raise ex 

635 

636 self._pipelines.append(pipeline) 

637 pipeline._parent = self 

638 

639 def _AddElement(self, pipeline: Pipeline) -> None: 

640 """ 

641 Add a pipeline, which names the group as its parent. 

642 

643 :param pipeline: The pipeline. 

644 """ 

645 self._pipelines.append(pipeline) 

646 

647 @readonly 

648 def Pipelines(self) -> list[Pipeline]: 

649 """ 

650 Read-only property to access the pipelines of the group (:attr:`_pipelines`). 

651 

652 :returns: The pipelines, in the order they were added. 

653 """ 

654 return self._pipelines 

655 

656 @readonly 

657 def CreatedAt(self) -> Nullable[datetime]: 

658 """ 

659 Read-only property to return when the first pipeline of the group was created. 

660 

661 :returns: The time, or ``None`` if no pipeline of the group reports one. 

662 """ 

663 return self._Earliest(pipeline.CreatedAt for pipeline in self._pipelines) 

664 

665 @readonly 

666 def StartedAt(self) -> Nullable[datetime]: 

667 """ 

668 Read-only property to return when the first pipeline of the group started. 

669 

670 :returns: The time, or ``None`` if no pipeline of the group has started. 

671 """ 

672 return self._Earliest(pipeline.StartedAt for pipeline in self._pipelines) 

673 

674 @readonly 

675 def CompletedAt(self) -> Nullable[datetime]: 

676 """ 

677 Read-only property to return when the last pipeline of the group completed. 

678 

679 :returns: The time, or ``None`` while a pipeline of the group hasn't completed, or while it holds none. 

680 """ 

681 return self._Latest(pipeline.CompletedAt for pipeline in self._pipelines) 

682 

683 @readonly 

684 def Outcome(self) -> Nullable[Outcome]: 

685 """ 

686 Read-only property to return how the group's pipelines ended, taken together (see :meth:`Outcome.Combine`). 

687 

688 :returns: The combined outcome, or ``None`` while a pipeline hasn't ended, or while the group holds none. 

689 """ 

690 return Outcome.Combine(pipeline.Outcome for pipeline in self._pipelines) 

691 

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

693 """ 

694 Iterate every job of every pipeline of the group. 

695 

696 :returns: An iterator over the jobs. 

697 """ 

698 for pipeline in self._pipelines: 

699 yield from pipeline.IterateJobs() 

700 

701 def Validate(self) -> None: 

702 """ 

703 Check that the needs of the group's pipelines, and of every group they contain, form no cycle. 

704 

705 :raises NeedDependencyCycleError: If the needs of the pipelines or of a group form a cycle. |br| 

706 The note names the cycle. 

707 """ 

708 if (cycle := DependencyMixin._FindCycle(self._pipelines)) is not None: 708 ↛ 713line 708 didn't jump to line 713 because the condition on line 708 was always true

709 ex = NeedDependencyCycleError(f"The needs of the pipelines of '{self}' form a cycle.") 

710 ex.add_note(f"Cycle: {' -> '.join(str(pipeline) for pipeline in cycle)}.") 

711 raise ex 

712 

713 for pipeline in self._pipelines: 

714 pipeline.Validate() 

715 

716 def __len__(self) -> int: 

717 """ 

718 Return the number of pipelines of the group. 

719 

720 :returns: Number of pipelines. 

721 """ 

722 return len(self._pipelines) 

723 

724 def __contains__(self, name: str) -> bool: 

725 """ 

726 Check whether a pipeline of that name belongs to the group. 

727 

728 :param name: Name of the pipeline to check for. 

729 :returns: ``True``, if a pipeline of that name belongs to the group. 

730 """ 

731 return any(str(pipeline) == name for pipeline in self._pipelines) 

732 

733 def __iter__(self) -> Iterator[Pipeline]: 

734 """ 

735 Iterate the pipelines of the group. 

736 

737 :returns: An iterator over the pipelines. 

738 """ 

739 return iter(self._pipelines) 

740 

741 

742@export 

743@abstractclass 

744class JobGroup(Base): 

745 """ 

746 A group of jobs: the shared behaviour of a :class:`Workflow` and a :class:`Matrix`. 

747 

748 A group the service reports as an element of its own - one that was given a time or an outcome - keeps the times 

749 and the outcome it was given. A group the service doesn't report, as GitHub doesn't report a called workflow or a 

750 matrix, derives them from what it holds. 

751 """ 

752 

753 _PARENT_TYPE: ClassVar[Nullable[type]] = None #: Declared by the groups deriving from this class. 

754 

755 _elements: list[Base] #: Elements of this group - jobs, matrices, workflows - in the order they were added. 

756 

757 def __init__( 

758 self, 

759 name: str, 

760 *, 

761 createdAt: Nullable[datetime] = None, 

762 startedAt: Nullable[datetime] = None, 

763 completedAt: Nullable[datetime] = None, 

764 outcome: Nullable[Outcome] = None, 

765 parent: Nullable[Base] = None 

766 ) -> None: 

767 """ 

768 Initializes a group of jobs. 

769 

770 :param name: Name of the group. 

771 :param createdAt: Optional, time the group was created, if the service reports it. Default: ``None``. 

772 :param startedAt: Optional, time the group started, if the service reports it. Default: ``None``. 

773 :param completedAt: Optional, time the group completed, if the service reports it. Default: ``None``. 

774 :param outcome: Optional, how the group ended, if the service reports it. Default: ``None``. 

775 :param parent: Optional, reference to the element containing the group. Default: ``None``. 

776 """ 

777 super().__init__( 

778 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, outcome=outcome, parent=parent 

779 ) 

780 

781 self._elements = [] 

782 

783 def _AddElement(self, element: Base) -> None: 

784 """ 

785 Add an element, which names the group as its parent. 

786 

787 :param element: The element. 

788 """ 

789 self._elements.append(element) 

790 

791 @readonly 

792 def Elements(self) -> list[Base]: 

793 """ 

794 Read-only property to access the elements of this group (:attr:`_elements`). 

795 

796 :returns: The jobs, matrices and workflows one level below the group, in the order they were added. 

797 """ 

798 return self._elements 

799 

800 @readonly 

801 def Jobs(self) -> list[Job]: 

802 """ 

803 Read-only property to return the jobs of this group. 

804 

805 :returns: The jobs one level below the group, in the order they were added. 

806 """ 

807 return [element for element in self._elements if isinstance(element, Job)] 

808 

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

810 """ 

811 Iterate every job below this group, including those of the groups it contains. 

812 

813 :returns: An iterator over the jobs, in the order their elements were added. 

814 """ 

815 for element in self._elements: 

816 if isinstance(element, Job): 

817 yield element 

818 else: 

819 yield from element.IterateJobs() 

820 

821 def Validate(self) -> None: 

822 """ 

823 Check that the needs of this group and of every group it contains form no cycle. 

824 

825 A pipeline is validated once it is completely built: each group is searched once, along every need. 

826 

827 :raises NeedDependencyCycleError: If the needs of a group form a cycle. |br| 

828 The note names the cycle. 

829 """ 

830 groups = [self] 

831 while len(groups) > 0: 

832 group = groups.pop() 

833 elements = (element for element in group._elements if isinstance(element, DependencyMixin)) 

834 if (cycle := DependencyMixin._FindCycle(elements)) is not None: 

835 ex = NeedDependencyCycleError(f"The needs of the elements of '{group}' form a cycle.") 

836 ex.add_note(f"Cycle: {' -> '.join(str(element) for element in cycle)}.") 

837 raise ex 

838 

839 groups.extend(element for element in group._elements if isinstance(element, JobGroup)) 

840 

841 @readonly 

842 def IsReported(self) -> bool: 

843 """ 

844 Check if the service reported the group as an element of its own. 

845 

846 A reported group keeps its own times and outcome; otherwise they span its contents. 

847 

848 :returns: ``True``, if the group was given a time or an outcome. 

849 """ 

850 return any(fact is not None for fact in (self._createdAt, self._startedAt, self._completedAt, self._outcome)) 

851 

852 @readonly 

853 def CreatedAt(self) -> Nullable[datetime]: 

854 """ 

855 Read-only property to return when the group was created. 

856 

857 :returns: The time the service reported, or - for a group it doesn't report - :attr:`ContentsCreatedAt`. 

858 """ 

859 return self._createdAt if self.IsReported else self.ContentsCreatedAt 

860 

861 @readonly 

862 def StartedAt(self) -> Nullable[datetime]: 

863 """ 

864 Read-only property to return when the group started. 

865 

866 :returns: The time the service reported, or - for a group it doesn't report - :attr:`ContentsStartedAt`. 

867 """ 

868 return self._startedAt if self.IsReported else self.ContentsStartedAt 

869 

870 @readonly 

871 def CompletedAt(self) -> Nullable[datetime]: 

872 """ 

873 Read-only property to return when the group completed. 

874 

875 :returns: The time the service reported, or - for a group it doesn't report - :attr:`ContentsCompletedAt`. 

876 """ 

877 return self._completedAt if self.IsReported else self.ContentsCompletedAt 

878 

879 @readonly 

880 def Outcome(self) -> Nullable[Outcome]: 

881 """ 

882 Read-only property to return how the group ended. 

883 

884 :returns: The outcome the service reported, or - for a group it doesn't report - :attr:`ContentsOutcome`. 

885 """ 

886 return self._outcome if self.IsReported else self.ContentsOutcome 

887 

888 @readonly 

889 def ContentsCreatedAt(self) -> Nullable[datetime]: 

890 """ 

891 Read-only property to return when the first element below this group was created. 

892 

893 :returns: The time, or ``None`` if no element below the group reports one. 

894 """ 

895 return self._Earliest(element.CreatedAt for element in self._elements) 

896 

897 @readonly 

898 def ContentsStartedAt(self) -> Nullable[datetime]: 

899 """ 

900 Read-only property to return when the first element below this group started. 

901 

902 :returns: The time, or ``None`` if no element below the group has started. 

903 """ 

904 return self._Earliest(element.StartedAt for element in self._elements) 

905 

906 @readonly 

907 def ContentsCompletedAt(self) -> Nullable[datetime]: 

908 """ 

909 Read-only property to return when the last element below this group completed. 

910 

911 :returns: The time, or ``None`` while an element below the group hasn't completed, or while it holds none. 

912 """ 

913 return self._Latest(element.CompletedAt for element in self._elements) 

914 

915 @readonly 

916 def ContentsOutcome(self) -> Nullable[Outcome]: 

917 """ 

918 Read-only property to return how the elements below this group ended, taken together. 

919 

920 :returns: The combined outcome (see :meth:`Outcome.Combine`), or ``None`` while an element below the group 

921 hasn't ended, or while it holds none. 

922 """ 

923 return Outcome.Combine(element.Outcome for element in self._elements) 

924 

925 def __len__(self) -> int: 

926 """ 

927 Return the number of elements of this group. 

928 

929 :returns: Number of elements one level below the group. 

930 """ 

931 return len(self._elements) 

932 

933 def __contains__(self, name: str) -> bool: 

934 """ 

935 Check whether an element of that name belongs to this group. 

936 

937 An element is named the way :func:`str` names it, so an instance of a :class:`Matrix` is asked for with its 

938 dimensions' values: ``"Unit Tests (ubuntu-26.04, 3.14)"``. 

939 

940 :param name: Name of the job, matrix or workflow to check for. 

941 :returns: ``True``, if an element of that name belongs to this group. 

942 """ 

943 return any(str(element) == name for element in self._elements) 

944 

945 def __getitem__(self, name: str) -> Base: 

946 """ 

947 Return the element of that name. 

948 

949 An element is named the way :func:`str` names it, as for :meth:`__contains__`. 

950 

951 :param name: Name of the job, matrix or workflow to return. 

952 :returns: The first element of that name, in the order they were added. 

953 :raises KeyError: If no element of that name belongs to this group. 

954 """ 

955 for element in self._elements: 

956 if str(element) == name: 

957 return element 

958 

959 raise KeyError(f"Group '{self._name}' contains no element '{name}'.") 

960 

961 def __iter__(self) -> Iterator[Base]: 

962 """ 

963 Iterate what this group holds, ordered by the time it was created. 

964 

965 The order is stable, so elements reporting no time - e.g. the elements of a definition - keep the order they 

966 were added in, which for a definition is the order of its file. 

967 

968 :returns: An iterator over the contained elements. 

969 """ 

970 def createdAt(element: Base) -> tuple[bool, datetime]: 

971 """ 

972 Nested function sorting an element without a creation time behind every element that has one. 

973 

974 :param element: The element. 

975 :returns: The sort key. 

976 """ 

977 return (element.CreatedAt is None, element.CreatedAt if element.CreatedAt is not None else datetime.min) 

978 

979 return iter(sorted(self._elements, key=createdAt)) 

980 

981 

982@export 

983class Workflow(JobGroup, QualifiedNameMixin, ConditionMixin, DependencyMixin): 

984 """ 

985 A called workflow or a child pipeline, grouping the elements it contains. 

986 

987 A workflow contains jobs, matrices and further called workflows, which can need each other. It is itself an 

988 element of the workflow calling it, and can need and be needed by its siblings. 

989 

990 :attr:`Reference` names what was called, as the service writes it. A workflow whose file isn't read - e.g. one in 

991 a repository that isn't at hand - is a workflow with a reference and no contents. 

992 """ 

993 

994 _PARENT_TYPE: ClassVar[Nullable[type]] = ThisClass #: A workflow is contained in a workflow. 

995 

996 _reference: Nullable[str] #: What the workflow calls, as written. 

997 _workflows: dict[str, Workflow] #: Workflows called by this workflow, by name. 

998 _matrices: dict[str, Matrix] #: Matrices of this workflow, by the name their jobs share. 

999 

1000 def __init__( 

1001 self, 

1002 name: str, 

1003 *, 

1004 reference: Nullable[str] = None, 

1005 condition: Nullable[str] = None, 

1006 createdAt: Nullable[datetime] = None, 

1007 startedAt: Nullable[datetime] = None, 

1008 completedAt: Nullable[datetime] = None, 

1009 outcome: Nullable[Outcome] = None, 

1010 parent: Nullable[Workflow] = None, 

1011 needs: Nullable[Iterable[DependencyMixin]] = None, 

1012 dependents: Nullable[Iterable[DependencyMixin]] = None 

1013 ) -> None: 

1014 """ 

1015 Initializes a called workflow. 

1016 

1017 :param name: Name of the workflow. 

1018 :param reference: Optional, what the workflow calls, as written. Default: ``None``. 

1019 :param condition: Optional, condition under which the workflow is called, as written. Default: ``None``. 

1020 :param createdAt: Optional, time the workflow was created, if the service reports it. Default: ``None``. 

1021 :param startedAt: Optional, time the workflow started, if the service reports it. Default: ``None``. 

1022 :param completedAt: Optional, time the workflow completed, if the service reports it. Default: ``None``. 

1023 :param outcome: Optional, how the workflow ended, if the service reports it. Default: ``None``. 

1024 :param parent: Optional, reference to the workflow calling this one. Default: ``None``. 

1025 :param needs: Optional, siblings this workflow needs. Default: ``None``. 

1026 :param dependents: Optional, siblings needing this workflow. Default: ``None``. 

1027 :raises TypeError: If parameter 'reference' is not of type :class:`str`. 

1028 :raises PipelineError: If the calling workflow calls a workflow of that name already. 

1029 """ 

1030 if reference is not None and not isinstance(reference, str): 

1031 ex = TypeError("Parameter 'reference' is not of type 'str'.") 

1032 ex.add_note(f"Got type '{getFullyQualifiedName(reference)}'.") 

1033 raise ex 

1034 

1035 super().__init__( 

1036 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, outcome=outcome, parent=parent 

1037 ) 

1038 ConditionMixin.__init__(self, condition) 

1039 DependencyMixin.__init__(self, needs, dependents) 

1040 

1041 self._reference = reference 

1042 self._workflows = {} 

1043 self._matrices = {} 

1044 

1045 def _AddElement(self, element: Base) -> None: 

1046 """ 

1047 Add an element, which names the workflow as its parent, and index a called workflow or a matrix by its name. 

1048 

1049 :param element: The element. 

1050 :raises PipelineError: If the workflow calls a workflow of that name already. 

1051 :raises PipelineError: If the workflow contains a matrix of that name already. 

1052 """ 

1053 if isinstance(element, Workflow): 

1054 if element._name in self._workflows: 

1055 raise PipelineError(f"Workflow '{self._name}' calls a workflow '{element._name}' already.") 

1056 

1057 self._workflows[element._name] = element 

1058 elif isinstance(element, Matrix): 

1059 if element._name in self._matrices: 

1060 raise PipelineError(f"Workflow '{self._name}' contains a matrix '{element._name}' already.") 

1061 

1062 self._matrices[element._name] = element 

1063 

1064 super()._AddElement(element) 

1065 

1066 @readonly 

1067 def Reference(self) -> Nullable[str]: 

1068 """ 

1069 Read-only property to access what the workflow calls, as written (:attr:`_reference`). 

1070 

1071 E.g. ``pyTooling/Actions/.github/workflows/Package.yml@r8`` for GitHub, or the file a GitLab trigger includes. 

1072 

1073 :returns: The reference, or ``None`` if it isn't known. 

1074 """ 

1075 return self._reference 

1076 

1077 @readonly 

1078 def Workflows(self) -> dict[str, Workflow]: 

1079 """ 

1080 Read-only property to access the workflows this workflow calls, by name (:attr:`_workflows`). 

1081 

1082 :returns: The called workflows, in the order they were added. 

1083 """ 

1084 return self._workflows 

1085 

1086 @readonly 

1087 def Matrices(self) -> dict[str, Matrix]: 

1088 """ 

1089 Read-only property to access the matrices of this workflow, by the name their instances share (:attr:`_matrices`). 

1090 

1091 :returns: The matrices, in the order they were added. 

1092 """ 

1093 return self._matrices 

1094 

1095 def ToGraph(self, depth: Nullable[int] = None, reduce: bool = True) -> Graph: 

1096 """ 

1097 Convert the workflow into a graph of the elements it contains and their dependencies. 

1098 

1099 Every element one level below the workflow becomes a :class:`~pyTooling.Graph.Vertex` of the graph, with the 

1100 element as its :attr:`~pyTooling.Graph.Vertex.ID` and its :attr:`~pyTooling.Graph.Vertex.Value`, so 

1101 :meth:`Graph.GetVertexByID <pyTooling.Graph.Graph.GetVertexByID>` finds an element's vertex. A vertex has no 

1102 name; a consumer labels it by :pycode:`vertex.Value.QualifiedName`. Every dependency becomes an 

1103 :class:`~pyTooling.Graph.Edge` from the element needing to the element it needs, so an edge reads *needs*, and 

1104 :meth:`Graph.IterateTopologically <pyTooling.Graph.BaseGraph.IterateTopologically>` yields the elements in an order 

1105 they can run in. 

1106 

1107 A called workflow or a matrix holding elements is expanded into a :class:`~pyTooling.Graph.Subgraph` named by 

1108 its qualified name, whose vertices and edges are built the same way. The group's vertex has a 

1109 :class:`~pyTooling.Graph.Link` to each vertex of its subgraph. The graph's own vertices and edges don't 

1110 include those of its subgraphs, since :mod:`pyTooling.Graph` registers them on the subgraph. 

1111 

1112 By default, the graph and every subgraph are reduced to their transitive reduction: a dependency a longer path 

1113 already implies - ``C`` needing ``A`` although it needs ``B``, which needs ``A`` - has no edge. With ``reduce`` 

1114 set to ``False``, every dependency has one. 

1115 

1116 :param depth: Optional, how many levels of nested groups to expand; ``0`` expands none, ``None`` every 

1117 level. Default: ``None``. 

1118 :param reduce: Optional, ``True``, if the edges a longer path already implies are removed. Default: 

1119 ``True``. 

1120 :returns: The graph, named like the workflow. 

1121 :raises TypeError: If parameter 'depth' is not of type :class:`int`. 

1122 :raises ValueError: If parameter 'depth' is negative. 

1123 :raises ValueError: If parameter 'reduce' is None. 

1124 :raises TypeError: If parameter 'reduce' is not of type :class:`bool`. 

1125 

1126 .. seealso:: 

1127 

1128 :meth:`Graph.RemoveTransitiveEdges <pyTooling.Graph.BaseGraph.RemoveTransitiveEdges>` 

1129 |rarr| Remove the edges a longer path already implies. 

1130 """ 

1131 if depth is not None: 

1132 if not isinstance(depth, int) or isinstance(depth, bool): 

1133 ex = TypeError("Parameter 'depth' is not of type 'int'.") 

1134 ex.add_note(f"Got type '{getFullyQualifiedName(depth)}'.") 

1135 raise ex 

1136 elif depth < 0: 

1137 ex = ValueError("Parameter 'depth' is negative.") 

1138 ex.add_note(f"Got value '{depth}'.") 

1139 raise ex 

1140 

1141 if reduce is None: 

1142 raise ValueError("Parameter 'reduce' is None.") 

1143 elif not isinstance(reduce, bool): 

1144 ex = TypeError("Parameter 'reduce' is not of type 'bool'.") 

1145 ex.add_note(f"Got type '{getFullyQualifiedName(reduce)}'.") 

1146 raise ex 

1147 

1148 graph = Graph(name=self._name) 

1149 

1150 def addContents( 

1151 group: JobGroup, 

1152 subgraph: Nullable[Subgraph], 

1153 groupVertex: Nullable[Vertex], 

1154 level: int 

1155 ) -> None: 

1156 """ 

1157 Nested function for recursion. 

1158 

1159 :param group: The group whose contents become vertices. 

1160 :param subgraph: The subgraph the vertices are placed in, or ``None`` for the graph itself. 

1161 :param groupVertex: The group's own vertex, which links to the new vertices, or ``None`` for the graph itself. 

1162 :param level: How many levels of nested groups are expanded above this one. 

1163 """ 

1164 vertices: dict[Base, Vertex] = {} 

1165 for element in group: 

1166 vertex = Vertex(vertexID=element, value=element, graph=graph, subgraph=subgraph) 

1167 vertices[element] = vertex 

1168 if groupVertex is not None: 

1169 groupVertex.LinkToVertex(vertex) 

1170 

1171 for element, vertex in vertices.items(): 

1172 for need in element._needs: 

1173 vertex.EdgeToVertex(vertices[need]) 

1174 

1175 if depth is not None and level >= depth: 

1176 return 

1177 

1178 for element, vertex in vertices.items(): 

1179 if isinstance(element, JobGroup) and len(element) > 0: 

1180 addContents(element, Subgraph(graph, name=element.QualifiedName), vertex, level + 1) 

1181 

1182 addContents(self, None, None, 0) 

1183 

1184 if reduce: 

1185 graph.RemoveTransitiveEdges() 

1186 for subgraph in graph.Subgraphs: 

1187 subgraph.RemoveTransitiveEdges() 

1188 

1189 return graph 

1190 

1191 

1192@export 

1193class Pipeline(Workflow): 

1194 """ 

1195 A pipeline: the workflow a service started, holding everything else. 

1196 

1197 A pipeline can need another pipeline of its :class:`PipelineGroup`, e.g. one triggered by another's completion. 

1198 """ 

1199 

1200 _PARENT_TYPE: ClassVar[Nullable[type]] = PipelineGroup #: A pipeline is contained in a pipeline group. 

1201 

1202 def __init__( 

1203 self, 

1204 name: str, 

1205 *, 

1206 condition: Nullable[str] = None, 

1207 createdAt: Nullable[datetime] = None, 

1208 startedAt: Nullable[datetime] = None, 

1209 completedAt: Nullable[datetime] = None, 

1210 outcome: Nullable[Outcome] = None, 

1211 parent: Nullable[PipelineGroup] = None, 

1212 needs: Nullable[Iterable[DependencyMixin]] = None, 

1213 dependents: Nullable[Iterable[DependencyMixin]] = None 

1214 ) -> None: 

1215 """ 

1216 Initializes a pipeline. 

1217 

1218 :param name: Name of the pipeline. 

1219 :param condition: Optional, condition under which the pipeline runs, as written. Default: ``None``. 

1220 :param createdAt: Optional, time the pipeline was created. Default: ``None``. 

1221 :param startedAt: Optional, time the pipeline started. Default: ``None``. 

1222 :param completedAt: Optional, time the pipeline completed. Default: ``None``. 

1223 :param outcome: Optional, how the pipeline ended. Default: ``None``. 

1224 :param parent: Optional, reference to the group of pipelines. Default: ``None``. 

1225 :param needs: Optional, siblings this pipeline needs. Default: ``None``. 

1226 :param dependents: Optional, siblings needing this pipeline. Default: ``None``. 

1227 """ 

1228 super().__init__( 

1229 name, condition=condition, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, outcome=outcome, 

1230 parent=parent, needs=needs, dependents=dependents 

1231 ) 

1232 

1233 self._pipeline = self 

1234 

1235 

1236 

1237@export 

1238class Matrix(JobGroup, QualifiedNameMixin, ConditionMixin, DependencyMixin): 

1239 """ 

1240 A matrix, grouping the instances it produced: jobs, or called workflows. 

1241 

1242 The matrix is an element of its workflow, so it can need and be needed by its siblings. A service doesn't report a 

1243 matrix as an element of its own, so it has no times of its own and spans its instances. 

1244 """ 

1245 

1246 _PARENT_TYPE: ClassVar[Nullable[type]] = Workflow #: A matrix is contained in a workflow. 

1247 

1248 def __init__( 

1249 self, 

1250 name: str, 

1251 *, 

1252 condition: Nullable[str] = None, 

1253 parent: Nullable[Workflow] = None, 

1254 needs: Nullable[Iterable[DependencyMixin]] = None, 

1255 dependents: Nullable[Iterable[DependencyMixin]] = None 

1256 ) -> None: 

1257 """ 

1258 Initializes a matrix. 

1259 

1260 :param name: Name of the matrix, which its instances share. 

1261 :param condition: Optional, condition under which the instances run, as written. Default: ``None``. 

1262 :param parent: Optional, reference to the workflow containing the matrix. Default: ``None``. 

1263 :param needs: Optional, siblings this matrix needs. Default: ``None``. 

1264 :param dependents: Optional, siblings needing this matrix. Default: ``None``. 

1265 :raises PipelineError: If the workflow contains a matrix of that name already. 

1266 """ 

1267 super().__init__(name, parent=parent) 

1268 ConditionMixin.__init__(self, condition) 

1269 DependencyMixin.__init__(self, needs, dependents) 

1270 

1271 @readonly 

1272 def Instances(self) -> list[Base]: 

1273 """ 

1274 Read-only property to access the instances this matrix produced (:attr:`_elements`). 

1275 

1276 :returns: The :class:`MatrixJob` or :class:`MatrixWorkflow` instances, in the order they were added. 

1277 """ 

1278 return self._elements 

1279 

1280 

1281@export 

1282class MatrixWorkflow(Workflow, MatrixInstanceMixin): 

1283 """ 

1284 One instance of a called workflow produced by a matrix, e.g. a GitHub job with ``strategy.matrix`` and ``uses:``. 

1285 """ 

1286 

1287 _PARENT_TYPE: ClassVar[Nullable[type]] = Matrix #: A matrix instance is contained in a matrix. 

1288 

1289 def __init__( 

1290 self, 

1291 name: str, 

1292 dimensions: Nullable[Mapping[str, Any]] = None, 

1293 *, 

1294 reference: Nullable[str] = None, 

1295 condition: Nullable[str] = None, 

1296 createdAt: Nullable[datetime] = None, 

1297 startedAt: Nullable[datetime] = None, 

1298 completedAt: Nullable[datetime] = None, 

1299 outcome: Nullable[Outcome] = None, 

1300 parent: Nullable[Matrix] = None, 

1301 needs: Nullable[Iterable[DependencyMixin]] = None, 

1302 dependents: Nullable[Iterable[DependencyMixin]] = None 

1303 ) -> None: 

1304 """ 

1305 Initializes one instance of a called workflow produced by a matrix. 

1306 

1307 :param name: Name of the workflow, without the dimensions' values. 

1308 :param dimensions: Optional, the dimensions' names and values this instance ran with. Default: ``None``. 

1309 :param reference: Optional, what the workflow calls, as written. Default: ``None``. 

1310 :param condition: Optional, condition under which the workflow is called, as written. Default: ``None``. 

1311 :param createdAt: Optional, time the workflow was created, if the service reports it. Default: ``None``. 

1312 :param startedAt: Optional, time the workflow started, if the service reports it. Default: ``None``. 

1313 :param completedAt: Optional, time the workflow completed, if the service reports it. Default: ``None``. 

1314 :param outcome: Optional, how the workflow ended, if the service reports it. Default: ``None``. 

1315 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``. 

1316 :param needs: Optional, siblings this instance needs. Default: ``None``. 

1317 :param dependents: Optional, siblings needing this instance. Default: ``None``. 

1318 """ 

1319 super().__init__( 

1320 name, reference=reference, condition=condition, createdAt=createdAt, startedAt=startedAt, 

1321 completedAt=completedAt, outcome=outcome, parent=parent 

1322 ) 

1323 MatrixInstanceMixin.__init__(self, dimensions) 

1324 DependencyMixin.__init__(self, needs, dependents) 

1325 

1326 def __str__(self) -> str: 

1327 """ 

1328 Return a string representation of the matrix instance. 

1329 

1330 :returns: The workflow's name, followed by its dimensions' values in brackets, if it has any. 

1331 """ 

1332 if len(self._dimensions) == 0: 1332 ↛ 1333line 1332 didn't jump to line 1333 because the condition on line 1332 was never true

1333 return self._name 

1334 

1335 return f"{self._name} ({', '.join(str(value) for value in self._dimensions.values())})" 

1336 

1337 

1338@export 

1339class Job(Base, QualifiedNameMixin, ConditionMixin, DependencyMixin): 

1340 """A job, which runs its steps on a worker.""" 

1341 

1342 _PARENT_TYPE: ClassVar[Nullable[type]] = JobGroup #: A job is contained in a job group. 

1343 

1344 _steps: list[Step] #: Steps of the job. 

1345 

1346 def __init__( 

1347 self, 

1348 name: str, 

1349 *, 

1350 condition: Nullable[str] = None, 

1351 createdAt: Nullable[datetime] = None, 

1352 startedAt: Nullable[datetime] = None, 

1353 completedAt: Nullable[datetime] = None, 

1354 outcome: Nullable[Outcome] = None, 

1355 parent: Nullable[JobGroup] = None, 

1356 needs: Nullable[Iterable[DependencyMixin]] = None, 

1357 dependents: Nullable[Iterable[DependencyMixin]] = None 

1358 ) -> None: 

1359 """ 

1360 Initializes a job. 

1361 

1362 :param name: Name of the job. 

1363 :param condition: Optional, condition under which the job runs, as written. Default: ``None``. 

1364 :param createdAt: Optional, time the job was created, i.e. queued for a worker. Default: ``None``. 

1365 :param startedAt: Optional, time the job started running on a worker. Default: ``None``. 

1366 :param completedAt: Optional, time the job completed. Default: ``None``. 

1367 :param outcome: Optional, how the job ended. Default: ``None``. 

1368 :param parent: Optional, reference to the group containing the job. Default: ``None``. 

1369 :param needs: Optional, siblings this job needs. Default: ``None``. 

1370 :param dependents: Optional, siblings needing this job. Default: ``None``. 

1371 """ 

1372 super().__init__( 

1373 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, outcome=outcome, parent=parent 

1374 ) 

1375 ConditionMixin.__init__(self, condition) 

1376 DependencyMixin.__init__(self, needs, dependents) 

1377 

1378 self._steps = [] 

1379 

1380 def _AddElement(self, step: Step) -> None: 

1381 """ 

1382 Add a step, which names the job as its parent. 

1383 

1384 :param step: The step. 

1385 """ 

1386 self._steps.append(step) 

1387 

1388 @readonly 

1389 def Steps(self) -> list[Step]: 

1390 """ 

1391 Read-only property to access the job's steps (:attr:`_steps`). 

1392 

1393 :returns: The steps, in the order they were added. 

1394 """ 

1395 return self._steps 

1396 

1397 def __len__(self) -> int: 

1398 """ 

1399 Return the number of steps of the job. 

1400 

1401 :returns: Number of steps. 

1402 """ 

1403 return len(self._steps) 

1404 

1405 def __contains__(self, name: str) -> bool: 

1406 """ 

1407 Check whether a step of that name belongs to the job. 

1408 

1409 :param name: Name of the step to check for. 

1410 :returns: ``True``, if a step of that name belongs to the job. 

1411 """ 

1412 return any(str(step) == name for step in self._steps) 

1413 

1414 def __iter__(self) -> Iterator[Step]: 

1415 """ 

1416 Iterate the job's steps. 

1417 

1418 :returns: An iterator over the steps. 

1419 """ 

1420 return iter(self._steps) 

1421 

1422 

1423@export 

1424class MatrixJob(Job, MatrixInstanceMixin): 

1425 """One instance of a job produced by a matrix.""" 

1426 

1427 _PARENT_TYPE: ClassVar[Nullable[type]] = Matrix #: A matrix instance is contained in a matrix. 

1428 

1429 def __init__( 

1430 self, 

1431 name: str, 

1432 dimensions: Nullable[Mapping[str, Any]] = None, 

1433 *, 

1434 condition: Nullable[str] = None, 

1435 createdAt: Nullable[datetime] = None, 

1436 startedAt: Nullable[datetime] = None, 

1437 completedAt: Nullable[datetime] = None, 

1438 outcome: Nullable[Outcome] = None, 

1439 parent: Nullable[Matrix] = None, 

1440 needs: Nullable[Iterable[DependencyMixin]] = None, 

1441 dependents: Nullable[Iterable[DependencyMixin]] = None 

1442 ) -> None: 

1443 """ 

1444 Initializes one instance of a job produced by a matrix. 

1445 

1446 :param name: Name of the job, without the dimensions' values. 

1447 :param dimensions: Optional, the dimensions' names and values this instance ran with. Default: ``None``. 

1448 :param condition: Optional, condition under which the job runs, as written. Default: ``None``. 

1449 :param createdAt: Optional, time the job was created, i.e. queued for a worker. Default: ``None``. 

1450 :param startedAt: Optional, time the job started running on a worker. Default: ``None``. 

1451 :param completedAt: Optional, time the job completed. Default: ``None``. 

1452 :param outcome: Optional, how the job ended. Default: ``None``. 

1453 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``. 

1454 :param needs: Optional, siblings this instance needs. Default: ``None``. 

1455 :param dependents: Optional, siblings needing this instance. Default: ``None``. 

1456 """ 

1457 super().__init__( 

1458 name, condition=condition, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, outcome=outcome, 

1459 parent=parent 

1460 ) 

1461 MatrixInstanceMixin.__init__(self, dimensions) 

1462 DependencyMixin.__init__(self, needs, dependents) 

1463 

1464 def __str__(self) -> str: 

1465 """ 

1466 Return a string representation of the matrix instance. 

1467 

1468 :returns: The job's name, followed by its dimensions' values in brackets, if it has any. 

1469 """ 

1470 if len(self._dimensions) == 0: 

1471 return self._name 

1472 

1473 return f"{self._name} ({', '.join(str(value) for value in self._dimensions.values())})" 

1474 

1475 

1476@export 

1477class Step(Base, ConditionMixin): 

1478 """A step within a job.""" 

1479 

1480 _PARENT_TYPE: ClassVar[Nullable[type]] = Job #: A step is contained in a job. 

1481 

1482 def __init__( 

1483 self, 

1484 name: str, 

1485 *, 

1486 condition: Nullable[str] = None, 

1487 startedAt: Nullable[datetime] = None, 

1488 completedAt: Nullable[datetime] = None, 

1489 outcome: Nullable[Outcome] = None, 

1490 parent: Nullable[Job] = None 

1491 ) -> None: 

1492 """ 

1493 Initializes a step within a job. 

1494 

1495 :param name: Name of the step. 

1496 :param condition: Optional, condition under which the step runs, as written. Default: ``None``. 

1497 :param startedAt: Optional, time the step started running. Default: ``None``. 

1498 :param completedAt: Optional, time the step completed. Default: ``None``. 

1499 :param outcome: Optional, how the step ended. Default: ``None``. 

1500 :param parent: Optional, reference to the job containing the step. Default: ``None``. 

1501 """ 

1502 super().__init__(name, startedAt=startedAt, completedAt=completedAt, outcome=outcome, parent=parent) 

1503 ConditionMixin.__init__(self, condition)