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

591 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-24 00:36 +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`. Workflows, matrices, jobs and steps carry an ordered list of 

56rules (:class:`RulesMixin`): the first matching :class:`Rule` decides whether - and how - the element runs. 

57 

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

59values, the interface of a reusable workflow. 

60 

61.. hint:: 

62 

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

64 

65.. seealso:: 

66 

67 `pyTooling.GitHub <https://pyTooling.github.io/pyTooling.GitHub/>`__ 

68 |rarr| The models of a GitHub Actions workflow run and workflow file, built on this model. 

69 :mod:`pyTooling.REST` 

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

71""" 

72from __future__ import annotations 

73 

74from datetime import datetime, timedelta 

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

76 

77from pyTooling.Common import getFullyQualifiedName, StringEnum 

78from pyTooling.DataModel import KeyValuePairsMixin 

79from pyTooling.Decorators import export, readonly 

80from pyTooling.Exceptions import AlreadyInHierarchyError, ToolingException 

81from pyTooling.Graph import Graph, Subgraph, Vertex 

82from pyTooling.MetaClasses import ExtendedType, ThisClass, abstractclass 

83from pyTooling.REST import JSONObject 

84 

85 

86@export 

87class CIError(ToolingException): 

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

89 

90 

91@export 

92class PipelineError(CIError): 

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

94 

95 

96@export 

97class NeedDependencyError(PipelineError): 

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

99 

100 _element: Nullable[DependencyMixin] #: Element that would need the other one. 

101 _need: Nullable[DependencyMixin] #: Element that would be needed. 

102 

103 def __init__( 

104 self, 

105 message: str, 

106 /, *, 

107 element: Nullable[DependencyMixin] = None, 

108 need: Nullable[DependencyMixin] = None 

109 ) -> None: 

110 """ 

111 Initializes the exception with the element and the element it would need. 

112 

113 :param message: The exception's message. 

114 :param element: Optional, the element that would need the other one. 

115 :param need: Optional, the element that would be needed. 

116 """ 

117 super().__init__(message) 

118 self._element = element 

119 self._need = need 

120 

121 @readonly 

122 def Element(self) -> Nullable[DependencyMixin]: 

123 """ 

124 Read-only property to access the element that would need the other one (:attr:`_element`). 

125 

126 :returns: The needing element, or ``None`` if it wasn't recorded. 

127 """ 

128 return self._element 

129 

130 @readonly 

131 def Need(self) -> Nullable[DependencyMixin]: 

132 """ 

133 Read-only property to access the element that would be needed (:attr:`_need`). 

134 

135 :returns: The needed element, or ``None`` if it wasn't recorded. 

136 """ 

137 return self._need 

138 

139 

140@export 

141class NeedDependencyCycleError(NeedDependencyError): 

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

143 

144 _cycle: Nullable[tuple[DependencyMixin, ...]] #: Elements forming the cycle, in the order they need each other. 

145 

146 def __init__( 

147 self, 

148 message: str, 

149 /, *, 

150 element: Nullable[DependencyMixin] = None, 

151 need: Nullable[DependencyMixin] = None, 

152 cycle: Nullable[Iterable[DependencyMixin]] = None 

153 ) -> None: 

154 """ 

155 Initializes the exception with the elements forming the cycle. 

156 

157 :param message: The exception's message. 

158 :param element: Optional, the element that would need the other one. 

159 :param need: Optional, the element that would be needed. 

160 :param cycle: Optional, the elements forming the cycle, in the order they need each other. 

161 """ 

162 super().__init__(message, element=element, need=need) 

163 self._cycle = None if cycle is None else tuple(cycle) 

164 

165 @readonly 

166 def Cycle(self) -> Nullable[tuple[DependencyMixin, ...]]: 

167 """ 

168 Read-only property to access the elements forming the cycle (:attr:`_cycle`). 

169 

170 :returns: Tuple of elements in the order they need each other, or ``None`` if they weren't recorded. 

171 """ 

172 return self._cycle 

173 

174 

175@export 

176class AlreadyInPipelineError(PipelineError, AlreadyInHierarchyError): 

177 """The exception raised for an element given to an element of a pipeline, but belonging to another one already.""" 

178 

179 _element: Any #: Element belonging to another element of a pipeline already. 

180 

181 def __init__(self, message: str, /, *, element: Any = None) -> None: 

182 """ 

183 Initializes the exception with the element belonging to another one already. 

184 

185 :param message: The exception's message. 

186 :param element: Optional, the element belonging to another element of a pipeline already. 

187 """ 

188 super().__init__(message) 

189 self._element = element 

190 

191 @readonly 

192 def Element(self) -> Any: 

193 """ 

194 Read-only property to access the element belonging to another element of a pipeline already (:attr:`_element`). 

195 

196 :returns: The element, or ``None`` if it wasn't recorded. 

197 """ 

198 return self._element 

199 

200 

201@export 

202class Outcome(StringEnum): 

203 """ 

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

