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
« 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.
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.
38The service-independent pipeline:
40.. code-block:: text
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
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`.
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.
60.. hint::
62 See :ref:`high-level help <CI/Pipeline>` for the mapping of GitHub Actions and GitLab CI onto this model.
64.. seealso::
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
73from datetime import datetime
74from typing import Any, ClassVar, Iterable, Iterator, Mapping, Optional as Nullable
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
84@export
85class CIError(ToolingException):
86 """Base-exception of all exceptions raised by :mod:`pyTooling.CI` and the models of the CI services."""
89@export
90class PipelineError(CIError):
91 """Base-exception of all exceptions raised by the service-independent pipeline model."""
94@export
95class NeedDependencyError(PipelineError):
96 """The exception raised for a need - a dependency - two elements of a pipeline can't have."""
99@export
100class NeedDependencyCycleError(NeedDependencyError):
101 """The exception raised for a need which would close a cycle of dependencies."""
104@export
105class Outcome(StringEnum):
106 """
107 How an element of a pipeline ended, in terms every CI service has.
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 """
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.
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.
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:
129 #. :attr:`Failure`
130 #. :attr:`Timeout`
131 #. :attr:`Error`
132 #. :attr:`Cancellation`
133 #. :attr:`Success`
134 #. :attr:`Skip`
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
144 return None
147@export
148@abstractclass
149class Base(metaclass=ExtendedType, slots=True):
150 """
151 Common behaviour of every element of a pipeline.
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 """
157 _PARENT_TYPE: ClassVar[Nullable[type]] = None #: Type a parent must have, or ``None`` when it has no parent.
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.
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.
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.
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.")
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
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
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
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
237 if parent is not None:
238 parent._AddElement(self)
240 @readonly
241 def Name(self) -> str:
242 """
243 Read-only property to access the element's name (:attr:`_name`).
245 :returns: Name of the element.
246 """
247 return self._name
249 @readonly
250 def Parent(self) -> Nullable[Base]:
251 """
252 Read-only property to access the containing element (:attr:`_parent`).
254 :returns: The containing element, or ``None`` for a :class:`PipelineGroup` and an element not yet placed.
255 """
256 return self._parent
258 @readonly
259 def Pipeline(self) -> Nullable[Pipeline]:
260 """
261 Read-only property to access the pipeline this element belongs to (:attr:`_pipeline`).
263 :returns: The pipeline, or ``None`` for an element outside one.
264 """
265 return self._pipeline
267 @readonly
268 def CreatedAt(self) -> Nullable[datetime]:
269 """
270 Read-only property to access the time the element was created (:attr:`_createdAt`).
272 :returns: The time, or ``None`` if none was reported.
273 """
274 return self._createdAt
276 @readonly
277 def StartedAt(self) -> Nullable[datetime]:
278 """
279 Read-only property to access the time the element started running (:attr:`_startedAt`).
281 :returns: The time, or ``None`` while it hasn't started.
282 """
283 return self._startedAt
285 @readonly
286 def CompletedAt(self) -> Nullable[datetime]:
287 """
288 Read-only property to access the time the element completed (:attr:`_completedAt`).
290 :returns: The time, or ``None`` while it hasn't completed.
291 """
292 return self._completedAt
294 @readonly
295 def Outcome(self) -> Nullable[Outcome]:
296 """
297 Read-only property to access how the element ended (:attr:`_outcome`).
299 :returns: The outcome, or ``None`` while the element hasn't ended.
300 """
301 return self._outcome
303 @readonly
304 def Duration(self) -> Nullable[float]:
305 """
306 Read-only property to return how long the element ran.
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.
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
316 return (self.CompletedAt - self.StartedAt).total_seconds()
318 @staticmethod
319 def _Earliest(times: Iterable[Nullable[datetime]]) -> Nullable[datetime]:
320 """
321 Return the earliest of the times that are known.
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]
328 return min(known) if len(known) > 0 else None
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.
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
343 known.append(time)
345 return max(known) if len(known) > 0 else None
347 def __str__(self) -> str:
348 """
349 Return a string representation of the element.
351 :returns: The element's name.
352 """
353 return self._name
356@export
357class QualifiedNameMixin(metaclass=ExtendedType, mixin=True, expects=("_parent",)):
358 """
359 Mixin-class for elements named by the workflows containing them.
360 """
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.
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.
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))
379 return " / ".join(reversed(names))
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.
387 The condition is kept as written - a GitHub ``if:`` expression or a GitLab ``rules:if`` - and not evaluated.
388 """
390 _condition: Nullable[str] #: Condition under which the element runs, as written.
392 def __init__(self, condition: Nullable[str] = None) -> None:
393 """
394 Initializes the condition of an element.
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
404 self._condition = condition
406 @readonly
407 def Condition(self) -> Nullable[str]:
408 """
409 Read-only property to access the condition under which the element runs (:attr:`_condition`).
411 :returns: The condition, or ``None`` if the element has none, or it isn't known.
412 """
413 return self._condition
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.
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`.
425 The mixin compares the elements' parents, so the ``expects`` contract requires :attr:`Base._parent` from whichever
426 class it ends up in.
427 """
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.
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.
440 The element must be contained in its group already, as a need is a sibling.
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
457 self._needs = []
458 self._dependents = []
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
472 dependent.AddNeed(self)
474 @readonly
475 def Needs(self) -> list[DependencyMixin]:
476 """
477 Read-only property to access the elements this element needs (:attr:`_needs`).
479 :returns: The needed elements, in the order they were added.
480 """
481 return self._needs
483 @readonly
484 def Dependents(self) -> list[DependencyMixin]:
485 """
486 Read-only property to access the elements needing this element (:attr:`_dependents`).
488 :returns: The dependent elements, in the order they were added.
489 """
490 return self._dependents
492 def AddNeed(self, need: DependencyMixin) -> None:
493 """
494 Add an element this element needs, and this element as its dependent.
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.")
518 self._needs.append(need)
519 need._dependents.append(self)
521 @staticmethod
522 def _FindCycle(elements: Iterable[DependencyMixin]) -> Nullable[list[DependencyMixin]]:
523 """
524 Find a cycle in the needs of sibling elements.
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.
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
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)
555 return None
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.
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 """
568 _dimensions: dict[str, Any] #: The matrix' dimensions this instance ran with, as name and value.
570 def __init__(self, dimensions: Nullable[Mapping[str, Any]] = None) -> None:
571 """
572 Initializes the dimensions of a matrix instance.
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
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
592 self._dimensions[name] = value
594 @readonly
595 def Dimensions(self) -> dict[str, Any]:
596 """
597 Read-only property to access the dimensions this instance ran with (:attr:`_dimensions`).
599 :returns: The dimensions' names and values, in the matrix' order.
600 """
601 return self._dimensions
604@export
605class PipelineGroup(Base):
606 """
607 The pipelines started for one commit, and the top of the tree.
609 The group has no times of its own and derives them from its pipelines.
610 """
612 _PARENT_TYPE: ClassVar[Nullable[type]] = None #: A pipeline group is the top of the tree and has no parent.
614 _pipelines: list[Pipeline] #: Pipelines of the group.
616 def __init__(self, name: str, pipelines: Nullable[Iterable[Pipeline]] = None) -> None:
617 """
618 Initializes a group of pipelines.
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)
626 self._pipelines = []
627 if pipelines is None:
628 return
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
636 self._pipelines.append(pipeline)
637 pipeline._parent = self
639 def _AddElement(self, pipeline: Pipeline) -> None:
640 """
641 Add a pipeline, which names the group as its parent.
643 :param pipeline: The pipeline.
644 """
645 self._pipelines.append(pipeline)
647 @readonly
648 def Pipelines(self) -> list[Pipeline]:
649 """
650 Read-only property to access the pipelines of the group (:attr:`_pipelines`).
652 :returns: The pipelines, in the order they were added.
653 """
654 return self._pipelines
656 @readonly
657 def CreatedAt(self) -> Nullable[datetime]:
658 """
659 Read-only property to return when the first pipeline of the group was created.
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)
665 @readonly
666 def StartedAt(self) -> Nullable[datetime]:
667 """
668 Read-only property to return when the first pipeline of the group started.
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)
674 @readonly
675 def CompletedAt(self) -> Nullable[datetime]:
676 """
677 Read-only property to return when the last pipeline of the group completed.
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)
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`).
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)
692 def IterateJobs(self) -> Iterator[Job]:
693 """
694 Iterate every job of every pipeline of the group.
696 :returns: An iterator over the jobs.
697 """
698 for pipeline in self._pipelines:
699 yield from pipeline.IterateJobs()
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.
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
713 for pipeline in self._pipelines:
714 pipeline.Validate()
716 def __len__(self) -> int:
717 """
718 Return the number of pipelines of the group.
720 :returns: Number of pipelines.
721 """
722 return len(self._pipelines)
724 def __contains__(self, name: str) -> bool:
725 """
726 Check whether a pipeline of that name belongs to the group.
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)
733 def __iter__(self) -> Iterator[Pipeline]:
734 """
735 Iterate the pipelines of the group.
737 :returns: An iterator over the pipelines.
738 """
739 return iter(self._pipelines)
742@export
743@abstractclass
744class JobGroup(Base):
745 """
746 A group of jobs: the shared behaviour of a :class:`Workflow` and a :class:`Matrix`.
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 """
753 _PARENT_TYPE: ClassVar[Nullable[type]] = None #: Declared by the groups deriving from this class.
755 _elements: list[Base] #: Elements of this group - jobs, matrices, workflows - in the order they were added.
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.
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 )
781 self._elements = []
783 def _AddElement(self, element: Base) -> None:
784 """
785 Add an element, which names the group as its parent.
787 :param element: The element.
788 """
789 self._elements.append(element)
791 @readonly
792 def Elements(self) -> list[Base]:
793 """
794 Read-only property to access the elements of this group (:attr:`_elements`).
796 :returns: The jobs, matrices and workflows one level below the group, in the order they were added.
797 """
798 return self._elements
800 @readonly
801 def Jobs(self) -> list[Job]:
802 """
803 Read-only property to return the jobs of this group.
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)]
809 def IterateJobs(self) -> Iterator[Job]:
810 """
811 Iterate every job below this group, including those of the groups it contains.
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()
821 def Validate(self) -> None:
822 """
823 Check that the needs of this group and of every group it contains form no cycle.
825 A pipeline is validated once it is completely built: each group is searched once, along every need.
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
839 groups.extend(element for element in group._elements if isinstance(element, JobGroup))
841 @readonly
842 def IsReported(self) -> bool:
843 """
844 Check if the service reported the group as an element of its own.
846 A reported group keeps its own times and outcome; otherwise they span its contents.
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))
852 @readonly
853 def CreatedAt(self) -> Nullable[datetime]:
854 """
855 Read-only property to return when the group was created.
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
861 @readonly
862 def StartedAt(self) -> Nullable[datetime]:
863 """
864 Read-only property to return when the group started.
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
870 @readonly
871 def CompletedAt(self) -> Nullable[datetime]:
872 """
873 Read-only property to return when the group completed.
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
879 @readonly
880 def Outcome(self) -> Nullable[Outcome]:
881 """
882 Read-only property to return how the group ended.
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
888 @readonly
889 def ContentsCreatedAt(self) -> Nullable[datetime]:
890 """
891 Read-only property to return when the first element below this group was created.
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)
897 @readonly
898 def ContentsStartedAt(self) -> Nullable[datetime]:
899 """
900 Read-only property to return when the first element below this group started.
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)
906 @readonly
907 def ContentsCompletedAt(self) -> Nullable[datetime]:
908 """
909 Read-only property to return when the last element below this group completed.
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)
915 @readonly
916 def ContentsOutcome(self) -> Nullable[Outcome]:
917 """
918 Read-only property to return how the elements below this group ended, taken together.
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)
925 def __len__(self) -> int:
926 """
927 Return the number of elements of this group.
929 :returns: Number of elements one level below the group.
930 """
931 return len(self._elements)
933 def __contains__(self, name: str) -> bool:
934 """
935 Check whether an element of that name belongs to this group.
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)"``.
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)
945 def __getitem__(self, name: str) -> Base:
946 """
947 Return the element of that name.
949 An element is named the way :func:`str` names it, as for :meth:`__contains__`.
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
959 raise KeyError(f"Group '{self._name}' contains no element '{name}'.")
961 def __iter__(self) -> Iterator[Base]:
962 """
963 Iterate what this group holds, ordered by the time it was created.
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.
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.
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)
979 return iter(sorted(self._elements, key=createdAt))
982@export
983class Workflow(JobGroup, QualifiedNameMixin, ConditionMixin, DependencyMixin):
984 """
985 A called workflow or a child pipeline, grouping the elements it contains.
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.
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 """
994 _PARENT_TYPE: ClassVar[Nullable[type]] = ThisClass #: A workflow is contained in a workflow.
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.
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.
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
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)
1041 self._reference = reference
1042 self._workflows = {}
1043 self._matrices = {}
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.
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.")
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.")
1062 self._matrices[element._name] = element
1064 super()._AddElement(element)
1066 @readonly
1067 def Reference(self) -> Nullable[str]:
1068 """
1069 Read-only property to access what the workflow calls, as written (:attr:`_reference`).
1071 E.g. ``pyTooling/Actions/.github/workflows/Package.yml@r8`` for GitHub, or the file a GitLab trigger includes.
1073 :returns: The reference, or ``None`` if it isn't known.
1074 """
1075 return self._reference
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`).
1082 :returns: The called workflows, in the order they were added.
1083 """
1084 return self._workflows
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`).
1091 :returns: The matrices, in the order they were added.
1092 """
1093 return self._matrices
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.
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.
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.
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.
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`.
1126 .. seealso::
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
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
1148 graph = Graph(name=self._name)
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.
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)
1171 for element, vertex in vertices.items():
1172 for need in element._needs:
1173 vertex.EdgeToVertex(vertices[need])
1175 if depth is not None and level >= depth:
1176 return
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)
1182 addContents(self, None, None, 0)
1184 if reduce:
1185 graph.RemoveTransitiveEdges()
1186 for subgraph in graph.Subgraphs:
1187 subgraph.RemoveTransitiveEdges()
1189 return graph
1192@export
1193class Pipeline(Workflow):
1194 """
1195 A pipeline: the workflow a service started, holding everything else.
1197 A pipeline can need another pipeline of its :class:`PipelineGroup`, e.g. one triggered by another's completion.
1198 """
1200 _PARENT_TYPE: ClassVar[Nullable[type]] = PipelineGroup #: A pipeline is contained in a pipeline group.
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.
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 )
1233 self._pipeline = self
1237@export
1238class Matrix(JobGroup, QualifiedNameMixin, ConditionMixin, DependencyMixin):
1239 """
1240 A matrix, grouping the instances it produced: jobs, or called workflows.
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 """
1246 _PARENT_TYPE: ClassVar[Nullable[type]] = Workflow #: A matrix is contained in a workflow.
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.
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)
1271 @readonly
1272 def Instances(self) -> list[Base]:
1273 """
1274 Read-only property to access the instances this matrix produced (:attr:`_elements`).
1276 :returns: The :class:`MatrixJob` or :class:`MatrixWorkflow` instances, in the order they were added.
1277 """
1278 return self._elements
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 """
1287 _PARENT_TYPE: ClassVar[Nullable[type]] = Matrix #: A matrix instance is contained in a matrix.
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.
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)
1326 def __str__(self) -> str:
1327 """
1328 Return a string representation of the matrix instance.
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
1335 return f"{self._name} ({', '.join(str(value) for value in self._dimensions.values())})"
1338@export
1339class Job(Base, QualifiedNameMixin, ConditionMixin, DependencyMixin):
1340 """A job, which runs its steps on a worker."""
1342 _PARENT_TYPE: ClassVar[Nullable[type]] = JobGroup #: A job is contained in a job group.
1344 _steps: list[Step] #: Steps of the job.
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.
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)
1378 self._steps = []
1380 def _AddElement(self, step: Step) -> None:
1381 """
1382 Add a step, which names the job as its parent.
1384 :param step: The step.
1385 """
1386 self._steps.append(step)
1388 @readonly
1389 def Steps(self) -> list[Step]:
1390 """
1391 Read-only property to access the job's steps (:attr:`_steps`).
1393 :returns: The steps, in the order they were added.
1394 """
1395 return self._steps
1397 def __len__(self) -> int:
1398 """
1399 Return the number of steps of the job.
1401 :returns: Number of steps.
1402 """
1403 return len(self._steps)
1405 def __contains__(self, name: str) -> bool:
1406 """
1407 Check whether a step of that name belongs to the job.
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)
1414 def __iter__(self) -> Iterator[Step]:
1415 """
1416 Iterate the job's steps.
1418 :returns: An iterator over the steps.
1419 """
1420 return iter(self._steps)
1423@export
1424class MatrixJob(Job, MatrixInstanceMixin):
1425 """One instance of a job produced by a matrix."""
1427 _PARENT_TYPE: ClassVar[Nullable[type]] = Matrix #: A matrix instance is contained in a matrix.
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.
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)
1464 def __str__(self) -> str:
1465 """
1466 Return a string representation of the matrix instance.
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
1473 return f"{self._name} ({', '.join(str(value) for value in self._dimensions.values())})"
1476@export
1477class Step(Base, ConditionMixin):
1478 """A step within a job."""
1480 _PARENT_TYPE: ClassVar[Nullable[type]] = Job #: A step is contained in a job.
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.
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)