Coverage for pyTooling/CI/GitHub/WorkflowFile.py: 88%
1487 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +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"""
32A data model of a GitHub Actions workflow file.
34A workflow file is read once into objects:
36.. code-block:: text
38 Workflow a workflow file, e.g. '.github/workflows/CompletePipeline.yml'
39 +-- Input an input of 'on.workflow_call'
40 +-- Output an output of 'on.workflow_call'
41 +-- Secret a secret of 'on.workflow_call'
42 +-- Permission a permission the workflow declares
43 +-- Job a job, in file order
44 +-- UsesReference the reusable workflow the job calls
45 +-- Permission a permission the job declares
46 +-- Matrix the job's 'strategy.matrix'
47 +-- Step a step of the job
48 +-- UsesReference the action the step runs
50 Action an action's file, e.g. '.github/actions/ComputeRequirements/action.yml'
51 +-- Step a step of a composite action
52 +-- UsesReference the action the step runs
54Every element knows its parent, the workflow it belongs to, the file it was read from - a workflow's or an action's -
55and the line it starts at, so a consumer can name the place a finding comes from, as ``CompletePipeline.yml:552``.
57:class:`WorkflowResolver` reads the reusable workflows a job calls and the actions a step runs, as far as they are in a
58local directory.
60The model is independent of :mod:`pyTooling.CI.GitHub`, which models a workflow *run* as the REST API reports it.
61:meth:`Workflow.ToPipeline` builds the pipeline a workflow defines as a :mod:`pyTooling.CI` model, whose
62elements link back to the jobs they were built from, and :meth:`Workflow.ApplyNeeds` gives a run the dependencies
63its workflow file declares.
65:raises MissingDependencyError: If the 'github' extra isn't installed.
66"""
67from __future__ import annotations
69from functools import cached_property
70from itertools import product
71from json import dumps as json_dumps
72from pathlib import Path, PurePosixPath
73from typing import Any, ClassVar, Generic, Hashable, Iterable, Iterator, Mapping, Optional as Nullable
74from typing import Self, TypeVar, Union
76from pyTooling.CI import CIError, DependencyMixin, JobGroup, Matrix as CIMatrix, MatrixInstanceMixin
77from pyTooling.CI import MatrixJob as CIMatrixJob, MatrixWorkflow as CIMatrixWorkflow
78from pyTooling.CI import Job as CIJob, Pipeline as CIPipeline, Step as CIStep, Workflow as CIWorkflow
79from pyTooling.Common import getFullyQualifiedName, StringEnum
80from pyTooling.Decorators import export, readonly
81from pyTooling.Exceptions import MissingDependencyError
82from pyTooling.MetaClasses import ExtendedType, abstractclass
84try:
85 from ruamel.yaml import YAML, YAMLError
86 from ruamel.yaml.comments import CommentedMap, CommentedSeq
87 from ruamel.yaml.scalarbool import ScalarBoolean
88 from ruamel.yaml.scalarfloat import ScalarFloat
89 from ruamel.yaml.scalarint import ScalarInt
90 from ruamel.yaml.scalarstring import ScalarString
91except ImportError as ex: # pragma: no cover
92 raise MissingDependencyError(dependency="ruamel.yaml", extra="github") from ex
95__all__ = ["ValueT"]
97ValueT = Union[str, bool, int, float, None, list["ValueT"], dict[str, "ValueT"]]
98"""A value read from a workflow file, converted to plain Python types."""
100ParentType = TypeVar("ParentType", bound="Base")
101"""A type variable for the type of an element's parent."""
103ParentTypes = Nullable[Union[type, tuple[type, ...]]]
104"""The type of :attr:`Base._PARENT_TYPE`: ``None``, a class, or a tuple of classes."""
106DefinitionType = TypeVar("DefinitionType", bound="Base")
107"""A type variable for the type of the workflow file's element an element of :mod:`pyTooling.CI` is built from."""
110@export
111class WorkflowError(CIError):
112 """
113 Base-exception of all exceptions raised by :mod:`pyTooling.CI.GitHub.WorkflowFile`.
115 The exception is raised for a workflow file that is not a well-formed workflow. It carries the file and the line
116 the problem was found at in :attr:`Path` and :attr:`Line`, and names both in a note.
117 """
119 _path: Nullable[Path] #: Path to the workflow file.
120 _line: Nullable[int] #: Line in the workflow file, starting at 1.
122 def __init__(self, message: str, path: Nullable[Path] = None, line: Nullable[int] = None) -> None:
123 """
124 Initializes a workflow error and names the place it was found at in a note.
126 :param message: The exception's message.
127 :param path: Optional, path to the workflow file. Default: ``None``.
128 :param line: Optional, line in the workflow file, starting at 1. Default: ``None``.
129 """
130 super().__init__(message)
132 self._path = path
133 self._line = line
135 if path is not None:
136 self.add_note(f"In '{path}'." if line is None else f"In '{path}:{line}'.")
138 @readonly
139 def Path(self) -> Nullable[Path]:
140 """
141 Read-only property to access the path to the workflow file (:attr:`_path`).
143 :returns: The path, or ``None`` if the problem isn't tied to a file.
144 """
145 return self._path
147 @readonly
148 def Line(self) -> Nullable[int]:
149 """
150 Read-only property to access the line the problem was found at (:attr:`_line`).
152 :returns: The line, starting at 1, or ``None`` if the problem isn't tied to a line.
153 """
154 return self._line
157@export
158class AccessLevel(StringEnum):
159 """
160 The access a permission grants to the ``GITHUB_TOKEN``.
162 The members are declared from the least to the most access, so :meth:`Rank` orders them.
163 """
165 NoAccess = "none" #: No access.
166 Read = "read" #: Read access.
167 Write = "write" #: Read and write access.
169 @cached_property
170 def Rank(self) -> int:
171 """
172 Read-only property to return the member's position in the order of access, so levels can be compared.
174 It is computed once per member.
176 :returns: ``0`` for :attr:`NoAccess`, ``1`` for :attr:`Read`, ``2`` for :attr:`Write`.
177 """
178 return list(AccessLevel).index(self)
181@export
182class PermissionScope(StringEnum):
183 """
184 The scope a permission grants the ``GITHUB_TOKEN`` access to, as a key of ``permissions``.
186 :attr:`All` stands for every scope at once, as ``read-all`` and ``write-all`` grant it.
187 """
189 All = "*" #: Every scope, from ``read-all`` or ``write-all``.
190 Actions = "actions" #: Workflows, runs and artifacts.
191 ArtifactMetadata = "artifact-metadata" #: Storage records of artifacts.
192 Attestations = "attestations" #: Artifact attestations.
193 Checks = "checks" #: Check runs and check suites.
194 CodeQuality = "code-quality" #: Code quality findings.
195 Contents = "contents" #: Repository contents, commits, branches, tags and releases.
196 Deployments = "deployments" #: Deployments.
197 Discussions = "discussions" #: GitHub Discussions.
198 IDToken = "id-token" #: An OpenID Connect token.
199 Issues = "issues" #: Issues and their comments.
200 Packages = "packages" #: GitHub Packages.
201 Pages = "pages" #: GitHub Pages builds.
202 PullRequests = "pull-requests" #: Pull requests.
203 SecurityEvents = "security-events" #: Code scanning alerts.
204 Statuses = "statuses" #: Commit statuses.
205 VulnerabilityAlerts = "vulnerability-alerts" #: Dependabot alerts.
208@export
209class InputType(StringEnum):
210 """The type of an input of a reusable workflow."""
212 String = "string" #: A string.
213 Boolean = "boolean" #: A boolean.
214 Number = "number" #: A number.
217@export
218@abstractclass
219class Base(Generic[ParentType], metaclass=ExtendedType, slots=True):
220 """
221 Common behaviour of every element of a workflow file or an action's file.
223 Every element knows the element containing it, the workflow it belongs to, the file it was read from, and the line
224 it starts at.
225 """
227 _PARENT_TYPE: ClassVar[ParentTypes] = None #: Type a parent must have, or ``None`` when the element has no parent.
229 _parent: Nullable[ParentType] #: Reference to the containing element.
230 _workflow: Nullable[Workflow] #: Reference to the workflow this element belongs to.
231 _file: Nullable[Path] #: Path to the file the element was read from: a workflow's or an action's file.
232 _line: int #: Line the element starts at in its file, starting at 1.
234 def __init__(self, line: int, *, parent: Nullable[ParentType] = None) -> None:
235 """
236 Initializes an element of a workflow file.
238 :param line: Line the element starts at in the workflow file, starting at 1.
239 :param parent: Optional, reference to the containing element. Default: ``None``.
240 :raises ValueError: If parameter 'line' is ``None``.
241 :raises TypeError: If parameter 'line' is not of type :class:`int`.
242 :raises ValueError: If parameter 'line' is not positive.
243 :raises TypeError: If parameter 'parent' is not of the type this class declares in :attr:`_PARENT_TYPE`.
244 """
245 if line is None: 245 ↛ 246line 245 didn't jump to line 246 because the condition on line 245 was never true
246 raise ValueError("Parameter 'line' is None.")
247 elif not isinstance(line, int) or isinstance(line, bool):
248 ex = TypeError("Parameter 'line' is not of type 'int'.")
249 ex.add_note(f"Got type '{getFullyQualifiedName(line)}'.")
250 raise ex
251 elif line < 1:
252 ex = ValueError("Parameter 'line' is not positive.")
253 ex.add_note(f"Got value '{line}'.")
254 raise ex
256 if parent is not None and not isinstance(parent, self._PARENT_TYPE):
257 parentTypes = self._PARENT_TYPE if isinstance(self._PARENT_TYPE, tuple) else (self._PARENT_TYPE, )
258 ex = TypeError(f"Parameter 'parent' is not of type {' or '.join(f'{t.__name__!r}' for t in parentTypes)}.")
259 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
260 raise ex
262 self._parent = parent
263 self._workflow = None if parent is None else parent._workflow
264 self._file = None if parent is None else parent._file
265 self._line = line
267 @property
268 def Parent(self) -> Nullable[ParentType]:
269 """
270 Property to access the containing element (:attr:`_parent`).
272 Assigning a parent attaches an element constructed before it: the element takes the parent's workflow and file,
273 and so do the elements it contains.
275 :returns: The containing element, or ``None`` for a :class:`Workflow`.
276 :raises ValueError: If ``None`` is assigned.
277 :raises TypeError: If a parent is assigned to a :class:`Workflow` or an :class:`Action`, which have no parent.
278 :raises TypeError: If an assigned value is not of the type this class declares in :attr:`_PARENT_TYPE`.
279 """
280 return self._parent
282 @Parent.setter
283 def Parent(self, value: ParentType) -> None:
284 if value is None:
285 raise ValueError("Parameter 'value' is None.")
286 elif not isinstance(value, self._PARENT_TYPE):
287 parentTypes = self._PARENT_TYPE if isinstance(self._PARENT_TYPE, tuple) else (self._PARENT_TYPE, )
288 ex = TypeError(f"Parameter 'value' is not of type {' or '.join(f'{t.__name__!r}' for t in parentTypes)}.")
289 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
290 raise ex
292 self._parent = value
293 self._workflow = value._workflow
294 self._file = value._file
296 @readonly
297 def Workflow(self) -> Nullable[Workflow]:
298 """
299 Read-only property to access the workflow this element belongs to (:attr:`_workflow`).
301 :returns: The workflow, or ``None`` for an element outside one.
302 """
303 return self._workflow
305 @readonly
306 def File(self) -> Nullable[Path]:
307 """
308 Read-only property to access the file the element was read from (:attr:`_file`).
310 :returns: Path to the workflow's or the action's file, or ``None`` for an element outside both.
311 """
312 return self._file
314 @readonly
315 def Line(self) -> int:
316 """
317 Read-only property to access the line the element starts at in its file (:attr:`_line`).
319 :returns: The line, starting at 1.
320 """
321 return self._line
323 @readonly
324 def Location(self) -> str:
325 """
326 Read-only property to return the place the element is written at, for a message.
328 :returns: The file's name and the line, as ``CompletePipeline.yml:552`` or ``action.yml:12``, or ``line 552`` for
329 an element outside a file.
330 """
331 if self._file is None:
332 return f"line {self._line}"
334 return f"{self._file.name}:{self._line}"
336 @staticmethod
337 def _KeyLine(mapping: CommentedMap, key: str) -> int:
338 """
339 Return the line a key of a mapping is written at.
341 :param mapping: The mapping read from the file.
342 :param key: The key.
343 :returns: The line, starting at 1.
344 """
345 return mapping.lc.key(key)[0] + 1
347 @staticmethod
348 def _ToPython(value: Any) -> ValueT:
349 """
350 Convert a value read by ``ruamel.yaml`` into plain Python types.
352 The round-trip loader returns its own types for mappings, lists, block scalars, anchored booleans, and numbers
353 written in another notation than a plain decimal. They derive from the Python types, but keep what they were
354 read with - an anchored boolean even prints as ``0`` or ``1``. Every other value is a Python type already.
356 :param value: The value read from the file.
357 :returns: The value as :class:`dict`, :class:`list`, :class:`str`, :class:`bool`, :class:`int`,
358 :class:`float` or ``None``.
359 """
360 if isinstance(value, CommentedMap):
361 return {str(key): Base._ToPython(item) for key, item in value.items()}
362 elif isinstance(value, CommentedSeq):
363 return [Base._ToPython(item) for item in value]
364 elif isinstance(value, ScalarBoolean):
365 return bool(value)
366 elif isinstance(value, ScalarInt):
367 return int(value)
368 elif isinstance(value, ScalarFloat):
369 return float(value)
370 elif isinstance(value, ScalarString):
371 return str(value)
373 return value
376@export
377class Workflow(Base[None]):
378 """
379 A GitHub Actions workflow file.
381 The workflow is named by its file's stem - ``CompletePipeline`` for ``CompletePipeline.yml`` - because that is how
382 a caller names it in ``uses``; the ``name`` key is kept as :attr:`DisplayName`.
383 """
385 _path: Path #: Path to the workflow file.
386 _name: str #: Name of the workflow, the file's stem.
387 _displayName: Nullable[str] #: Name of the workflow, as GitHub displays it.
388 _triggers: tuple[str, ...] #: Events triggering the workflow.
389 _inputs: dict[str, Input] #: Inputs of ``on.workflow_call``, by name.
390 _outputs: dict[str, Output] #: Outputs of ``on.workflow_call``, by name.
391 _secrets: dict[str, Secret] #: Secrets of ``on.workflow_call``, by name.
392 _permissions: Nullable[dict[PermissionScope, Permission]] #: Permissions the workflow declares, by scope.
393 _jobs: dict[str, Job] #: Jobs of the workflow, by name, in file order.
395 def __init__(
396 self,
397 path: Path,
398 displayName: Nullable[str] = None,
399 triggers: Nullable[Iterable[str]] = None,
400 inputs: Nullable[Iterable[Input]] = None,
401 outputs: Nullable[Iterable[Output]] = None,
402 secrets: Nullable[Iterable[Secret]] = None,
403 permissions: Nullable[Iterable[Permission]] = None,
404 jobs: Nullable[Iterable[Job]] = None
405 ) -> None:
406 """
407 Initializes a workflow.
409 An input, output, secret, permission or job is attached by passing it, or by constructing it with the workflow as
410 parent. Use :meth:`FromFile` to read a workflow file.
412 :param path: Path to the workflow file.
413 :param displayName: Optional, name of the workflow, as GitHub displays it. Default: ``None``.
414 :param triggers: Optional, events triggering the workflow, as ``workflow_call``. Default: ``None``.
415 :param inputs: Optional, inputs of ``on.workflow_call``, which are attached to the workflow. Default: ``None``.
416 :param outputs: Optional, outputs of ``on.workflow_call``, which are attached to the workflow. Default:
417 ``None``.
418 :param secrets: Optional, secrets of ``on.workflow_call``, which are attached to the workflow. Default:
419 ``None``.
420 :param permissions: Optional, permissions the workflow declares for all its jobs, which are attached to the
421 workflow. Default: ``None``, for a workflow without a ``permissions`` key.
422 :param jobs: Optional, jobs, which are attached to the workflow. Default: ``None``.
423 :raises ValueError: If parameter 'path' is ``None``.
424 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`.
425 :raises TypeError: If parameter 'displayName' is not of type :class:`str`.
426 :raises TypeError: If an element of parameter 'triggers' is not of type :class:`str`.
427 :raises TypeError: If an element of parameter 'inputs' is not of type :class:`Input`.
428 :raises TypeError: If an element of parameter 'outputs' is not of type :class:`Output`.
429 :raises TypeError: If an element of parameter 'secrets' is not of type :class:`Secret`.
430 :raises TypeError: If an element of parameter 'permissions' is not of type :class:`Permission`.
431 :raises TypeError: If an element of parameter 'jobs' is not of type :class:`Job`.
432 """
433 super().__init__(1)
435 if path is None: 435 ↛ 436line 435 didn't jump to line 436 because the condition on line 435 was never true
436 raise ValueError("Parameter 'path' is None.")
437 elif not isinstance(path, Path): 437 ↛ 438line 437 didn't jump to line 438 because the condition on line 437 was never true
438 ex = TypeError("Parameter 'path' is not of type 'Path'.")
439 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
440 raise ex
442 if displayName is not None and not isinstance(displayName, str): 442 ↛ 443line 442 didn't jump to line 443 because the condition on line 442 was never true
443 ex = TypeError("Parameter 'displayName' is not of type 'str'.")
444 ex.add_note(f"Got type '{getFullyQualifiedName(displayName)}'.")
445 raise ex
447 self._workflow = self
448 self._file = path
449 self._path = path
450 self._name = path.stem
451 self._displayName = displayName
452 self._triggers = ()
453 self._inputs = {}
454 self._outputs = {}
455 self._secrets = {}
456 self._permissions = None
457 self._jobs = {}
459 if triggers is not None:
460 self._triggers = tuple(triggers)
461 for trigger in self._triggers:
462 if not isinstance(trigger, str):
463 ex = TypeError("An element of parameter 'triggers' is not of type 'str'.")
464 ex.add_note(f"Got type '{getFullyQualifiedName(trigger)}'.")
465 raise ex
467 for parameterName, elements, elementClass, container in (
468 ("inputs", inputs, Input, self._inputs),
469 ("outputs", outputs, Output, self._outputs),
470 ("secrets", secrets, Secret, self._secrets),
471 ("jobs", jobs, Job, self._jobs)
472 ):
473 if elements is None:
474 continue
476 for element in elements:
477 if not isinstance(element, elementClass):
478 ex = TypeError(f"An element of parameter '{parameterName}' is not of type '{elementClass.__name__}'.")
479 ex.add_note(f"Got type '{getFullyQualifiedName(element)}'.")
480 raise ex
482 container[element._name] = element
483 element.Parent = self
485 if permissions is not None:
486 self._permissions = {}
487 for permission in permissions:
488 if not isinstance(permission, Permission):
489 ex = TypeError("An element of parameter 'permissions' is not of type 'Permission'.")
490 ex.add_note(f"Got type '{getFullyQualifiedName(permission)}'.")
491 raise ex
493 self._permissions[permission._scope] = permission
494 permission.Parent = self
496 @Base.Parent.setter
497 def Parent(self, value: None) -> None:
498 ex = TypeError(f"A '{getFullyQualifiedName(self)}' has no parent.")
499 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
500 raise ex
502 @readonly
503 def Path(self) -> Path:
504 """
505 Read-only property to access the path to the workflow file (:attr:`_path`).
507 :returns: The path.
508 """
509 return self._path
511 @readonly
512 def Name(self) -> str:
513 """
514 Read-only property to access the workflow's name, its file's stem (:attr:`_name`).
516 :returns: Name of the workflow, as ``CompletePipeline``.
517 """
518 return self._name
520 @readonly
521 def DisplayName(self) -> Nullable[str]:
522 """
523 Read-only property to access the workflow's name, as GitHub displays it (:attr:`_displayName`).
525 :returns: The ``name`` key, as written, or ``None`` if the workflow gives none.
526 """
527 return self._displayName
529 @readonly
530 def Triggers(self) -> tuple[str, ...]:
531 """
532 Read-only property to access the events triggering the workflow (:attr:`_triggers`).
534 :returns: The events, as ``workflow_call`` or ``push``, in the order the ``on`` key lists them.
535 """
536 return self._triggers
538 @readonly
539 def IsCallable(self) -> bool:
540 """
541 Read-only property to return whether the workflow is a reusable workflow.
543 :returns: ``True``, if the workflow is triggered by ``workflow_call``.
544 """
545 return "workflow_call" in self._triggers
547 @readonly
548 def Inputs(self) -> dict[str, Input]:
549 """
550 Read-only property to access the inputs of ``on.workflow_call`` (:attr:`_inputs`).
552 :returns: The inputs, by name, in file order.
553 """
554 return self._inputs
556 @readonly
557 def Outputs(self) -> dict[str, Output]:
558 """
559 Read-only property to access the outputs of ``on.workflow_call`` (:attr:`_outputs`).
561 :returns: The outputs, by name, in file order.
562 """
563 return self._outputs
565 @readonly
566 def Secrets(self) -> dict[str, Secret]:
567 """
568 Read-only property to access the secrets of ``on.workflow_call`` (:attr:`_secrets`).
570 :returns: The secrets, by name, in file order.
571 """
572 return self._secrets
574 @readonly
575 def Permissions(self) -> Nullable[dict[PermissionScope, Permission]]:
576 """
577 Read-only property to access the permissions the workflow declares for all its jobs (:attr:`_permissions`).
579 :returns: The permissions, by scope, or ``None`` if the workflow has no ``permissions`` key.
580 """
581 return self._permissions
583 @readonly
584 def Jobs(self) -> dict[str, Job]:
585 """
586 Read-only property to access the workflow's jobs (:attr:`_jobs`).
588 :returns: The jobs, by name, in file order.
589 """
590 return self._jobs
592 def ToPipeline(self, resolver: Nullable[WorkflowResolver] = None, depth: Nullable[int] = None) -> DefinedPipeline:
593 """
594 Build the service-independent model of the pipeline this workflow defines.
596 Every job becomes an element of :mod:`pyTooling.CI`, named by its key and linked to the job by
597 :attr:`~DefinitionMixin.Definition`:
599 * a job running steps becomes a :class:`DefinedJob`, its steps :class:`DefinedStep`\\ s;
600 * a job calling a reusable workflow becomes a :class:`DefinedWorkflow`, holding the elements of the called
601 workflow, if the resolver reads it and the depth allows it;
602 * a job with a ``strategy.matrix`` becomes a :class:`DefinedMatrix`, holding a :class:`DefinedMatrixJob` or a
603 :class:`DefinedMatrixWorkflow` per combination of :attr:`Matrix.Combinations`. A dynamic matrix holds no
604 instances, because its combinations are known at run time only.
606 The ``needs`` of the jobs become the elements' :attr:`~pyTooling.CI.DependencyMixin.Needs`, so
607 :meth:`~pyTooling.CI.Workflow.ToGraph` converts the result into a graph.
609 :param resolver: Optional, the resolver reading the workflows the jobs call. Without it, called workflows
610 are not expanded. Default: ``None``.
611 :param depth: Optional, how many levels of called workflows to expand; ``0`` expands none, ``None``
612 every level. Default: ``None``.
613 :returns: The pipeline.
614 :raises TypeError: If parameter 'resolver' is not of type :class:`WorkflowResolver`.
615 :raises TypeError: If parameter 'depth' is not of type :class:`int`.
616 :raises ValueError: If parameter 'depth' is negative.
617 :raises WorkflowError: If a workflow to expand doesn't exist, or is not a well-formed workflow.
618 :raises WorkflowError: If a workflow to expand calls itself, directly or through others.
619 :raises WorkflowError: If ``include`` or ``exclude`` of a matrix is not a list of mappings.
620 """
621 if resolver is not None and not isinstance(resolver, WorkflowResolver):
622 ex = TypeError("Parameter 'resolver' is not of type 'WorkflowResolver'.")
623 ex.add_note(f"Got type '{getFullyQualifiedName(resolver)}'.")
624 raise ex
626 if depth is not None and (not isinstance(depth, int) or isinstance(depth, bool)):
627 ex = TypeError("Parameter 'depth' is not of type 'int'.")
628 ex.add_note(f"Got type '{getFullyQualifiedName(depth)}'.")
629 raise ex
630 elif depth is not None and depth < 0:
631 ex = ValueError("Parameter 'depth' is negative.")
632 ex.add_note(f"Got value '{depth}'.")
633 raise ex
635 def addElements(workflow: Workflow, group: CIWorkflow, level: int, callers: tuple[Workflow, ...]) -> None:
636 """
637 Nested function for recursion.
639 :param workflow: The workflow whose jobs become elements.
640 :param group: The group the elements are added to.
641 :param level: How many levels of called workflows are expanded above this one.
642 :param callers: The workflows expanded above this one, this one last.
643 :raises WorkflowError: If a workflow to expand calls itself, directly or through others.
644 """
645 elements: dict[str, DependencyMixin] = {}
646 for job in workflow._jobs.values():
647 called = None
648 if job._uses is not None and resolver is not None and (depth is None or level < depth):
649 called = resolver.Resolve(job._uses)
650 if called is not None and any(caller._path.resolve() == called._path.resolve() for caller in callers):
651 ex = WorkflowError(f"Workflow '{called._name}' calls itself.", workflow._path, job._uses._line)
652 ex.add_note(f"Calls: {' -> '.join(caller._name for caller in (*callers, called))}.")
653 raise ex
655 if job._matrix is not None:
656 element = DefinedMatrix(job, parent=group)
657 if not job._matrix.IsDynamic:
658 for combination in job._matrix.Combinations:
659 dimensions = Matrix._FormatCombination(combination)
660 if job._uses is None:
661 instance = DefinedMatrixJob(job, dimensions, parent=element)
662 for step in job._steps:
663 DefinedStep(step, parent=instance)
664 else:
665 instance = DefinedMatrixWorkflow(job, dimensions, calledWorkflow=called, parent=element)
666 if called is not None:
667 addElements(called, instance, level + 1, (*callers, called))
668 elif job._uses is not None:
669 element = DefinedWorkflow(job, calledWorkflow=called, parent=group)
670 if called is not None:
671 addElements(called, element, level + 1, (*callers, called))
672 else:
673 element = DefinedJob(job, parent=group)
674 for step in job._steps:
675 DefinedStep(step, parent=element)
677 elements[job._name] = element
679 for job in workflow._jobs.values():
680 for need in job.Needs:
681 elements[job._name].AddNeed(elements[need._name])
683 pipeline = DefinedPipeline(self)
684 addElements(self, pipeline, 0, (self, ))
686 return pipeline
688 def ApplyNeeds(self, pipeline: CIWorkflow, resolver: Nullable[WorkflowResolver] = None) -> list[str]:
689 """
690 Give a run of this workflow the dependencies its jobs declare with ``needs``.
692 A run read from a service's API, as :class:`pyTooling.CI.GitHub.Pipeline`, knows no ``needs``. Each job of this
693 workflow is looked up in the run by its display name, or else by its key, and gets as
694 :attr:`~pyTooling.CI.DependencyMixin.Needs` the elements the jobs it needs were found as. A job calling
695 a reusable workflow is followed into the called workflow of the run - into each instance, if it is a matrix -,
696 as far as the resolver reads the called file. A dependency the run's element has already is kept once.
698 A job whose display name is an expression, as ``${{ matrix.os }} Tests``, can't be looked up, and is skipped. A
699 job with a condition may have been skipped in the run, so it isn't reported when it is missing.
701 A run names a matrix instance's dimensions by position, as ``{"0": "ubuntu-26.04", "1": "3.14"}``. If the job
702 declares a static matrix, an instance whose values are those of one of :attr:`Matrix.Combinations` gets that
703 combination's names, ``{"os": "ubuntu-26.04", "python": "3.14"}``. The instances of a dynamic matrix, and an
704 instance matching no combination, keep the positions.
706 :param pipeline: The run, or a called workflow of a run.
707 :param resolver: Optional, the resolver reading the workflows the jobs call. Without it, called
708 workflows are not followed. Default: ``None``.
709 :returns: The qualified names of the jobs of this workflow, and of the workflows followed,
710 missing in the run - as the run would name them -, in the order they were looked
711 up.
712 :raises ValueError: If parameter 'pipeline' is ``None``.
713 :raises TypeError: If parameter 'pipeline' is not of type :class:`pyTooling.CI.Workflow`.
714 :raises TypeError: If parameter 'resolver' is not of type :class:`WorkflowResolver`.
715 :raises WorkflowError: If a workflow to follow doesn't exist, or is not a well-formed workflow.
716 :raises WorkflowError: If ``include`` or ``exclude`` of a matrix is not a list of mappings.
717 :raises NeedDependencyCycleError: If the needs of the run, with the needs added, form a cycle.
718 """
719 if pipeline is None:
720 raise ValueError("Parameter 'pipeline' is None.")
721 elif not isinstance(pipeline, CIWorkflow):
722 ex = TypeError("Parameter 'pipeline' is not of type 'Workflow'.")
723 ex.add_note(f"Got type '{getFullyQualifiedName(pipeline)}'.")
724 raise ex
726 if resolver is not None and not isinstance(resolver, WorkflowResolver):
727 ex = TypeError("Parameter 'resolver' is not of type 'WorkflowResolver'.")
728 ex.add_note(f"Got type '{getFullyQualifiedName(resolver)}'.")
729 raise ex
731 missing: list[str] = []
733 def apply(workflow: Workflow, group: CIWorkflow) -> None:
734 """
735 Nested function for recursion.
737 :param workflow: The workflow whose jobs are looked up.
738 :param group: The group of the run the jobs are looked up in.
739 """
740 elements: dict[str, DependencyMixin] = {}
741 for job in workflow._jobs.values():
742 if job._displayName is None:
743 names = (job._name, )
744 elif "${{" in job._displayName:
745 continue
746 else:
747 names = (job._displayName, job._name)
749 if (name := next((name for name in names if group.ContainsElement(name)), None)) is None:
750 if job._condition is None: 750 ↛ 752line 750 didn't jump to line 752 because the condition on line 750 was always true
751 missing.append(names[0] if isinstance(group, CIPipeline) else f"{group.QualifiedName} / {names[0]}")
752 continue
754 element = group.GetElement(name)
755 elements[job._name] = element
756 if job._matrix is not None and not job._matrix.IsDynamic and isinstance(element, CIMatrix):
757 combinations = [Matrix._FormatCombination(combination) for combination in job._matrix.Combinations]
758 for instance in element.Instances:
759 if not isinstance(instance, MatrixInstanceMixin): 759 ↛ 760line 759 didn't jump to line 760 because the condition on line 759 was never true
760 continue
762 values = [str(value) for value in instance._dimensions.values()]
763 if (names := next((c for c in combinations if list(c.values()) == values), None)) is not None:
764 instance._dimensions = dict(zip(names, instance._dimensions.values()))
766 if job._uses is None or resolver is None or (called := resolver.Resolve(job._uses)) is None:
767 continue
768 elif isinstance(element, CIWorkflow): 768 ↛ 770line 768 didn't jump to line 770 because the condition on line 768 was always true
769 apply(called, element)
770 elif isinstance(element, CIMatrix):
771 for instance in element.Instances:
772 if isinstance(instance, CIWorkflow):
773 apply(called, instance)
775 for job in workflow._jobs.values():
776 if (element := elements.get(job._name, None)) is None:
777 continue
779 for need in job.Needs:
780 if (needed := elements.get(need._name, None)) is not None and needed not in element._needs:
781 element.AddNeed(needed)
783 apply(self, pipeline)
784 pipeline.Validate()
786 return missing
788 def IterateActions(self) -> Iterator[UsesReference]:
789 """
790 Iterate the actions the workflow's steps run.
792 An action is yielded as often as a step runs it. The reusable workflows the jobs call are in :attr:`Job.Uses`.
794 :returns: An iterator over the actions, in file order.
795 """
796 for job in self._jobs.values():
797 for step in job._steps:
798 if step._uses is not None:
799 yield step._uses
801 def CollectPermissions(self, resolver: Nullable[WorkflowResolver] = None) -> dict[PermissionScope, Permission]:
802 """
803 Collect the permissions the workflow and its jobs declare, and those of the workflows its jobs call.
805 A called workflow can keep or reduce the permissions of the ``GITHUB_TOKEN``, never raise them, so what a
806 workflow's jobs declare is what a caller has to grant. When several elements declare a scope, the permission
807 granting the most access is returned, so its :attr:`~Base.Location` names where that access is asked for.
809 :param resolver: Optional, the resolver reading the workflows the jobs call. Without it, called workflows are
810 not followed. Default: ``None``.
811 :returns: The permissions, by scope, in the order they are first declared.
812 :raises TypeError: If parameter 'resolver' is not of type :class:`WorkflowResolver`.
813 """
814 if resolver is not None and not isinstance(resolver, WorkflowResolver): 814 ↛ 815line 814 didn't jump to line 815 because the condition on line 814 was never true
815 ex = TypeError("Parameter 'resolver' is not of type 'WorkflowResolver'.")
816 ex.add_note(f"Got type '{getFullyQualifiedName(resolver)}'.")
817 raise ex
819 collected: dict[PermissionScope, Permission] = {}
820 visited: set[int] = set()
822 def collect(workflow: Workflow) -> None:
823 """
824 Nested function for recursion.
826 :param workflow: The workflow whose permissions are collected.
827 """
828 visited.add(id(workflow))
830 declarations = [] if workflow._permissions is None else [workflow._permissions]
831 for job in workflow._jobs.values():
832 if job._permissions is not None:
833 declarations.append(job._permissions)
835 for permissions in declarations:
836 for scope, permission in permissions.items():
837 if (known := collected.get(scope, None)) is None or permission._level.Rank > known._level.Rank:
838 collected[scope] = permission
840 if resolver is not None:
841 for job in workflow._jobs.values():
842 if job._uses is None or (called := resolver.Resolve(job._uses)) is None:
843 continue
844 elif id(called) not in visited:
845 collect(called)
847 collect(self)
849 return collected
851 @readonly
852 def JobCount(self) -> int:
853 """
854 Read-only property to return the number of jobs of the workflow.
856 :returns: Number of jobs.
857 """
858 return len(self._jobs)
860 def ContainsJob(self, name: str) -> bool:
861 """
862 Check whether the workflow has a job of that name.
864 :param name: Name of the job, the key it is declared under.
865 :returns: ``True``, if the workflow has a job of that name.
866 """
867 return name in self._jobs
869 def IterateJobs(self) -> Iterator[Job]:
870 """
871 Iterate the workflow's jobs.
873 :returns: An iterator over the jobs, in file order.
874 """
875 return iter(self._jobs.values())
877 def __str__(self) -> str:
878 """
879 Return the workflow's name.
881 :returns: Name of the workflow, the file's stem.
882 """
883 return self._name
885 @classmethod
886 def FromFile(cls, path: Path) -> Self:
887 """
888 Read a workflow file.
890 :param path: Path to the workflow file.
891 :returns: The workflow, with its parameters, permissions and jobs attached.
892 :raises ValueError: If parameter 'path' is ``None``.
893 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`.
894 :raises WorkflowError: If the file doesn't exist.
895 :raises WorkflowError: If the file can't be read.
896 :raises WorkflowError: If the file is not a YAML document.
897 :raises WorkflowError: If the document is not a mapping, or has no ``on`` or ``jobs`` key.
898 :raises WorkflowError: If a parameter of ``on.workflow_call`` lacks a key GitHub requires, or has a value of the
899 wrong kind. |br|
900 For an unknown input type, the note lists the allowed values.
901 :raises WorkflowError: If a job is malformed, needs a job the workflow doesn't have, or the jobs need each other in
902 a cycle. |br|
903 For an unknown job, the note lists the workflow's jobs.
904 """
905 if path is None:
906 raise ValueError("Parameter 'path' is None.")
907 elif not isinstance(path, Path):
908 ex = TypeError("Parameter 'path' is not of type 'Path'.")
909 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
910 raise ex
911 elif not path.exists():
912 raise WorkflowError("Workflow file doesn't exist.", path) from FileNotFoundError(path)
914 try:
915 content = path.read_text(encoding="utf-8")
916 except OSError as cause:
917 raise WorkflowError("Workflow file can't be read.", path) from cause
919 try:
920 document = YAML(typ="rt").load(content)
921 except YAMLError as cause:
922 mark = getattr(cause, "problem_mark", None)
923 line = None if mark is None else mark.line + 1
924 raise WorkflowError("Workflow file is not a YAML document.", path, line) from cause
926 if document is None:
927 raise WorkflowError("Workflow file is empty.", path)
928 elif not isinstance(document, CommentedMap): 928 ↛ 929line 928 didn't jump to line 929 because the condition on line 928 was never true
929 ex = WorkflowError("Workflow file is not a mapping.", path, 1)
930 ex.add_note(f"Got type '{getFullyQualifiedName(document)}'.")
931 raise ex
932 elif "on" not in document:
933 raise WorkflowError("Workflow file has no 'on' key.", path)
934 elif "jobs" not in document:
935 raise WorkflowError("Workflow file has no 'jobs' key.", path)
937 workflow = cls._Parse(document, path)
938 workflow._Validate()
940 return workflow
942 @classmethod
943 def _Parse(cls, document: CommentedMap, path: Path) -> Self:
944 """
945 Build a workflow and the elements it contains from the document read from its file.
947 :param document: The document, a mapping with an ``on`` and a ``jobs`` key.
948 :param path: Path to the workflow file.
949 :returns: The workflow, with its parameters, permissions and jobs attached.
950 :raises WorkflowError: If key ``on`` is neither an event, a list nor a mapping.
951 :raises WorkflowError: If a parameter of ``on.workflow_call`` lacks a key GitHub requires, or has a value of the
952 wrong kind. |br|
953 For an unknown input type, the note lists the allowed values.
954 :raises WorkflowError: If key ``jobs`` or a job is not a mapping, or a job is malformed.
955 """
956 on = document["on"]
957 if isinstance(on, str):
958 triggers = (on, )
959 elif isinstance(on, (CommentedSeq, CommentedMap)): 959 ↛ 962line 959 didn't jump to line 962 because the condition on line 959 was always true
960 triggers = tuple(str(trigger) for trigger in on)
961 else:
962 ex = WorkflowError("Key 'on' is neither an event, a list nor a mapping.", path, Base._KeyLine(document, "on"))
963 ex.add_note(f"Got type '{getFullyQualifiedName(on)}'.")
964 raise ex
966 parameters: dict[str, list[Parameter]] = {"inputs": [], "outputs": [], "secrets": []}
967 if isinstance(on, CommentedMap) and (call := on.get("workflow_call", None)) is not None:
968 if not isinstance(call, CommentedMap): 968 ↛ 969line 968 didn't jump to line 969 because the condition on line 968 was never true
969 ex = WorkflowError("Key 'on.workflow_call' is not a mapping.", path, Base._KeyLine(on, "workflow_call"))
970 ex.add_note(f"Got type '{getFullyQualifiedName(call)}'.")
971 raise ex
973 for section, parameterClass in (("inputs", Input), ("outputs", Output), ("secrets", Secret)):
974 if (declarations := call.get(section, None)) is None:
975 continue
976 elif not isinstance(declarations, CommentedMap): 976 ↛ 977line 976 didn't jump to line 977 because the condition on line 976 was never true
977 ex = WorkflowError(f"Key 'on.workflow_call.{section}' is not a mapping.", path, Base._KeyLine(call, section))
978 ex.add_note(f"Got type '{getFullyQualifiedName(declarations)}'.")
979 raise ex
981 parameters[section] = [
982 parameterClass._FromYAML(str(name), declaration, path, Base._KeyLine(declarations, name))
983 for name, declaration in declarations.items()
984 ]
986 jobs = document["jobs"]
987 if not isinstance(jobs, CommentedMap): 987 ↛ 988line 987 didn't jump to line 988 because the condition on line 987 was never true
988 ex = WorkflowError("Key 'jobs' is not a mapping.", path, Base._KeyLine(document, "jobs"))
989 ex.add_note(f"Got type '{getFullyQualifiedName(jobs)}'.")
990 raise ex
992 jobList = []
993 for name, job in jobs.items():
994 line = Base._KeyLine(jobs, name)
995 if not isinstance(job, CommentedMap):
996 ex = WorkflowError(f"Job '{name}' is not a mapping.", path, line)
997 ex.add_note(f"Got type '{getFullyQualifiedName(job)}'.")
998 raise ex
1000 jobList.append(Job._FromYAML(str(name), job, path, line))
1002 permissions = None
1003 if "permissions" in document:
1004 permissions = Permission._FromYAML(document["permissions"], path, Base._KeyLine(document, "permissions"))
1006 displayName = document.get("name", None)
1007 return cls(
1008 path,
1009 None if displayName is None else str(displayName),
1010 triggers,
1011 parameters["inputs"],
1012 parameters["outputs"],
1013 parameters["secrets"],
1014 permissions,
1015 jobList
1016 )
1018 def _Validate(self) -> None:
1019 """
1020 Validate the workflow read from a file.
1022 :raises WorkflowError: If a job needs a job the workflow doesn't have. |br|
1023 The note lists the workflow's jobs.
1024 :raises WorkflowError: If the jobs need each other in a cycle.
1025 """
1026 self._ValidateNeeds()
1027 self._ValidateAcyclic()
1029 def _ValidateNeeds(self) -> None:
1030 """
1031 Validate that every job names only jobs of the workflow in its ``needs`` key.
1033 :raises WorkflowError: If a job needs a job the workflow doesn't have. |br|
1034 The note lists the workflow's jobs.
1035 """
1036 for job in self._jobs.values():
1037 for need in job._needNames:
1038 if need not in self._jobs:
1039 ex = WorkflowError(
1040 f"Job '{job._name}' needs job '{need}', which the workflow doesn't have.", self._path, job._line
1041 )
1042 ex.add_note(f"Jobs: {', '.join(self._jobs)}.")
1043 raise ex
1045 def _ValidateAcyclic(self) -> None:
1046 """
1047 Validate that the jobs don't need each other in a cycle.
1049 :raises WorkflowError: If the jobs need each other in a cycle.
1050 """
1051 # Depth-first search: a job still on the stack when it is reached again closes a cycle.
1052 finished: set[str] = set()
1053 stack: list[str] = []
1055 def visit(job: Job) -> None:
1056 """
1057 Nested function for recursion.
1059 :param job: The job whose needs are followed.
1060 :raises WorkflowError: If the job is reached again while its needs are followed.
1061 """
1062 if job._name in finished:
1063 return
1064 elif job._name in stack:
1065 cycle = stack[stack.index(job._name):] + [job._name]
1066 raise WorkflowError(f"Jobs need each other in a cycle: {' -> '.join(cycle)}.", self._path, job._line)
1068 stack.append(job._name)
1069 for need in job.Needs:
1070 visit(need)
1071 stack.pop()
1072 finished.add(job._name)
1074 for job in self._jobs.values():
1075 visit(job)
1078@export
1079class Job(Base[Workflow]):
1080 """
1081 A job of a workflow.
1083 A job either runs :attr:`Steps` on a runner selected by :attr:`RunsOn`, or calls the reusable workflow named by
1084 :attr:`Uses`.
1085 """
1087 _PARENT_TYPE: ClassVar[ParentTypes] = Workflow #: A job is contained in a workflow.
1089 _name: str #: Name of the job, the key it is declared under.
1090 _displayName: Nullable[str] #: Name of the job, as GitHub displays it.
1091 _needNames: tuple[str, ...] #: Names of the jobs this job needs.
1092 _condition: Nullable[str] #: Condition under which the job runs.
1093 _permissions: Nullable[dict[PermissionScope, Permission]] #: Permissions the job declares, by scope.
1094 _runsOn: tuple[str, ...] #: Labels selecting the runner.
1095 _uses: Nullable[UsesReference] #: The reusable workflow the job calls.
1096 _with: dict[str, ValueT] #: Inputs passed to the called workflow, by name.
1097 _secrets: dict[str, str] #: Secrets passed to the called workflow, by name.
1098 _inheritsSecrets: bool #: ``True``, if secrets are inherited.
1099 _matrix: Nullable[Matrix] #: The job's matrix.
1100 _steps: list[Step] #: Steps of the job.
1101 _outputs: dict[str, str] #: Outputs of the job, by name.
1102 _container: Nullable[str] #: Image of the container the job's steps run in.
1103 _services: dict[str, str] #: Images of the service containers, by service name.
1105 def __init__(
1106 self,
1107 name: str,
1108 line: int,
1109 displayName: Nullable[str] = None,
1110 needs: Nullable[Iterable[str]] = None,
1111 condition: Nullable[str] = None,
1112 runsOn: Nullable[Iterable[str]] = None,
1113 container: Nullable[str] = None,
1114 services: Nullable[Mapping[str, str]] = None,
1115 uses: Nullable[UsesReference] = None,
1116 withInputs: Nullable[Mapping[str, ValueT]] = None,
1117 secrets: Nullable[Mapping[str, str]] = None,
1118 inheritsSecrets: bool = False,
1119 outputs: Nullable[Mapping[str, str]] = None,
1120 permissions: Nullable[Iterable[Permission]] = None,
1121 matrix: Nullable[Matrix] = None,
1122 steps: Nullable[Iterable[Step]] = None,
1123 *,
1124 parent: Nullable[Workflow] = None
1125 ) -> None:
1126 """
1127 Initializes a job of a workflow.
1129 The reusable workflow a job calls, its permissions, its matrix and its steps are attached by passing them, or by
1130 constructing a :class:`UsesReference`, :class:`Permission`, :class:`Matrix` or :class:`Step` with the job as
1131 parent.
1133 :param name: Name of the job, the key it is declared under.
1134 :param line: Line the job's name is written at, starting at 1.
1135 :param displayName: Optional, name of the job, as GitHub displays it. Default: ``None``.
1136 :param needs: Optional, names of the jobs this job needs. Default: ``None``.
1137 :param condition: Optional, condition under which the job runs. Default: ``None``.
1138 :param runsOn: Optional, labels selecting the runner. Default: ``None``.
1139 :param container: Optional, image of the container the job's steps run in. Default: ``None``.
1140 :param services: Optional, images of the service containers, by service name. Default: ``None``.
1141 :param uses: Optional, the reusable workflow the job calls, which is attached to the job. Default:
1142 ``None``.
1143 :param withInputs: Optional, inputs passed to the called workflow, by name. Default: ``None``.
1144 :param secrets: Optional, secrets passed to the called workflow, by name. Default: ``None``.
1145 :param inheritsSecrets: Optional, ``True``, if the called workflow inherits every secret. Default: ``False``.
1146 :param outputs: Optional, outputs of the job, by name. Default: ``None``.
1147 :param permissions: Optional, permissions the job declares, which are attached to the job. Default: ``None``,
1148 for a job without a ``permissions`` key.
1149 :param matrix: Optional, the job's matrix, which is attached to the job. Default: ``None``.
1150 :param steps: Optional, the job's steps, which are attached to the job. Default: ``None``.
1151 :param parent: Optional, reference to the workflow containing the job. Default: ``None``.
1152 :raises ValueError: If parameter 'name' is ``None``.
1153 :raises TypeError: If parameter 'name' is not of type :class:`str`.
1154 :raises ValueError: If parameter 'name' is empty.
1155 :raises TypeError: If parameter 'displayName' is not of type :class:`str`.
1156 :raises TypeError: If parameter 'condition' is not of type :class:`str`.
1157 :raises TypeError: If parameter 'container' is not of type :class:`str`.
1158 :raises TypeError: If an element of parameter 'needs' is not of type :class:`str`.
1159 :raises TypeError: If an element of parameter 'runsOn' is not of type :class:`str`.
1160 :raises TypeError: If parameter 'uses' is not of type :class:`UsesReference`.
1161 :raises TypeError: If parameter 'inheritsSecrets' is not of type :class:`bool`.
1162 :raises TypeError: If an element of parameter 'permissions' is not of type :class:`Permission`.
1163 :raises TypeError: If parameter 'matrix' is not of type :class:`Matrix`.
1164 :raises TypeError: If an element of parameter 'steps' is not of type :class:`Step`.
1165 """
1166 super().__init__(line, parent=parent)
1168 if name is None:
1169 raise ValueError("Parameter 'name' is None.")
1170 elif not isinstance(name, str):
1171 ex = TypeError("Parameter 'name' is not of type 'str'.")
1172 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
1173 raise ex
1174 elif name == "":
1175 raise ValueError("Parameter 'name' is empty.")
1177 for parameterName, value in (
1178 ("displayName", displayName),
1179 ("condition", condition),
1180 ("container", container)
1181 ):
1182 if value is not None and not isinstance(value, str):
1183 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
1184 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
1185 raise ex
1187 self._needNames = () if needs is None else tuple(needs)
1188 self._runsOn = () if runsOn is None else tuple(runsOn)
1189 for parameterName, values in (("needs", self._needNames), ("runsOn", self._runsOn)):
1190 for value in values:
1191 if not isinstance(value, str):
1192 ex = TypeError(f"An element of parameter '{parameterName}' is not of type 'str'.")
1193 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
1194 raise ex
1196 for parameterName, value, valueClass in (
1197 ("uses", uses, UsesReference),
1198 ("matrix", matrix, Matrix)
1199 ):
1200 if value is not None and not isinstance(value, valueClass):
1201 ex = TypeError(f"Parameter '{parameterName}' is not of type '{valueClass.__name__}'.")
1202 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
1203 raise ex
1205 if not isinstance(inheritsSecrets, bool): 1205 ↛ 1206line 1205 didn't jump to line 1206 because the condition on line 1205 was never true
1206 ex = TypeError("Parameter 'inheritsSecrets' is not of type 'bool'.")
1207 ex.add_note(f"Got type '{getFullyQualifiedName(inheritsSecrets)}'.")
1208 raise ex
1210 self._name = name
1211 self._displayName = displayName
1212 self._condition = condition
1213 self._permissions = None
1214 self._uses = uses
1215 self._with = {} if withInputs is None else dict(withInputs)
1216 self._secrets = {} if secrets is None else dict(secrets)
1217 self._inheritsSecrets = inheritsSecrets
1218 self._matrix = matrix
1219 self._steps = []
1220 self._outputs = {} if outputs is None else dict(outputs)
1221 self._container = container
1222 self._services = {} if services is None else dict(services)
1224 if uses is not None:
1225 uses.Parent = self
1227 if matrix is not None:
1228 matrix.Parent = self
1230 if permissions is not None:
1231 self._permissions = {}
1232 for permission in permissions:
1233 if not isinstance(permission, Permission):
1234 ex = TypeError("An element of parameter 'permissions' is not of type 'Permission'.")
1235 ex.add_note(f"Got type '{getFullyQualifiedName(permission)}'.")
1236 raise ex
1238 self._permissions[permission._scope] = permission
1239 permission.Parent = self
1241 if steps is not None:
1242 for step in steps:
1243 if not isinstance(step, Step):
1244 ex = TypeError("An element of parameter 'steps' is not of type 'Step'.")
1245 ex.add_note(f"Got type '{getFullyQualifiedName(step)}'.")
1246 raise ex
1248 self._steps.append(step)
1249 step.Parent = self
1251 if parent is not None:
1252 parent._jobs[name] = self
1254 @Base.Parent.setter
1255 def Parent(self, value: Workflow) -> None:
1256 Base.Parent.fset(self, value)
1258 if self._uses is not None:
1259 self._uses.Parent = self
1261 if self._matrix is not None:
1262 self._matrix.Parent = self
1264 for step in self._steps:
1265 step.Parent = self
1267 if self._permissions is not None:
1268 for permission in self._permissions.values():
1269 permission.Parent = self
1271 @readonly
1272 def Name(self) -> str:
1273 """
1274 Read-only property to access the job's name, the key it is declared under (:attr:`_name`).
1276 :returns: Name of the job.
1277 """
1278 return self._name
1280 @readonly
1281 def DisplayName(self) -> Nullable[str]:
1282 """
1283 Read-only property to access the job's name, as GitHub displays it (:attr:`_displayName`).
1285 :returns: The ``name`` key, as written, or ``None`` if the workflow gives none.
1286 """
1287 return self._displayName
1289 @readonly
1290 def NeedNames(self) -> tuple[str, ...]:
1291 """
1292 Read-only property to access the names of the jobs this job needs (:attr:`_needNames`).
1294 :returns: The names, in the order the ``needs`` key lists them.
1295 """
1296 return self._needNames
1298 @readonly
1299 def Needs(self) -> tuple[Job, ...]:
1300 """
1301 Read-only property to return the jobs this job needs.
1303 The names in :attr:`NeedNames` are looked up in the workflow containing the job. :meth:`Workflow.FromFile`
1304 rejects a name naming no job, so for a workflow read from a file every name is resolved.
1306 :returns: The jobs, in the order the ``needs`` key lists them, skipping names naming no job of the workflow.
1307 """
1308 if self._workflow is None: 1308 ↛ 1309line 1308 didn't jump to line 1309 because the condition on line 1308 was never true
1309 return ()
1311 jobs = self._workflow._jobs
1312 return tuple(jobs[name] for name in self._needNames if name in jobs)
1314 @readonly
1315 def Condition(self) -> Nullable[str]:
1316 """
1317 Read-only property to access the condition under which the job runs (:attr:`_condition`).
1319 The expression is not evaluated.
1321 :returns: The ``if`` expression, as written, or ``None`` if the job has no condition.
1322 """
1323 return self._condition
1325 @readonly
1326 def Permissions(self) -> Nullable[dict[PermissionScope, Permission]]:
1327 """
1328 Read-only property to access the permissions the job declares (:attr:`_permissions`).
1330 :returns: The permissions, by scope, or ``None`` if the job has no ``permissions`` key and inherits them.
1331 """
1332 return self._permissions
1334 @readonly
1335 def RunsOn(self) -> tuple[str, ...]:
1336 """
1337 Read-only property to access the labels selecting the runner (:attr:`_runsOn`).
1339 :returns: The labels, as written - an expression is not evaluated -, or ``()`` for a job calling a workflow.
1340 """
1341 return self._runsOn
1343 @readonly
1344 def Uses(self) -> Nullable[UsesReference]:
1345 """
1346 Read-only property to access the reusable workflow the job calls (:attr:`_uses`).
1348 :returns: The reference, or ``None`` for a job running steps.
1349 """
1350 return self._uses
1352 @readonly
1353 def With(self) -> dict[str, ValueT]:
1354 """
1355 Read-only property to access the inputs passed to the called workflow (:attr:`_with`).
1357 :returns: The inputs, by name.
1358 """
1359 return self._with
1361 @readonly
1362 def Secrets(self) -> dict[str, str]:
1363 """
1364 Read-only property to access the secrets passed to the called workflow (:attr:`_secrets`).
1366 :returns: The secrets, by name, or an empty dictionary if the job passes none or :attr:`InheritsSecrets`.
1367 """
1368 return self._secrets
1370 @readonly
1371 def InheritsSecrets(self) -> bool:
1372 """
1373 Read-only property to access whether the called workflow inherits every secret (:attr:`_inheritsSecrets`).
1375 :returns: ``True``, if the job says ``secrets: inherit``.
1376 """
1377 return self._inheritsSecrets
1379 @readonly
1380 def Matrix(self) -> Nullable[Matrix]:
1381 """
1382 Read-only property to access the job's matrix (:attr:`_matrix`).
1384 :returns: The matrix, or ``None`` if the job has no ``strategy.matrix``.
1385 """
1386 return self._matrix
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 file order, or an empty list for a job calling a workflow.
1394 """
1395 return self._steps
1397 @readonly
1398 def Outputs(self) -> dict[str, str]:
1399 """
1400 Read-only property to access the job's outputs (:attr:`_outputs`).
1402 :returns: The expressions the outputs are taken from, by name.
1403 """
1404 return self._outputs
1406 @readonly
1407 def Container(self) -> Nullable[str]:
1408 """
1409 Read-only property to access the image of the container the job's steps run in (:attr:`_container`).
1411 :returns: The image, as written - e.g. ``pytooling/miktex:sphinx`` or an expression -, or ``None`` if the job has no
1412 ``container``.
1413 """
1414 return self._container
1416 @readonly
1417 def Services(self) -> dict[str, str]:
1418 """
1419 Read-only property to access the images of the job's service containers (:attr:`_services`).
1421 :returns: The images, as written, by service name.
1422 """
1423 return self._services
1425 @readonly
1426 def StepCount(self) -> int:
1427 """
1428 Read-only property to return the number of steps of the job.
1430 :returns: Number of steps.
1431 """
1432 return len(self._steps)
1434 def IterateSteps(self) -> Iterator[Step]:
1435 """
1436 Iterate the job's steps.
1438 :returns: An iterator over the steps, in file order.
1439 """
1440 return iter(self._steps)
1442 def __str__(self) -> str:
1443 """
1444 Return the job's name.
1446 :returns: Name of the job.
1447 """
1448 return self._name
1450 @classmethod
1451 def _FromYAML(cls, name: str, mapping: CommentedMap, path: Path, line: int) -> Self:
1452 """
1453 Build a job and the elements it contains from its mapping in the workflow file.
1455 :param name: Name of the job.
1456 :param mapping: The job's mapping.
1457 :param path: Path to the workflow file.
1458 :param line: Line the job's name is written at, starting at 1.
1459 :returns: The job.
1460 :raises WorkflowError: If the job has neither ``runs-on`` nor ``uses``, or both.
1461 :raises WorkflowError: If a key of the job holds a value of the wrong kind.
1462 """
1463 if ("runs-on" in mapping) == ("uses" in mapping):
1464 raise WorkflowError(f"Job '{name}' needs either 'runs-on' or 'uses'.", path, line)
1466 needs = mapping.get("needs", ())
1467 if isinstance(needs, str):
1468 needs = (needs, )
1469 elif not isinstance(needs, (list, tuple)): 1469 ↛ 1470line 1469 didn't jump to line 1470 because the condition on line 1469 was never true
1470 ex = WorkflowError(
1471 f"Key 'needs' of job '{name}' is neither a job name nor a list.", path, Base._KeyLine(mapping, "needs")
1472 )
1473 ex.add_note(f"Got type '{getFullyQualifiedName(needs)}'.")
1474 raise ex
1476 runsOn = mapping.get("runs-on", ())
1477 if isinstance(runsOn, dict): 1477 ↛ 1478line 1477 didn't jump to line 1478 because the condition on line 1477 was never true
1478 runsOn = runsOn.get("labels", ())
1480 if isinstance(runsOn, str):
1481 runsOn = (runsOn, )
1483 condition = mapping.get("if", None)
1485 secrets = mapping.get("secrets", None)
1486 inheritsSecrets = secrets == "inherit"
1487 if inheritsSecrets:
1488 secrets = None
1489 elif secrets is not None:
1490 if not isinstance(secrets, CommentedMap): 1490 ↛ 1491line 1490 didn't jump to line 1491 because the condition on line 1490 was never true
1491 ex = WorkflowError(f"Key 'secrets' of job '{name}' is not a mapping.", path, Base._KeyLine(mapping, "secrets"))
1492 ex.add_note(f"Got type '{getFullyQualifiedName(secrets)}'.")
1493 raise ex
1495 secrets = {str(key): str(value) for key, value in secrets.items()}
1497 if (outputs := mapping.get("outputs", None)) is not None:
1498 if not isinstance(outputs, CommentedMap): 1498 ↛ 1499line 1498 didn't jump to line 1499 because the condition on line 1498 was never true
1499 ex = WorkflowError(f"Key 'outputs' of job '{name}' is not a mapping.", path, Base._KeyLine(mapping, "outputs"))
1500 ex.add_note(f"Got type '{getFullyQualifiedName(outputs)}'.")
1501 raise ex
1503 outputs = {str(key): str(value) for key, value in outputs.items()}
1505 if (withValues := mapping.get("with", None)) is not None:
1506 if not isinstance(withValues, CommentedMap): 1506 ↛ 1507line 1506 didn't jump to line 1507 because the condition on line 1506 was never true
1507 ex = WorkflowError(f"Key 'with' of job '{name}' is not a mapping.", path, Base._KeyLine(mapping, "with"))
1508 ex.add_note(f"Got type '{getFullyQualifiedName(withValues)}'.")
1509 raise ex
1511 withValues = Base._ToPython(withValues)
1513 uses = None
1514 if "uses" in mapping:
1515 uses = UsesReference._FromYAML(mapping, f"job '{name}'", path)
1517 permissions = None
1518 if "permissions" in mapping:
1519 permissions = Permission._FromYAML(mapping["permissions"], path, Base._KeyLine(mapping, "permissions"))
1521 matrix = None
1522 if (strategy := mapping.get("strategy", None)) is not None:
1523 if not isinstance(strategy, CommentedMap): 1523 ↛ 1524line 1523 didn't jump to line 1524 because the condition on line 1523 was never true
1524 ex = WorkflowError(
1525 f"Key 'strategy' of job '{name}' is not a mapping.", path, Base._KeyLine(mapping, "strategy")
1526 )
1527 ex.add_note(f"Got type '{getFullyQualifiedName(strategy)}'.")
1528 raise ex
1530 if "matrix" in strategy: 1530 ↛ 1533line 1530 didn't jump to line 1533 because the condition on line 1530 was always true
1531 matrix = Matrix._FromYAML(strategy["matrix"], path, Base._KeyLine(strategy, "matrix"))
1533 container = mapping.get("container", None)
1534 if isinstance(container, CommentedMap):
1535 container = container.get("image", None)
1537 services = None
1538 if (serviceMap := mapping.get("services", None)) is not None:
1539 if not isinstance(serviceMap, CommentedMap): 1539 ↛ 1540line 1539 didn't jump to line 1540 because the condition on line 1539 was never true
1540 ex = WorkflowError(
1541 f"Key 'services' of job '{name}' is not a mapping.", path, Base._KeyLine(mapping, "services")
1542 )
1543 ex.add_note(f"Got type '{getFullyQualifiedName(serviceMap)}'.")
1544 raise ex
1546 services = {}
1547 for serviceName, service in serviceMap.items():
1548 if isinstance(service, CommentedMap):
1549 service = service.get("image", None)
1551 if service is not None: 1551 ↛ 1547line 1551 didn't jump to line 1547 because the condition on line 1551 was always true
1552 services[str(serviceName)] = str(service)
1554 steps = None
1555 if (stepList := mapping.get("steps", None)) is not None:
1556 if not isinstance(stepList, CommentedSeq):
1557 ex = WorkflowError(f"Key 'steps' of job '{name}' is not a list.", path, Base._KeyLine(mapping, "steps"))
1558 ex.add_note(f"Got type '{getFullyQualifiedName(stepList)}'.")
1559 raise ex
1561 steps = [
1562 Step._FromYAML(step, position, f"job '{name}'", path, stepList.lc.item(position)[0] + 1)
1563 for position, step in enumerate(stepList)
1564 ]
1566 displayName = mapping.get("name", None)
1568 return cls(
1569 name, line,
1570 displayName=None if displayName is None else str(displayName),
1571 needs=(str(need) for need in needs),
1572 condition=None if condition is None else str(condition),
1573 runsOn=(str(label) for label in runsOn),
1574 container=None if container is None else str(container),
1575 services=services,
1576 uses=uses,
1577 withInputs=withValues,
1578 secrets=secrets,
1579 inheritsSecrets=inheritsSecrets,
1580 outputs=outputs,
1581 permissions=permissions,
1582 matrix=matrix,
1583 steps=steps
1584 )
1587@export
1588class Action(Base[None]):
1589 """
1590 An action's file, ``action.yml``.
1592 The action is named by its directory - ``ComputeRequirements`` for ``.github/actions/ComputeRequirements/action.yml``
1593 - because that is how a step names it in ``uses``; the ``name`` key is kept as :attr:`DisplayName`. Of a composite
1594 action, the steps are read, so the actions it runs in turn are known.
1595 """
1597 _path: Path #: Path to the action's file.
1598 _name: str #: Name of the action, its directory's name.
1599 _displayName: Nullable[str] #: Name of the action, as GitHub displays it.
1600 _using: str #: How the action runs, as ``composite``, ``docker`` or ``node24``.
1601 _image: Nullable[str] #: The image a Docker action runs, as ``Dockerfile`` or ``docker://alpine:3.22``.
1602 _steps: list[Step] #: Steps of a composite action.
1604 def __init__(
1605 self,
1606 path: Path,
1607 using: str,
1608 displayName: Nullable[str] = None,
1609 image: Nullable[str] = None,
1610 steps: Nullable[Iterable[Step]] = None
1611 ) -> None:
1612 """
1613 Initializes an action.
1615 The steps of a composite action are attached by passing them, or by constructing them with the action as parent.
1616 Use :meth:`FromFile` to read an action's file.
1618 :param path: Path to the action's file.
1619 :param using: How the action runs, as ``composite``.
1620 :param displayName: Optional, name of the action, as GitHub displays it. Default: ``None``.
1621 :param image: Optional, the image a Docker action runs. Default: ``None``.
1622 :param steps: Optional, the steps of a composite action, which are attached to the action. Default:
1623 ``None``.
1624 :raises ValueError: If parameter 'path' is ``None``.
1625 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`.
1626 :raises ValueError: If parameter 'using' is ``None``.
1627 :raises TypeError: If parameter 'using' is not of type :class:`str`.
1628 :raises TypeError: If parameter 'displayName' is not of type :class:`str`.
1629 :raises TypeError: If parameter 'image' is not of type :class:`str`.
1630 :raises TypeError: If an element of parameter 'steps' is not of type :class:`Step`.
1631 """
1632 super().__init__(1)
1634 if path is None:
1635 raise ValueError("Parameter 'path' is None.")
1636 elif not isinstance(path, Path): 1636 ↛ 1637line 1636 didn't jump to line 1637 because the condition on line 1636 was never true
1637 ex = TypeError("Parameter 'path' is not of type 'Path'.")
1638 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
1639 raise ex
1641 if using is None:
1642 raise ValueError("Parameter 'using' is None.")
1644 for parameterName, value in (
1645 ("using", using),
1646 ("displayName", displayName),
1647 ("image", image)
1648 ):
1649 if value is not None and not isinstance(value, str): 1649 ↛ 1650line 1649 didn't jump to line 1650 because the condition on line 1649 was never true
1650 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
1651 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
1652 raise ex
1654 self._file = path
1655 self._path = path
1656 self._name = path.parent.name
1657 self._displayName = displayName
1658 self._using = using
1659 self._image = image
1660 self._steps = []
1662 if steps is not None:
1663 for step in steps:
1664 if not isinstance(step, Step):
1665 ex = TypeError("An element of parameter 'steps' is not of type 'Step'.")
1666 ex.add_note(f"Got type '{getFullyQualifiedName(step)}'.")
1667 raise ex
1669 self._steps.append(step)
1670 step.Parent = self
1672 @Base.Parent.setter
1673 def Parent(self, value: None) -> None:
1674 ex = TypeError(f"A '{getFullyQualifiedName(self)}' has no parent.")
1675 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
1676 raise ex
1678 @readonly
1679 def Path(self) -> Path:
1680 """
1681 Read-only property to access the path to the action's file (:attr:`_path`).
1683 :returns: Path to the action's file.
1684 """
1685 return self._path
1687 @readonly
1688 def Name(self) -> str:
1689 """
1690 Read-only property to access the action's name, its directory's name (:attr:`_name`).
1692 :returns: Name of the action.
1693 """
1694 return self._name
1696 @readonly
1697 def DisplayName(self) -> Nullable[str]:
1698 """
1699 Read-only property to access the action's name, as GitHub displays it (:attr:`_displayName`).
1701 :returns: The ``name`` key, or ``None`` if the file has none.
1702 """
1703 return self._displayName
1705 @readonly
1706 def Using(self) -> str:
1707 """
1708 Read-only property to access how the action runs (:attr:`_using`).
1710 :returns: The ``runs.using`` key, as ``composite``, ``docker`` or ``node24``.
1711 """
1712 return self._using
1714 @readonly
1715 def IsComposite(self) -> bool:
1716 """
1717 Read-only property to return whether the action is a composite action, running steps.
1719 :returns: ``True``, if ``runs.using`` is ``composite``.
1720 """
1721 return self._using == "composite"
1723 @readonly
1724 def Image(self) -> Nullable[str]:
1725 """
1726 Read-only property to access the image a Docker action runs (:attr:`_image`).
1728 :returns: The ``runs.image`` key, as ``Dockerfile`` or ``docker://alpine:3.22``, or ``None`` for another action.
1729 """
1730 return self._image
1732 @readonly
1733 def Steps(self) -> list[Step]:
1734 """
1735 Read-only property to access the steps of a composite action (:attr:`_steps`).
1737 :returns: The steps, in file order, or an empty list for another action.
1738 """
1739 return self._steps
1741 def IterateActions(self) -> Iterator[UsesReference]:
1742 """
1743 Iterate the actions the steps of a composite action run.
1745 :returns: An iterator over the actions, in file order.
1746 """
1747 for step in self._steps:
1748 if step._uses is not None:
1749 yield step._uses
1751 @readonly
1752 def StepCount(self) -> int:
1753 """
1754 Read-only property to return the number of steps of the action.
1756 :returns: Number of steps.
1757 """
1758 return len(self._steps)
1760 def IterateSteps(self) -> Iterator[Step]:
1761 """
1762 Iterate the action's steps.
1764 :returns: An iterator over the steps, in file order.
1765 """
1766 return iter(self._steps)
1768 def __str__(self) -> str:
1769 """
1770 Return the action's name.
1772 :returns: Name of the action, its directory's name.
1773 """
1774 return self._name
1776 @classmethod
1777 def FromFile(cls, path: Path) -> Self:
1778 """
1779 Read an action's file.
1781 :param path: Path to the action's file.
1782 :returns: The action, with the steps of a composite action attached.
1783 :raises ValueError: If parameter 'path' is ``None``.
1784 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`.
1785 :raises WorkflowError: If the file doesn't exist.
1786 :raises WorkflowError: If the file can't be read.
1787 :raises WorkflowError: If the file is not a YAML document.
1788 :raises WorkflowError: If the document is not a mapping, or has no ``runs`` key.
1789 :raises WorkflowError: If ``runs`` is not a mapping or has no ``using`` key, or a step is malformed.
1790 """
1791 if path is None: 1791 ↛ 1792line 1791 didn't jump to line 1792 because the condition on line 1791 was never true
1792 raise ValueError("Parameter 'path' is None.")
1793 elif not isinstance(path, Path): 1793 ↛ 1794line 1793 didn't jump to line 1794 because the condition on line 1793 was never true
1794 ex = TypeError("Parameter 'path' is not of type 'Path'.")
1795 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
1796 raise ex
1797 elif not path.exists():
1798 raise WorkflowError("Action file doesn't exist.", path) from FileNotFoundError(path)
1800 try:
1801 content = path.read_text(encoding="utf-8")
1802 except OSError as cause:
1803 raise WorkflowError("Action file can't be read.", path) from cause
1805 try:
1806 document = YAML(typ="rt").load(content)
1807 except YAMLError as cause:
1808 mark = getattr(cause, "problem_mark", None)
1809 line = None if mark is None else mark.line + 1
1810 raise WorkflowError("Action file is not a YAML document.", path, line) from cause
1812 if document is None: 1812 ↛ 1813line 1812 didn't jump to line 1813 because the condition on line 1812 was never true
1813 raise WorkflowError("Action file is empty.", path)
1814 elif not isinstance(document, CommentedMap): 1814 ↛ 1815line 1814 didn't jump to line 1815 because the condition on line 1814 was never true
1815 ex = WorkflowError("Action file is not a mapping.", path, 1)
1816 ex.add_note(f"Got type '{getFullyQualifiedName(document)}'.")
1817 raise ex
1818 elif "runs" not in document:
1819 raise WorkflowError("Action file has no 'runs' key.", path)
1821 return cls._Parse(document, path)
1823 @classmethod
1824 def _Parse(cls, document: CommentedMap, path: Path) -> Self:
1825 """
1826 Build an action and the steps it contains from the document read from its file.
1828 :param document: The document, a mapping with a ``runs`` key.
1829 :param path: Path to the action's file.
1830 :returns: The action, with the steps of a composite action attached.
1831 :raises WorkflowError: If key ``runs`` is not a mapping, or has no ``using`` key.
1832 :raises WorkflowError: If key ``runs.steps`` is not a list, or a step is malformed.
1833 """
1834 runs = document["runs"]
1835 if not isinstance(runs, CommentedMap): 1835 ↛ 1836line 1835 didn't jump to line 1836 because the condition on line 1835 was never true
1836 ex = WorkflowError("Key 'runs' is not a mapping.", path, Base._KeyLine(document, "runs"))
1837 ex.add_note(f"Got type '{getFullyQualifiedName(runs)}'.")
1838 raise ex
1839 elif "using" not in runs:
1840 raise WorkflowError("Key 'runs' has no 'using' key.", path, Base._KeyLine(document, "runs"))
1842 steps = None
1843 if (stepList := runs.get("steps", None)) is not None:
1844 if not isinstance(stepList, CommentedSeq): 1844 ↛ 1845line 1844 didn't jump to line 1845 because the condition on line 1844 was never true
1845 ex = WorkflowError("Key 'runs.steps' is not a list.", path, Base._KeyLine(runs, "steps"))
1846 ex.add_note(f"Got type '{getFullyQualifiedName(stepList)}'.")
1847 raise ex
1849 steps = [
1850 Step._FromYAML(step, position, f"action '{path.parent.name}'", path, stepList.lc.item(position)[0] + 1)
1851 for position, step in enumerate(stepList)
1852 ]
1854 displayName = document.get("name", None)
1855 image = runs.get("image", None)
1856 return cls(
1857 path,
1858 str(runs["using"]),
1859 displayName=None if displayName is None else str(displayName),
1860 image=None if image is None else str(image),
1861 steps=steps
1862 )
1865@export
1866class Step(Base[Union[Job, Action]]):
1867 """A step of a job or of a composite action."""
1869 _PARENT_TYPE: ClassVar[ParentTypes] = (Job, Action) #: A step is contained in a job or an action.
1871 _name: Nullable[str] #: Name of the step.
1872 _identifier: Nullable[str] #: Identifier of the step, as referenced by ``steps.<id>``.
1873 _condition: Nullable[str] #: Condition under which the step runs.
1874 _uses: Nullable[UsesReference] #: The action the step runs.
1875 _run: Nullable[str] #: The script the step runs.
1877 def __init__(
1878 self,
1879 line: int,
1880 name: Nullable[str] = None,
1881 identifier: Nullable[str] = None,
1882 condition: Nullable[str] = None,
1883 run: Nullable[str] = None,
1884 uses: Nullable[UsesReference] = None,
1885 *,
1886 parent: Nullable[Union[Job, Action]] = None
1887 ) -> None:
1888 """
1889 Initializes a step of a job or of a composite action.
1891 The action a step runs is attached by passing it, or by constructing a :class:`UsesReference` with the step as
1892 parent.
1894 :param line: Line the step starts at, starting at 1.
1895 :param name: Optional, name of the step. Default: ``None``.
1896 :param identifier: Optional, identifier of the step. Default: ``None``.
1897 :param condition: Optional, condition under which the step runs. Default: ``None``.
1898 :param run: Optional, the script the step runs. Default: ``None``.
1899 :param uses: Optional, the action the step runs, which is attached to the step. Default: ``None``.
1900 :param parent: Optional, reference to the job or the composite action containing the step, which the step is
1901 attached to. Default: ``None``.
1902 :raises TypeError: If parameter 'name' is not of type :class:`str`.
1903 :raises TypeError: If parameter 'identifier' is not of type :class:`str`.
1904 :raises TypeError: If parameter 'condition' is not of type :class:`str`.
1905 :raises TypeError: If parameter 'run' is not of type :class:`str`.
1906 :raises TypeError: If parameter 'uses' is not of type :class:`UsesReference`.
1907 """
1908 super().__init__(line, parent=parent)
1910 for parameterName, value in (
1911 ("name", name),
1912 ("identifier", identifier),
1913 ("condition", condition),
1914 ("run", run)
1915 ):
1916 if value is not None and not isinstance(value, str): 1916 ↛ 1917line 1916 didn't jump to line 1917 because the condition on line 1916 was never true
1917 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
1918 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
1919 raise ex
1921 if uses is not None and not isinstance(uses, UsesReference):
1922 ex = TypeError("Parameter 'uses' is not of type 'UsesReference'.")
1923 ex.add_note(f"Got type '{getFullyQualifiedName(uses)}'.")
1924 raise ex
1926 self._name = name
1927 self._identifier = identifier
1928 self._condition = condition
1929 self._uses = uses
1930 self._run = run
1932 if uses is not None:
1933 uses.Parent = self
1935 if parent is not None:
1936 parent._steps.append(self)
1938 @Base.Parent.setter
1939 def Parent(self, value: Job) -> None:
1940 Base.Parent.fset(self, value)
1942 if self._uses is not None:
1943 self._uses.Parent = self
1945 @readonly
1946 def Name(self) -> Nullable[str]:
1947 """
1948 Read-only property to access the step's name (:attr:`_name`).
1950 :returns: Name of the step, or ``None`` if the workflow gives none.
1951 """
1952 return self._name
1954 @readonly
1955 def ID(self) -> Nullable[str]:
1956 """
1957 Read-only property to access the step's identifier (:attr:`_identifier`).
1959 :returns: Identifier of the step, or ``None`` if the workflow gives none.
1960 """
1961 return self._identifier
1963 @readonly
1964 def Condition(self) -> Nullable[str]:
1965 """
1966 Read-only property to access the condition under which the step runs (:attr:`_condition`).
1968 :returns: The ``if`` expression, as written, or ``None`` if the step always runs.
1969 """
1970 return self._condition
1972 @readonly
1973 def Uses(self) -> Nullable[UsesReference]:
1974 """
1975 Read-only property to access the action the step runs (:attr:`_uses`).
1977 :returns: The action, or ``None`` for a step running a script.
1978 """
1979 return self._uses
1981 @readonly
1982 def Run(self) -> Nullable[str]:
1983 """
1984 Read-only property to access the script the step runs (:attr:`_run`).
1986 :returns: The script, or ``None`` for a step running an action.
1987 """
1988 return self._run
1990 @classmethod
1991 def _FromYAML(cls, mapping: Any, position: int, what: str, path: Path, line: int) -> Self:
1992 """
1993 Read a step from the ``steps`` list of a job or of a composite action.
1995 :param mapping: The step's mapping.
1996 :param position: Position of the step in its list, starting at 0.
1997 :param what: The job or action containing the step, for the exception's message, as ``job 'Build'`` or
1998 ``action 'Setup'``.
1999 :param path: Path to the file, for a message.
2000 :param line: Line the step starts at, starting at 1.
2001 :returns: The step.
2002 :raises WorkflowError: If the step is not a mapping.
2003 :raises WorkflowError: If the step's ``uses`` is not a reference.
2004 """
2005 if not isinstance(mapping, CommentedMap):
2006 ex = WorkflowError(f"Step {position + 1} of {what} is not a mapping.", path, line)
2007 ex.add_note(f"Got type '{getFullyQualifiedName(mapping)}'.")
2008 raise ex
2010 name = mapping.get("name", None)
2011 identifier = mapping.get("id", None)
2012 condition = mapping.get("if", None)
2013 run = mapping.get("run", None)
2014 uses = None
2015 if "uses" in mapping:
2016 uses = UsesReference._FromYAML(mapping, f"step {position + 1} of {what}", path)
2018 return cls(
2019 line,
2020 name=None if name is None else str(name),
2021 identifier=None if identifier is None else str(identifier),
2022 condition=None if condition is None else str(condition),
2023 run=None if run is None else str(run),
2024 uses=uses
2025 )
2028@export
2029class Matrix(Base[Job]):
2030 """
2031 The ``strategy.matrix`` of a job.
2033 A matrix is *dynamic*, if a part of it is an expression - as ``include: ${{ fromJson(inputs.jobs) }}`` - because
2034 its instances are then known at run time only.
2035 """
2037 _PARENT_TYPE: ClassVar[ParentTypes] = Job #: A matrix belongs to a job.
2039 _dimensions: dict[str, ValueT] #: The dimensions, by name.
2040 _include: ValueT #: The combinations added, or an expression producing them.
2041 _exclude: ValueT #: The combinations removed, or an expression producing them.
2042 _expression: Nullable[str] #: The expression the whole matrix is taken from.
2044 def __init__(
2045 self,
2046 line: int,
2047 dimensions: Nullable[Mapping[str, ValueT]] = None,
2048 include: ValueT = None,
2049 exclude: ValueT = None,
2050 expression: Nullable[str] = None,
2051 *,
2052 parent: Nullable[Job] = None
2053 ) -> None:
2054 """
2055 Initializes a job's matrix.
2057 :param line: Line the ``matrix`` key is written at, starting at 1.
2058 :param dimensions: Optional, the dimensions, by name; a dimension's value is a list or an expression.
2059 Default: ``None``.
2060 :param include: Optional, the combinations added, or an expression producing them. Default: ``None``.
2061 :param exclude: Optional, the combinations removed, or an expression producing them. Default: ``None``.
2062 :param expression: Optional, the expression the whole matrix is taken from. Default: ``None``.
2063 :param parent: Optional, reference to the job the matrix belongs to, which the matrix is attached to.
2064 Default: ``None``.
2065 :raises TypeError: If parameter 'dimensions' is not a mapping.
2066 :raises TypeError: If parameter 'expression' is not of type :class:`str`.
2067 """
2068 super().__init__(line, parent=parent)
2070 if dimensions is not None and not isinstance(dimensions, Mapping): 2070 ↛ 2071line 2070 didn't jump to line 2071 because the condition on line 2070 was never true
2071 ex = TypeError("Parameter 'dimensions' is not a mapping.")
2072 ex.add_note(f"Got type '{getFullyQualifiedName(dimensions)}'.")
2073 raise ex
2075 if expression is not None and not isinstance(expression, str): 2075 ↛ 2076line 2075 didn't jump to line 2076 because the condition on line 2075 was never true
2076 ex = TypeError("Parameter 'expression' is not of type 'str'.")
2077 ex.add_note(f"Got type '{getFullyQualifiedName(expression)}'.")
2078 raise ex
2080 self._dimensions = {} if dimensions is None else dict(dimensions)
2081 self._include = include
2082 self._exclude = exclude
2083 self._expression = expression
2085 if parent is not None:
2086 parent._matrix = self
2088 @readonly
2089 def Dimensions(self) -> dict[str, ValueT]:
2090 """
2091 Read-only property to access the matrix' dimensions (:attr:`_dimensions`).
2093 :returns: The dimensions, by name; a dimension's value is a list, or an expression producing one.
2094 """
2095 return self._dimensions
2097 @readonly
2098 def Include(self) -> ValueT:
2099 """
2100 Read-only property to access the combinations added to the matrix (:attr:`_include`).
2102 :returns: A list of combinations, an expression producing them, or ``None`` if the matrix has no ``include``.
2103 """
2104 return self._include
2106 @readonly
2107 def Exclude(self) -> ValueT:
2108 """
2109 Read-only property to access the combinations removed from the matrix (:attr:`_exclude`).
2111 :returns: A list of combinations, an expression producing them, or ``None`` if the matrix has no ``exclude``.
2112 """
2113 return self._exclude
2115 @readonly
2116 def Expression(self) -> Nullable[str]:
2117 """
2118 Read-only property to access the expression the whole matrix is taken from (:attr:`_expression`).
2120 :returns: The expression, as ``${{ fromJson(needs.Params.outputs.matrix) }}``, or ``None`` if the matrix is a
2121 mapping.
2122 """
2123 return self._expression
2125 @readonly
2126 def IsDynamic(self) -> bool:
2127 """
2128 Read-only property to return whether the matrix' instances are known at run time only.
2130 :returns: ``True``, if the matrix, its ``include``, its ``exclude`` or one of its dimensions is an expression.
2131 """
2132 return (
2133 self._expression is not None or isinstance(self._include, str) or isinstance(self._exclude, str) or
2134 any(isinstance(value, str) for value in self._dimensions.values())
2135 )
2137 @readonly
2138 def Combinations(self) -> list[dict[str, ValueT]]:
2139 """
2140 Read-only property to return the combinations the matrix produces, as GitHub computes them.
2142 The dimensions are combined in the order they are written, the last one varying fastest. Then ``exclude``
2143 removes every combination matching all key-value pairs of an entry, and ``include`` extends every remaining
2144 combination whose dimension values the entry doesn't change - its other keys, and those an earlier entry
2145 added, it may change. An entry extending no combination is a combination of its own.
2147 :returns: The combinations, each a mapping of the dimensions' and included keys' names to values.
2148 :raises WorkflowError: If the matrix is dynamic, so its combinations are known at run time only.
2149 :raises WorkflowError: If ``include`` or ``exclude`` is not a list of mappings.
2150 """
2151 path = self._file
2152 if self.IsDynamic:
2153 raise WorkflowError("Matrix is dynamic; its combinations are known at run time only.", path, self._line)
2155 for key, entries in (
2156 ("include", self._include),
2157 ("exclude", self._exclude)
2158 ):
2159 if entries is not None and (
2160 not isinstance(entries, list) or not all(isinstance(entry, dict) for entry in entries)
2161 ):
2162 raise WorkflowError(f"Key '{key}' of the matrix is not a list of mappings.", path, self._line)
2164 combinations = []
2165 if len(self._dimensions) > 0:
2166 dimensions = {name: value if isinstance(value, list) else [value] for name, value in self._dimensions.items()}
2167 combinations = [dict(zip(dimensions, values)) for values in product(*dimensions.values())]
2169 if self._exclude is not None:
2170 for entry in self._exclude:
2171 combinations = [
2172 combination for combination in combinations
2173 if not all(combination.get(key, None) == value for key, value in entry.items())
2174 ]
2176 if self._include is None:
2177 return combinations
2179 originals = [dict(combination) for combination in combinations]
2180 for entry in self._include:
2181 extended = False
2182 for combination, original in zip(combinations, originals):
2183 if all(original[key] == value for key, value in entry.items() if key in original):
2184 combination.update(entry)
2185 extended = True
2187 if not extended:
2188 combinations.append(dict(entry))
2190 return combinations
2192 @staticmethod
2193 def _FormatCombination(combination: Mapping[str, ValueT]) -> dict[str, str]:
2194 """
2195 Format the values of a matrix' combination as GitHub prints them in the name of a matrix instance.
2197 A string is printed as it is, any other value as JSON: ``true``, ``3``, ``{"os": "ubuntu"}``.
2199 :param combination: The combination, as :attr:`Matrix.Combinations` returns it.
2200 :returns: The combination's names and formatted values, in the combination's order.
2201 """
2202 return {
2203 name: value if isinstance(value, str) else json_dumps(value, separators=(", ", ": "))
2204 for name, value in combination.items()
2205 }
2207 @classmethod
2208 def _FromYAML(cls, value: Any, path: Path, line: int) -> Self:
2209 """
2210 Read the value of a job's ``strategy.matrix`` key.
2212 :param value: The value of the ``matrix`` key: a mapping, or an expression.
2213 :param path: Path to the workflow file.
2214 :param line: Line the key is written at, starting at 1.
2215 :returns: The matrix.
2216 :raises WorkflowError: If the value is neither a mapping nor an expression.
2217 """
2218 if isinstance(value, str):
2219 return cls(line, expression=str(value))
2220 elif not isinstance(value, CommentedMap):
2221 ex = WorkflowError("Key 'strategy.matrix' is not a mapping.", path, line)
2222 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
2223 raise ex
2225 return cls(
2226 line,
2227 dimensions={key: Base._ToPython(item) for key, item in value.items() if key not in ("include", "exclude")},
2228 include=Base._ToPython(value.get("include", None)),
2229 exclude=Base._ToPython(value.get("exclude", None))
2230 )
2233@export
2234class UsesReference(Base[Union[Job, Step]]):
2235 """
2236 The value of a ``uses`` key: a reusable workflow called by a job, or an action run by a step.
2238 The forms GitHub accepts are read into their parts:
2240 .. code-block:: text
2242 pyTooling/Actions/.github/workflows/Package.yml@r8 repository, path and ref
2243 actions/checkout@v6 an action in a repository's root
2244 ./.github/workflows/Package.yml a file of the same repository and commit
2245 docker://alpine:3.22 a Docker image
2246 """
2248 _PARENT_TYPE: ClassVar[ParentTypes] = (Job, Step) #: A reference is contained in a job or a step.
2250 _rawReference: str #: The reference, as written.
2251 _repository: Nullable[str] #: The repository, as ``owner/repo``.
2252 _path: str #: The path within the repository.
2253 _reference: Nullable[str] #: The branch, tag or commit.
2254 _isLocal: bool #: ``True``, if the reference names a file of the same repository.
2255 _isDocker: bool #: ``True``, if the reference names a Docker image.
2257 def __init__(self, rawReference: str, line: int, *, parent: Nullable[Union[Job, Step]] = None) -> None:
2258 """
2259 Initializes a ``uses`` reference by reading it into its parts.
2261 :param rawReference: The reference, as written.
2262 :param line: Line the reference is written at, starting at 1.
2263 :param parent: Optional, reference to the job or step containing it, which the reference is attached to.
2264 Default: ``None``.
2265 :raises ValueError: If parameter 'rawReference' is ``None``.
2266 :raises TypeError: If parameter 'rawReference' is not of type :class:`str`.
2267 :raises ValueError: If parameter 'rawReference' is empty.
2268 :raises ValueError: If parameter 'rawReference' names a repository without a ref.
2269 :raises ValueError: If parameter 'rawReference' names no repository as ``owner/repo``.
2270 """
2271 super().__init__(line, parent=parent)
2273 if rawReference is None:
2274 raise ValueError("Parameter 'rawReference' is None.")
2275 elif not isinstance(rawReference, str): 2275 ↛ 2276line 2275 didn't jump to line 2276 because the condition on line 2275 was never true
2276 ex = TypeError("Parameter 'rawReference' is not of type 'str'.")
2277 ex.add_note(f"Got type '{getFullyQualifiedName(rawReference)}'.")
2278 raise ex
2279 elif rawReference == "":
2280 raise ValueError("Parameter 'rawReference' is empty.")
2282 self._rawReference = rawReference
2283 self._isLocal = False
2284 self._isDocker = False
2285 self._repository = None
2286 self._reference = None
2288 if rawReference.startswith("docker://"):
2289 self._isDocker = True
2290 self._path = rawReference[len("docker://"):]
2291 elif rawReference.startswith("./"):
2292 self._isLocal = True
2293 self._path = rawReference[len("./"):]
2294 else:
2295 location, separator, reference = rawReference.partition("@")
2296 if separator == "" or reference == "":
2297 ex = ValueError("Parameter 'rawReference' names a repository without a ref.")
2298 ex.add_note(f"Got '{rawReference}'.")
2299 raise ex
2301 owner, _, remainder = location.partition("/")
2302 repository, _, path = remainder.partition("/")
2303 if owner == "" or repository == "":
2304 ex = ValueError("Parameter 'rawReference' names no repository as 'owner/repo'.")
2305 ex.add_note(f"Got '{rawReference}'.")
2306 raise ex
2308 self._repository = f"{owner}/{repository}"
2309 self._path = path
2310 self._reference = reference
2312 if parent is not None:
2313 parent._uses = self
2315 @readonly
2316 def Repository(self) -> Nullable[str]:
2317 """
2318 Read-only property to access the repository (:attr:`_repository`).
2320 :returns: The repository, as ``owner/repo``, or ``None`` for a local reference and a Docker image.
2321 """
2322 return self._repository
2324 @readonly
2325 def Path(self) -> str:
2326 """
2327 Read-only property to access the path within the repository (:attr:`_path`).
2329 :returns: The path, as ``.github/workflows/Package.yml``, without the leading ``./`` of a local reference. It
2330 is empty for an action in a repository's root, and the image for a Docker image.
2331 """
2332 return self._path
2334 @readonly
2335 def Reference(self) -> Nullable[str]:
2336 """
2337 Read-only property to access the branch, tag or commit (:attr:`_reference`).
2339 :returns: The branch, tag or commit, as ``r8``, or ``None`` for a local reference and a Docker image.
2340 """
2341 return self._reference
2343 @readonly
2344 def IsLocal(self) -> bool:
2345 """
2346 Read-only property to access whether the reference names a file of the same repository (:attr:`_isLocal`).
2348 :returns: ``True``, if the reference starts with ``./``.
2349 """
2350 return self._isLocal
2352 @readonly
2353 def IsDocker(self) -> bool:
2354 """
2355 Read-only property to access whether the reference names a Docker image (:attr:`_isDocker`).
2357 :returns: ``True``, if the reference starts with ``docker://``.
2358 """
2359 return self._isDocker
2361 @readonly
2362 def IsWorkflow(self) -> bool:
2363 """
2364 Read-only property to return whether the reference names a reusable workflow rather than an action.
2366 :returns: ``True``, if the path names a ``.yml`` or ``.yaml`` file in ``.github/workflows``.
2367 """
2368 path = PurePosixPath(self._path)
2369 return (
2370 not self._isDocker and path.parent == PurePosixPath(".github/workflows") and path.suffix in (".yml", ".yaml")
2371 )
2373 @readonly
2374 def FileName(self) -> str:
2375 """
2376 Read-only property to return the last element of the path.
2378 :returns: The file name, as ``Package.yml`` for a reusable workflow, or ``""`` for an action in a repository's
2379 root.
2380 """
2381 return PurePosixPath(self._path).name if not self._isDocker else ""
2383 @readonly
2384 def Stem(self) -> str:
2385 """
2386 Read-only property to return the file name without its extension.
2388 For a reusable workflow, it is the name :class:`Workflow` gives the file it reads, e.g. ``Package``.
2390 :returns: The file name without its extension, or ``""`` for an action in a repository's root.
2391 """
2392 return PurePosixPath(self._path).stem if not self._isDocker else ""
2394 def __str__(self) -> str:
2395 """
2396 Return the reference, as written.
2398 :returns: The reference.
2399 """
2400 return self._rawReference
2402 @classmethod
2403 def _FromYAML(cls, mapping: CommentedMap, what: str, path: Path) -> Self:
2404 """
2405 Read the ``uses`` key of a job or step.
2407 :param mapping: The mapping of the job or step, which has a ``uses`` key.
2408 :param what: The job or step, for the exception's message, as ``job 'Build'``.
2409 :param path: Path to the workflow file.
2410 :returns: The reference.
2411 :raises WorkflowError: If the value is not a reference.
2412 """
2413 line = Base._KeyLine(mapping, "uses")
2414 try:
2415 return cls(str(mapping["uses"]), line)
2416 except ValueError as cause:
2417 raise WorkflowError(f"Key 'uses' of {what} is not a reference.", path, line) from cause
2420@export
2421class Permission(Base[Union[Workflow, Job]]):
2422 """
2423 A permission a workflow or job declares for the ``GITHUB_TOKEN``, as ``contents: write``.
2425 The short forms ``read-all`` and ``write-all`` are read as one permission of scope :attr:`PermissionScope.All`.
2426 """
2428 _PARENT_TYPE: ClassVar[ParentTypes] = (Workflow, Job) #: A permission is declared by a workflow or a job.
2430 _scope: PermissionScope #: The scope, as ``contents``.
2431 _level: AccessLevel #: The access granted.
2433 def __init__(
2434 self,
2435 scope: PermissionScope,
2436 level: AccessLevel,
2437 line: int,
2438 *,
2439 parent: Nullable[Union[Workflow, Job]] = None
2440 ) -> None:
2441 """
2442 Initializes a permission.
2444 :param scope: The scope, as ``contents``.
2445 :param level: The access granted.
2446 :param line: Line the permission is written at, starting at 1.
2447 :param parent: Optional, reference to the workflow or job declaring it, which the permission is attached to.
2448 Default: ``None``.
2449 :raises ValueError: If parameter 'scope' is ``None``.
2450 :raises TypeError: If parameter 'scope' is not of type :class:`PermissionScope`.
2451 :raises ValueError: If parameter 'level' is ``None``.
2452 :raises TypeError: If parameter 'level' is not of type :class:`AccessLevel`.
2453 """
2454 super().__init__(line, parent=parent)
2456 if scope is None: 2456 ↛ 2457line 2456 didn't jump to line 2457 because the condition on line 2456 was never true
2457 raise ValueError("Parameter 'scope' is None.")
2458 elif not isinstance(scope, PermissionScope):
2459 ex = TypeError("Parameter 'scope' is not of type 'PermissionScope'.")
2460 ex.add_note(f"Got type '{getFullyQualifiedName(scope)}'.")
2461 raise ex
2463 if level is None: 2463 ↛ 2464line 2463 didn't jump to line 2464 because the condition on line 2463 was never true
2464 raise ValueError("Parameter 'level' is None.")
2465 elif not isinstance(level, AccessLevel):
2466 ex = TypeError("Parameter 'level' is not of type 'AccessLevel'.")
2467 ex.add_note(f"Got type '{getFullyQualifiedName(level)}'.")
2468 raise ex
2470 self._scope = scope
2471 self._level = level
2473 if parent is not None:
2474 if parent._permissions is None: 2474 ↛ 2477line 2474 didn't jump to line 2477 because the condition on line 2474 was always true
2475 parent._permissions = {}
2477 parent._permissions[scope] = self
2479 @readonly
2480 def Scope(self) -> PermissionScope:
2481 """
2482 Read-only property to access the scope (:attr:`_scope`).
2484 :returns: The scope, as :attr:`PermissionScope.Contents`, or :attr:`PermissionScope.All` for ``read-all`` and
2485 ``write-all``.
2486 """
2487 return self._scope
2489 @readonly
2490 def Level(self) -> AccessLevel:
2491 """
2492 Read-only property to access the access granted (:attr:`_level`).
2494 :returns: The access level.
2495 """
2496 return self._level
2498 def __str__(self) -> str:
2499 """
2500 Return the permission, as written in a workflow file.
2502 :returns: The permission, as ``contents: write``, or ``read-all`` for scope :attr:`PermissionScope.All`.
2503 """
2504 if self._scope is PermissionScope.All:
2505 return f"{self._level.value}-all"
2507 return f"{self._scope}: {self._level.value}"
2509 @classmethod
2510 def _FromYAML(cls, value: Any, path: Path, line: int) -> list[Self]:
2511 """
2512 Read the value of a ``permissions`` key into the permissions a workflow or job declares.
2514 :param value: The value of the ``permissions`` key.
2515 :param path: Path to the workflow file.
2516 :param line: Line the key is written at, starting at 1.
2517 :returns: The permissions, in file order.
2518 :raises WorkflowError: If the value is neither ``read-all``, ``write-all`` nor a mapping.
2519 :raises WorkflowError: If a key is not a permission scope. |br|
2520 The note lists the allowed values.
2521 :raises WorkflowError: If a scope's value is not an access level. |br|
2522 The note lists the allowed values.
2523 """
2524 if value == "read-all":
2525 return [cls(PermissionScope.All, AccessLevel.Read, line)]
2526 elif value == "write-all":
2527 return [cls(PermissionScope.All, AccessLevel.Write, line)]
2528 elif not isinstance(value, CommentedMap):
2529 ex = WorkflowError("Key 'permissions' is neither 'read-all', 'write-all' nor a mapping.", path, line)
2530 ex.add_note(f"Got '{value}'." if isinstance(value, str) else f"Got type '{getFullyQualifiedName(value)}'.")
2531 raise ex
2533 permissions = []
2534 for scope, level in value.items():
2535 scopeLine = Base._KeyLine(value, scope)
2536 try:
2537 permissionScope = PermissionScope(scope)
2538 except ValueError as cause:
2539 ex = WorkflowError(f"Key '{scope}' of 'permissions' is not a permission scope.", path, scopeLine)
2540 scopes = (member.value for member in PermissionScope if member is not PermissionScope.All)
2541 ex.add_note(f"Allowed values: {', '.join(scopes)}.")
2542 raise ex from cause
2544 try:
2545 accessLevel = AccessLevel(level)
2546 except ValueError as cause:
2547 ex = WorkflowError(f"Permission '{scope}' is not an access level.", path, scopeLine)
2548 ex.add_note(f"Got '{level}'.")
2549 ex.add_note(f"Allowed values: {', '.join(member.value for member in AccessLevel)}.")
2550 raise ex from cause
2552 permissions.append(cls(permissionScope, accessLevel, scopeLine))
2554 return permissions
2557@export
2558@abstractclass
2559class Parameter(Base[Workflow]):
2560 """
2561 Common behaviour of the inputs, outputs and secrets of a reusable workflow.
2563 Every parameter has a name and an optional description, and belongs to a :class:`Workflow`.
2564 """
2566 _PARENT_TYPE: ClassVar[ParentTypes] = Workflow #: A parameter is declared by a workflow.
2568 _name: str #: Name of the parameter.
2569 _description: Nullable[str] #: Description of the parameter.
2571 def __init__(
2572 self,
2573 name: str,
2574 line: int,
2575 description: Nullable[str] = None,
2576 *,
2577 parent: Nullable[Workflow] = None
2578 ) -> None:
2579 """
2580 Initializes a parameter of a reusable workflow.
2582 :param name: Name of the parameter.
2583 :param line: Line the parameter's name is written at, starting at 1.
2584 :param description: Optional, description of the parameter. Default: ``None``.
2585 :param parent: Optional, reference to the workflow declaring it. Default: ``None``.
2586 :raises ValueError: If parameter 'name' is ``None``.
2587 :raises TypeError: If parameter 'name' is not of type :class:`str`.
2588 :raises ValueError: If parameter 'name' is empty.
2589 :raises TypeError: If parameter 'description' is not of type :class:`str`.
2590 """
2591 super().__init__(line, parent=parent)
2593 if name is None: 2593 ↛ 2594line 2593 didn't jump to line 2594 because the condition on line 2593 was never true
2594 raise ValueError("Parameter 'name' is None.")
2595 elif not isinstance(name, str): 2595 ↛ 2596line 2595 didn't jump to line 2596 because the condition on line 2595 was never true
2596 ex = TypeError("Parameter 'name' is not of type 'str'.")
2597 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
2598 raise ex
2599 elif name == "": 2599 ↛ 2600line 2599 didn't jump to line 2600 because the condition on line 2599 was never true
2600 raise ValueError("Parameter 'name' is empty.")
2602 if description is not None and not isinstance(description, str): 2602 ↛ 2603line 2602 didn't jump to line 2603 because the condition on line 2602 was never true
2603 ex = TypeError("Parameter 'description' is not of type 'str'.")
2604 ex.add_note(f"Got type '{getFullyQualifiedName(description)}'.")
2605 raise ex
2607 self._name = name
2608 self._description = description
2610 @readonly
2611 def Name(self) -> str:
2612 """
2613 Read-only property to access the parameter's name (:attr:`_name`).
2615 :returns: Name of the parameter.
2616 """
2617 return self._name
2619 @readonly
2620 def Description(self) -> Nullable[str]:
2621 """
2622 Read-only property to access the parameter's description (:attr:`_description`).
2624 :returns: The description, or ``None`` if the workflow gives none.
2625 """
2626 return self._description
2628 def __str__(self) -> str:
2629 """
2630 Return the parameter's name.
2632 :returns: Name of the parameter.
2633 """
2634 return self._name
2637@export
2638class Input(Parameter):
2639 """An input of a reusable workflow, declared in ``on.workflow_call.inputs``."""
2641 _type: InputType #: Type of the input.
2642 _required: bool #: ``True``, if a caller has to pass the input.
2643 _default: ValueT #: Value of the input, if a caller doesn't pass it.
2645 def __init__(
2646 self,
2647 name: str,
2648 line: int,
2649 inputType: InputType,
2650 required: bool = False,
2651 default: ValueT = None,
2652 description: Nullable[str] = None,
2653 *,
2654 parent: Nullable[Workflow] = None
2655 ) -> None:
2656 """
2657 Initializes an input of a reusable workflow.
2659 :param name: Name of the input.
2660 :param line: Line the input's name is written at, starting at 1.
2661 :param inputType: Type of the input.
2662 :param required: Optional, ``True``, if a caller has to pass the input. Default: ``False``.
2663 :param default: Optional, value of the input, if a caller doesn't pass it. Default: ``None``.
2664 :param description: Optional, description of the input. Default: ``None``.
2665 :param parent: Optional, reference to the workflow declaring it. Default: ``None``.
2666 :raises ValueError: If parameter 'inputType' is ``None``.
2667 :raises TypeError: If parameter 'inputType' is not of type :class:`InputType`.
2668 :raises TypeError: If parameter 'required' is not of type :class:`bool`.
2669 """
2670 super().__init__(name, line, description, parent=parent)
2672 if inputType is None:
2673 raise ValueError("Parameter 'inputType' is None.")
2674 elif not isinstance(inputType, InputType):
2675 ex = TypeError("Parameter 'inputType' is not of type 'InputType'.")
2676 ex.add_note(f"Got type '{getFullyQualifiedName(inputType)}'.")
2677 raise ex
2679 if not isinstance(required, bool): 2679 ↛ 2680line 2679 didn't jump to line 2680 because the condition on line 2679 was never true
2680 ex = TypeError("Parameter 'required' is not of type 'bool'.")
2681 ex.add_note(f"Got type '{getFullyQualifiedName(required)}'.")
2682 raise ex
2684 self._type = inputType
2685 self._required = required
2686 self._default = default
2688 if parent is not None: 2688 ↛ 2689line 2688 didn't jump to line 2689 because the condition on line 2688 was never true
2689 parent._inputs[name] = self
2691 @readonly
2692 def Type(self) -> InputType:
2693 """
2694 Read-only property to access the input's type (:attr:`_type`).
2696 :returns: Type of the input.
2697 """
2698 return self._type
2700 @readonly
2701 def Required(self) -> bool:
2702 """
2703 Read-only property to access whether a caller has to pass the input (:attr:`_required`).
2705 :returns: ``True``, if the input is required.
2706 """
2707 return self._required
2709 @readonly
2710 def Default(self) -> ValueT:
2711 """
2712 Read-only property to access the input's value, if a caller doesn't pass it (:attr:`_default`).
2714 The value keeps the type it is written with, as ``'3.14'`` or ``false``, and a multi-line value keeps its line
2715 breaks.
2717 :returns: The default value, or ``None`` if the workflow gives none.
2718 """
2719 return self._default
2721 @classmethod
2722 def _FromYAML(cls, name: str, declaration: Any, path: Path, line: int) -> Self:
2723 """
2724 Read an input's declaration below ``on.workflow_call.inputs``.
2727 :param name: Name of the input.
2728 :param declaration: The declaration.
2729 :param path: Path to the workflow file.
2730 :param line: Line the input's name is written at, starting at 1.
2731 :returns: The input.
2732 :raises WorkflowError: If the declaration is not a mapping.
2733 :raises WorkflowError: If key ``required`` is not a boolean.
2734 :raises WorkflowError: If the declaration has no ``type`` key.
2735 :raises WorkflowError: If key ``type`` is not an input type. |br|
2736 The note lists the allowed values.
2737 """
2738 if declaration is None:
2739 raise WorkflowError(f"Input '{name}' has no 'type' key.", path, line)
2740 elif not isinstance(declaration, CommentedMap):
2741 ex = WorkflowError(f"Declaration of '{name}' is not a mapping.", path, line)
2742 ex.add_note(f"Got type '{getFullyQualifiedName(declaration)}'.")
2743 raise ex
2745 description = declaration.get("description", None)
2746 required = Base._ToPython(declaration.get("required", False))
2747 if not isinstance(required, bool): 2747 ↛ 2748line 2747 didn't jump to line 2748 because the condition on line 2747 was never true
2748 ex = WorkflowError(f"Key 'required' of '{name}' is not a boolean.", path, line)
2749 ex.add_note(f"Got '{required}'.")
2750 raise ex
2752 if (inputType := declaration.get("type", None)) is None:
2753 raise WorkflowError(f"Input '{name}' has no 'type' key.", path, line)
2755 try:
2756 inputType = InputType.Parse(str(inputType))
2757 except ValueError as cause:
2758 ex = WorkflowError(f"Key 'type' of input '{name}' is not an input type.", path, line)
2759 ex.add_note(f"Got '{inputType}'.")
2760 ex.add_note(f"Allowed values: {', '.join(member.value for member in InputType)}.")
2761 raise ex from cause
2763 default = Base._ToPython(declaration.get("default", None))
2765 return cls(name, line, inputType, required, default, None if description is None else str(description))
2768@export
2769class Output(Parameter):
2770 """An output of a reusable workflow, declared in ``on.workflow_call.outputs``."""
2772 _value: str #: Expression the output's value is taken from.
2774 def __init__(
2775 self,
2776 name: str,
2777 line: int,
2778 value: str,
2779 description: Nullable[str] = None,
2780 *,
2781 parent: Nullable[Workflow] = None
2782 ) -> None:
2783 """
2784 Initializes an output of a reusable workflow.
2786 :param name: Name of the output.
2787 :param line: Line the output's name is written at, starting at 1.
2788 :param value: Expression the output's value is taken from, as ``${{ jobs.Build.outputs.version }}``.
2789 :param description: Optional, description of the output. Default: ``None``.
2790 :param parent: Optional, reference to the workflow declaring it. Default: ``None``.
2791 :raises ValueError: If parameter 'value' is ``None``.
2792 :raises TypeError: If parameter 'value' is not of type :class:`str`.
2793 """
2794 super().__init__(name, line, description, parent=parent)
2796 if value is None: 2796 ↛ 2797line 2796 didn't jump to line 2797 because the condition on line 2796 was never true
2797 raise ValueError("Parameter 'value' is None.")
2798 elif not isinstance(value, str): 2798 ↛ 2799line 2798 didn't jump to line 2799 because the condition on line 2798 was never true
2799 ex = TypeError("Parameter 'value' is not of type 'str'.")
2800 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
2801 raise ex
2803 self._value = value
2805 if parent is not None: 2805 ↛ 2806line 2805 didn't jump to line 2806 because the condition on line 2805 was never true
2806 parent._outputs[name] = self
2808 @readonly
2809 def Value(self) -> str:
2810 """
2811 Read-only property to access the expression the output's value is taken from (:attr:`_value`).
2813 :returns: The expression, as ``${{ jobs.Build.outputs.version }}``.
2814 """
2815 return self._value
2817 @classmethod
2818 def _FromYAML(cls, name: str, declaration: Any, path: Path, line: int) -> Self:
2819 """
2820 Read an output's declaration below ``on.workflow_call.outputs``.
2823 :param name: Name of the output.
2824 :param declaration: The declaration.
2825 :param path: Path to the workflow file.
2826 :param line: Line the output's name is written at, starting at 1.
2827 :returns: The output.
2828 :raises WorkflowError: If the declaration is not a mapping.
2829 :raises WorkflowError: If the declaration has no ``value`` key.
2830 """
2831 if declaration is None:
2832 raise WorkflowError(f"Output '{name}' has no 'value' key.", path, line)
2833 elif not isinstance(declaration, CommentedMap): 2833 ↛ 2834line 2833 didn't jump to line 2834 because the condition on line 2833 was never true
2834 ex = WorkflowError(f"Declaration of '{name}' is not a mapping.", path, line)
2835 ex.add_note(f"Got type '{getFullyQualifiedName(declaration)}'.")
2836 raise ex
2838 description = declaration.get("description", None)
2840 if (value := declaration.get("value", None)) is None:
2841 raise WorkflowError(f"Output '{name}' has no 'value' key.", path, line)
2843 return cls(name, line, str(value), None if description is None else str(description))
2846@export
2847class Secret(Parameter):
2848 """A secret of a reusable workflow, declared in ``on.workflow_call.secrets``."""
2850 _required: bool #: ``True``, if a caller has to pass the secret.
2852 def __init__(
2853 self,
2854 name: str,
2855 line: int,
2856 required: bool = False,
2857 description: Nullable[str] = None,
2858 *,
2859 parent: Nullable[Workflow] = None
2860 ) -> None:
2861 """
2862 Initializes a secret of a reusable workflow.
2864 :param name: Name of the secret.
2865 :param line: Line the secret's name is written at, starting at 1.
2866 :param required: Optional, ``True``, if a caller has to pass the secret. Default: ``False``.
2867 :param description: Optional, description of the secret. Default: ``None``.
2868 :param parent: Optional, reference to the workflow declaring it. Default: ``None``.
2869 :raises TypeError: If parameter 'required' is not of type :class:`bool`.
2870 """
2871 super().__init__(name, line, description, parent=parent)
2873 if not isinstance(required, bool): 2873 ↛ 2874line 2873 didn't jump to line 2874 because the condition on line 2873 was never true
2874 ex = TypeError("Parameter 'required' is not of type 'bool'.")
2875 ex.add_note(f"Got type '{getFullyQualifiedName(required)}'.")
2876 raise ex
2878 self._required = required
2880 if parent is not None: 2880 ↛ 2881line 2880 didn't jump to line 2881 because the condition on line 2880 was never true
2881 parent._secrets[name] = self
2883 @readonly
2884 def Required(self) -> bool:
2885 """
2886 Read-only property to access whether a caller has to pass the secret (:attr:`_required`).
2888 :returns: ``True``, if the secret is required.
2889 """
2890 return self._required
2892 @classmethod
2893 def _FromYAML(cls, name: str, declaration: Any, path: Path, line: int) -> Self:
2894 """
2895 Read a secret's declaration below ``on.workflow_call.secrets``.
2898 :param name: Name of the secret.
2899 :param declaration: The declaration.
2900 :param path: Path to the workflow file.
2901 :param line: Line the secret's name is written at, starting at 1.
2902 :returns: The secret.
2903 :raises WorkflowError: If the declaration is not a mapping.
2904 :raises WorkflowError: If key ``required`` is not a boolean.
2905 """
2906 if declaration is None:
2907 return cls(name, line)
2908 elif not isinstance(declaration, CommentedMap): 2908 ↛ 2909line 2908 didn't jump to line 2909 because the condition on line 2908 was never true
2909 ex = WorkflowError(f"Declaration of '{name}' is not a mapping.", path, line)
2910 ex.add_note(f"Got type '{getFullyQualifiedName(declaration)}'.")
2911 raise ex
2913 description = declaration.get("description", None)
2914 required = Base._ToPython(declaration.get("required", False))
2915 if not isinstance(required, bool):
2916 ex = WorkflowError(f"Key 'required' of '{name}' is not a boolean.", path, line)
2917 ex.add_note(f"Got '{required}'.")
2918 raise ex
2920 return cls(name, line, required, None if description is None else str(description))
2923@export
2924class WorkflowResolver(metaclass=ExtendedType, slots=True):
2925 """
2926 Reads the reusable workflows jobs call, and the actions steps run, as far as they are in a local directory.
2928 A repository is mapped to the directory holding its workflow files, so a reference like
2929 ``pyTooling/Actions/.github/workflows/Package.yml@r8`` reads ``Package.yml`` from that directory, whatever its ref.
2930 A local reference like ``./.github/workflows/Package.yml`` reads the file next to the calling workflow's file.
2932 An action of a mapped repository, like ``pyTooling/Actions/.github/actions/ComputeRequirements@r8``, is read from
2933 the repository's root - the directory holding the ``.github`` directory the mapped directory is in. A local action,
2934 like ``./.github/actions/ComputeRequirements``, is read from the root of the calling workflow's or action's
2935 repository.
2937 Every file is read once; asking for it again returns the same :class:`Workflow` or :class:`Action`.
2938 """
2940 _repositories: dict[str, Path] #: Directories holding the workflow files, by repository in lower case.
2941 _workflows: dict[Path, Workflow] #: Workflows already read, by resolved path.
2942 _actions: dict[Path, Action] #: Actions already read, by resolved path.
2944 def __init__(self, repositories: Nullable[Mapping[str, Path]] = None) -> None:
2945 """
2946 Initializes a resolver.
2948 :param repositories: Optional, directories holding the workflow files, by repository, as
2949 ``{"pyTooling/Actions": Path(".github/workflows")}``. Default: ``None``.
2950 :raises TypeError: If parameter 'repositories' is not a mapping.
2951 :raises TypeError: If a key of parameter 'repositories' is not of type :class:`str`.
2952 :raises ValueError: If a key of parameter 'repositories' is not of the form ``owner/repo``.
2953 :raises TypeError: If a value of parameter 'repositories' is not of type :class:`~pathlib.Path`.
2954 """
2955 self._repositories = {}
2956 self._workflows = {}
2957 self._actions = {}
2959 if repositories is None:
2960 return
2961 elif not isinstance(repositories, Mapping): 2961 ↛ 2962line 2961 didn't jump to line 2962 because the condition on line 2961 was never true
2962 ex = TypeError("Parameter 'repositories' is not a mapping.")
2963 ex.add_note(f"Got type '{getFullyQualifiedName(repositories)}'.")
2964 raise ex
2966 for repository, directory in repositories.items():
2967 if not isinstance(repository, str): 2967 ↛ 2968line 2967 didn't jump to line 2968 because the condition on line 2967 was never true
2968 ex = TypeError("Key of parameter 'repositories' is not of type 'str'.")
2969 ex.add_note(f"Got type '{getFullyQualifiedName(repository)}'.")
2970 raise ex
2971 elif repository.count("/") != 1 or repository.startswith("/") or repository.endswith("/"):
2972 ex = ValueError("Key of parameter 'repositories' is not of the form 'owner/repo'.")
2973 ex.add_note(f"Got '{repository}'.")
2974 raise ex
2975 elif not isinstance(directory, Path):
2976 ex = TypeError(f"Value of parameter 'repositories' for '{repository}' is not of type 'Path'.")
2977 ex.add_note(f"Got type '{getFullyQualifiedName(directory)}'.")
2978 raise ex
2980 self._repositories[repository.lower()] = directory
2982 @readonly
2983 def Repositories(self) -> dict[str, Path]:
2984 """
2985 Read-only property to access the directories holding the workflow files (:attr:`_repositories`).
2987 :returns: The directories, by repository in lower case.
2988 """
2989 return self._repositories
2991 def CanResolve(self, uses: UsesReference) -> bool:
2992 """
2993 Return whether a reference names a file the resolver reads: a local one, or one of a mapped repository.
2995 :param uses: The reference, as :attr:`Job.Uses`.
2996 :returns: ``True``, if the reference is local, or its repository is in :attr:`Repositories`.
2997 :raises ValueError: If parameter 'uses' is ``None``.
2998 :raises TypeError: If parameter 'uses' is not of type :class:`UsesReference`.
2999 """
3000 if uses is None:
3001 raise ValueError("Parameter 'uses' is None.")
3002 elif not isinstance(uses, UsesReference):
3003 ex = TypeError("Parameter 'uses' is not of type 'UsesReference'.")
3004 ex.add_note(f"Got type '{getFullyQualifiedName(uses)}'.")
3005 raise ex
3007 return uses._isLocal or (uses._repository is not None and uses._repository.lower() in self._repositories)
3009 def Load(self, path: Path) -> Workflow:
3010 """
3011 Read a workflow file, or return it if it was read before.
3013 :param path: Path to the workflow file.
3014 :returns: The workflow.
3015 :raises ValueError: If parameter 'path' is ``None``.
3016 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`.
3017 :raises WorkflowError: If the file doesn't exist, can't be read, or is not a well-formed workflow.
3018 """
3019 if path is None: 3019 ↛ 3020line 3019 didn't jump to line 3020 because the condition on line 3019 was never true
3020 raise ValueError("Parameter 'path' is None.")
3021 elif not isinstance(path, Path): 3021 ↛ 3022line 3021 didn't jump to line 3022 because the condition on line 3021 was never true
3022 ex = TypeError("Parameter 'path' is not of type 'Path'.")
3023 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
3024 raise ex
3026 key = path.resolve()
3027 if (workflow := self._workflows.get(key, None)) is None:
3028 workflow = Workflow.FromFile(path)
3029 self._workflows[key] = workflow
3031 return workflow
3033 def Resolve(self, uses: UsesReference) -> Nullable[Workflow]:
3034 """
3035 Return the reusable workflow a reference names, if its file is in a local directory.
3037 :param uses: The reference, as :attr:`Job.Uses`.
3038 :returns: The workflow, or ``None`` if the reference names an action, or a repository without a
3039 directory.
3040 :raises ValueError: If parameter 'uses' is ``None``.
3041 :raises TypeError: If parameter 'uses' is not of type :class:`UsesReference`.
3042 :raises ValueError: If parameter 'uses' is a local reference outside a workflow.
3043 :raises WorkflowError: If the workflow file doesn't exist in the directory. |br|
3044 The note names the reference's location.
3045 :raises WorkflowError: If the file is not a well-formed workflow.
3046 """
3047 if uses is None: 3047 ↛ 3048line 3047 didn't jump to line 3048 because the condition on line 3047 was never true
3048 raise ValueError("Parameter 'uses' is None.")
3049 elif not isinstance(uses, UsesReference): 3049 ↛ 3050line 3049 didn't jump to line 3050 because the condition on line 3049 was never true
3050 ex = TypeError("Parameter 'uses' is not of type 'UsesReference'.")
3051 ex.add_note(f"Got type '{getFullyQualifiedName(uses)}'.")
3052 raise ex
3054 if not uses.IsWorkflow:
3055 return None
3056 elif uses._isLocal:
3057 if uses._workflow is None: 3057 ↛ 3058line 3057 didn't jump to line 3058 because the condition on line 3057 was never true
3058 ex = ValueError("Parameter 'uses' is a local reference outside a workflow.")
3059 ex.add_note(f"Got '{uses}'.")
3060 raise ex
3062 directory = uses._workflow._path.parent
3063 elif (directory := self._repositories.get(uses._repository.lower(), None)) is None:
3064 return None
3066 path = directory / uses.FileName
3067 if not path.exists():
3068 ex = WorkflowError(
3069 f"Workflow '{uses.FileName}' doesn't exist in '{directory}'.",
3070 uses._file,
3071 uses._line
3072 )
3073 ex.add_note(f"Called as '{uses}'.")
3074 raise ex
3076 return self.Load(path)
3078 def LoadAction(self, path: Path) -> Action:
3079 """
3080 Read an action's file, or return it if it was read before.
3082 :param path: Path to the action's file.
3083 :returns: The action.
3084 :raises ValueError: If parameter 'path' is ``None``.
3085 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`.
3086 :raises WorkflowError: If the file doesn't exist, can't be read, or is not a well-formed action.
3087 """
3088 if path is None: 3088 ↛ 3089line 3088 didn't jump to line 3089 because the condition on line 3088 was never true
3089 raise ValueError("Parameter 'path' is None.")
3090 elif not isinstance(path, Path): 3090 ↛ 3091line 3090 didn't jump to line 3091 because the condition on line 3090 was never true
3091 ex = TypeError("Parameter 'path' is not of type 'Path'.")
3092 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
3093 raise ex
3095 key = path.resolve()
3096 if (action := self._actions.get(key, None)) is None:
3097 action = Action.FromFile(path)
3098 self._actions[key] = action
3100 return action
3102 def ResolveAction(self, uses: UsesReference) -> Nullable[Action]:
3103 """
3104 Return the action a step's reference names, if its file is in a local directory.
3106 :param uses: The reference, as :attr:`Step.Uses`.
3107 :returns: The action, or ``None`` if the reference names a reusable workflow, a Docker image, a
3108 repository without a directory, or a local action outside a repository's ``.github``
3109 directory.
3110 :raises ValueError: If parameter 'uses' is ``None``.
3111 :raises TypeError: If parameter 'uses' is not of type :class:`UsesReference`.
3112 :raises WorkflowError: If the directory has neither an ``action.yml`` nor an ``action.yaml``. |br|
3113 The note names the reference's location.
3114 :raises WorkflowError: If the file is not a well-formed action.
3115 """
3116 if uses is None: 3116 ↛ 3117line 3116 didn't jump to line 3117 because the condition on line 3116 was never true
3117 raise ValueError("Parameter 'uses' is None.")
3118 elif not isinstance(uses, UsesReference): 3118 ↛ 3119line 3118 didn't jump to line 3119 because the condition on line 3118 was never true
3119 ex = TypeError("Parameter 'uses' is not of type 'UsesReference'.")
3120 ex.add_note(f"Got type '{getFullyQualifiedName(uses)}'.")
3121 raise ex
3123 if uses.IsWorkflow or uses._isDocker:
3124 return None
3126 if uses._isLocal:
3127 if uses._file is None:
3128 return None
3130 base = uses._file.parent
3131 elif (base := self._repositories.get(uses._repository.lower(), None)) is None:
3132 return None
3134 root = next((directory.parent for directory in (base, *base.parents) if directory.name == ".github"), None)
3135 if root is None:
3136 return None
3138 directory = root / uses._path
3139 for fileName in ("action.yml", "action.yaml"):
3140 if (path := directory / fileName).exists():
3141 return self.LoadAction(path)
3143 ex = WorkflowError(f"Action '{uses._path}' has no 'action.yml' in '{directory}'.", uses._file, uses._line)
3144 ex.add_note(f"Called as '{uses}'.")
3145 raise ex
3148@export
3149class DefinitionMixin(Generic[DefinitionType], metaclass=ExtendedType, mixin=True, expects=("_DEFINITION_TYPE",)):
3150 """
3151 Mixin-class for an element of :mod:`pyTooling.CI` built from a workflow file, linking it to its definition.
3153 :meth:`Workflow.ToPipeline` builds the elements, so a consumer of the generic model still reaches the facts only
3154 the file has: the line an element is written at, the reference a job calls, its permissions.
3155 """
3157 _definition: DefinitionType #: The element of the workflow file this element was built from.
3159 @classmethod
3160 def _CheckDefinition(cls, definition: DefinitionType) -> None:
3161 """
3162 Check a definition before the element is built from it.
3164 The host class names the element by its definition, so it checks the definition before calling
3165 ``super().__init__()``.
3167 :param definition: The element of the workflow file the element is built from.
3168 :raises ValueError: If parameter 'definition' is ``None``.
3169 :raises TypeError: If parameter 'definition' is not of the type the host class declares in
3170 :attr:`_DEFINITION_TYPE`.
3171 """
3172 if definition is None:
3173 raise ValueError("Parameter 'definition' is None.")
3174 elif not isinstance(definition, cls._DEFINITION_TYPE):
3175 ex = TypeError(f"Parameter 'definition' is not of type '{cls._DEFINITION_TYPE.__name__}'.")
3176 ex.add_note(f"Got type '{getFullyQualifiedName(definition)}'.")
3177 raise ex
3179 def __init__(self, definition: DefinitionType) -> None:
3180 """
3181 Initializes the link of an element to its definition, which :meth:`_CheckDefinition` checked.
3183 :param definition: The element of the workflow file this element is built from.
3184 """
3185 self._definition = definition
3187 @readonly
3188 def Definition(self) -> DefinitionType:
3189 """
3190 Read-only property to access the element of the workflow file this element was built from (:attr:`_definition`).
3192 :returns: The :class:`Workflow` of a pipeline, the :class:`Job` of a called workflow, a matrix, a matrix instance
3193 and a job, or the :class:`Step` of a step.
3194 """
3195 return self._definition
3198@export
3199class CallMixin(metaclass=ExtendedType, mixin=True):
3200 """Mixin-class for a called workflow built from a workflow file, holding the workflow file it was expanded from."""
3202 _calledWorkflow: Nullable[Workflow] #: The workflow file the called workflow's elements were built from.
3204 def __init__(self, calledWorkflow: Nullable[Workflow] = None) -> None:
3205 """
3206 Initializes the called workflow file.
3208 :param calledWorkflow: Optional, the workflow file the called workflow's elements are built from. Default:
3209 ``None``.
3210 :raises TypeError: If parameter 'calledWorkflow' is not of type :class:`Workflow`.
3211 """
3212 if calledWorkflow is not None and not isinstance(calledWorkflow, Workflow): 3212 ↛ 3213line 3212 didn't jump to line 3213 because the condition on line 3212 was never true
3213 ex = TypeError("Parameter 'calledWorkflow' is not of type 'Workflow'.")
3214 ex.add_note(f"Got type '{getFullyQualifiedName(calledWorkflow)}'.")
3215 raise ex
3217 self._calledWorkflow = calledWorkflow
3219 @readonly
3220 def CalledWorkflow(self) -> Nullable[Workflow]:
3221 """
3222 Read-only property to access the workflow file the called workflow was expanded from (:attr:`_calledWorkflow`).
3224 :returns: The workflow, or ``None`` if the call wasn't expanded - its file isn't at hand, or the depth was used
3225 up.
3226 """
3227 return self._calledWorkflow
3230@export
3231class DefinedPipeline(CIPipeline, DefinitionMixin[Workflow]):
3232 """The pipeline a workflow file defines, as :meth:`Workflow.ToPipeline` builds it."""
3234 _DEFINITION_TYPE: ClassVar[type] = Workflow #: A pipeline is built from a workflow file.
3236 def __init__(
3237 self,
3238 definition: Workflow,
3239 *,
3240 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None
3241 ) -> None:
3242 """
3243 Initializes a pipeline built from a workflow file, named by the file's stem.
3245 :param definition: The workflow file.
3246 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
3247 """
3248 self._CheckDefinition(definition)
3250 super().__init__(definition._name, keyValuePairs=keyValuePairs)
3251 DefinitionMixin.__init__(self, definition)
3254@export
3255class DefinedWorkflow(CIWorkflow, CallMixin, DefinitionMixin[Job]):
3256 """A called workflow built from the job calling it, as :meth:`Workflow.ToPipeline` builds it."""
3258 _DEFINITION_TYPE: ClassVar[type] = Job #: A called workflow is built from the job calling it.
3260 def __init__(
3261 self,
3262 definition: Job,
3263 *,
3264 calledWorkflow: Nullable[Workflow] = None,
3265 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
3266 parent: Nullable[CIWorkflow] = None
3267 ) -> None:
3268 """
3269 Initializes a called workflow built from the job calling it, named by the job's key.
3271 The job's ``uses`` is the workflow's :attr:`~pyTooling.CI.Workflow.Reference`, its ``if`` the
3272 workflow's :attr:`~pyTooling.CI.ConditionMixin.Condition`.
3274 :param definition: The job calling the workflow.
3275 :param calledWorkflow: Optional, the workflow file the called workflow's elements are built from. Default:
3276 ``None``.
3277 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
3278 :param parent: Optional, reference to the workflow containing the call. Default: ``None``.
3279 :raises ValueError: If parameter 'definition' calls no workflow.
3280 """
3281 self._CheckDefinition(definition)
3283 if definition._uses is None: 3283 ↛ 3284line 3283 didn't jump to line 3284 because the condition on line 3283 was never true
3284 ex = ValueError("Parameter 'definition' calls no workflow.")
3285 ex.add_note(f"Got job '{definition._name}'.")
3286 raise ex
3288 super().__init__(
3289 definition._name, reference=str(definition._uses), condition=definition._condition, keyValuePairs=keyValuePairs,
3290 parent=parent
3291 )
3292 DefinitionMixin.__init__(self, definition)
3293 CallMixin.__init__(self, calledWorkflow)
3296@export
3297class DefinedMatrix(CIMatrix, DefinitionMixin[Job]):
3298 """
3299 A matrix built from the job declaring it, as :meth:`Workflow.ToPipeline` builds it.
3301 A dynamic matrix - see :attr:`Matrix.IsDynamic` - holds no instances, since its combinations are known at run time
3302 only.
3303 """
3305 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix is built from the job declaring it.
3307 def __init__(
3308 self,
3309 definition: Job,
3310 *,
3311 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
3312 parent: Nullable[CIWorkflow] = None
3313 ) -> None:
3314 """
3315 Initializes a matrix built from the job declaring it, named by the job's key.
3317 :param definition: The job declaring the matrix.
3318 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
3319 :param parent: Optional, reference to the workflow containing the matrix. Default: ``None``.
3320 :raises ValueError: If parameter 'definition' declares no matrix.
3321 """
3322 self._CheckDefinition(definition)
3324 if definition._matrix is None:
3325 ex = ValueError("Parameter 'definition' declares no matrix.")
3326 ex.add_note(f"Got job '{definition._name}'.")
3327 raise ex
3329 super().__init__(definition._name, condition=definition._condition, keyValuePairs=keyValuePairs, parent=parent)
3330 DefinitionMixin.__init__(self, definition)
3333@export
3334class DefinedMatrixWorkflow(CIMatrixWorkflow, CallMixin, DefinitionMixin[Job]):
3335 """One instance of a matrix calling a reusable workflow, as :meth:`Workflow.ToPipeline` builds it."""
3337 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix instance is built from the job declaring the matrix.
3339 def __init__(
3340 self,
3341 definition: Job,
3342 dimensions: Mapping[str, Any],
3343 *,
3344 calledWorkflow: Nullable[Workflow] = None,
3345 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
3346 parent: Nullable[CIMatrix] = None
3347 ) -> None:
3348 """
3349 Initializes one instance of a matrix calling a reusable workflow, named by the job's key.
3351 :param definition: The job declaring the matrix.
3352 :param dimensions: The matrix' combination this instance is called with, the values as GitHub prints them.
3353 :param calledWorkflow: Optional, the workflow file the called workflow's elements are built from. Default:
3354 ``None``.
3355 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
3356 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
3357 :raises ValueError: If parameter 'definition' calls no workflow.
3358 :raises ValueError: If parameter 'dimensions' is ``None``.
3359 """
3360 self._CheckDefinition(definition)
3362 if definition._uses is None: 3362 ↛ 3363line 3362 didn't jump to line 3363 because the condition on line 3362 was never true
3363 ex = ValueError("Parameter 'definition' calls no workflow.")
3364 ex.add_note(f"Got job '{definition._name}'.")
3365 raise ex
3366 elif dimensions is None:
3367 raise ValueError("Parameter 'dimensions' is None.")
3369 super().__init__(
3370 definition._name, dimensions, reference=str(definition._uses), condition=definition._condition,
3371 keyValuePairs=keyValuePairs, parent=parent
3372 )
3373 DefinitionMixin.__init__(self, definition)
3374 CallMixin.__init__(self, calledWorkflow)
3377@export
3378class DefinedJob(CIJob, DefinitionMixin[Job]):
3379 """A job running steps, as :meth:`Workflow.ToPipeline` builds it."""
3381 _DEFINITION_TYPE: ClassVar[type] = Job #: A job is built from its job in the workflow file.
3383 def __init__(
3384 self,
3385 definition: Job,
3386 *,
3387 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
3388 parent: Nullable[JobGroup] = None
3389 ) -> None:
3390 """
3391 Initializes a job built from its job in the workflow file, named by the job's key.
3393 :param definition: The job.
3394 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
3395 :param parent: Optional, reference to the group containing the job. Default: ``None``.
3396 """
3397 self._CheckDefinition(definition)
3399 super().__init__(definition._name, condition=definition._condition, keyValuePairs=keyValuePairs, parent=parent)
3400 DefinitionMixin.__init__(self, definition)
3403@export
3404class DefinedMatrixJob(CIMatrixJob, DefinitionMixin[Job]):
3405 """One instance of a matrix running steps, as :meth:`Workflow.ToPipeline` builds it."""
3407 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix instance is built from the job declaring the matrix.
3409 def __init__(
3410 self,
3411 definition: Job,
3412 dimensions: Mapping[str, Any],
3413 *,
3414 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
3415 parent: Nullable[CIMatrix] = None
3416 ) -> None:
3417 """
3418 Initializes one instance of a matrix running steps, named by the job's key.
3420 :param definition: The job declaring the matrix.
3421 :param dimensions: The matrix' combination this instance runs with, the values as GitHub prints them.
3422 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
3423 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
3424 :raises ValueError: If parameter 'dimensions' is ``None``.
3425 """
3426 self._CheckDefinition(definition)
3428 if dimensions is None:
3429 raise ValueError("Parameter 'dimensions' is None.")
3431 super().__init__(
3432 definition._name, dimensions, condition=definition._condition, keyValuePairs=keyValuePairs, parent=parent
3433 )
3434 DefinitionMixin.__init__(self, definition)
3437@export
3438class DefinedStep(CIStep, DefinitionMixin[Step]):
3439 """A step of a job, as :meth:`Workflow.ToPipeline` builds it."""
3441 _DEFINITION_TYPE: ClassVar[type] = Step #: A step is built from its step in the workflow file.
3443 def __init__(
3444 self,
3445 definition: Step,
3446 *,
3447 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
3448 parent: Nullable[CIJob] = None
3449 ) -> None:
3450 """
3451 Initializes a step built from its step in the workflow file.
3453 The step is named as GitHub displays it: by its ``name``, or else ``Run`` followed by the action it runs or the
3454 first line of its script.
3456 :param definition: The step.
3457 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
3458 :param parent: Optional, reference to the job containing the step. Default: ``None``.
3459 """
3460 self._CheckDefinition(definition)
3462 if definition._name is not None:
3463 name = definition._name
3464 elif definition._uses is not None:
3465 name = f"Run {definition._uses}"
3466 elif definition._run is not None: 3466 ↛ 3470line 3466 didn't jump to line 3470 because the condition on line 3466 was always true
3467 firstLine = definition._run.strip().partition("\n")[0]
3468 name = f"Run {firstLine}"
3469 else:
3470 name = f"Step at line {definition._line}"
3472 super().__init__(name, condition=definition._condition, keyValuePairs=keyValuePairs, parent=parent)
3473 DefinitionMixin.__init__(self, definition)