205 

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

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

208 ``startup_failure`` onto :attr:`Error`. 

209 """ 

210 

211 Success = "success" #: It succeeded. 

212 Failure = "failure" #: It failed. 

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

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

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

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

217 

218 @classmethod 

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

220 """ 

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

222 

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

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

225 

226 #. :attr:`Failure` 

227 #. :attr:`Timeout` 

228 #. :attr:`Error` 

229 #. :attr:`Cancellation` 

230 #. :attr:`Success` 

231 #. :attr:`Skip` 

232 

233 :param outcomes: The elements' outcomes. 

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

235 """ 

236 found = set(outcomes) 

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

238 if outcome in found: 

239 return outcome 

240 

241 return None 

242 

243 

244@export 

245class RuleOutcome(StringEnum): 

246 """ 

247 What a rule decides for its element, when the rule is the first one matching. 

248 

249 The members and values are those of GitLab's ``rules:when``. GitHub has no counterpart: its status functions - 

250 ``success()``, ``always()`` - are part of the ``if:`` expression, so a GitHub rule decides :attr:`DEFAULT`. 

251 """ 

252 

253 OnSuccess = "on_success" #: It runs, when no element it waits for failed. 

254 OnFailure = "on_failure" #: It runs, when an element it waits for failed. 

255 Always = "always" #: It runs, whatever the elements it waits for did. 

256 Manual = "manual" #: It is added, and runs when started by hand. 

257 Delayed = "delayed" #: It is added, and runs after a delay. 

258 Never = "never" #: It is excluded. 

259 

260 DEFAULT = OnSuccess #: What a rule decides, if it doesn't say. 

261 

262 

263@export 

264@abstractclass 

265class Base(KeyValuePairsMixin, metaclass=ExtendedType, slots=True): 

266 """ 

267 Common behaviour of every element of a pipeline. 

268 

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

270 :class:`Outcome`. Every element carries a dictionary of arbitrary key-value-pairs as well, which a consumer 

271 attaches its own information to, like the elements of :mod:`pyTooling.Graph`. It is accessed like a dictionary: 

272 :pycode:`job["runner.os"] = "Linux"`. 

273 """ 

274 

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

276 

277 _name: str #: Name of the element. 

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

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

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

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

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

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

284 

285 def __init__( 

286 self, 

287 name: str, 

288 *, 

289 createdAt: Nullable[datetime] = None, 

290 startedAt: Nullable[datetime] = None, 

291 completedAt: Nullable[datetime] = None, 

292 outcome: Nullable[Outcome] = None, 

293 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

294 parent: Nullable[Base] = None 

295 ) -> None: 

296 """ 

297 Initializes an element of a pipeline. 

298 

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

300 before it calls this initializer. 

301 

302 :param name: Name of the element. 

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

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

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

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

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

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

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

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

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

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

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

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

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

316 :raises TypeError: If parameter 'keyValuePairs' is not a mapping. 

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

318 """ 

319 super().__init__(keyValuePairs) 

320 

321 if name is None: 

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

323 elif not isinstance(name, str): 

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

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

326 raise ex 

327 elif name == "": 

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

329 

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

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

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

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

334 raise ex 

335 

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

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

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

339 raise ex 

340 

341 if parent is not None and not isinstance(parent, self._PARENT_TYPE): 

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

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

344 raise ex 

345 

346 self._name = name 

347 self._parent = parent 

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

349 self._createdAt = createdAt 

350 self._startedAt = startedAt 

351 self._completedAt = completedAt 

352 self._outcome = outcome 

353 

354 if parent is not None: 

355 parent._AddElement(self) 

356 

357 @readonly 

358 def Name(self) -> str: 

359 """ 

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

361 

362 :returns: Name of the element. 

363 """ 

364 return self._name 

365 

366 @readonly 

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

368 """ 

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

370 

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

372 """ 

373 return self._parent 

374 

375 @readonly 

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

377 """ 

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

379 

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

381 """ 

382 return self._pipeline 

383 

384 @readonly 

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

386 """ 

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

388 

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

390 """ 

391 return self._createdAt 

392 

393 @readonly 

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

395 """ 

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

397 

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

399 """ 

400 return self._startedAt 

401 

402 @readonly 

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

404 """ 

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

406 

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

408 """ 

409 return self._completedAt 

410 

411 @readonly 

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

413 """ 

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

415 

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

417 """ 

418 return self._outcome 

419 

420 @readonly 

421 def Duration(self) -> Nullable[timedelta]: 

422 """ 

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

424 

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

426 it holds reports a duration too. 

427 

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

429 """ 

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

431 return None 

432 

433 return self.CompletedAt - self.StartedAt 

434 

435 @readonly 

436 def DurationInSeconds(self) -> Nullable[float]: 

437 """ 

438 Read-only property to return how long the element ran, in seconds. 

439 

440 It is :attr:`Duration` as a number. 

441 

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

