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
« 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.
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`. 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.
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.
61.. hint::
63 See :ref:`high-level help <CI/Pipeline>` for the mapping of GitHub Actions and GitLab CI onto this model.
65.. seealso::
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
74from datetime import datetime, timedelta
75from typing import Any, ClassVar, Hashable, Iterable, Iterator, Mapping, Optional as Nullable
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
86@export
87class CIError(ToolingException):
88 """Base-exception of all exceptions raised by :mod:`pyTooling.CI` and the models of the CI services."""
91@export
92class PipelineError(CIError):
93 """Base-exception of all exceptions raised by the service-independent pipeline model."""
96@export
97class NeedDependencyError(PipelineError):
98 """The exception raised for a need - a dependency - two elements of a pipeline can't have."""
100 _element: Nullable[DependencyMixin] #: Element that would need the other one.
101 _need: Nullable[DependencyMixin] #: Element that would be needed.
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.
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
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`).
126 :returns: The needing element, or ``None`` if it wasn't recorded.
127 """
128 return self._element
130 @readonly
131 def Need(self) -> Nullable[DependencyMixin]:
132 """
133 Read-only property to access the element that would be needed (:attr:`_need`).
135 :returns: The needed element, or ``None`` if it wasn't recorded.
136 """
137 return self._need
140@export
141class NeedDependencyCycleError(NeedDependencyError):
142 """The exception raised for a need which would close a cycle of dependencies."""
144 _cycle: Nullable[tuple[DependencyMixin, ...]] #: Elements forming the cycle, in the order they need each other.
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.
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)
165 @readonly
166 def Cycle(self) -> Nullable[tuple[DependencyMixin, ...]]:
167 """
168 Read-only property to access the elements forming the cycle (:attr:`_cycle`).
170 :returns: Tuple of elements in the order they need each other, or ``None`` if they weren't recorded.
171 """
172 return self._cycle
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."""
179 _element: Any #: Element belonging to another element of a pipeline already.
181 def __init__(self, message: str, /, *, element: Any = None) -> None:
182 """
183 Initializes the exception with the element belonging to another one already.
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
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`).
196 :returns: The element, or ``None`` if it wasn't recorded.
197 """
198 return self._element
201@export
202class Outcome(StringEnum):
203 """
204 How an element of a pipeline ended, in terms every CI service has.
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 """
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.
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.
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:
226 #. :attr:`Failure`
227 #. :attr:`Timeout`
228 #. :attr:`Error`
229 #. :attr:`Cancellation`
230 #. :attr:`Success`
231 #. :attr:`Skip`
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
241 return None
244@export
245class RuleOutcome(StringEnum):
246 """
247 What a rule decides for its element, when the rule is the first one matching.
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 """
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.
260 DEFAULT = OnSuccess #: What a rule decides, if it doesn't say.
263@export
264@abstractclass
265class Base(KeyValuePairsMixin, metaclass=ExtendedType, slots=True):
266 """
267 Common behaviour of every element of a pipeline.
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 """
275 _PARENT_TYPE: ClassVar[Nullable[type]] = None #: Type a parent must have, or ``None`` when it has no parent.
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.
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.
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.
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)
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.")
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
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
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
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
354 if parent is not None:
355 parent._AddElement(self)
357 @readonly
358 def Name(self) -> str:
359 """
360 Read-only property to access the element's name (:attr:`_name`).
362 :returns: Name of the element.
363 """
364 return self._name
366 @readonly
367 def Parent(self) -> Nullable[Base]:
368 """
369 Read-only property to access the containing element (:attr:`_parent`).
371 :returns: The containing element, or ``None`` for a :class:`PipelineGroup` and an element not yet placed.
372 """
373 return self._parent
375 @readonly
376 def Pipeline(self) -> Nullable[Pipeline]:
377 """
378 Read-only property to access the pipeline this element belongs to (:attr:`_pipeline`).
380 :returns: The pipeline, or ``None`` for an element outside one.
381 """
382 return self._pipeline
384 @readonly
385 def CreatedAt(self) -> Nullable[datetime]:
386 """
387 Read-only property to access the time the element was created (:attr:`_createdAt`).
389 :returns: The time, or ``None`` if none was reported.
390 """
391 return self._createdAt
393 @readonly
394 def StartedAt(self) -> Nullable[datetime]:
395 """
396 Read-only property to access the time the element started running (:attr:`_startedAt`).
398 :returns: The time, or ``None`` while it hasn't started.
399 """
400 return self._startedAt
402 @readonly
403 def CompletedAt(self) -> Nullable[datetime]:
404 """
405 Read-only property to access the time the element completed (:attr:`_completedAt`).
407 :returns: The time, or ``None`` while it hasn't completed.
408 """
409 return self._completedAt
411 @readonly
412 def Outcome(self) -> Nullable[Outcome]:
413 """
414 Read-only property to access how the element ended (:attr:`_outcome`).
416 :returns: The outcome, or ``None`` while the element hasn't ended.
417 """
418 return self._outcome
420 @readonly
421 def Duration(self) -> Nullable[timedelta]:
422 """
423 Read-only property to return how long the element ran.
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.
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
433 return self.CompletedAt - self.StartedAt
435 @readonly
436 def DurationInSeconds(self) -> Nullable[float]:
437 """
438 Read-only property to return how long the element ran, in seconds.
440 It is :attr:`Duration` as a number.
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
447 return duration.total_seconds()
449 @staticmethod
450 def _Earliest(times: Iterable[Nullable[datetime]]) -> Nullable[datetime]:
451 """
452 Return the earliest of the times that are known.
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]
459 return min(known) if len(known) > 0 else None
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.
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
474 known.append(time)
476 return max(known) if len(known) > 0 else None
478 def __str__(self) -> str:
479 """
480 Return a string representation of the element.
482 :returns: The element's name.
483 """
484 return self._name
487@export
488class QualifiedNameMixin(metaclass=ExtendedType, mixin=True, expects=("_parent",)):
489 """
490 Mixin-class for elements named by the workflows containing them.
491 """
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.
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.
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))
510 return " / ".join(reversed(names))
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.
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.
522 What else a service's rule holds - GitLab's ``changes``, ``exists``, ``allow_failure`` or ``variables`` - is
523 attached as key-value-pairs.
524 """
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.
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.
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)
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.")
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
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
577 self._parent = None
578 self._condition = condition
579 self._when = when
581 if parent is not None:
582 parent.AddRule(self)
584 @readonly
585 def Parent(self) -> Nullable[RulesMixin]:
586 """
587 Read-only property to access the element the rule belongs to (:attr:`_parent`).
589 :returns: The element, or ``None`` for a rule not yet added to one.
590 """
591 return self._parent
593 @readonly
594 def Condition(self) -> Nullable[str]:
595 """
596 Read-only property to access the condition under which the rule matches (:attr:`_condition`).
598 :returns: The condition, as written, or ``None`` for a rule matching always.
599 """
600 return self._condition
602 @readonly
603 def When(self) -> When:
604 """
605 Read-only property to access what the rule decides, if it matches (:attr:`_when`).
607 :returns: What the rule decides for its element.
608 """
609 return self._when
612@export
613class RulesMixin(metaclass=ExtendedType, mixin=True):
614 """
615 Mixin-class for elements a definition can give rules: workflows, matrices, jobs and steps.
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 """
622 _rules: list[Rule] #: Rules of the element, in the order they are evaluated.
624 def __init__(self, rules: Nullable[Iterable[Rule]] = None) -> None:
625 """
626 Initializes the rules of an element.
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
643 for rule in rules:
644 self.AddRule(rule)
646 @readonly
647 def Rules(self) -> list[Rule]:
648 """
649 Read-only property to access the element's rules (:attr:`_rules`).
651 :returns: The rules, in the order they are evaluated.
652 """
653 return self._rules
655 @readonly
656 def RuleCount(self) -> int:
657 """
658 Read-only property to return the number of rules of the element.
660 :returns: Number of rules.
661 """
662 return len(self._rules)
664 def AddRule(self, rule: Rule) -> None:
665 """
666 Add a rule, which is evaluated after the rules the element has already.
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)
682 self._rules.append(rule)
683 rule._parent = self
685 def IterateRules(self) -> Iterator[Rule]:
686 """
687 Iterate the element's rules.
689 :returns: An iterator over the rules, in the order they are evaluated.
690 """
691 return iter(self._rules)
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.
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`.
703 The mixin compares the elements' parents, so the ``expects`` contract requires :attr:`Base._parent` from whichever
704 class it ends up in.
705 """
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.
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.
718 The element must be contained in its group already, as a need is a sibling.
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
735 self._needs = []
736 self._dependents = []
738 if needs is not None:
739 for need in needs:
740 self.AddNeed(need)
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
751 dependent.AddNeed(self)
753 @readonly
754 def Needs(self) -> list[DependencyMixin]:
755 """
756 Read-only property to access the elements this element needs (:attr:`_needs`).
758 :returns: The needed elements, in the order they were added.
759 """
760 return self._needs
762 @readonly
763 def Dependents(self) -> list[DependencyMixin]:
764 """
765 Read-only property to access the elements needing this element (:attr:`_dependents`).
767 :returns: The dependent elements, in the order they were added.
768 """
769 return self._dependents
771 def AddNeed(self, need: DependencyMixin) -> None:
772 """
773 Add an element this element needs, and this element as its dependent.
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)
801 self._needs.append(need)
802 need._dependents.append(self)
804 @staticmethod
805 def _FindCycle(elements: Iterable[DependencyMixin]) -> Nullable[list[DependencyMixin]]:
806 """
807 Find a cycle in the needs of sibling elements.
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.
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
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)
838 return None
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.
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 """
851 _dimensions: dict[str, Any] #: The matrix' dimensions this instance ran with, as name and value.
853 def __init__(self, dimensions: Nullable[Mapping[str, Any]] = None) -> None:
854 """
855 Initializes the dimensions of a matrix instance.
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
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
875 self._dimensions[name] = value
877 @readonly
878 def Dimensions(self) -> dict[str, Any]:
879 """
880 Read-only property to access the dimensions this instance ran with (:attr:`_dimensions`).
882 :returns: The dimensions' names and values, in the matrix' order.
883 """
884 return self._dimensions
887@export
888class PipelineGroup(Base):
889 """
890 The pipelines started for one commit, and the top of the tree.
892 The group has no times of its own and derives them from its pipelines.
893 """
895 _PARENT_TYPE: ClassVar[Nullable[type]] = None #: A pipeline group is the top of the tree and has no parent.
897 _pipelines: list[Pipeline] #: Pipelines of the group.
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.
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)
916 self._pipelines = []
917 if pipelines is None:
918 return
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
926 self._pipelines.append(pipeline)
927 pipeline._parent = self
929 def _AddElement(self, pipeline: Pipeline) -> None:
930 """
931 Add a pipeline, which names the group as its parent.
933 :param pipeline: The pipeline.
934 """
935 self._pipelines.append(pipeline)
937 @readonly
938 def Pipelines(self) -> list[Pipeline]:
939 """
940 Read-only property to access the pipelines of the group (:attr:`_pipelines`).
942 :returns: The pipelines, in the order they were added.
943 """
944 return self._pipelines
946 @readonly
947 def CreatedAt(self) -> Nullable[datetime]:
948 """
949 Read-only property to return when the first pipeline of the group was created.
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)
955 @readonly
956 def StartedAt(self) -> Nullable[datetime]:
957 """
958 Read-only property to return when the first pipeline of the group started.
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)
964 @readonly
965 def CompletedAt(self) -> Nullable[datetime]:
966 """
967 Read-only property to return when the last pipeline of the group completed.
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)
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`).
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)
982 def IterateJobs(self) -> Iterator[Job]:
983 """
984 Iterate every job of every pipeline of the group.
986 :returns: An iterator over the jobs.
987 """
988 for pipeline in self._pipelines:
989 yield from pipeline.IterateJobs()
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.
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
1003 for pipeline in self._pipelines:
1004 pipeline.Validate()
1006 @readonly
1007 def PipelineCount(self) -> int:
1008 """
1009 Read-only property to return the number of pipelines of the group.
1011 :returns: Number of pipelines.
1012 """
1013 return len(self._pipelines)
1015 def ContainsPipeline(self, name: str) -> bool:
1016 """
1017 Check whether a pipeline of that name belongs to the group.
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)
1024 def IteratePipelines(self) -> Iterator[Pipeline]:
1025 """
1026 Iterate the pipelines of the group.
1028 :returns: An iterator over the pipelines, in the order they were added.
1029 """
1030 return iter(self._pipelines)
1033@export
1034@abstractclass
1035class JobGroup(Base):
1036 """
1037 A group of jobs: the shared behaviour of a :class:`Workflow` and a :class:`Matrix`.
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 """
1044 _PARENT_TYPE: ClassVar[Nullable[type]] = None #: Declared by the groups deriving from this class.
1046 _elements: list[Base] #: Elements of this group - jobs, matrices, workflows - in the order they were added.
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.
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 )
1075 self._elements = []
1077 def _AddElement(self, element: Base) -> None:
1078 """
1079 Add an element, which names the group as its parent.
1081 :param element: The element.
1082 """
1083 self._elements.append(element)
1085 @readonly
1086 def Elements(self) -> list[Base]:
1087 """
1088 Read-only property to access the elements of this group (:attr:`_elements`).
1090 :returns: The jobs, matrices and workflows one level below the group, in the order they were added.
1091 """
1092 return self._elements
1094 @readonly
1095 def Jobs(self) -> list[Job]:
1096 """
1097 Read-only property to return the jobs of this group.
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)]
1103 def IterateJobs(self) -> Iterator[Job]:
1104 """
1105 Iterate every job below this group, including those of the groups it contains.
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()
1115 def Validate(self) -> None:
1116 """
1117 Check that the needs of this group and of every group it contains form no cycle.
1119 A pipeline is validated once it is completely built: each group is searched once, along every need.
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
1133 groups.extend(element for element in group._elements if isinstance(element, JobGroup))
1135 @readonly
1136 def IsReported(self) -> bool:
1137 """
1138 Check if the service reported the group as an element of its own.
1140 A reported group keeps its own times and outcome; otherwise they span its contents.
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))
1146 @readonly
1147 def CreatedAt(self) -> Nullable[datetime]:
1148 """
1149 Read-only property to return when the group was created.
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
1155 @readonly
1156 def StartedAt(self) -> Nullable[datetime]:
1157 """
1158 Read-only property to return when the group started.
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
1164 @readonly
1165 def CompletedAt(self) -> Nullable[datetime]:
1166 """
1167 Read-only property to return when the group completed.
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
1173 @readonly
1174 def Outcome(self) -> Nullable[Outcome]:
1175 """
1176 Read-only property to return how the group ended.
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
1182 @readonly
1183 def ContentsCreatedAt(self) -> Nullable[datetime]:
1184 """
1185 Read-only property to return when the first element below this group was created.
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)
1191 @readonly
1192 def ContentsStartedAt(self) -> Nullable[datetime]:
1193 """
1194 Read-only property to return when the first element below this group started.
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)
1200 @readonly
1201 def ContentsCompletedAt(self) -> Nullable[datetime]:
1202 """
1203 Read-only property to return when the last element below this group completed.
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)
1209 @readonly
1210 def ContentsOutcome(self) -> Nullable[Outcome]:
1211 """
1212 Read-only property to return how the elements below this group ended, taken together.
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)
1219 @readonly
1220 def ElementCount(self) -> int:
1221 """
1222 Read-only property to return the number of elements of this group.
1224 :returns: Number of elements one level below the group.
1225 """
1226 return len(self._elements)
1228 def ContainsElement(self, name: str) -> bool:
1229 """
1230 Check whether an element of that name belongs to this group.
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)"``.
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)
1240 def GetElement(self, name: str) -> Base:
1241 """
1242 Return the element of that name.
1244 An element is named the way :func:`str` names it, as for :meth:`ContainsElement`.
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
1254 raise KeyError(f"Group '{self._name}' contains no element '{name}'.")
1256 def IterateElements(self) -> Iterator[Base]:
1257 """
1258 Iterate what this group holds, ordered by the time it was created.
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.
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.
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)
1274 return iter(sorted(self._elements, key=createdAt))
1277@export
1278class Workflow(JobGroup, QualifiedNameMixin, RulesMixin, DependencyMixin):
1279 """
1280 A called workflow or a child pipeline, grouping the elements it contains.
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.
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 """
1289 _PARENT_TYPE: ClassVar[Nullable[type]] = ThisClass #: A workflow is contained in a workflow.
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.
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.
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
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)
1339 self._reference = reference
1340 self._workflows = {}
1341 self._matrices = {}
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.
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.")
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.")
1360 self._matrices[element._name] = element
1362 super()._AddElement(element)
1364 @readonly
1365 def Reference(self) -> Nullable[str]:
1366 """
1367 Read-only property to access what the workflow calls, as written (:attr:`_reference`).
1369 E.g. ``pyTooling/Actions/.github/workflows/Package.yml@r8`` for GitHub, or the file a GitLab trigger includes.
1371 :returns: The reference, or ``None`` if it isn't known.
1372 """
1373 return self._reference
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`).
1380 :returns: The called workflows, in the order they were added.
1381 """
1382 return self._workflows
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`).
1389 :returns: The matrices, in the order they were added.
1390 """
1391 return self._matrices
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.
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.
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.
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.
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`.
1424 .. seealso::
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
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
1446 graph = Graph(name=self._name)
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.
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)
1469 for element, vertex in vertices.items():
1470 for need in element._needs:
1471 vertex.EdgeToVertex(vertices[need])
1473 if depth is not None and level >= depth:
1474 return
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)
1480 addContents(self, None, None, 0)
1482 if reduce:
1483 graph.RemoveTransitiveEdges()
1484 for subgraph in graph.Subgraphs:
1485 subgraph.RemoveTransitiveEdges()
1487 return graph
1490@export
1491class Pipeline(Workflow):
1492 """
1493 A pipeline: the workflow a service started, holding everything else.
1495 A pipeline can need another pipeline of its :class:`PipelineGroup`, e.g. one triggered by another's completion.
1496 """
1498 _PARENT_TYPE: ClassVar[Nullable[type]] = PipelineGroup #: A pipeline is contained in a pipeline group.
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.
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 )
1533 self._pipeline = self
1537@export
1538class Matrix(JobGroup, QualifiedNameMixin, RulesMixin, DependencyMixin):
1539 """
1540 A matrix, grouping the instances it produced: jobs, or called workflows.
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 """
1546 _PARENT_TYPE: ClassVar[Nullable[type]] = Workflow #: A matrix is contained in a workflow.
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.
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)
1573 @readonly
1574 def Instances(self) -> list[Base]:
1575 """
1576 Read-only property to access the instances this matrix produced (:attr:`_elements`).
1578 :returns: The :class:`MatrixJob` or :class:`MatrixWorkflow` instances, in the order they were added.
1579 """
1580 return self._elements
1583@export
1584class MatrixWorkflow(Workflow, MatrixInstanceMixin):
1585 """
1586 One instance of a called workflow produced by a matrix.
1588 A GitHub job with ``strategy.matrix`` and ``uses:`` produces such instances.
1589 """
1591 _PARENT_TYPE: ClassVar[Nullable[type]] = Matrix #: A matrix instance is contained in a matrix.
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.
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)
1632 def __str__(self) -> str:
1633 """
1634 Return a string representation of the matrix instance.
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
1641 return f"{self._name} ({', '.join(str(value) for value in self._dimensions.values())})"
1644@export
1645class Job(Base, QualifiedNameMixin, RulesMixin, DependencyMixin):
1646 """A job, which runs its steps on a worker."""
1648 _PARENT_TYPE: ClassVar[Nullable[type]] = JobGroup #: A job is contained in a job group.
1650 _steps: list[Step] #: Steps of the job.
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.
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)
1687 self._steps = []
1689 def _AddElement(self, step: Step) -> None:
1690 """
1691 Add a step, which names the job as its parent.
1693 :param step: The step.
1694 """
1695 self._steps.append(step)
1697 @readonly
1698 def Steps(self) -> list[Step]:
1699 """
1700 Read-only property to access the job's steps (:attr:`_steps`).
1702 :returns: The steps, in the order they were added.
1703 """
1704 return self._steps
1706 @readonly
1707 def StepCount(self) -> int:
1708 """
1709 Read-only property to return the number of steps of the job.
1711 :returns: Number of steps.
1712 """
1713 return len(self._steps)
1715 def ContainsStep(self, name: str) -> bool:
1716 """
1717 Check whether a step of that name belongs to the job.
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)
1724 def IterateSteps(self) -> Iterator[Step]:
1725 """
1726 Iterate the job's steps.
1728 :returns: An iterator over the steps, in the order they were added.
1729 """
1730 return iter(self._steps)
1733@export
1734class MatrixJob(Job, MatrixInstanceMixin):
1735 """One instance of a job produced by a matrix."""
1737 _PARENT_TYPE: ClassVar[Nullable[type]] = Matrix #: A matrix instance is contained in a matrix.
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.
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)
1776 def __str__(self) -> str:
1777 """
1778 Return a string representation of the matrix instance.
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
1785 return f"{self._name} ({', '.join(str(value) for value in self._dimensions.values())})"
1788@export
1789class Step(Base, RulesMixin):
1790 """A step within a job."""
1792 _PARENT_TYPE: ClassVar[Nullable[type]] = Job #: A step is contained in a job.
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.
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)