443 """ 

444 if (duration := self.Duration) is None: 444 ↛ 445line 444 didn't jump to line 445 because the condition on line 444 was never true

445 return None 

446 

447 return duration.total_seconds() 

448 

449 @staticmethod 

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

451 """ 

452 Return the earliest of the times that are known. 

453 

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

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

456 """ 

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

458 

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

460 

461 @staticmethod 

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

463 """ 

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

465 

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

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

468 """ 

469 known = [] 

470 for time in times: 

471 if time is None: 

472 return None 

473 

474 known.append(time) 

475 

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

477 

478 def __str__(self) -> str: 

479 """ 

480 Return a string representation of the element. 

481 

482 :returns: The element's name. 

483 """ 

484 return self._name 

485 

486 

487@export 

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

489 """ 

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

491 """ 

492 

493 @readonly 

494 def QualifiedName(self) -> str: 

495 """ 

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

497 

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

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

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

501 

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

503 """ 

504 names = [str(self)] 

505 element = self 

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

507 if isinstance(element, Workflow): 

508 names.append(str(element)) 

509 

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

511 

512 

513@export 

514class Rule(KeyValuePairsMixin, metaclass=ExtendedType, slots=True): 

515 """ 

516 A rule of an element of a pipeline: a condition, and what it decides for the element, if the condition matches. 

517 

518 An element's rules are evaluated in order, and the first rule matching decides by its :attr:`When`. An element 

519 none of whose rules matches doesn't run. The condition is kept as written - a GitHub ``if:`` expression or a GitLab 

520 ``rules:if`` - and not evaluated. 

521 

522 What else a service's rule holds - GitLab's ``changes``, ``exists``, ``allow_failure`` or ``variables`` - is 

523 attached as key-value-pairs. 

524 """ 

525 

526 _parent: Nullable[RulesMixin] #: Reference to the element the rule belongs to. 

527 _condition: Nullable[str] #: Condition under which the rule matches, as written. 

528 _when: RuleOutcome #: What the rule decides, if it matches. 

529 

530 def __init__( 

531 self, 

532 condition: Nullable[str] = None, 

533 when: RuleOutcome = RuleOutcome.DEFAULT, 

534 *, 

535 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

536 parent: Nullable[RulesMixin] = None 

537 ) -> None: 

538 """ 

539 Initializes a rule. 

540 

541 :param condition: Optional, condition under which the rule matches, as written; ``None`` matches always. 

542 Default: ``None``. 

543 :param when: Optional, what the rule decides, if it matches. Default: :attr:`RuleOutcome.DEFAULT`. 

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

545 :param parent: Optional, reference to the element the rule belongs to, which adds it as its last rule. 

546 Default: ``None``. 

547 :raises TypeError: If parameter 'keyValuePairs' is not a mapping. 

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

549 :raises ValueError: If parameter 'condition' is empty. 

550 :raises ValueError: If parameter 'when' is ``None``. 

551 :raises TypeError: If parameter 'when' is not of type :class:`RuleOutcome`. 

552 :raises TypeError: If parameter 'parent' is not of type :class:`RulesMixin`. 

553 """ 

554 super().__init__(keyValuePairs) 

555 

556 if condition is None: 

557 pass 

558 elif not isinstance(condition, str): 

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

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

561 raise ex 

562 elif condition == "": 

563 raise ValueError("Parameter 'condition' is empty.") 

564 

565 if when is None: 

566 raise ValueError("Parameter 'when' is None.") 

567 elif not isinstance(when, RuleOutcome): 

568 ex = TypeError("Parameter 'when' is not of type 'RuleOutcome'.") 

569 ex.add_note(f"Got type '{getFullyQualifiedName(when)}'.") 

570 raise ex 

571 

572 if parent is not None and not isinstance(parent, RulesMixin): 

573 ex = TypeError("Parameter 'parent' is not of type 'RulesMixin'.") 

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

575 raise ex 

576 

577 self._parent = None 

578 self._condition = condition 

579 self._when = when 

580 

581 if parent is not None: 

582 parent.AddRule(self) 

583 

584 @readonly 

585 def Parent(self) -> Nullable[RulesMixin]: 

586 """ 

587 Read-only property to access the element the rule belongs to (:attr:`_parent`). 

588 

589 :returns: The element, or ``None`` for a rule not yet added to one. 

590 """ 

591 return self._parent 

592 

593 @readonly 

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

595 """ 

596 Read-only property to access the condition under which the rule matches (:attr:`_condition`). 

597 

598 :returns: The condition, as written, or ``None`` for a rule matching always. 

599 """ 

600 return self._condition 

601 

602 @readonly 

603 def When(self) -> When: 

604 """ 

605 Read-only property to access what the rule decides, if it matches (:attr:`_when`). 

606 

607 :returns: What the rule decides for its element. 

608 """ 

609 return self._when 

610 

611 

612@export 

613class RulesMixin(metaclass=ExtendedType, mixin=True): 

614 """ 

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

616 

617 The rules are kept in the order they are evaluated. An element without rules runs unconditionally; an element with 

618 rules runs as the first matching rule decides, and doesn't run if none matches. A GitHub element has one rule at 

619 most - its ``if:`` - a GitLab element the list of its ``rules:``. 

620 """ 

621 

622 _rules: list[Rule] #: Rules of the element, in the order they are evaluated. 

623 

624 def __init__(self, rules: Nullable[Iterable[Rule]] = None) -> None: 

625 """ 

626 Initializes the rules of an element. 

627 

628 :param rules: Optional, rules of the element, in the order they are evaluated. Default: 

629 ``None``. 

630 :raises TypeError: If parameter 'rules' is not iterable. 

631 :raises ValueError: If an element of parameter 'rules' is ``None``. 

632 :raises TypeError: If an element of parameter 'rules' is not of type :class:`Rule`. 

633 :raises AlreadyInPipelineError: If an element of parameter 'rules' belongs to an element already. 

634 """ 

635 self._rules = [] 

636 if rules is None: 

637 return 

638 elif not isinstance(rules, Iterable): 

639 ex = TypeError("Parameter 'rules' is not iterable.") 

640 ex.add_note(f"Got type '{getFullyQualifiedName(rules)}'.") 

641 raise ex 

642 

643 for rule in rules: 

644 self.AddRule(rule) 

645 

646 @readonly 

647 def Rules(self) -> list[Rule]: 

648 """ 

649 Read-only property to access the element's rules (:attr:`_rules`). 

650 

651 :returns: The rules, in the order they are evaluated. 

652 """ 

653 return self._rules 

654 

655 @readonly 

656 def RuleCount(self) -> int: 

657 """ 

658 Read-only property to return the number of rules of the element. 

659 

660 :returns: Number of rules. 

661 """ 

662 return len(self._rules) 

663 

664 def AddRule(self, rule: Rule) -> None: 

665 """ 

666 Add a rule, which is evaluated after the rules the element has already. 

667 

668 :param rule: The rule. 

669 :raises ValueError: If parameter 'rule' is ``None``. 

670 :raises TypeError: If parameter 'rule' is not of type :class:`Rule`. 

671 :raises AlreadyInPipelineError: If parameter 'rule' belongs to an element already. 

672 """ 

673 if rule is None: 

674 raise ValueError("Parameter 'rule' is None.") 

675 elif not isinstance(rule, Rule): 

676 ex = TypeError("Parameter 'rule' is not of type 'Rule'.") 

677 ex.add_note(f"Got type '{getFullyQualifiedName(rule)}'.") 

678 raise ex 

679 elif rule._parent is not None: 

680 raise AlreadyInPipelineError(f"Rule belongs to '{rule._parent}' already.", element=rule) 

681 

682 self._rules.append(rule) 

683 rule._parent = self 

684 

685 def IterateRules(self) -> Iterator[Rule]: 

686 """ 

687 Iterate the element's rules. 

688 

689 :returns: An iterator over the rules, in the order they are evaluated. 

690 """ 

691 return iter(self._rules) 

692 

693 

694@export 

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

696 """ 

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

698 

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

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

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

702 

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

704 class it ends up in. 

705 """ 

706 

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

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

709 

710 def __init__( 

711 self, 

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

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

714 ) -> None: 

715 """ 

716 Initializes an element's dependencies. 

717 

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

719 

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

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

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

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

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

725 :class:`DependencyMixin`. 

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

727 group. 

728 """ 

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

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

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

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

733 raise ex 

734 

735 self._needs = [] 

736 self._dependents = [] 

737 

738 if needs is not None: 

739 for need in needs: 

740 self.AddNeed(need) 

741 

742 if dependents is not None: 

743 for dependent in dependents: 

744 if dependent is None: 

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

746 elif not isinstance(dependent, DependencyMixin): 

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

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

749 raise ex 

750 

751 dependent.AddNeed(self) 

752 

753 @readonly 

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

755 """ 

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

757 

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

759 """ 

760 return self._needs 

761 

762 @readonly 

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

764 """ 

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

766 

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

768 """ 

769 return self._dependents 

770 

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

772 """ 

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

774 

775 :param need: The element this element needs. 

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

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

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

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

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

781 """ 

782 if need is None: 

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

784 elif not isinstance(need, DependencyMixin): 

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

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

787 raise ex 

788 elif need is self: 

789 raise NeedDependencyCycleError(f"'{self}' can't need itself.", element=self, need=need, cycle=(self, self)) 

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

791 ex = NeedDependencyError( 

792 f"'{self}' can't need '{need}', which isn't contained in the same group.", 

793 element=self, 

794 need=need 

795 ) 

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

797 raise ex 

798 elif need in self._needs: 

799 raise NeedDependencyError(f"'{self}' needs '{need}' already.", element=self, need=need) 

800 

801 self._needs.append(need) 

802 need._dependents.append(self) 

803 

804 @staticmethod 

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

806 """ 

807 Find a cycle in the needs of sibling elements. 

808 

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

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

811 

812 :param elements: The elements of one group. 

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

814 """ 

815 onPath = set() 

816 finished = set() 

817 for start in elements: 

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

819 continue 

820 

821 path = [start] 

822 stack = [iter(start._needs)] 

823 onPath.add(start) 

824 while len(stack) > 0: 

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

826 if need is None: 

827 element = path.pop() 

828 stack.pop() 

829 onPath.discard(element) 

830 finished.add(element) 

831 elif need in onPath: 

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

833 elif need not in finished: 

834 path.append(need) 

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

836 onPath.add(need) 

837 

838 return None 

839 

840 

841@export 

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

843 """ 

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

845 

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

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

848 :class:`Job`. 

849 """ 

850 

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

852 

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

854 """ 

855 Initializes the dimensions of a matrix instance. 

856 

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

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

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

860 """ 

861 self._dimensions = {} 

862 if dimensions is None: 

863 return 

864 elif not isinstance(dimensions, Mapping): 

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

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

867 raise ex 

868 

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

870 if not isinstance(name, str): 

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

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

873 raise ex 

874 

875 self._dimensions[name] = value 

876 

877 @readonly 

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

879 """ 

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

881 

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

883 """ 

884 return self._dimensions 

885 

886 

887@export 

888class PipelineGroup(Base): 

889 """ 

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

891 

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

893 """ 

894 

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

896 

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

898 

899 def __init__( 

900 self, 

901 name: str, 

902 pipelines: Nullable[Iterable[Pipeline]] = None, 

903 *, 

904 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None 

905 ) -> None: 

906 """ 

907 Initializes a group of pipelines. 

908 

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

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

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

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

913 """ 

914 super().__init__(name, keyValuePairs=keyValuePairs) 

915 

916 self._pipelines = [] 

917 if pipelines is None: 

918 return 

919 

920 for pipeline in pipelines: 

921 if not isinstance(pipeline, Pipeline): 

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

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

924 raise ex 

925 

926 self._pipelines.append(pipeline) 

927 pipeline._parent = self 

928 

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

930 """ 

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

932 

933 :param pipeline: The pipeline. 

934 """ 

935 self._pipelines.append(pipeline) 

936 

937 @readonly 

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

939 """ 

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

941 

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

943 """ 

944 return self._pipelines 

945 

946 @readonly 

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

948 """ 

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

950 

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

952 """ 

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

954 

955 @readonly 

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

957 """ 

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

959 

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

961 """ 

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

963 

964 @readonly 

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

966 """ 

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

968 

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

970 """ 

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

972 

973 @readonly 

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

975 """ 

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

977 

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

979 """ 

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

981 

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

983 """ 

984 Iterate every job of every pipeline of the group. 

985 

986 :returns: An iterator over the jobs. 

987 """ 

988 for pipeline in self._pipelines: 

989 yield from pipeline.IterateJobs() 

990 

991 def Validate(self) -> None: 

992 """ 

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

994 

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

996 The note names the cycle. 

997 """ 

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

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

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

1001 raise ex 

1002 

1003 for pipeline in self._pipelines: 

1004 pipeline.Validate() 

1005 

1006 @readonly 

1007 def PipelineCount(self) -> int: 

1008 """ 

1009 Read-only property to return the number of pipelines of the group. 

1010 

1011 :returns: Number of pipelines. 

1012 """ 

1013 return len(self._pipelines) 

1014 

1015 def ContainsPipeline(self, name: str) -> bool: 

1016 """ 

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

1018 

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

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

1021 """ 

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

1023 

1024 def IteratePipelines(self) -> Iterator[Pipeline]: 

1025 """ 

1026 Iterate the pipelines of the group. 

1027 

1028 :returns: An iterator over the pipelines, in the order they were added. 

1029 """ 

1030 return iter(self._pipelines) 

1031 

1032 

1033@export 

1034@abstractclass 

1035class JobGroup(Base): 

1036 """ 

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

1038 

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

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

1041 matrix, derives them from what it holds. 

1042 """ 

1043 

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

1045 

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

1047 

1048 def __init__( 

1049 self, 

1050 name: str, 

1051 *, 

1052 createdAt: Nullable[datetime] = None, 

1053 startedAt: Nullable[datetime] = None, 

1054 completedAt: Nullable[datetime] = None, 

1055 outcome: Nullable[Outcome] = None, 

1056 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

1057 parent: Nullable[Base] = None 

1058 ) -> None: 

1059 """ 

1060 Initializes a group of jobs. 

1061 

1062 :param name: Name of the group. 

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

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

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

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

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

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

1069 """ 

1070 super().__init__( 

1071 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, outcome=outcome, 

1072 keyValuePairs=keyValuePairs, parent=parent 

1073 ) 

1074 

1075 self._elements = [] 

1076 

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

1078 """ 

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

1080 

1081 :param element: The element. 

1082 """ 

1083 self._elements.append(element) 

1084 

1085 @readonly 

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

1087 """ 

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

1089 

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

1091 """ 

1092 return self._elements 

1093 

1094 @readonly 

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

1096 """ 

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

1098 

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

1100 """ 

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

1102 

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

1104 """ 

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

1106 

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

1108 """ 

1109 for element in self._elements: 

1110 if isinstance(element, Job): 

1111 yield element 

1112 else: 

1113 yield from element.IterateJobs() 

1114 

1115 def Validate(self) -> None: 

1116 """ 

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

1118 

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

1120 

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

1122 The note names the cycle. 

1123 """ 

1124 groups = [self] 

1125 while len(groups) > 0: 

1126 group = groups.pop() 

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

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

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

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

1131 raise ex 

1132 

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

1134 

1135 @readonly 

1136 def IsReported(self) -> bool: 

1137 """ 

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

1139 

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

1141 

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

1143 """ 

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

1145 

1146 @readonly 

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

1148 """ 

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

1150 

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

1152 """ 

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

1154 

1155 @readonly 

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

1157 """ 

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

1159 

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

1161 """ 

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

1163 

1164 @readonly 

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

1166 """ 

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

1168 

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

1170 """ 

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

1172 

1173 @readonly 

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

1175 """ 

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

1177 

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

1179 """ 

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

1181 

1182 @readonly 

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

1184 """ 

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

1186 

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

1188 """ 

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

1190 

1191 @readonly 

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

1193 """ 

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

1195 

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

1197 """ 

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

1199 

1200 @readonly 

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

1202 """ 

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

1204 

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

1206 """ 

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

1208 

1209 @readonly 

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

1211 """ 

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

1213 

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

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

1216 """ 

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

1218 

1219 @readonly 

1220 def ElementCount(self) -> int: 

1221 """ 

1222 Read-only property to return the number of elements of this group. 

1223 

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

1225 """ 

1226 return len(self._elements) 

1227 

1228 def ContainsElement(self, name: str) -> bool: 

1229 """ 

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

1231 

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

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

1234 

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

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

1237 """ 

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

1239 

1240 def GetElement(self, name: str) -> Base: 

1241 """ 

1242 Return the element of that name. 

1243 

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

1245 

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

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

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

1249 """ 

1250 for element in self._elements: 

1251 if str(element) == name: 

1252 return element 

1253 

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

1255 

1256 def IterateElements(self) -> Iterator[Base]: 

1257 """ 

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

1259 

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

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

1262 

1263 :returns: An iterator over the contained elements. 

1264 """ 

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

1266 """ 

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

1268 

1269 :param element: The element. 

1270 :returns: The sort key. 

1271 """ 

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

1273 

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

1275 

1276 

1277@export 

1278class Workflow(JobGroup, QualifiedNameMixin, RulesMixin, DependencyMixin): 

1279 """ 

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

1281 

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

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

1284 

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

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

1287 """ 

1288 

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

1290 

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

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

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

1294 

1295 def __init__( 

1296 self, 

1297 name: str, 

1298 *, 

1299 reference: Nullable[str] = None, 

1300 rules: Nullable[Iterable[Rule]] = None, 

1301 createdAt: Nullable[datetime] = None, 

1302 startedAt: Nullable[datetime] = None, 

1303 completedAt: Nullable[datetime] = None, 

1304 outcome: Nullable[Outcome] = None, 

1305 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

1306 parent: Nullable[Workflow] = None, 

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

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

1309 ) -> None: 

1310 """ 

1311 Initializes a called workflow. 

1312 

1313 :param name: Name of the workflow. 

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

1315 :param rules: Optional, rules deciding whether the workflow is called. Default: ``None``. 

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

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

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

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

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

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

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

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

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

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

1326 """ 

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

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

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

1330 raise ex 

1331 

1332 super().__init__( 

1333 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, outcome=outcome, 

1334 keyValuePairs=keyValuePairs, parent=parent 

1335 ) 

1336 RulesMixin.__init__(self, rules) 

1337 DependencyMixin.__init__(self, needs, dependents) 

1338 

1339 self._reference = reference 

1340 self._workflows = {} 

1341 self._matrices = {} 

1342 

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

1344 """ 

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

1346 

1347 :param element: The element. 

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

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

1350 """ 

1351 if isinstance(element, Workflow): 

1352 if element._name in self._workflows: 

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

1354 

1355 self._workflows[element._name] = element 

1356 elif isinstance(element, Matrix): 

1357 if element._name in self._matrices: 

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

1359 

1360 self._matrices[element._name] = element 

1361 

1362 super()._AddElement(element) 

1363 

1364 @readonly 

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

1366 """ 

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

1368 

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

1370 

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

1372 """ 

1373 return self._reference 

1374 

1375 @readonly 

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

1377 """ 

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

1379 

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

1381 """ 

1382 return self._workflows 

1383 

1384 @readonly 

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

1386 """ 

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

1388 

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

1390 """ 

1391 return self._matrices 

1392 

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

1394 """ 

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

1396 

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

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

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

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

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

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

1403 they can run in. 

1404 

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

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

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

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

1409 

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

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

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

1413 

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

1415 level. Default: ``None``. 

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

1417 ``True``. 

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

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

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

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

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

1423 

1424 .. seealso:: 

1425 

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

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

1428 """ 

1429 if depth is not None: 

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

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

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

1433 raise ex 

1434 elif depth < 0: 

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

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

1437 raise ex 

1438 

1439 if reduce is None: 

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

1441 elif not isinstance(reduce, bool): 

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

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

1444 raise ex 

1445 

1446 graph = Graph(name=self._name) 

1447 

1448 def addContents( 

1449 group: JobGroup, 

1450 subgraph: Nullable[Subgraph], 

1451 groupVertex: Nullable[Vertex], 

1452 level: int 

1453 ) -> None: 

1454 """ 

1455 Nested function for recursion. 

1456 

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

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

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

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

1461 """ 

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

1463 for element in group.IterateElements(): 

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

1465 vertices[element] = vertex 

1466 if groupVertex is not None: 

1467 groupVertex.LinkToVertex(vertex) 

1468 

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

1470 for need in element._needs: 

1471 vertex.EdgeToVertex(vertices[need]) 

1472 

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

1474 return 

1475 

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

1477 if isinstance(element, JobGroup) and element.ElementCount > 0: 

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

1479 

1480 addContents(self, None, None, 0) 

1481 

1482 if reduce: 

1483 graph.RemoveTransitiveEdges() 

1484 for subgraph in graph.Subgraphs: 

1485 subgraph.RemoveTransitiveEdges() 

1486 

1487 return graph 

1488 

1489 

1490@export 

1491class Pipeline(Workflow): 

1492 """ 

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

1494 

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

1496 """ 

1497 

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

1499 

1500 def __init__( 

1501 self, 

1502 name: str, 

1503 *, 

1504 rules: Nullable[Iterable[Rule]] = None, 

1505 createdAt: Nullable[datetime] = None, 

1506 startedAt: Nullable[datetime] = None, 

1507 completedAt: Nullable[datetime] = None, 

1508 outcome: Nullable[Outcome] = None, 

1509 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

1510 parent: Nullable[PipelineGroup] = None, 

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

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

1513 ) -> None: 

1514 """ 

1515 Initializes a pipeline. 

1516 

1517 :param name: Name of the pipeline. 

1518 :param rules: Optional, rules deciding whether the pipeline runs. Default: ``None``. 

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

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

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

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

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

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

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

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

1527 """ 

1528 super().__init__( 

1529 name, rules=rules, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, outcome=outcome, 

1530 keyValuePairs=keyValuePairs, parent=parent, needs=needs, dependents=dependents 

1531 ) 

1532 

1533 self._pipeline = self 

1534 

1535 

1536 

1537@export 

1538class Matrix(JobGroup, QualifiedNameMixin, RulesMixin, DependencyMixin): 

1539 """ 

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

1541 

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

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

1544 """ 

1545 

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

1547 

1548 def __init__( 

1549 self, 

1550 name: str, 

1551 *, 

1552 rules: Nullable[Iterable[Rule]] = None, 

1553 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

1554 parent: Nullable[Workflow] = None, 

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

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

1557 ) -> None: 

1558 """ 

1559 Initializes a matrix. 

1560 

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

1562 :param rules: Optional, rules deciding whether the instances run. Default: ``None``. 

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

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

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

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

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

1568 """ 

1569 super().__init__(name, keyValuePairs=keyValuePairs, parent=parent) 

1570 RulesMixin.__init__(self, rules) 

1571 DependencyMixin.__init__(self, needs, dependents) 

1572 

1573 @readonly 

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

1575 """ 

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

1577 

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

1579 """ 

1580 return self._elements 

1581 

1582 

1583@export 

1584class MatrixWorkflow(Workflow, MatrixInstanceMixin): 

1585 """ 

1586 One instance of a called workflow produced by a matrix. 

1587 

1588 A GitHub job with ``strategy.matrix`` and ``uses:`` produces such instances. 

1589 """ 

1590 

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

1592 

1593 def __init__( 

1594 self, 

1595 name: str, 

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

1597 *, 

1598 reference: Nullable[str] = None, 

1599 rules: Nullable[Iterable[Rule]] = None, 

1600 createdAt: Nullable[datetime] = None, 

1601 startedAt: Nullable[datetime] = None, 

1602 completedAt: Nullable[datetime] = None, 

1603 outcome: Nullable[Outcome] = None, 

1604 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

1605 parent: Nullable[Matrix] = None, 

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

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

1608 ) -> None: 

1609 """ 

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

1611 

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

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

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

1615 :param rules: Optional, rules deciding whether the workflow is called. Default: ``None``. 

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

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

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

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

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

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

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

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

1624 """ 

1625 super().__init__( 

1626 name, reference=reference, rules=rules, createdAt=createdAt, startedAt=startedAt, 

1627 completedAt=completedAt, outcome=outcome, keyValuePairs=keyValuePairs, parent=parent 

1628 ) 

1629 MatrixInstanceMixin.__init__(self, dimensions) 

1630 DependencyMixin.__init__(self, needs, dependents) 

1631 

1632 def __str__(self) -> str: 

1633 """ 

1634 Return a string representation of the matrix instance. 

1635 

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

1637 """ 

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

1639 return self._name 

1640 

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

1642 

1643 

1644@export 

1645class Job(Base, QualifiedNameMixin, RulesMixin, DependencyMixin): 

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

1647 

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

1649 

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

1651 

1652 def __init__( 

1653 self, 

1654 name: str, 

1655 *, 

1656 rules: Nullable[Iterable[Rule]] = None, 

1657 createdAt: Nullable[datetime] = None, 

1658 startedAt: Nullable[datetime] = None, 

1659 completedAt: Nullable[datetime] = None, 

1660 outcome: Nullable[Outcome] = None, 

1661 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

1662 parent: Nullable[JobGroup] = None, 

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

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

1665 ) -> None: 

1666 """ 

1667 Initializes a job. 

1668 

1669 :param name: Name of the job. 

1670 :param rules: Optional, rules deciding whether the job runs. Default: ``None``. 

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

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

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

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

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

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

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

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

1679 """ 

1680 super().__init__( 

1681 name, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, outcome=outcome, 

1682 keyValuePairs=keyValuePairs, parent=parent 

1683 ) 

1684 RulesMixin.__init__(self, rules) 

1685 DependencyMixin.__init__(self, needs, dependents) 

1686 

1687 self._steps = [] 

1688 

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

1690 """ 

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

1692 

1693 :param step: The step. 

1694 """ 

1695 self._steps.append(step) 

1696 

1697 @readonly 

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

1699 """ 

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

1701 

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

1703 """ 

1704 return self._steps 

1705 

1706 @readonly 

1707 def StepCount(self) -> int: 

1708 """ 

1709 Read-only property to return the number of steps of the job. 

1710 

1711 :returns: Number of steps. 

1712 """ 

1713 return len(self._steps) 

1714 

1715 def ContainsStep(self, name: str) -> bool: 

1716 """ 

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

1718 

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

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

1721 """ 

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

1723 

1724 def IterateSteps(self) -> Iterator[Step]: 

1725 """ 

1726 Iterate the job's steps. 

1727 

1728 :returns: An iterator over the steps, in the order they were added. 

1729 """ 

1730 return iter(self._steps) 

1731 

1732 

1733@export 

1734class MatrixJob(Job, MatrixInstanceMixin): 

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

1736 

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

1738 

1739 def __init__( 

1740 self, 

1741 name: str, 

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

1743 *, 

1744 rules: Nullable[Iterable[Rule]] = None, 

1745 createdAt: Nullable[datetime] = None, 

1746 startedAt: Nullable[datetime] = None, 

1747 completedAt: Nullable[datetime] = None, 

1748 outcome: Nullable[Outcome] = None, 

1749 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

1750 parent: Nullable[Matrix] = None, 

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

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

1753 ) -> None: 

1754 """ 

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

1756 

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

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

1759 :param rules: Optional, rules deciding whether the job runs. Default: ``None``. 

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

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

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

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

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

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

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

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

1768 """ 

1769 super().__init__( 

1770 name, rules=rules, createdAt=createdAt, startedAt=startedAt, completedAt=completedAt, outcome=outcome, 

1771 keyValuePairs=keyValuePairs, parent=parent 

1772 ) 

1773 MatrixInstanceMixin.__init__(self, dimensions) 

1774 DependencyMixin.__init__(self, needs, dependents) 

1775 

1776 def __str__(self) -> str: 

1777 """ 

1778 Return a string representation of the matrix instance. 

1779 

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

1781 """ 

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

1783 return self._name 

1784 

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

1786 

1787 

1788@export 

1789class Step(Base, RulesMixin): 

1790 """A step within a job.""" 

1791 

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

1793 

1794 def __init__( 

1795 self, 

1796 name: str, 

1797 *, 

1798 rules: Nullable[Iterable[Rule]] = None, 

1799 startedAt: Nullable[datetime] = None, 

1800 completedAt: Nullable[datetime] = None, 

1801 outcome: Nullable[Outcome] = None, 

1802 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

1803 parent: Nullable[Job] = None 

1804 ) -> None: 

1805 """ 

1806 Initializes a step within a job. 

1807 

1808 :param name: Name of the step. 

1809 :param rules: Optional, rules deciding whether the step runs. Default: ``None``. 

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

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

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

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

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

1815 """ 

1816 super().__init__( 

1817 name, startedAt=startedAt, completedAt=completedAt, outcome=outcome, keyValuePairs=keyValuePairs, parent=parent 

1818 ) 

1819 RulesMixin.__init__(self, rules)