Coverage for pyTooling/GitHub/WorkflowFile.py: 88%
1482 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 00:39 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 00:39 +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.. tree::
38 - :class:`Workflow` | a workflow file, e.g. :file:`.github/workflows/CompletePipeline.yml`
39 - :class:`Input` | an input of ``on.workflow_call``
40 - :class:`Output` | an output of ``on.workflow_call``
41 - :class:`Secret` | a secret of ``on.workflow_call``
42 - :class:`Permission` | a permission the workflow declares
43 - :class:`Job` | a job, in file order
44 - :class:`UsesReference` | the reusable workflow the job calls
45 - :class:`Permission` | a permission the job declares
46 - :class:`Matrix` | the job's ``strategy.matrix``
47 - :class:`Step` | a step of the job
48 - :class:`UsesReference` | the action the step runs
50 - :class:`Action` | an action's file, e.g. :file:`.github/actions/ComputeRequirements/action.yml`
51 - :class:`Step` | a step of a composite action
52 - :class:`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.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.
64"""
65from __future__ import annotations
67from functools import cached_property
68from itertools import product
69from json import dumps as json_dumps
70from pathlib import Path, PurePosixPath
71from typing import Any, ClassVar, Generic, Hashable, Iterable, Iterator, Mapping, \
72 Optional as Nullable, Self, TypeVar, Union
74from pyTooling.CI import CIError, DependencyMixin, JobGroup, Matrix as CIMatrix, MatrixInstanceMixin, \
75 MatrixJob as CIMatrixJob, MatrixWorkflow as CIMatrixWorkflow, Rule, Job as CIJob, \
76 Pipeline as CIPipeline, Step as CIStep, Workflow as CIWorkflow
77from pyTooling.Common import getFullyQualifiedName, StringEnum
78from pyTooling.Decorators import export, readonly
79from pyTooling.MetaClasses import ExtendedType, abstractclass
81from ruamel.yaml import YAML, YAMLError
82from ruamel.yaml.comments import CommentedMap, CommentedSeq
83from ruamel.yaml.scalarbool import ScalarBoolean
84from ruamel.yaml.scalarfloat import ScalarFloat
85from ruamel.yaml.scalarint import ScalarInt
86from ruamel.yaml.scalarstring import ScalarString
89__all__ = ["ValueT"]
91ValueT = Union[str, bool, int, float, None, list["ValueT"], dict[str, "ValueT"]]
92"""A value read from a workflow file, converted to plain Python types."""
94ParentType = TypeVar("ParentType", bound="Base")
95"""A type variable for the type of an element's parent."""
97ParentTypes = Nullable[Union[type, tuple[type, ...]]]
98"""The type of :attr:`Base._PARENT_TYPE`: ``None``, a class, or a tuple of classes."""
100DefinitionType = TypeVar("DefinitionType", bound="Base")
101"""A type variable for the type of the workflow file's element an element of :mod:`pyTooling.CI` is built from."""
104@export
105class WorkflowError(CIError):
106 """
107 Base-exception of all exceptions raised by :mod:`pyTooling.GitHub.WorkflowFile`.
109 The exception is raised for a workflow file that is not a well-formed workflow. It carries the file and the line
110 the problem was found at in :attr:`Path` and :attr:`Line`, and names both in a note.
111 """
113 _path: Nullable[Path] #: Path to the workflow file.
114 _line: Nullable[int] #: Line in the workflow file, starting at 1.
116 def __init__(self, message: str, path: Nullable[Path] = None, line: Nullable[int] = None) -> None:
117 """
118 Initializes a workflow error and names the place it was found at in a note.
120 :param message: The exception's message.
121 :param path: Optional, path to the workflow file. Default: ``None``.
122 :param line: Optional, line in the workflow file, starting at 1. Default: ``None``.
123 """
124 super().__init__(message)
126 self._path = path
127 self._line = line
129 if path is not None:
130 self.add_note(f"In '{path}'." if line is None else f"In '{path}:{line}'.")
132 @readonly
133 def Path(self) -> Nullable[Path]:
134 """
135 Read-only property to access the path to the workflow file (:attr:`_path`).
137 :returns: The path, or ``None`` if the problem isn't tied to a file.
138 """
139 return self._path
141 @readonly
142 def Line(self) -> Nullable[int]:
143 """
144 Read-only property to access the line the problem was found at (:attr:`_line`).
146 :returns: The line, starting at 1, or ``None`` if the problem isn't tied to a line.
147 """
148 return self._line
151@export
152class AccessLevel(StringEnum):
153 """
154 The access a permission grants to the ``GITHUB_TOKEN``.
156 The members are declared from the least to the most access, so :meth:`Rank` orders them.
157 """
159 NoAccess = "none" #: No access.
160 Read = "read" #: Read access.
161 Write = "write" #: Read and write access.
163 @cached_property
164 def Rank(self) -> int:
165 """
166 Read-only property to return the member's position in the order of access, so levels can be compared.
168 It is computed once per member.
170 :returns: ``0`` for :attr:`NoAccess`, ``1`` for :attr:`Read`, ``2`` for :attr:`Write`.
171 """
172 return list(AccessLevel).index(self)
175@export
176class PermissionScope(StringEnum):
177 """
178 The scope a permission grants the ``GITHUB_TOKEN`` access to, as a key of ``permissions``.
180 :attr:`All` stands for every scope at once, as ``read-all`` and ``write-all`` grant it.
181 """
183 All = "*" #: Every scope, from ``read-all`` or ``write-all``.
184 Actions = "actions" #: Workflows, runs and artifacts.
185 ArtifactMetadata = "artifact-metadata" #: Storage records of artifacts.
186 Attestations = "attestations" #: Artifact attestations.
187 Checks = "checks" #: Check runs and check suites.
188 CodeQuality = "code-quality" #: Code quality findings.
189 Contents = "contents" #: Repository contents, commits, branches, tags and releases.
190 Deployments = "deployments" #: Deployments.
191 Discussions = "discussions" #: GitHub Discussions.
192 IDToken = "id-token" #: An OpenID Connect token.
193 Issues = "issues" #: Issues and their comments.
194 Packages = "packages" #: GitHub Packages.
195 Pages = "pages" #: GitHub Pages builds.
196 PullRequests = "pull-requests" #: Pull requests.
197 SecurityEvents = "security-events" #: Code scanning alerts.
198 Statuses = "statuses" #: Commit statuses.
199 VulnerabilityAlerts = "vulnerability-alerts" #: Dependabot alerts.
202@export
203class InputType(StringEnum):
204 """The type of an input of a reusable workflow."""
206 String = "string" #: A string.
207 Boolean = "boolean" #: A boolean.
208 Number = "number" #: A number.
211@export
212@abstractclass
213class Base(Generic[ParentType], metaclass=ExtendedType, slots=True):
214 """
215 Common behaviour of every element of a workflow file or an action's file.
217 Every element knows the element containing it, the workflow it belongs to, the file it was read from, and the line
218 it starts at.
219 """
221 _PARENT_TYPE: ClassVar[ParentTypes] = None #: Type a parent must have, or ``None`` when the element has no parent.
223 _parent: Nullable[ParentType] #: Reference to the containing element.
224 _workflow: Nullable[Workflow] #: Reference to the workflow this element belongs to.
225 _file: Nullable[Path] #: Path to the file the element was read from: a workflow's or an action's file.
226 _line: int #: Line the element starts at in its file, starting at 1.
228 def __init__(self, line: int, *, parent: Nullable[ParentType] = None) -> None:
229 """
230 Initializes an element of a workflow file.
232 :param line: Line the element starts at in the workflow file, starting at 1.
233 :param parent: Optional, reference to the containing element. Default: ``None``.
234 :raises ValueError: If parameter 'line' is ``None``.
235 :raises TypeError: If parameter 'line' is not of type :class:`int`.
236 :raises ValueError: If parameter 'line' is not positive.
237 :raises TypeError: If parameter 'parent' is not of the type this class declares in :attr:`_PARENT_TYPE`.
238 """
239 if line is None: 239 ↛ 240line 239 didn't jump to line 240 because the condition on line 239 was never true
240 raise ValueError("Parameter 'line' is None.")
241 elif not isinstance(line, int) or isinstance(line, bool):
242 ex = TypeError("Parameter 'line' is not of type 'int'.")
243 ex.add_note(f"Got type '{getFullyQualifiedName(line)}'.")
244 raise ex
245 elif line < 1:
246 ex = ValueError("Parameter 'line' is not positive.")
247 ex.add_note(f"Got value '{line}'.")
248 raise ex
250 if parent is not None and not isinstance(parent, self._PARENT_TYPE):
251 parentTypes = self._PARENT_TYPE if isinstance(self._PARENT_TYPE, tuple) else (self._PARENT_TYPE, )
252 ex = TypeError(f"Parameter 'parent' is not of type {' or '.join(f'{t.__name__!r}' for t in parentTypes)}.")
253 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
254 raise ex
256 self._parent = parent
257 self._workflow = None if parent is None else parent._workflow
258 self._file = None if parent is None else parent._file
259 self._line = line
261 @property
262 def Parent(self) -> Nullable[ParentType]:
263 """
264 Property to access the containing element (:attr:`_parent`).
266 Assigning a parent attaches an element constructed before it: the element takes the parent's workflow and file,
267 and so do the elements it contains.
269 :returns: The containing element, or ``None`` for a :class:`Workflow`.
270 :raises ValueError: If ``None`` is assigned.
271 :raises TypeError: If a parent is assigned to a :class:`Workflow` or an :class:`Action`, which have no parent.
272 :raises TypeError: If an assigned value is not of the type this class declares in :attr:`_PARENT_TYPE`.
273 """
274 return self._parent
276 @Parent.setter
277 def Parent(self, value: ParentType) -> None:
278 if value is None:
279 raise ValueError("Parameter 'value' is None.")
280 elif not isinstance(value, self._PARENT_TYPE):
281 parentTypes = self._PARENT_TYPE if isinstance(self._PARENT_TYPE, tuple) else (self._PARENT_TYPE, )
282 ex = TypeError(f"Parameter 'value' is not of type {' or '.join(f'{t.__name__!r}' for t in parentTypes)}.")
283 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
284 raise ex
286 self._parent = value
287 self._workflow = value._workflow
288 self._file = value._file
290 @readonly
291 def Workflow(self) -> Nullable[Workflow]:
292 """
293 Read-only property to access the workflow this element belongs to (:attr:`_workflow`).
295 :returns: The workflow, or ``None`` for an element outside one.
296 """
297 return self._workflow
299 @readonly
300 def File(self) -> Nullable[Path]:
301 """
302 Read-only property to access the file the element was read from (:attr:`_file`).
304 :returns: Path to the workflow's or the action's file, or ``None`` for an element outside both.
305 """
306 return self._file
308 @readonly
309 def Line(self) -> int:
310 """
311 Read-only property to access the line the element starts at in its file (:attr:`_line`).
313 :returns: The line, starting at 1.
314 """
315 return self._line
317 @readonly
318 def Location(self) -> str:
319 """
320 Read-only property to return the place the element is written at, for a message.
322 :returns: The file's name and the line, as ``CompletePipeline.yml:552`` or ``action.yml:12``, or ``line 552`` for
323 an element outside a file.
324 """
325 if self._file is None:
326 return f"line {self._line}"
328 return f"{self._file.name}:{self._line}"
330 @staticmethod
331 def _KeyLine(mapping: CommentedMap, key: str) -> int:
332 """
333 Return the line a key of a mapping is written at.
335 :param mapping: The mapping read from the file.
336 :param key: The key.
337 :returns: The line, starting at 1.
338 """
339 return mapping.lc.key(key)[0] + 1
341 @staticmethod
342 def _ToPython(value: Any) -> ValueT:
343 """
344 Convert a value read by ``ruamel.yaml`` into plain Python types.
346 The round-trip loader returns its own types for mappings, lists, block scalars, anchored booleans, and numbers
347 written in another notation than a plain decimal. They derive from the Python types, but keep what they were
348 read with - an anchored boolean even prints as ``0`` or ``1``. Every other value is a Python type already.
350 :param value: The value read from the file.
351 :returns: The value as :class:`dict`, :class:`list`, :class:`str`, :class:`bool`, :class:`int`,
352 :class:`float` or ``None``.
353 """
354 if isinstance(value, CommentedMap):
355 return {str(key): Base._ToPython(item) for key, item in value.items()}
356 elif isinstance(value, CommentedSeq):
357 return [Base._ToPython(item) for item in value]
358 elif isinstance(value, ScalarBoolean):
359 return bool(value)
360 elif isinstance(value, ScalarInt):
361 return int(value)
362 elif isinstance(value, ScalarFloat):
363 return float(value)
364 elif isinstance(value, ScalarString):
365 return str(value)
367 return value
370@export
371class Workflow(Base[None]):
372 """
373 A GitHub Actions workflow file.
375 The workflow is named by its file's stem - ``CompletePipeline`` for ``CompletePipeline.yml`` - because that is how
376 a caller names it in ``uses``; the ``name`` key is kept as :attr:`DisplayName`.
377 """
379 _path: Path #: Path to the workflow file.
380 _name: str #: Name of the workflow, the file's stem.
381 _displayName: Nullable[str] #: Name of the workflow, as GitHub displays it.
382 _triggers: tuple[str, ...] #: Events triggering the workflow.
383 _inputs: dict[str, Input] #: Inputs of ``on.workflow_call``, by name.
384 _outputs: dict[str, Output] #: Outputs of ``on.workflow_call``, by name.
385 _secrets: dict[str, Secret] #: Secrets of ``on.workflow_call``, by name.
386 _permissions: Nullable[dict[PermissionScope, Permission]] #: Permissions the workflow declares, by scope.
387 _jobs: dict[str, Job] #: Jobs of the workflow, by name, in file order.
389 def __init__(
390 self,
391 path: Path,
392 displayName: Nullable[str] = None,
393 triggers: Nullable[Iterable[str]] = None,
394 inputs: Nullable[Iterable[Input]] = None,
395 outputs: Nullable[Iterable[Output]] = None,
396 secrets: Nullable[Iterable[Secret]] = None,
397 permissions: Nullable[Iterable[Permission]] = None,
398 jobs: Nullable[Iterable[Job]] = None
399 ) -> None:
400 """
401 Initializes a workflow.
403 An input, output, secret, permission or job is attached by passing it, or by constructing it with the workflow as
404 parent. Use :meth:`FromFile` to read a workflow file.
406 :param path: Path to the workflow file.
407 :param displayName: Optional, name of the workflow, as GitHub displays it. Default: ``None``.
408 :param triggers: Optional, events triggering the workflow, as ``workflow_call``. Default: ``None``.
409 :param inputs: Optional, inputs of ``on.workflow_call``, which are attached to the workflow. Default: ``None``.
410 :param outputs: Optional, outputs of ``on.workflow_call``, which are attached to the workflow. Default:
411 ``None``.
412 :param secrets: Optional, secrets of ``on.workflow_call``, which are attached to the workflow. Default:
413 ``None``.
414 :param permissions: Optional, permissions the workflow declares for all its jobs, which are attached to the
415 workflow. Default: ``None``, for a workflow without a ``permissions`` key.
416 :param jobs: Optional, jobs, which are attached to the workflow. Default: ``None``.
417 :raises ValueError: If parameter 'path' is ``None``.
418 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`.
419 :raises TypeError: If parameter 'displayName' is not of type :class:`str`.
420 :raises TypeError: If an element of parameter 'triggers' is not of type :class:`str`.
421 :raises TypeError: If an element of parameter 'inputs' is not of type :class:`Input`.
422 :raises TypeError: If an element of parameter 'outputs' is not of type :class:`Output`.
423 :raises TypeError: If an element of parameter 'secrets' is not of type :class:`Secret`.
424 :raises TypeError: If an element of parameter 'permissions' is not of type :class:`Permission`.
425 :raises TypeError: If an element of parameter 'jobs' is not of type :class:`Job`.
426 """
427 super().__init__(1)
429 if path is None: 429 ↛ 430line 429 didn't jump to line 430 because the condition on line 429 was never true
430 raise ValueError("Parameter 'path' is None.")
431 elif not isinstance(path, Path): 431 ↛ 432line 431 didn't jump to line 432 because the condition on line 431 was never true
432 ex = TypeError("Parameter 'path' is not of type 'Path'.")
433 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
434 raise ex
436 if displayName is not None and not isinstance(displayName, str): 436 ↛ 437line 436 didn't jump to line 437 because the condition on line 436 was never true
437 ex = TypeError("Parameter 'displayName' is not of type 'str'.")
438 ex.add_note(f"Got type '{getFullyQualifiedName(displayName)}'.")
439 raise ex
441 self._workflow = self
442 self._file = path
443 self._path = path
444 self._name = path.stem
445 self._displayName = displayName
446 self._triggers = ()
447 self._inputs = {}
448 self._outputs = {}
449 self._secrets = {}
450 self._permissions = None
451 self._jobs = {}
453 if triggers is not None:
454 self._triggers = tuple(triggers)
455 for trigger in self._triggers:
456 if not isinstance(trigger, str):
457 ex = TypeError("An element of parameter 'triggers' is not of type 'str'.")
458 ex.add_note(f"Got type '{getFullyQualifiedName(trigger)}'.")
459 raise ex
461 for parameterName, elements, elementClass, container in (
462 ("inputs", inputs, Input, self._inputs),
463 ("outputs", outputs, Output, self._outputs),
464 ("secrets", secrets, Secret, self._secrets),
465 ("jobs", jobs, Job, self._jobs)
466 ):
467 if elements is None:
468 continue
470 for element in elements:
471 if not isinstance(element, elementClass):
472 ex = TypeError(f"An element of parameter '{parameterName}' is not of type '{elementClass.__name__}'.")
473 ex.add_note(f"Got type '{getFullyQualifiedName(element)}'.")
474 raise ex
476 container[element._name] = element
477 element.Parent = self
479 if permissions is not None:
480 self._permissions = {}
481 for permission in permissions:
482 if not isinstance(permission, Permission):
483 ex = TypeError("An element of parameter 'permissions' is not of type 'Permission'.")
484 ex.add_note(f"Got type '{getFullyQualifiedName(permission)}'.")
485 raise ex
487 self._permissions[permission._scope] = permission
488 permission.Parent = self
490 @Base.Parent.setter
491 def Parent(self, value: None) -> None:
492 ex = TypeError(f"A '{getFullyQualifiedName(self)}' has no parent.")
493 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
494 raise ex
496 @readonly
497 def Path(self) -> Path:
498 """
499 Read-only property to access the path to the workflow file (:attr:`_path`).
501 :returns: The path.
502 """
503 return self._path
505 @readonly
506 def Name(self) -> str:
507 """
508 Read-only property to access the workflow's name, its file's stem (:attr:`_name`).
510 :returns: Name of the workflow, as ``CompletePipeline``.
511 """
512 return self._name
514 @readonly
515 def DisplayName(self) -> Nullable[str]:
516 """
517 Read-only property to access the workflow's name, as GitHub displays it (:attr:`_displayName`).
519 :returns: The ``name`` key, as written, or ``None`` if the workflow gives none.
520 """
521 return self._displayName
523 @readonly
524 def Triggers(self) -> tuple[str, ...]:
525 """
526 Read-only property to access the events triggering the workflow (:attr:`_triggers`).
528 :returns: The events, as ``workflow_call`` or ``push``, in the order the ``on`` key lists them.
529 """
530 return self._triggers
532 @readonly
533 def IsCallable(self) -> bool:
534 """
535 Read-only property to return whether the workflow is a reusable workflow.
537 :returns: ``True``, if the workflow is triggered by ``workflow_call``.
538 """
539 return "workflow_call" in self._triggers
541 @readonly
542 def Inputs(self) -> dict[str, Input]:
543 """
544 Read-only property to access the inputs of ``on.workflow_call`` (:attr:`_inputs`).
546 :returns: The inputs, by name, in file order.
547 """
548 return self._inputs
550 @readonly
551 def Outputs(self) -> dict[str, Output]:
552 """
553 Read-only property to access the outputs of ``on.workflow_call`` (:attr:`_outputs`).
555 :returns: The outputs, by name, in file order.
556 """
557 return self._outputs
559 @readonly
560 def Secrets(self) -> dict[str, Secret]:
561 """
562 Read-only property to access the secrets of ``on.workflow_call`` (:attr:`_secrets`).
564 :returns: The secrets, by name, in file order.
565 """
566 return self._secrets
568 @readonly
569 def Permissions(self) -> Nullable[dict[PermissionScope, Permission]]:
570 """
571 Read-only property to access the permissions the workflow declares for all its jobs (:attr:`_permissions`).
573 :returns: The permissions, by scope, or ``None`` if the workflow has no ``permissions`` key.
574 """
575 return self._permissions
577 @readonly
578 def Jobs(self) -> dict[str, Job]:
579 """
580 Read-only property to access the workflow's jobs (:attr:`_jobs`).
582 :returns: The jobs, by name, in file order.
583 """
584 return self._jobs
586 def ToPipeline(self, resolver: Nullable[WorkflowResolver] = None, depth: Nullable[int] = None) -> DefinedPipeline:
587 """
588 Build the service-independent model of the pipeline this workflow defines.
590 Every job becomes an element of :mod:`pyTooling.CI`, named by its key and linked to the job by
591 :attr:`~DefinitionMixin.Definition`:
593 * a job running steps becomes a :class:`DefinedJob`, its steps :class:`DefinedStep`\\ s;
594 * a job calling a reusable workflow becomes a :class:`DefinedWorkflow`, holding the elements of the called
595 workflow, if the resolver reads it and the depth allows it;
596 * a job with a ``strategy.matrix`` becomes a :class:`DefinedMatrix`, holding a :class:`DefinedMatrixJob` or a
597 :class:`DefinedMatrixWorkflow` per combination of :attr:`Matrix.Combinations`. A dynamic matrix holds no
598 instances, because its combinations are known at run time only.
600 The ``needs`` of the jobs become the elements' :attr:`~pyTooling.CI.DependencyMixin.Needs`, so
601 :meth:`~pyTooling.CI.Workflow.ToGraph` converts the result into a graph.
603 :param resolver: Optional, the resolver reading the workflows the jobs call. Without it, called workflows
604 are not expanded. Default: ``None``.
605 :param depth: Optional, how many levels of called workflows to expand; ``0`` expands none, ``None``
606 every level. Default: ``None``.
607 :returns: The pipeline.
608 :raises TypeError: If parameter 'resolver' is not of type :class:`WorkflowResolver`.
609 :raises TypeError: If parameter 'depth' is not of type :class:`int`.
610 :raises ValueError: If parameter 'depth' is negative.
611 :raises WorkflowError: If a workflow to expand doesn't exist, or is not a well-formed workflow.
612 :raises WorkflowError: If a workflow to expand calls itself, directly or through others.
613 :raises WorkflowError: If ``include`` or ``exclude`` of a matrix is not a list of mappings.
614 """
615 if resolver is not None and not isinstance(resolver, WorkflowResolver):
616 ex = TypeError("Parameter 'resolver' is not of type 'WorkflowResolver'.")
617 ex.add_note(f"Got type '{getFullyQualifiedName(resolver)}'.")
618 raise ex
620 if depth is not None and (not isinstance(depth, int) or isinstance(depth, bool)):
621 ex = TypeError("Parameter 'depth' is not of type 'int'.")
622 ex.add_note(f"Got type '{getFullyQualifiedName(depth)}'.")
623 raise ex
624 elif depth is not None and depth < 0:
625 ex = ValueError("Parameter 'depth' is negative.")
626 ex.add_note(f"Got value '{depth}'.")
627 raise ex
629 def addElements(workflow: Workflow, group: CIWorkflow, level: int, callers: tuple[Workflow, ...]) -> None:
630 """
631 Nested function for recursion.
633 :param workflow: The workflow whose jobs become elements.
634 :param group: The group the elements are added to.
635 :param level: How many levels of called workflows are expanded above this one.
636 :param callers: The workflows expanded above this one, this one last.
637 :raises WorkflowError: If a workflow to expand calls itself, directly or through others.
638 """
639 elements: dict[str, DependencyMixin] = {}
640 for job in workflow._jobs.values():
641 called = None
642 if job._uses is not None and resolver is not None and (depth is None or level < depth):
643 called = resolver.Resolve(job._uses)
644 if called is not None and any(caller._path.resolve() == called._path.resolve() for caller in callers):
645 ex = WorkflowError(f"Workflow '{called._name}' calls itself.", workflow._path, job._uses._line)
646 ex.add_note(f"Calls: {' -> '.join(caller._name for caller in (*callers, called))}.")
647 raise ex
649 if job._matrix is not None:
650 element = DefinedMatrix(job, parent=group)
651 if not job._matrix.IsDynamic:
652 for combination in job._matrix.Combinations:
653 dimensions = Matrix._FormatCombination(combination)
654 if job._uses is None:
655 instance = DefinedMatrixJob(job, dimensions, parent=element)
656 for step in job._steps:
657 DefinedStep(step, parent=instance)
658 else:
659 instance = DefinedMatrixWorkflow(job, dimensions, calledWorkflow=called, parent=element)
660 if called is not None:
661 addElements(called, instance, level + 1, (*callers, called))
662 elif job._uses is not None:
663 element = DefinedWorkflow(job, calledWorkflow=called, parent=group)
664 if called is not None:
665 addElements(called, element, level + 1, (*callers, called))
666 else:
667 element = DefinedJob(job, parent=group)
668 for step in job._steps:
669 DefinedStep(step, parent=element)
671 elements[job._name] = element
673 for job in workflow._jobs.values():
674 for need in job.Needs:
675 elements[job._name].AddNeed(elements[need._name])
677 pipeline = DefinedPipeline(self)
678 addElements(self, pipeline, 0, (self, ))
680 return pipeline
682 def ApplyNeeds(self, pipeline: CIWorkflow, resolver: Nullable[WorkflowResolver] = None) -> list[str]:
683 """
684 Give a run of this workflow the dependencies its jobs declare with ``needs``.
686 A run read from a service's API, as :class:`pyTooling.GitHub.Pipeline`, knows no ``needs``. Each job of this
687 workflow is looked up in the run by its display name, or else by its key, and gets as
688 :attr:`~pyTooling.CI.DependencyMixin.Needs` the elements the jobs it needs were found as. A job calling
689 a reusable workflow is followed into the called workflow of the run - into each instance, if it is a matrix -,
690 as far as the resolver reads the called file. A dependency the run's element has already is kept once.
692 A job whose display name is an expression, as ``${{ matrix.os }} Tests``, can't be looked up, and is skipped. A
693 job with a condition may have been skipped in the run, so it isn't reported when it is missing.
695 A run names a matrix instance's dimensions by position, as ``{"0": "ubuntu-26.04", "1": "3.14"}``. If the job
696 declares a static matrix, an instance whose values are those of one of :attr:`Matrix.Combinations` gets that
697 combination's names, ``{"os": "ubuntu-26.04", "python": "3.14"}``. The instances of a dynamic matrix, and an
698 instance matching no combination, keep the positions.
700 :param pipeline: The run, or a called workflow of a run.
701 :param resolver: Optional, the resolver reading the workflows the jobs call. Without it, called
702 workflows are not followed. Default: ``None``.
703 :returns: The qualified names of the jobs of this workflow, and of the workflows followed,
704 missing in the run - as the run would name them -, in the order they were looked
705 up.
706 :raises ValueError: If parameter 'pipeline' is ``None``.
707 :raises TypeError: If parameter 'pipeline' is not of type :class:`pyTooling.CI.Workflow`.
708 :raises TypeError: If parameter 'resolver' is not of type :class:`WorkflowResolver`.
709 :raises WorkflowError: If a workflow to follow doesn't exist, or is not a well-formed workflow.
710 :raises WorkflowError: If ``include`` or ``exclude`` of a matrix is not a list of mappings.
711 :raises NeedDependencyCycleError: If the needs of the run, with the needs added, form a cycle.
712 """
713 if pipeline is None:
714 raise ValueError("Parameter 'pipeline' is None.")
715 elif not isinstance(pipeline, CIWorkflow):
716 ex = TypeError("Parameter 'pipeline' is not of type 'Workflow'.")
717 ex.add_note(f"Got type '{getFullyQualifiedName(pipeline)}'.")
718 raise ex
720 if resolver is not None and not isinstance(resolver, WorkflowResolver):
721 ex = TypeError("Parameter 'resolver' is not of type 'WorkflowResolver'.")
722 ex.add_note(f"Got type '{getFullyQualifiedName(resolver)}'.")
723 raise ex
725 missing: list[str] = []
727 def apply(workflow: Workflow, group: CIWorkflow) -> None:
728 """
729 Nested function for recursion.
731 :param workflow: The workflow whose jobs are looked up.
732 :param group: The group of the run the jobs are looked up in.
733 """
734 elements: dict[str, DependencyMixin] = {}
735 for job in workflow._jobs.values():
736 if job._displayName is None:
737 names = (job._name, )
738 elif "${{" in job._displayName:
739 continue
740 else:
741 names = (job._displayName, job._name)
743 if (name := next((name for name in names if group.ContainsElement(name)), None)) is None:
744 if job._condition is None: 744 ↛ 746line 744 didn't jump to line 746 because the condition on line 744 was always true
745 missing.append(names[0] if isinstance(group, CIPipeline) else f"{group.QualifiedName} / {names[0]}")
746 continue
748 element = group.GetElement(name)
749 elements[job._name] = element
750 if job._matrix is not None and not job._matrix.IsDynamic and isinstance(element, CIMatrix):
751 combinations = [Matrix._FormatCombination(combination) for combination in job._matrix.Combinations]
752 for instance in element.Instances:
753 if not isinstance(instance, MatrixInstanceMixin): 753 ↛ 754line 753 didn't jump to line 754 because the condition on line 753 was never true
754 continue
756 values = [str(value) for value in instance._dimensions.values()]
757 if (names := next((c for c in combinations if list(c.values()) == values), None)) is not None:
758 instance._dimensions = dict(zip(names, instance._dimensions.values()))
760 if job._uses is None or resolver is None or (called := resolver.Resolve(job._uses)) is None:
761 continue
762 elif isinstance(element, CIWorkflow): 762 ↛ 764line 762 didn't jump to line 764 because the condition on line 762 was always true
763 apply(called, element)
764 elif isinstance(element, CIMatrix):
765 for instance in element.Instances:
766 if isinstance(instance, CIWorkflow):
767 apply(called, instance)
769 for job in workflow._jobs.values():
770 if (element := elements.get(job._name, None)) is None:
771 continue
773 for need in job.Needs:
774 if (needed := elements.get(need._name, None)) is not None and needed not in element._needs:
775 element.AddNeed(needed)
777 apply(self, pipeline)
778 pipeline.Validate()
780 return missing
782 def IterateActions(self) -> Iterator[UsesReference]:
783 """
784 Iterate the actions the workflow's steps run.
786 An action is yielded as often as a step runs it. The reusable workflows the jobs call are in :attr:`Job.Uses`.
788 :returns: An iterator over the actions, in file order.
789 """
790 for job in self._jobs.values():
791 for step in job._steps:
792 if step._uses is not None:
793 yield step._uses
795 def CollectPermissions(self, resolver: Nullable[WorkflowResolver] = None) -> dict[PermissionScope, Permission]:
796 """
797 Collect the permissions the workflow and its jobs declare, and those of the workflows its jobs call.
799 A called workflow can keep or reduce the permissions of the ``GITHUB_TOKEN``, never raise them, so what a
800 workflow's jobs declare is what a caller has to grant. When several elements declare a scope, the permission
801 granting the most access is returned, so its :attr:`~Base.Location` names where that access is asked for.
803 :param resolver: Optional, the resolver reading the workflows the jobs call. Without it, called workflows are
804 not followed. Default: ``None``.
805 :returns: The permissions, by scope, in the order they are first declared.
806 :raises TypeError: If parameter 'resolver' is not of type :class:`WorkflowResolver`.
807 """
808 if resolver is not None and not isinstance(resolver, WorkflowResolver): 808 ↛ 809line 808 didn't jump to line 809 because the condition on line 808 was never true
809 ex = TypeError("Parameter 'resolver' is not of type 'WorkflowResolver'.")
810 ex.add_note(f"Got type '{getFullyQualifiedName(resolver)}'.")
811 raise ex
813 collected: dict[PermissionScope, Permission] = {}
814 visited: set[int] = set()
816 def collect(workflow: Workflow) -> None:
817 """
818 Nested function for recursion.
820 :param workflow: The workflow whose permissions are collected.
821 """
822 visited.add(id(workflow))
824 declarations = [] if workflow._permissions is None else [workflow._permissions]
825 for job in workflow._jobs.values():
826 if job._permissions is not None:
827 declarations.append(job._permissions)
829 for permissions in declarations:
830 for scope, permission in permissions.items():
831 if (known := collected.get(scope, None)) is None or permission._level.Rank > known._level.Rank:
832 collected[scope] = permission
834 if resolver is not None:
835 for job in workflow._jobs.values():
836 if job._uses is None or (called := resolver.Resolve(job._uses)) is None:
837 continue
838 elif id(called) not in visited:
839 collect(called)
841 collect(self)
843 return collected
845 @readonly
846 def JobCount(self) -> int:
847 """
848 Read-only property to return the number of jobs of the workflow.
850 :returns: Number of jobs.
851 """
852 return len(self._jobs)
854 def ContainsJob(self, name: str) -> bool:
855 """
856 Check whether the workflow has a job of that name.
858 :param name: Name of the job, the key it is declared under.
859 :returns: ``True``, if the workflow has a job of that name.
860 """
861 return name in self._jobs
863 def IterateJobs(self) -> Iterator[Job]:
864 """
865 Iterate the workflow's jobs.
867 :returns: An iterator over the jobs, in file order.
868 """
869 return iter(self._jobs.values())
871 def __str__(self) -> str:
872 """
873 Return the workflow's name.
875 :returns: Name of the workflow, the file's stem.
876 """
877 return self._name
879 @classmethod
880 def FromFile(cls, path: Path) -> Self:
881 """
882 Read a workflow file.
884 :param path: Path to the workflow file.
885 :returns: The workflow, with its parameters, permissions and jobs attached.
886 :raises ValueError: If parameter 'path' is ``None``.
887 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`.
888 :raises WorkflowError: If the file doesn't exist.
889 :raises WorkflowError: If the file can't be read.
890 :raises WorkflowError: If the file is not a YAML document.
891 :raises WorkflowError: If the document is not a mapping, or has no ``on`` or ``jobs`` key.
892 :raises WorkflowError: If a parameter of ``on.workflow_call`` lacks a key GitHub requires, or has a value of the
893 wrong kind. |br|
894 For an unknown input type, the note lists the allowed values.
895 :raises WorkflowError: If a job is malformed, needs a job the workflow doesn't have, or the jobs need each other in
896 a cycle. |br|
897 For an unknown job, the note lists the workflow's jobs.
898 """
899 if path is None:
900 raise ValueError("Parameter 'path' is None.")
901 elif not isinstance(path, Path):
902 ex = TypeError("Parameter 'path' is not of type 'Path'.")
903 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
904 raise ex
905 elif not path.exists():
906 raise WorkflowError("Workflow file doesn't exist.", path) from FileNotFoundError(path)
908 try:
909 content = path.read_text(encoding="utf-8")
910 except OSError as cause:
911 raise WorkflowError("Workflow file can't be read.", path) from cause
913 try:
914 document = YAML(typ="rt").load(content)
915 except YAMLError as cause:
916 mark = getattr(cause, "problem_mark", None)
917 line = None if mark is None else mark.line + 1
918 raise WorkflowError("Workflow file is not a YAML document.", path, line) from cause
920 if document is None:
921 raise WorkflowError("Workflow file is empty.", path)
922 elif not isinstance(document, CommentedMap): 922 ↛ 923line 922 didn't jump to line 923 because the condition on line 922 was never true
923 ex = WorkflowError("Workflow file is not a mapping.", path, 1)
924 ex.add_note(f"Got type '{getFullyQualifiedName(document)}'.")
925 raise ex
926 elif "on" not in document:
927 raise WorkflowError("Workflow file has no 'on' key.", path)
928 elif "jobs" not in document:
929 raise WorkflowError("Workflow file has no 'jobs' key.", path)
931 workflow = cls._Parse(document, path)
932 workflow._Validate()
934 return workflow
936 @classmethod
937 def _Parse(cls, document: CommentedMap, path: Path) -> Self:
938 """
939 Build a workflow and the elements it contains from the document read from its file.
941 :param document: The document, a mapping with an ``on`` and a ``jobs`` key.
942 :param path: Path to the workflow file.
943 :returns: The workflow, with its parameters, permissions and jobs attached.
944 :raises WorkflowError: If key ``on`` is neither an event, a list nor a mapping.
945 :raises WorkflowError: If a parameter of ``on.workflow_call`` lacks a key GitHub requires, or has a value of the
946 wrong kind. |br|
947 For an unknown input type, the note lists the allowed values.
948 :raises WorkflowError: If key ``jobs`` or a job is not a mapping, or a job is malformed.
949 """
950 on = document["on"]
951 if isinstance(on, str):
952 triggers = (on, )
953 elif isinstance(on, (CommentedSeq, CommentedMap)): 953 ↛ 956line 953 didn't jump to line 956 because the condition on line 953 was always true
954 triggers = tuple(str(trigger) for trigger in on)
955 else:
956 ex = WorkflowError("Key 'on' is neither an event, a list nor a mapping.", path, Base._KeyLine(document, "on"))
957 ex.add_note(f"Got type '{getFullyQualifiedName(on)}'.")
958 raise ex
960 parameters: dict[str, list[Parameter]] = {"inputs": [], "outputs": [], "secrets": []}
961 if isinstance(on, CommentedMap) and (call := on.get("workflow_call", None)) is not None:
962 if not isinstance(call, CommentedMap): 962 ↛ 963line 962 didn't jump to line 963 because the condition on line 962 was never true
963 ex = WorkflowError("Key 'on.workflow_call' is not a mapping.", path, Base._KeyLine(on, "workflow_call"))
964 ex.add_note(f"Got type '{getFullyQualifiedName(call)}'.")
965 raise ex
967 for section, parameterClass in (("inputs", Input), ("outputs", Output), ("secrets", Secret)):
968 if (declarations := call.get(section, None)) is None:
969 continue
970 elif not isinstance(declarations, CommentedMap): 970 ↛ 971line 970 didn't jump to line 971 because the condition on line 970 was never true
971 ex = WorkflowError(f"Key 'on.workflow_call.{section}' is not a mapping.", path, Base._KeyLine(call, section))
972 ex.add_note(f"Got type '{getFullyQualifiedName(declarations)}'.")
973 raise ex
975 parameters[section] = [
976 parameterClass._FromYAML(str(name), declaration, path, Base._KeyLine(declarations, name))
977 for name, declaration in declarations.items()
978 ]
980 jobs = document["jobs"]
981 if not isinstance(jobs, CommentedMap): 981 ↛ 982line 981 didn't jump to line 982 because the condition on line 981 was never true
982 ex = WorkflowError("Key 'jobs' is not a mapping.", path, Base._KeyLine(document, "jobs"))
983 ex.add_note(f"Got type '{getFullyQualifiedName(jobs)}'.")
984 raise ex
986 jobList = []
987 for name, job in jobs.items():
988 line = Base._KeyLine(jobs, name)
989 if not isinstance(job, CommentedMap):
990 ex = WorkflowError(f"Job '{name}' is not a mapping.", path, line)
991 ex.add_note(f"Got type '{getFullyQualifiedName(job)}'.")
992 raise ex
994 jobList.append(Job._FromYAML(str(name), job, path, line))
996 permissions = None
997 if "permissions" in document:
998 permissions = Permission._FromYAML(document["permissions"], path, Base._KeyLine(document, "permissions"))
1000 displayName = document.get("name", None)
1001 return cls(
1002 path,
1003 None if displayName is None else str(displayName),
1004 triggers,
1005 parameters["inputs"],
1006 parameters["outputs"],
1007 parameters["secrets"],
1008 permissions,
1009 jobList
1010 )
1012 def _Validate(self) -> None:
1013 """
1014 Validate the workflow read from a file.
1016 :raises WorkflowError: If a job needs a job the workflow doesn't have. |br|
1017 The note lists the workflow's jobs.
1018 :raises WorkflowError: If the jobs need each other in a cycle.
1019 """
1020 self._ValidateNeeds()
1021 self._ValidateAcyclic()
1023 def _ValidateNeeds(self) -> None:
1024 """
1025 Validate that every job names only jobs of the workflow in its ``needs`` key.
1027 :raises WorkflowError: If a job needs a job the workflow doesn't have. |br|
1028 The note lists the workflow's jobs.
1029 """
1030 for job in self._jobs.values():
1031 for need in job._needNames:
1032 if need not in self._jobs:
1033 ex = WorkflowError(
1034 f"Job '{job._name}' needs job '{need}', which the workflow doesn't have.", self._path, job._line
1035 )
1036 ex.add_note(f"Jobs: {', '.join(self._jobs)}.")
1037 raise ex
1039 def _ValidateAcyclic(self) -> None:
1040 """
1041 Validate that the jobs don't need each other in a cycle.
1043 :raises WorkflowError: If the jobs need each other in a cycle.
1044 """
1045 # Depth-first search: a job still on the stack when it is reached again closes a cycle.
1046 finished: set[str] = set()
1047 stack: list[str] = []
1049 def visit(job: Job) -> None:
1050 """
1051 Nested function for recursion.
1053 :param job: The job whose needs are followed.
1054 :raises WorkflowError: If the job is reached again while its needs are followed.
1055 """
1056 if job._name in finished:
1057 return
1058 elif job._name in stack:
1059 cycle = stack[stack.index(job._name):] + [job._name]
1060 raise WorkflowError(f"Jobs need each other in a cycle: {' -> '.join(cycle)}.", self._path, job._line)
1062 stack.append(job._name)
1063 for need in job.Needs:
1064 visit(need)
1065 stack.pop()
1066 finished.add(job._name)
1068 for job in self._jobs.values():
1069 visit(job)
1072@export
1073class Job(Base[Workflow]):
1074 """
1075 A job of a workflow.
1077 A job either runs :attr:`Steps` on a runner selected by :attr:`RunsOn`, or calls the reusable workflow named by
1078 :attr:`Uses`.
1079 """
1081 _PARENT_TYPE: ClassVar[ParentTypes] = Workflow #: A job is contained in a workflow.
1083 _name: str #: Name of the job, the key it is declared under.
1084 _displayName: Nullable[str] #: Name of the job, as GitHub displays it.
1085 _needNames: tuple[str, ...] #: Names of the jobs this job needs.
1086 _condition: Nullable[str] #: Condition under which the job runs.
1087 _permissions: Nullable[dict[PermissionScope, Permission]] #: Permissions the job declares, by scope.
1088 _runsOn: tuple[str, ...] #: Labels selecting the runner.
1089 _uses: Nullable[UsesReference] #: The reusable workflow the job calls.
1090 _with: dict[str, ValueT] #: Inputs passed to the called workflow, by name.
1091 _secrets: dict[str, str] #: Secrets passed to the called workflow, by name.
1092 _inheritsSecrets: bool #: ``True``, if secrets are inherited.
1093 _matrix: Nullable[Matrix] #: The job's matrix.
1094 _steps: list[Step] #: Steps of the job.
1095 _outputs: dict[str, str] #: Outputs of the job, by name.
1096 _container: Nullable[str] #: Image of the container the job's steps run in.
1097 _services: dict[str, str] #: Images of the service containers, by service name.
1099 def __init__(
1100 self,
1101 name: str,
1102 line: int,
1103 displayName: Nullable[str] = None,
1104 needs: Nullable[Iterable[str]] = None,
1105 condition: Nullable[str] = None,
1106 runsOn: Nullable[Iterable[str]] = None,
1107 container: Nullable[str] = None,
1108 services: Nullable[Mapping[str, str]] = None,
1109 uses: Nullable[UsesReference] = None,
1110 withInputs: Nullable[Mapping[str, ValueT]] = None,
1111 secrets: Nullable[Mapping[str, str]] = None,
1112 inheritsSecrets: bool = False,
1113 outputs: Nullable[Mapping[str, str]] = None,
1114 permissions: Nullable[Iterable[Permission]] = None,
1115 matrix: Nullable[Matrix] = None,
1116 steps: Nullable[Iterable[Step]] = None,
1117 *,
1118 parent: Nullable[Workflow] = None
1119 ) -> None:
1120 """
1121 Initializes a job of a workflow.
1123 The reusable workflow a job calls, its permissions, its matrix and its steps are attached by passing them, or by
1124 constructing a :class:`UsesReference`, :class:`Permission`, :class:`Matrix` or :class:`Step` with the job as
1125 parent.
1127 :param name: Name of the job, the key it is declared under.
1128 :param line: Line the job's name is written at, starting at 1.
1129 :param displayName: Optional, name of the job, as GitHub displays it. Default: ``None``.
1130 :param needs: Optional, names of the jobs this job needs. Default: ``None``.
1131 :param condition: Optional, condition under which the job runs. Default: ``None``.
1132 :param runsOn: Optional, labels selecting the runner. Default: ``None``.
1133 :param container: Optional, image of the container the job's steps run in. Default: ``None``.
1134 :param services: Optional, images of the service containers, by service name. Default: ``None``.
1135 :param uses: Optional, the reusable workflow the job calls, which is attached to the job. Default:
1136 ``None``.
1137 :param withInputs: Optional, inputs passed to the called workflow, by name. Default: ``None``.
1138 :param secrets: Optional, secrets passed to the called workflow, by name. Default: ``None``.
1139 :param inheritsSecrets: Optional, ``True``, if the called workflow inherits every secret. Default: ``False``.
1140 :param outputs: Optional, outputs of the job, by name. Default: ``None``.
1141 :param permissions: Optional, permissions the job declares, which are attached to the job. Default: ``None``,
1142 for a job without a ``permissions`` key.
1143 :param matrix: Optional, the job's matrix, which is attached to the job. Default: ``None``.
1144 :param steps: Optional, the job's steps, which are attached to the job. Default: ``None``.
1145 :param parent: Optional, reference to the workflow containing the job. Default: ``None``.
1146 :raises ValueError: If parameter 'name' is ``None``.
1147 :raises TypeError: If parameter 'name' is not of type :class:`str`.
1148 :raises ValueError: If parameter 'name' is empty.
1149 :raises TypeError: If parameter 'displayName' is not of type :class:`str`.
1150 :raises TypeError: If parameter 'condition' is not of type :class:`str`.
1151 :raises TypeError: If parameter 'container' is not of type :class:`str`.
1152 :raises TypeError: If an element of parameter 'needs' is not of type :class:`str`.
1153 :raises TypeError: If an element of parameter 'runsOn' is not of type :class:`str`.
1154 :raises TypeError: If parameter 'uses' is not of type :class:`UsesReference`.
1155 :raises TypeError: If parameter 'inheritsSecrets' is not of type :class:`bool`.
1156 :raises TypeError: If an element of parameter 'permissions' is not of type :class:`Permission`.
1157 :raises TypeError: If parameter 'matrix' is not of type :class:`Matrix`.
1158 :raises TypeError: If an element of parameter 'steps' is not of type :class:`Step`.
1159 """
1160 super().__init__(line, parent=parent)
1162 if name is None:
1163 raise ValueError("Parameter 'name' is None.")
1164 elif not isinstance(name, str):
1165 ex = TypeError("Parameter 'name' is not of type 'str'.")
1166 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
1167 raise ex
1168 elif name == "":
1169 raise ValueError("Parameter 'name' is empty.")
1171 for parameterName, value in (
1172 ("displayName", displayName),
1173 ("condition", condition),
1174 ("container", container)
1175 ):
1176 if value is not None and not isinstance(value, str):
1177 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
1178 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
1179 raise ex
1181 self._needNames = () if needs is None else tuple(needs)
1182 self._runsOn = () if runsOn is None else tuple(runsOn)
1183 for parameterName, values in (("needs", self._needNames), ("runsOn", self._runsOn)):
1184 for value in values:
1185 if not isinstance(value, str):
1186 ex = TypeError(f"An element of parameter '{parameterName}' is not of type 'str'.")
1187 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
1188 raise ex
1190 for parameterName, value, valueClass in (
1191 ("uses", uses, UsesReference),
1192 ("matrix", matrix, Matrix)
1193 ):
1194 if value is not None and not isinstance(value, valueClass):
1195 ex = TypeError(f"Parameter '{parameterName}' is not of type '{valueClass.__name__}'.")
1196 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
1197 raise ex
1199 if not isinstance(inheritsSecrets, bool): 1199 ↛ 1200line 1199 didn't jump to line 1200 because the condition on line 1199 was never true
1200 ex = TypeError("Parameter 'inheritsSecrets' is not of type 'bool'.")
1201 ex.add_note(f"Got type '{getFullyQualifiedName(inheritsSecrets)}'.")
1202 raise ex
1204 self._name = name
1205 self._displayName = displayName
1206 self._condition = condition
1207 self._permissions = None
1208 self._uses = uses
1209 self._with = {} if withInputs is None else dict(withInputs)
1210 self._secrets = {} if secrets is None else dict(secrets)
1211 self._inheritsSecrets = inheritsSecrets
1212 self._matrix = matrix
1213 self._steps = stepList = []
1214 self._outputs = {} if outputs is None else dict(outputs)
1215 self._container = container
1216 self._services = {} if services is None else dict(services)
1218 if uses is not None:
1219 uses.Parent = self
1221 if matrix is not None:
1222 matrix.Parent = self
1224 if permissions is not None:
1225 self._permissions = {}
1226 for permission in permissions:
1227 if not isinstance(permission, Permission):
1228 ex = TypeError("An element of parameter 'permissions' is not of type 'Permission'.")
1229 ex.add_note(f"Got type '{getFullyQualifiedName(permission)}'.")
1230 raise ex
1232 self._permissions[permission._scope] = permission
1233 permission.Parent = self
1235 if steps is not None:
1236 for step in steps:
1237 if not isinstance(step, Step):
1238 ex = TypeError("An element of parameter 'steps' is not of type 'Step'.")
1239 ex.add_note(f"Got type '{getFullyQualifiedName(step)}'.")
1240 raise ex
1242 stepList.append(step)
1243 step.Parent = self
1245 if parent is not None:
1246 parent._jobs[name] = self
1248 @Base.Parent.setter
1249 def Parent(self, value: Workflow) -> None:
1250 Base.Parent.fset(self, value)
1252 if self._uses is not None:
1253 self._uses.Parent = self
1255 if self._matrix is not None:
1256 self._matrix.Parent = self
1258 for step in self._steps:
1259 step.Parent = self
1261 if self._permissions is not None:
1262 for permission in self._permissions.values():
1263 permission.Parent = self
1265 @readonly
1266 def Name(self) -> str:
1267 """
1268 Read-only property to access the job's name, the key it is declared under (:attr:`_name`).
1270 :returns: Name of the job.
1271 """
1272 return self._name
1274 @readonly
1275 def DisplayName(self) -> Nullable[str]:
1276 """
1277 Read-only property to access the job's name, as GitHub displays it (:attr:`_displayName`).
1279 :returns: The ``name`` key, as written, or ``None`` if the workflow gives none.
1280 """
1281 return self._displayName
1283 @readonly
1284 def NeedNames(self) -> tuple[str, ...]:
1285 """
1286 Read-only property to access the names of the jobs this job needs (:attr:`_needNames`).
1288 :returns: The names, in the order the ``needs`` key lists them.
1289 """
1290 return self._needNames
1292 @readonly
1293 def Needs(self) -> tuple[Job, ...]:
1294 """
1295 Read-only property to return the jobs this job needs.
1297 The names in :attr:`NeedNames` are looked up in the workflow containing the job. :meth:`Workflow.FromFile`
1298 rejects a name naming no job, so for a workflow read from a file every name is resolved.
1300 :returns: The jobs, in the order the ``needs`` key lists them, skipping names naming no job of the workflow.
1301 """
1302 if self._workflow is None: 1302 ↛ 1303line 1302 didn't jump to line 1303 because the condition on line 1302 was never true
1303 return ()
1305 jobs = self._workflow._jobs
1306 return tuple(jobs[name] for name in self._needNames if name in jobs)
1308 @readonly
1309 def Condition(self) -> Nullable[str]:
1310 """
1311 Read-only property to access the condition under which the job runs (:attr:`_condition`).
1313 The expression is not evaluated.
1315 :returns: The ``if`` expression, as written, or ``None`` if the job has no condition.
1316 """
1317 return self._condition
1319 @readonly
1320 def Permissions(self) -> Nullable[dict[PermissionScope, Permission]]:
1321 """
1322 Read-only property to access the permissions the job declares (:attr:`_permissions`).
1324 :returns: The permissions, by scope, or ``None`` if the job has no ``permissions`` key and inherits them.
1325 """
1326 return self._permissions
1328 @readonly
1329 def RunsOn(self) -> tuple[str, ...]:
1330 """
1331 Read-only property to access the labels selecting the runner (:attr:`_runsOn`).
1333 :returns: The labels, as written - an expression is not evaluated -, or ``()`` for a job calling a workflow.
1334 """
1335 return self._runsOn
1337 @readonly
1338 def Uses(self) -> Nullable[UsesReference]:
1339 """
1340 Read-only property to access the reusable workflow the job calls (:attr:`_uses`).
1342 :returns: The reference, or ``None`` for a job running steps.
1343 """
1344 return self._uses
1346 @readonly
1347 def With(self) -> dict[str, ValueT]:
1348 """
1349 Read-only property to access the inputs passed to the called workflow (:attr:`_with`).
1351 :returns: The inputs, by name.
1352 """
1353 return self._with
1355 @readonly
1356 def Secrets(self) -> dict[str, str]:
1357 """
1358 Read-only property to access the secrets passed to the called workflow (:attr:`_secrets`).
1360 :returns: The secrets, by name, or an empty dictionary if the job passes none or :attr:`InheritsSecrets`.
1361 """
1362 return self._secrets
1364 @readonly
1365 def InheritsSecrets(self) -> bool:
1366 """
1367 Read-only property to access whether the called workflow inherits every secret (:attr:`_inheritsSecrets`).
1369 :returns: ``True``, if the job says ``secrets: inherit``.
1370 """
1371 return self._inheritsSecrets
1373 @readonly
1374 def Matrix(self) -> Nullable[Matrix]:
1375 """
1376 Read-only property to access the job's matrix (:attr:`_matrix`).
1378 :returns: The matrix, or ``None`` if the job has no ``strategy.matrix``.
1379 """
1380 return self._matrix
1382 @readonly
1383 def Steps(self) -> list[Step]:
1384 """
1385 Read-only property to access the job's steps (:attr:`_steps`).
1387 :returns: The steps, in file order, or an empty list for a job calling a workflow.
1388 """
1389 return self._steps
1391 @readonly
1392 def Outputs(self) -> dict[str, str]:
1393 """
1394 Read-only property to access the job's outputs (:attr:`_outputs`).
1396 :returns: The expressions the outputs are taken from, by name.
1397 """
1398 return self._outputs
1400 @readonly
1401 def Container(self) -> Nullable[str]:
1402 """
1403 Read-only property to access the image of the container the job's steps run in (:attr:`_container`).
1405 :returns: The image, as written - e.g. ``pytooling/miktex:sphinx`` or an expression -, or ``None`` if the job has no
1406 ``container``.
1407 """
1408 return self._container
1410 @readonly
1411 def Services(self) -> dict[str, str]:
1412 """
1413 Read-only property to access the images of the job's service containers (:attr:`_services`).
1415 :returns: The images, as written, by service name.
1416 """
1417 return self._services
1419 @readonly
1420 def StepCount(self) -> int:
1421 """
1422 Read-only property to return the number of steps of the job.
1424 :returns: Number of steps.
1425 """
1426 return len(self._steps)
1428 def IterateSteps(self) -> Iterator[Step]:
1429 """
1430 Iterate the job's steps.
1432 :returns: An iterator over the steps, in file order.
1433 """
1434 return iter(self._steps)
1436 def __str__(self) -> str:
1437 """
1438 Return the job's name.
1440 :returns: Name of the job.
1441 """
1442 return self._name
1444 @classmethod
1445 def _FromYAML(cls, name: str, mapping: CommentedMap, path: Path, line: int) -> Self:
1446 """
1447 Build a job and the elements it contains from its mapping in the workflow file.
1449 :param name: Name of the job.
1450 :param mapping: The job's mapping.
1451 :param path: Path to the workflow file.
1452 :param line: Line the job's name is written at, starting at 1.
1453 :returns: The job.
1454 :raises WorkflowError: If the job has neither ``runs-on`` nor ``uses``, or both.
1455 :raises WorkflowError: If a key of the job holds a value of the wrong kind.
1456 """
1457 if ("runs-on" in mapping) == ("uses" in mapping):
1458 raise WorkflowError(f"Job '{name}' needs either 'runs-on' or 'uses'.", path, line)
1460 needs = mapping.get("needs", ())
1461 if isinstance(needs, str):
1462 needs = (needs, )
1463 elif not isinstance(needs, (list, tuple)): 1463 ↛ 1464line 1463 didn't jump to line 1464 because the condition on line 1463 was never true
1464 ex = WorkflowError(
1465 f"Key 'needs' of job '{name}' is neither a job name nor a list.", path, Base._KeyLine(mapping, "needs")
1466 )
1467 ex.add_note(f"Got type '{getFullyQualifiedName(needs)}'.")
1468 raise ex
1470 runsOn = mapping.get("runs-on", ())
1471 if isinstance(runsOn, dict): 1471 ↛ 1472line 1471 didn't jump to line 1472 because the condition on line 1471 was never true
1472 runsOn = runsOn.get("labels", ())
1474 if isinstance(runsOn, str):
1475 runsOn = (runsOn, )
1477 condition = mapping.get("if", None)
1479 secrets = mapping.get("secrets", None)
1480 inheritsSecrets = secrets == "inherit"
1481 if inheritsSecrets:
1482 secrets = None
1483 elif secrets is not None:
1484 if not isinstance(secrets, CommentedMap): 1484 ↛ 1485line 1484 didn't jump to line 1485 because the condition on line 1484 was never true
1485 ex = WorkflowError(f"Key 'secrets' of job '{name}' is not a mapping.", path, Base._KeyLine(mapping, "secrets"))
1486 ex.add_note(f"Got type '{getFullyQualifiedName(secrets)}'.")
1487 raise ex
1489 secrets = {str(key): str(value) for key, value in secrets.items()}
1491 if (outputs := mapping.get("outputs", None)) is not None:
1492 if not isinstance(outputs, CommentedMap): 1492 ↛ 1493line 1492 didn't jump to line 1493 because the condition on line 1492 was never true
1493 ex = WorkflowError(f"Key 'outputs' of job '{name}' is not a mapping.", path, Base._KeyLine(mapping, "outputs"))
1494 ex.add_note(f"Got type '{getFullyQualifiedName(outputs)}'.")
1495 raise ex
1497 outputs = {str(key): str(value) for key, value in outputs.items()}
1499 if (withValues := mapping.get("with", None)) is not None:
1500 if not isinstance(withValues, CommentedMap): 1500 ↛ 1501line 1500 didn't jump to line 1501 because the condition on line 1500 was never true
1501 ex = WorkflowError(f"Key 'with' of job '{name}' is not a mapping.", path, Base._KeyLine(mapping, "with"))
1502 ex.add_note(f"Got type '{getFullyQualifiedName(withValues)}'.")
1503 raise ex
1505 withValues = Base._ToPython(withValues)
1507 uses = None
1508 if "uses" in mapping:
1509 uses = UsesReference._FromYAML(mapping, f"job '{name}'", path)
1511 permissions = None
1512 if "permissions" in mapping:
1513 permissions = Permission._FromYAML(mapping["permissions"], path, Base._KeyLine(mapping, "permissions"))
1515 matrix = None
1516 if (strategy := mapping.get("strategy", None)) is not None:
1517 if not isinstance(strategy, CommentedMap): 1517 ↛ 1518line 1517 didn't jump to line 1518 because the condition on line 1517 was never true
1518 ex = WorkflowError(
1519 f"Key 'strategy' of job '{name}' is not a mapping.", path, Base._KeyLine(mapping, "strategy")
1520 )
1521 ex.add_note(f"Got type '{getFullyQualifiedName(strategy)}'.")
1522 raise ex
1524 if "matrix" in strategy: 1524 ↛ 1527line 1524 didn't jump to line 1527 because the condition on line 1524 was always true
1525 matrix = Matrix._FromYAML(strategy["matrix"], path, Base._KeyLine(strategy, "matrix"))
1527 container = mapping.get("container", None)
1528 if isinstance(container, CommentedMap):
1529 container = container.get("image", None)
1531 services = None
1532 if (serviceMap := mapping.get("services", None)) is not None:
1533 if not isinstance(serviceMap, CommentedMap): 1533 ↛ 1534line 1533 didn't jump to line 1534 because the condition on line 1533 was never true
1534 ex = WorkflowError(
1535 f"Key 'services' of job '{name}' is not a mapping.", path, Base._KeyLine(mapping, "services")
1536 )
1537 ex.add_note(f"Got type '{getFullyQualifiedName(serviceMap)}'.")
1538 raise ex
1540 services = {}
1541 for serviceName, service in serviceMap.items():
1542 if isinstance(service, CommentedMap):
1543 service = service.get("image", None)
1545 if service is not None: 1545 ↛ 1541line 1545 didn't jump to line 1541 because the condition on line 1545 was always true
1546 services[str(serviceName)] = str(service)
1548 steps = None
1549 if (stepList := mapping.get("steps", None)) is not None:
1550 if not isinstance(stepList, CommentedSeq):
1551 ex = WorkflowError(f"Key 'steps' of job '{name}' is not a list.", path, Base._KeyLine(mapping, "steps"))
1552 ex.add_note(f"Got type '{getFullyQualifiedName(stepList)}'.")
1553 raise ex
1555 steps = [
1556 Step._FromYAML(step, position, f"job '{name}'", path, stepList.lc.item(position)[0] + 1)
1557 for position, step in enumerate(stepList)
1558 ]
1560 displayName = mapping.get("name", None)
1562 return cls(
1563 name, line,
1564 displayName=None if displayName is None else str(displayName),
1565 needs=(str(need) for need in needs),
1566 condition=None if condition is None else str(condition),
1567 runsOn=(str(label) for label in runsOn),
1568 container=None if container is None else str(container),
1569 services=services,
1570 uses=uses,
1571 withInputs=withValues,
1572 secrets=secrets,
1573 inheritsSecrets=inheritsSecrets,
1574 outputs=outputs,
1575 permissions=permissions,
1576 matrix=matrix,
1577 steps=steps
1578 )
1581@export
1582class Action(Base[None]):
1583 """
1584 An action's file, ``action.yml``.
1586 The action is named by its directory - ``ComputeRequirements`` for ``.github/actions/ComputeRequirements/action.yml``
1587 - because that is how a step names it in ``uses``; the ``name`` key is kept as :attr:`DisplayName`. Of a composite
1588 action, the steps are read, so the actions it runs in turn are known.
1589 """
1591 _path: Path #: Path to the action's file.
1592 _name: str #: Name of the action, its directory's name.
1593 _displayName: Nullable[str] #: Name of the action, as GitHub displays it.
1594 _using: str #: How the action runs, as ``composite``, ``docker`` or ``node24``.
1595 _image: Nullable[str] #: The image a Docker action runs, as ``Dockerfile`` or ``docker://alpine:3.22``.
1596 _steps: list[Step] #: Steps of a composite action.
1598 def __init__(
1599 self,
1600 path: Path,
1601 using: str,
1602 displayName: Nullable[str] = None,
1603 image: Nullable[str] = None,
1604 steps: Nullable[Iterable[Step]] = None
1605 ) -> None:
1606 """
1607 Initializes an action.
1609 The steps of a composite action are attached by passing them, or by constructing them with the action as parent.
1610 Use :meth:`FromFile` to read an action's file.
1612 :param path: Path to the action's file.
1613 :param using: How the action runs, as ``composite``.
1614 :param displayName: Optional, name of the action, as GitHub displays it. Default: ``None``.
1615 :param image: Optional, the image a Docker action runs. Default: ``None``.
1616 :param steps: Optional, the steps of a composite action, which are attached to the action. Default:
1617 ``None``.
1618 :raises ValueError: If parameter 'path' is ``None``.
1619 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`.
1620 :raises ValueError: If parameter 'using' is ``None``.
1621 :raises TypeError: If parameter 'using' is not of type :class:`str`.
1622 :raises TypeError: If parameter 'displayName' is not of type :class:`str`.
1623 :raises TypeError: If parameter 'image' is not of type :class:`str`.
1624 :raises TypeError: If an element of parameter 'steps' is not of type :class:`Step`.
1625 """
1626 super().__init__(1)
1628 if path is None:
1629 raise ValueError("Parameter 'path' is None.")
1630 elif not isinstance(path, Path): 1630 ↛ 1631line 1630 didn't jump to line 1631 because the condition on line 1630 was never true
1631 ex = TypeError("Parameter 'path' is not of type 'Path'.")
1632 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
1633 raise ex
1635 if using is None:
1636 raise ValueError("Parameter 'using' is None.")
1638 for parameterName, value in (
1639 ("using", using),
1640 ("displayName", displayName),
1641 ("image", image)
1642 ):
1643 if value is not None and not isinstance(value, str): 1643 ↛ 1644line 1643 didn't jump to line 1644 because the condition on line 1643 was never true
1644 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
1645 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
1646 raise ex
1648 self._file = path
1649 self._path = path
1650 self._name = path.parent.name
1651 self._displayName = displayName
1652 self._using = using
1653 self._image = image
1654 self._steps = stepList = []
1656 if steps is not None:
1657 for step in steps:
1658 if not isinstance(step, Step):
1659 ex = TypeError("An element of parameter 'steps' is not of type 'Step'.")
1660 ex.add_note(f"Got type '{getFullyQualifiedName(step)}'.")
1661 raise ex
1663 stepList.append(step)
1664 step.Parent = self
1666 @Base.Parent.setter
1667 def Parent(self, value: None) -> None:
1668 ex = TypeError(f"A '{getFullyQualifiedName(self)}' has no parent.")
1669 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
1670 raise ex
1672 @readonly
1673 def Path(self) -> Path:
1674 """
1675 Read-only property to access the path to the action's file (:attr:`_path`).
1677 :returns: Path to the action's file.
1678 """
1679 return self._path
1681 @readonly
1682 def Name(self) -> str:
1683 """
1684 Read-only property to access the action's name, its directory's name (:attr:`_name`).
1686 :returns: Name of the action.
1687 """
1688 return self._name
1690 @readonly
1691 def DisplayName(self) -> Nullable[str]:
1692 """
1693 Read-only property to access the action's name, as GitHub displays it (:attr:`_displayName`).
1695 :returns: The ``name`` key, or ``None`` if the file has none.
1696 """
1697 return self._displayName
1699 @readonly
1700 def Using(self) -> str:
1701 """
1702 Read-only property to access how the action runs (:attr:`_using`).
1704 :returns: The ``runs.using`` key, as ``composite``, ``docker`` or ``node24``.
1705 """
1706 return self._using
1708 @readonly
1709 def IsComposite(self) -> bool:
1710 """
1711 Read-only property to return whether the action is a composite action, running steps.
1713 :returns: ``True``, if ``runs.using`` is ``composite``.
1714 """
1715 return self._using == "composite"
1717 @readonly
1718 def Image(self) -> Nullable[str]:
1719 """
1720 Read-only property to access the image a Docker action runs (:attr:`_image`).
1722 :returns: The ``runs.image`` key, as ``Dockerfile`` or ``docker://alpine:3.22``, or ``None`` for another action.
1723 """
1724 return self._image
1726 @readonly
1727 def Steps(self) -> list[Step]:
1728 """
1729 Read-only property to access the steps of a composite action (:attr:`_steps`).
1731 :returns: The steps, in file order, or an empty list for another action.
1732 """
1733 return self._steps
1735 def IterateActions(self) -> Iterator[UsesReference]:
1736 """
1737 Iterate the actions the steps of a composite action run.
1739 :returns: An iterator over the actions, in file order.
1740 """
1741 for step in self._steps:
1742 if step._uses is not None:
1743 yield step._uses
1745 @readonly
1746 def StepCount(self) -> int:
1747 """
1748 Read-only property to return the number of steps of the action.
1750 :returns: Number of steps.
1751 """
1752 return len(self._steps)
1754 def IterateSteps(self) -> Iterator[Step]:
1755 """
1756 Iterate the action's steps.
1758 :returns: An iterator over the steps, in file order.
1759 """
1760 return iter(self._steps)
1762 def __str__(self) -> str:
1763 """
1764 Return the action's name.
1766 :returns: Name of the action, its directory's name.
1767 """
1768 return self._name
1770 @classmethod
1771 def FromFile(cls, path: Path) -> Self:
1772 """
1773 Read an action's file.
1775 :param path: Path to the action's file.
1776 :returns: The action, with the steps of a composite action attached.
1777 :raises ValueError: If parameter 'path' is ``None``.
1778 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`.
1779 :raises WorkflowError: If the file doesn't exist.
1780 :raises WorkflowError: If the file can't be read.
1781 :raises WorkflowError: If the file is not a YAML document.
1782 :raises WorkflowError: If the document is not a mapping, or has no ``runs`` key.
1783 :raises WorkflowError: If ``runs`` is not a mapping or has no ``using`` key, or a step is malformed.
1784 """
1785 if path is None: 1785 ↛ 1786line 1785 didn't jump to line 1786 because the condition on line 1785 was never true
1786 raise ValueError("Parameter 'path' is None.")
1787 elif not isinstance(path, Path): 1787 ↛ 1788line 1787 didn't jump to line 1788 because the condition on line 1787 was never true
1788 ex = TypeError("Parameter 'path' is not of type 'Path'.")
1789 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
1790 raise ex
1791 elif not path.exists():
1792 raise WorkflowError("Action file doesn't exist.", path) from FileNotFoundError(path)
1794 try:
1795 content = path.read_text(encoding="utf-8")
1796 except OSError as cause:
1797 raise WorkflowError("Action file can't be read.", path) from cause
1799 try:
1800 document = YAML(typ="rt").load(content)
1801 except YAMLError as cause:
1802 mark = getattr(cause, "problem_mark", None)
1803 line = None if mark is None else mark.line + 1
1804 raise WorkflowError("Action file is not a YAML document.", path, line) from cause
1806 if document is None: 1806 ↛ 1807line 1806 didn't jump to line 1807 because the condition on line 1806 was never true
1807 raise WorkflowError("Action file is empty.", path)
1808 elif not isinstance(document, CommentedMap): 1808 ↛ 1809line 1808 didn't jump to line 1809 because the condition on line 1808 was never true
1809 ex = WorkflowError("Action file is not a mapping.", path, 1)
1810 ex.add_note(f"Got type '{getFullyQualifiedName(document)}'.")
1811 raise ex
1812 elif "runs" not in document:
1813 raise WorkflowError("Action file has no 'runs' key.", path)
1815 return cls._Parse(document, path)
1817 @classmethod
1818 def _Parse(cls, document: CommentedMap, path: Path) -> Self:
1819 """
1820 Build an action and the steps it contains from the document read from its file.
1822 :param document: The document, a mapping with a ``runs`` key.
1823 :param path: Path to the action's file.
1824 :returns: The action, with the steps of a composite action attached.
1825 :raises WorkflowError: If key ``runs`` is not a mapping, or has no ``using`` key.
1826 :raises WorkflowError: If key ``runs.steps`` is not a list, or a step is malformed.
1827 """
1828 runs = document["runs"]
1829 if not isinstance(runs, CommentedMap): 1829 ↛ 1830line 1829 didn't jump to line 1830 because the condition on line 1829 was never true
1830 ex = WorkflowError("Key 'runs' is not a mapping.", path, Base._KeyLine(document, "runs"))
1831 ex.add_note(f"Got type '{getFullyQualifiedName(runs)}'.")
1832 raise ex
1833 elif "using" not in runs:
1834 raise WorkflowError("Key 'runs' has no 'using' key.", path, Base._KeyLine(document, "runs"))
1836 steps = None
1837 if (stepList := runs.get("steps", None)) is not None:
1838 if not isinstance(stepList, CommentedSeq): 1838 ↛ 1839line 1838 didn't jump to line 1839 because the condition on line 1838 was never true
1839 ex = WorkflowError("Key 'runs.steps' is not a list.", path, Base._KeyLine(runs, "steps"))
1840 ex.add_note(f"Got type '{getFullyQualifiedName(stepList)}'.")
1841 raise ex
1843 steps = [
1844 Step._FromYAML(step, position, f"action '{path.parent.name}'", path, stepList.lc.item(position)[0] + 1)
1845 for position, step in enumerate(stepList)
1846 ]
1848 displayName = document.get("name", None)
1849 image = runs.get("image", None)
1850 return cls(
1851 path,
1852 str(runs["using"]),
1853 displayName=None if displayName is None else str(displayName),
1854 image=None if image is None else str(image),
1855 steps=steps
1856 )
1859@export
1860class Step(Base[Union[Job, Action]]):
1861 """A step of a job or of a composite action."""
1863 _PARENT_TYPE: ClassVar[ParentTypes] = (Job, Action) #: A step is contained in a job or an action.
1865 _name: Nullable[str] #: Name of the step.
1866 _identifier: Nullable[str] #: Identifier of the step, as referenced by ``steps.<id>``.
1867 _condition: Nullable[str] #: Condition under which the step runs.
1868 _uses: Nullable[UsesReference] #: The action the step runs.
1869 _run: Nullable[str] #: The script the step runs.
1871 def __init__(
1872 self,
1873 line: int,
1874 name: Nullable[str] = None,
1875 identifier: Nullable[str] = None,
1876 condition: Nullable[str] = None,
1877 run: Nullable[str] = None,
1878 uses: Nullable[UsesReference] = None,
1879 *,
1880 parent: Nullable[Union[Job, Action]] = None
1881 ) -> None:
1882 """
1883 Initializes a step of a job or of a composite action.
1885 The action a step runs is attached by passing it, or by constructing a :class:`UsesReference` with the step as
1886 parent.
1888 :param line: Line the step starts at, starting at 1.
1889 :param name: Optional, name of the step. Default: ``None``.
1890 :param identifier: Optional, identifier of the step. Default: ``None``.
1891 :param condition: Optional, condition under which the step runs. Default: ``None``.
1892 :param run: Optional, the script the step runs. Default: ``None``.
1893 :param uses: Optional, the action the step runs, which is attached to the step. Default: ``None``.
1894 :param parent: Optional, reference to the job or the composite action containing the step, which the step is
1895 attached to. Default: ``None``.
1896 :raises TypeError: If parameter 'name' is not of type :class:`str`.
1897 :raises TypeError: If parameter 'identifier' is not of type :class:`str`.
1898 :raises TypeError: If parameter 'condition' is not of type :class:`str`.
1899 :raises TypeError: If parameter 'run' is not of type :class:`str`.
1900 :raises TypeError: If parameter 'uses' is not of type :class:`UsesReference`.
1901 """
1902 super().__init__(line, parent=parent)
1904 for parameterName, value in (
1905 ("name", name),
1906 ("identifier", identifier),
1907 ("condition", condition),
1908 ("run", run)
1909 ):
1910 if value is not None and not isinstance(value, str): 1910 ↛ 1911line 1910 didn't jump to line 1911 because the condition on line 1910 was never true
1911 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
1912 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
1913 raise ex
1915 if uses is not None and not isinstance(uses, UsesReference):
1916 ex = TypeError("Parameter 'uses' is not of type 'UsesReference'.")
1917 ex.add_note(f"Got type '{getFullyQualifiedName(uses)}'.")
1918 raise ex
1920 self._name = name
1921 self._identifier = identifier
1922 self._condition = condition
1923 self._uses = uses
1924 self._run = run
1926 if uses is not None:
1927 uses.Parent = self
1929 if parent is not None:
1930 parent._steps.append(self)
1932 @Base.Parent.setter
1933 def Parent(self, value: Job) -> None:
1934 Base.Parent.fset(self, value)
1936 if self._uses is not None:
1937 self._uses.Parent = self
1939 @readonly
1940 def Name(self) -> Nullable[str]:
1941 """
1942 Read-only property to access the step's name (:attr:`_name`).
1944 :returns: Name of the step, or ``None`` if the workflow gives none.
1945 """
1946 return self._name
1948 @readonly
1949 def ID(self) -> Nullable[str]:
1950 """
1951 Read-only property to access the step's identifier (:attr:`_identifier`).
1953 :returns: Identifier of the step, or ``None`` if the workflow gives none.
1954 """
1955 return self._identifier
1957 @readonly
1958 def Condition(self) -> Nullable[str]:
1959 """
1960 Read-only property to access the condition under which the step runs (:attr:`_condition`).
1962 :returns: The ``if`` expression, as written, or ``None`` if the step always runs.
1963 """
1964 return self._condition
1966 @readonly
1967 def Uses(self) -> Nullable[UsesReference]:
1968 """
1969 Read-only property to access the action the step runs (:attr:`_uses`).
1971 :returns: The action, or ``None`` for a step running a script.
1972 """
1973 return self._uses
1975 @readonly
1976 def Run(self) -> Nullable[str]:
1977 """
1978 Read-only property to access the script the step runs (:attr:`_run`).
1980 :returns: The script, or ``None`` for a step running an action.
1981 """
1982 return self._run
1984 @classmethod
1985 def _FromYAML(cls, mapping: Any, position: int, what: str, path: Path, line: int) -> Self:
1986 """
1987 Read a step from the ``steps`` list of a job or of a composite action.
1989 :param mapping: The step's mapping.
1990 :param position: Position of the step in its list, starting at 0.
1991 :param what: The job or action containing the step, for the exception's message, as ``job 'Build'`` or
1992 ``action 'Setup'``.
1993 :param path: Path to the file, for a message.
1994 :param line: Line the step starts at, starting at 1.
1995 :returns: The step.
1996 :raises WorkflowError: If the step is not a mapping.
1997 :raises WorkflowError: If the step's ``uses`` is not a reference.
1998 """
1999 if not isinstance(mapping, CommentedMap):
2000 ex = WorkflowError(f"Step {position + 1} of {what} is not a mapping.", path, line)
2001 ex.add_note(f"Got type '{getFullyQualifiedName(mapping)}'.")
2002 raise ex
2004 name = mapping.get("name", None)
2005 identifier = mapping.get("id", None)
2006 condition = mapping.get("if", None)
2007 run = mapping.get("run", None)
2008 uses = None
2009 if "uses" in mapping:
2010 uses = UsesReference._FromYAML(mapping, f"step {position + 1} of {what}", path)
2012 return cls(
2013 line,
2014 name=None if name is None else str(name),
2015 identifier=None if identifier is None else str(identifier),
2016 condition=None if condition is None else str(condition),
2017 run=None if run is None else str(run),
2018 uses=uses
2019 )
2022@export
2023class Matrix(Base[Job]):
2024 """
2025 The ``strategy.matrix`` of a job.
2027 A matrix is *dynamic*, if a part of it is an expression - as ``include: ${{ fromJson(inputs.jobs) }}`` - because
2028 its instances are then known at run time only.
2029 """
2031 _PARENT_TYPE: ClassVar[ParentTypes] = Job #: A matrix belongs to a job.
2033 _dimensions: dict[str, ValueT] #: The dimensions, by name.
2034 _include: ValueT #: The combinations added, or an expression producing them.
2035 _exclude: ValueT #: The combinations removed, or an expression producing them.
2036 _expression: Nullable[str] #: The expression the whole matrix is taken from.
2038 def __init__(
2039 self,
2040 line: int,
2041 dimensions: Nullable[Mapping[str, ValueT]] = None,
2042 include: ValueT = None,
2043 exclude: ValueT = None,
2044 expression: Nullable[str] = None,
2045 *,
2046 parent: Nullable[Job] = None
2047 ) -> None:
2048 """
2049 Initializes a job's matrix.
2051 :param line: Line the ``matrix`` key is written at, starting at 1.
2052 :param dimensions: Optional, the dimensions, by name; a dimension's value is a list or an expression.
2053 Default: ``None``.
2054 :param include: Optional, the combinations added, or an expression producing them. Default: ``None``.
2055 :param exclude: Optional, the combinations removed, or an expression producing them. Default: ``None``.
2056 :param expression: Optional, the expression the whole matrix is taken from. Default: ``None``.
2057 :param parent: Optional, reference to the job the matrix belongs to, which the matrix is attached to.
2058 Default: ``None``.
2059 :raises TypeError: If parameter 'dimensions' is not a mapping.
2060 :raises TypeError: If parameter 'expression' is not of type :class:`str`.
2061 """
2062 super().__init__(line, parent=parent)
2064 if dimensions is not None and not isinstance(dimensions, Mapping): 2064 ↛ 2065line 2064 didn't jump to line 2065 because the condition on line 2064 was never true
2065 ex = TypeError("Parameter 'dimensions' is not a mapping.")
2066 ex.add_note(f"Got type '{getFullyQualifiedName(dimensions)}'.")
2067 raise ex
2069 if expression is not None and not isinstance(expression, str): 2069 ↛ 2070line 2069 didn't jump to line 2070 because the condition on line 2069 was never true
2070 ex = TypeError("Parameter 'expression' is not of type 'str'.")
2071 ex.add_note(f"Got type '{getFullyQualifiedName(expression)}'.")
2072 raise ex
2074 self._dimensions = {} if dimensions is None else dict(dimensions)
2075 self._include = include
2076 self._exclude = exclude
2077 self._expression = expression
2079 if parent is not None:
2080 parent._matrix = self
2082 @readonly
2083 def Dimensions(self) -> dict[str, ValueT]:
2084 """
2085 Read-only property to access the matrix' dimensions (:attr:`_dimensions`).
2087 :returns: The dimensions, by name; a dimension's value is a list, or an expression producing one.
2088 """
2089 return self._dimensions
2091 @readonly
2092 def Include(self) -> ValueT:
2093 """
2094 Read-only property to access the combinations added to the matrix (:attr:`_include`).
2096 :returns: A list of combinations, an expression producing them, or ``None`` if the matrix has no ``include``.
2097 """
2098 return self._include
2100 @readonly
2101 def Exclude(self) -> ValueT:
2102 """
2103 Read-only property to access the combinations removed from the matrix (:attr:`_exclude`).
2105 :returns: A list of combinations, an expression producing them, or ``None`` if the matrix has no ``exclude``.
2106 """
2107 return self._exclude
2109 @readonly
2110 def Expression(self) -> Nullable[str]:
2111 """
2112 Read-only property to access the expression the whole matrix is taken from (:attr:`_expression`).
2114 :returns: The expression, as ``${{ fromJson(needs.Params.outputs.matrix) }}``, or ``None`` if the matrix is a
2115 mapping.
2116 """
2117 return self._expression
2119 @readonly
2120 def IsDynamic(self) -> bool:
2121 """
2122 Read-only property to return whether the matrix' instances are known at run time only.
2124 :returns: ``True``, if the matrix, its ``include``, its ``exclude`` or one of its dimensions is an expression.
2125 """
2126 return (
2127 self._expression is not None or isinstance(self._include, str) or isinstance(self._exclude, str) or
2128 any(isinstance(value, str) for value in self._dimensions.values())
2129 )
2131 @readonly
2132 def Combinations(self) -> list[dict[str, ValueT]]:
2133 """
2134 Read-only property to return the combinations the matrix produces, as GitHub computes them.
2136 The dimensions are combined in the order they are written, the last one varying fastest. Then ``exclude``
2137 removes every combination matching all key-value pairs of an entry, and ``include`` extends every remaining
2138 combination whose dimension values the entry doesn't change - its other keys, and those an earlier entry
2139 added, it may change. An entry extending no combination is a combination of its own.
2141 :returns: The combinations, each a mapping of the dimensions' and included keys' names to values.
2142 :raises WorkflowError: If the matrix is dynamic, so its combinations are known at run time only.
2143 :raises WorkflowError: If ``include`` or ``exclude`` is not a list of mappings.
2144 """
2145 path = self._file
2146 if self.IsDynamic:
2147 raise WorkflowError("Matrix is dynamic; its combinations are known at run time only.", path, self._line)
2149 for key, entries in (
2150 ("include", self._include),
2151 ("exclude", self._exclude)
2152 ):
2153 if entries is not None and (
2154 not isinstance(entries, list) or not all(isinstance(entry, dict) for entry in entries)
2155 ):
2156 raise WorkflowError(f"Key '{key}' of the matrix is not a list of mappings.", path, self._line)
2158 combinations = []
2159 if len(self._dimensions) > 0:
2160 dimensions = {name: value if isinstance(value, list) else [value] for name, value in self._dimensions.items()}
2161 combinations = [dict(zip(dimensions, values)) for values in product(*dimensions.values())]
2163 if self._exclude is not None:
2164 for entry in self._exclude:
2165 combinations = [
2166 combination for combination in combinations
2167 if not all(combination.get(key, None) == value for key, value in entry.items())
2168 ]
2170 if self._include is None:
2171 return combinations
2173 originals = [dict(combination) for combination in combinations]
2174 for entry in self._include:
2175 extended = False
2176 for combination, original in zip(combinations, originals):
2177 if all(original[key] == value for key, value in entry.items() if key in original):
2178 combination.update(entry)
2179 extended = True
2181 if not extended:
2182 combinations.append(dict(entry))
2184 return combinations
2186 @staticmethod
2187 def _FormatCombination(combination: Mapping[str, ValueT]) -> dict[str, str]:
2188 """
2189 Format the values of a matrix' combination as GitHub prints them in the name of a matrix instance.
2191 A string is printed as it is, any other value as JSON: ``true``, ``3``, ``{"os": "ubuntu"}``.
2193 :param combination: The combination, as :attr:`Matrix.Combinations` returns it.
2194 :returns: The combination's names and formatted values, in the combination's order.
2195 """
2196 return {
2197 name: value if isinstance(value, str) else json_dumps(value, separators=(", ", ": "))
2198 for name, value in combination.items()
2199 }
2201 @classmethod
2202 def _FromYAML(cls, value: Any, path: Path, line: int) -> Self:
2203 """
2204 Read the value of a job's ``strategy.matrix`` key.
2206 :param value: The value of the ``matrix`` key: a mapping, or an expression.
2207 :param path: Path to the workflow file.
2208 :param line: Line the key is written at, starting at 1.
2209 :returns: The matrix.
2210 :raises WorkflowError: If the value is neither a mapping nor an expression.
2211 """
2212 if isinstance(value, str):
2213 return cls(line, expression=str(value))
2214 elif not isinstance(value, CommentedMap):
2215 ex = WorkflowError("Key 'strategy.matrix' is not a mapping.", path, line)
2216 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
2217 raise ex
2219 return cls(
2220 line,
2221 dimensions={key: Base._ToPython(item) for key, item in value.items() if key not in ("include", "exclude")},
2222 include=Base._ToPython(value.get("include", None)),
2223 exclude=Base._ToPython(value.get("exclude", None))
2224 )
2227@export
2228class UsesReference(Base[Union[Job, Step]]):
2229 """
2230 The value of a ``uses`` key: a reusable workflow called by a job, or an action run by a step.
2232 The forms GitHub accepts are read into their parts:
2234 .. code-block:: text
2236 pyTooling/Actions/.github/workflows/Package.yml@r8 repository, path and ref
2237 actions/checkout@v6 an action in a repository's root
2238 ./.github/workflows/Package.yml a file of the same repository and commit
2239 docker://alpine:3.22 a Docker image
2240 """
2242 _PARENT_TYPE: ClassVar[ParentTypes] = (Job, Step) #: A reference is contained in a job or a step.
2244 _rawReference: str #: The reference, as written.
2245 _repository: Nullable[str] #: The repository, as ``owner/repo``.
2246 _path: str #: The path within the repository.
2247 _reference: Nullable[str] #: The branch, tag or commit.
2248 _isLocal: bool #: ``True``, if the reference names a file of the same repository.
2249 _isDocker: bool #: ``True``, if the reference names a Docker image.
2251 def __init__(self, rawReference: str, line: int, *, parent: Nullable[Union[Job, Step]] = None) -> None:
2252 """
2253 Initializes a ``uses`` reference by reading it into its parts.
2255 :param rawReference: The reference, as written.
2256 :param line: Line the reference is written at, starting at 1.
2257 :param parent: Optional, reference to the job or step containing it, which the reference is attached to.
2258 Default: ``None``.
2259 :raises ValueError: If parameter 'rawReference' is ``None``.
2260 :raises TypeError: If parameter 'rawReference' is not of type :class:`str`.
2261 :raises ValueError: If parameter 'rawReference' is empty.
2262 :raises ValueError: If parameter 'rawReference' names a repository without a ref.
2263 :raises ValueError: If parameter 'rawReference' names no repository as ``owner/repo``.
2264 """
2265 super().__init__(line, parent=parent)
2267 if rawReference is None:
2268 raise ValueError("Parameter 'rawReference' is None.")
2269 elif not isinstance(rawReference, str): 2269 ↛ 2270line 2269 didn't jump to line 2270 because the condition on line 2269 was never true
2270 ex = TypeError("Parameter 'rawReference' is not of type 'str'.")
2271 ex.add_note(f"Got type '{getFullyQualifiedName(rawReference)}'.")
2272 raise ex
2273 elif rawReference == "":
2274 raise ValueError("Parameter 'rawReference' is empty.")
2276 self._rawReference = rawReference
2277 self._isLocal = False
2278 self._isDocker = False
2279 self._repository = None
2280 self._reference = None
2282 if rawReference.startswith("docker://"):
2283 self._isDocker = True
2284 self._path = rawReference[len("docker://"):]
2285 elif rawReference.startswith("./"):
2286 self._isLocal = True
2287 self._path = rawReference[len("./"):]
2288 else:
2289 location, separator, reference = rawReference.partition("@")
2290 if separator == "" or reference == "":
2291 ex = ValueError("Parameter 'rawReference' names a repository without a ref.")
2292 ex.add_note(f"Got '{rawReference}'.")
2293 raise ex
2295 owner, _, remainder = location.partition("/")
2296 repository, _, path = remainder.partition("/")
2297 if owner == "" or repository == "":
2298 ex = ValueError("Parameter 'rawReference' names no repository as 'owner/repo'.")
2299 ex.add_note(f"Got '{rawReference}'.")
2300 raise ex
2302 self._repository = f"{owner}/{repository}"
2303 self._path = path
2304 self._reference = reference
2306 if parent is not None:
2307 parent._uses = self
2309 @readonly
2310 def Repository(self) -> Nullable[str]:
2311 """
2312 Read-only property to access the repository (:attr:`_repository`).
2314 :returns: The repository, as ``owner/repo``, or ``None`` for a local reference and a Docker image.
2315 """
2316 return self._repository
2318 @readonly
2319 def Path(self) -> str:
2320 """
2321 Read-only property to access the path within the repository (:attr:`_path`).
2323 :returns: The path, as ``.github/workflows/Package.yml``, without the leading ``./`` of a local reference. It
2324 is empty for an action in a repository's root, and the image for a Docker image.
2325 """
2326 return self._path
2328 @readonly
2329 def Reference(self) -> Nullable[str]:
2330 """
2331 Read-only property to access the branch, tag or commit (:attr:`_reference`).
2333 :returns: The branch, tag or commit, as ``r8``, or ``None`` for a local reference and a Docker image.
2334 """
2335 return self._reference
2337 @readonly
2338 def IsLocal(self) -> bool:
2339 """
2340 Read-only property to access whether the reference names a file of the same repository (:attr:`_isLocal`).
2342 :returns: ``True``, if the reference starts with ``./``.
2343 """
2344 return self._isLocal
2346 @readonly
2347 def IsDocker(self) -> bool:
2348 """
2349 Read-only property to access whether the reference names a Docker image (:attr:`_isDocker`).
2351 :returns: ``True``, if the reference starts with ``docker://``.
2352 """
2353 return self._isDocker
2355 @readonly
2356 def IsWorkflow(self) -> bool:
2357 """
2358 Read-only property to return whether the reference names a reusable workflow rather than an action.
2360 :returns: ``True``, if the path names a ``.yml`` or ``.yaml`` file in ``.github/workflows``.
2361 """
2362 path = PurePosixPath(self._path)
2363 return (
2364 not self._isDocker and path.parent == PurePosixPath(".github/workflows") and path.suffix in (".yml", ".yaml")
2365 )
2367 @readonly
2368 def FileName(self) -> str:
2369 """
2370 Read-only property to return the last element of the path.
2372 :returns: The file name, as ``Package.yml`` for a reusable workflow, or ``""`` for an action in a repository's
2373 root.
2374 """
2375 return PurePosixPath(self._path).name if not self._isDocker else ""
2377 @readonly
2378 def Stem(self) -> str:
2379 """
2380 Read-only property to return the file name without its extension.
2382 For a reusable workflow, it is the name :class:`Workflow` gives the file it reads, e.g. ``Package``.
2384 :returns: The file name without its extension, or ``""`` for an action in a repository's root.
2385 """
2386 return PurePosixPath(self._path).stem if not self._isDocker else ""
2388 def __str__(self) -> str:
2389 """
2390 Return the reference, as written.
2392 :returns: The reference.
2393 """
2394 return self._rawReference
2396 @classmethod
2397 def _FromYAML(cls, mapping: CommentedMap, what: str, path: Path) -> Self:
2398 """
2399 Read the ``uses`` key of a job or step.
2401 :param mapping: The mapping of the job or step, which has a ``uses`` key.
2402 :param what: The job or step, for the exception's message, as ``job 'Build'``.
2403 :param path: Path to the workflow file.
2404 :returns: The reference.
2405 :raises WorkflowError: If the value is not a reference.
2406 """
2407 line = Base._KeyLine(mapping, "uses")
2408 try:
2409 return cls(str(mapping["uses"]), line)
2410 except ValueError as cause:
2411 raise WorkflowError(f"Key 'uses' of {what} is not a reference.", path, line) from cause
2414@export
2415class Permission(Base[Union[Workflow, Job]]):
2416 """
2417 A permission a workflow or job declares for the ``GITHUB_TOKEN``, as ``contents: write``.
2419 The short forms ``read-all`` and ``write-all`` are read as one permission of scope :attr:`PermissionScope.All`.
2420 """
2422 _PARENT_TYPE: ClassVar[ParentTypes] = (Workflow, Job) #: A permission is declared by a workflow or a job.
2424 _scope: PermissionScope #: The scope, as ``contents``.
2425 _level: AccessLevel #: The access granted.
2427 def __init__(
2428 self,
2429 scope: PermissionScope,
2430 level: AccessLevel,
2431 line: int,
2432 *,
2433 parent: Nullable[Union[Workflow, Job]] = None
2434 ) -> None:
2435 """
2436 Initializes a permission.
2438 :param scope: The scope, as ``contents``.
2439 :param level: The access granted.
2440 :param line: Line the permission is written at, starting at 1.
2441 :param parent: Optional, reference to the workflow or job declaring it, which the permission is attached to.
2442 Default: ``None``.
2443 :raises ValueError: If parameter 'scope' is ``None``.
2444 :raises TypeError: If parameter 'scope' is not of type :class:`PermissionScope`.
2445 :raises ValueError: If parameter 'level' is ``None``.
2446 :raises TypeError: If parameter 'level' is not of type :class:`AccessLevel`.
2447 """
2448 super().__init__(line, parent=parent)
2450 if scope is None: 2450 ↛ 2451line 2450 didn't jump to line 2451 because the condition on line 2450 was never true
2451 raise ValueError("Parameter 'scope' is None.")
2452 elif not isinstance(scope, PermissionScope):
2453 ex = TypeError("Parameter 'scope' is not of type 'PermissionScope'.")
2454 ex.add_note(f"Got type '{getFullyQualifiedName(scope)}'.")
2455 raise ex
2457 if level is None: 2457 ↛ 2458line 2457 didn't jump to line 2458 because the condition on line 2457 was never true
2458 raise ValueError("Parameter 'level' is None.")
2459 elif not isinstance(level, AccessLevel):
2460 ex = TypeError("Parameter 'level' is not of type 'AccessLevel'.")
2461 ex.add_note(f"Got type '{getFullyQualifiedName(level)}'.")
2462 raise ex
2464 self._scope = scope
2465 self._level = level
2467 if parent is not None:
2468 if parent._permissions is None: 2468 ↛ 2471line 2468 didn't jump to line 2471 because the condition on line 2468 was always true
2469 parent._permissions = {}
2471 parent._permissions[scope] = self
2473 @readonly
2474 def Scope(self) -> PermissionScope:
2475 """
2476 Read-only property to access the scope (:attr:`_scope`).
2478 :returns: The scope the permission grants access to.
2479 """
2480 return self._scope
2482 @readonly
2483 def Level(self) -> AccessLevel:
2484 """
2485 Read-only property to access the access granted (:attr:`_level`).
2487 :returns: The access level.
2488 """
2489 return self._level
2491 def __str__(self) -> str:
2492 """
2493 Return the permission, as written in a workflow file.
2495 :returns: The permission, as ``contents: write``, or ``read-all`` for scope :attr:`PermissionScope.All`.
2496 """
2497 if self._scope is PermissionScope.All:
2498 return f"{self._level.value}-all"
2500 return f"{self._scope}: {self._level.value}"
2502 @classmethod
2503 def _FromYAML(cls, value: Any, path: Path, line: int) -> list[Self]:
2504 """
2505 Read the value of a ``permissions`` key into the permissions a workflow or job declares.
2507 :param value: The value of the ``permissions`` key.
2508 :param path: Path to the workflow file.
2509 :param line: Line the key is written at, starting at 1.
2510 :returns: The permissions, in file order.
2511 :raises WorkflowError: If the value is neither ``read-all``, ``write-all`` nor a mapping.
2512 :raises WorkflowError: If a key is not a permission scope. |br|
2513 The note lists the allowed values.
2514 :raises WorkflowError: If a scope's value is not an access level. |br|
2515 The note lists the allowed values.
2516 """
2517 if value == "read-all":
2518 return [cls(PermissionScope.All, AccessLevel.Read, line)]
2519 elif value == "write-all":
2520 return [cls(PermissionScope.All, AccessLevel.Write, line)]
2521 elif not isinstance(value, CommentedMap):
2522 ex = WorkflowError("Key 'permissions' is neither 'read-all', 'write-all' nor a mapping.", path, line)
2523 ex.add_note(f"Got '{value}'." if isinstance(value, str) else f"Got type '{getFullyQualifiedName(value)}'.")
2524 raise ex
2526 permissions = []
2527 for scope, level in value.items():
2528 scopeLine = Base._KeyLine(value, scope)
2529 try:
2530 permissionScope = PermissionScope(scope)
2531 except ValueError as cause:
2532 ex = WorkflowError(f"Key '{scope}' of 'permissions' is not a permission scope.", path, scopeLine)
2533 scopes = (member.value for member in PermissionScope if member is not PermissionScope.All)
2534 ex.add_note(f"Allowed values: {', '.join(scopes)}.")
2535 raise ex from cause
2537 try:
2538 accessLevel = AccessLevel(level)
2539 except ValueError as cause:
2540 ex = WorkflowError(f"Permission '{scope}' is not an access level.", path, scopeLine)
2541 ex.add_note(f"Got '{level}'.")
2542 ex.add_note(f"Allowed values: {', '.join(member.value for member in AccessLevel)}.")
2543 raise ex from cause
2545 permissions.append(cls(permissionScope, accessLevel, scopeLine))
2547 return permissions
2550@export
2551@abstractclass
2552class Parameter(Base[Workflow]):
2553 """
2554 Common behaviour of the inputs, outputs and secrets of a reusable workflow.
2556 Every parameter has a name and an optional description, and belongs to a :class:`Workflow`.
2557 """
2559 _PARENT_TYPE: ClassVar[ParentTypes] = Workflow #: A parameter is declared by a workflow.
2561 _name: str #: Name of the parameter.
2562 _description: Nullable[str] #: Description of the parameter.
2564 def __init__(
2565 self,
2566 name: str,
2567 line: int,
2568 description: Nullable[str] = None,
2569 *,
2570 parent: Nullable[Workflow] = None
2571 ) -> None:
2572 """
2573 Initializes a parameter of a reusable workflow.
2575 :param name: Name of the parameter.
2576 :param line: Line the parameter's name is written at, starting at 1.
2577 :param description: Optional, description of the parameter. Default: ``None``.
2578 :param parent: Optional, reference to the workflow declaring it. Default: ``None``.
2579 :raises ValueError: If parameter 'name' is ``None``.
2580 :raises TypeError: If parameter 'name' is not of type :class:`str`.
2581 :raises ValueError: If parameter 'name' is empty.
2582 :raises TypeError: If parameter 'description' is not of type :class:`str`.
2583 """
2584 super().__init__(line, parent=parent)
2586 if name is None: 2586 ↛ 2587line 2586 didn't jump to line 2587 because the condition on line 2586 was never true
2587 raise ValueError("Parameter 'name' is None.")
2588 elif not isinstance(name, str): 2588 ↛ 2589line 2588 didn't jump to line 2589 because the condition on line 2588 was never true
2589 ex = TypeError("Parameter 'name' is not of type 'str'.")
2590 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
2591 raise ex
2592 elif name == "": 2592 ↛ 2593line 2592 didn't jump to line 2593 because the condition on line 2592 was never true
2593 raise ValueError("Parameter 'name' is empty.")
2595 if description is not None and not isinstance(description, str): 2595 ↛ 2596line 2595 didn't jump to line 2596 because the condition on line 2595 was never true
2596 ex = TypeError("Parameter 'description' is not of type 'str'.")
2597 ex.add_note(f"Got type '{getFullyQualifiedName(description)}'.")
2598 raise ex
2600 self._name = name
2601 self._description = description
2603 @readonly
2604 def Name(self) -> str:
2605 """
2606 Read-only property to access the parameter's name (:attr:`_name`).
2608 :returns: Name of the parameter.
2609 """
2610 return self._name
2612 @readonly
2613 def Description(self) -> Nullable[str]:
2614 """
2615 Read-only property to access the parameter's description (:attr:`_description`).
2617 :returns: The description, or ``None`` if the workflow gives none.
2618 """
2619 return self._description
2621 def __str__(self) -> str:
2622 """
2623 Return the parameter's name.
2625 :returns: Name of the parameter.
2626 """
2627 return self._name
2630@export
2631class Input(Parameter):
2632 """An input of a reusable workflow, declared in ``on.workflow_call.inputs``."""
2634 _type: InputType #: Type of the input.
2635 _required: bool #: ``True``, if a caller has to pass the input.
2636 _default: ValueT #: Value of the input, if a caller doesn't pass it.
2638 def __init__(
2639 self,
2640 name: str,
2641 line: int,
2642 inputType: InputType,
2643 required: bool = False,
2644 default: ValueT = None,
2645 description: Nullable[str] = None,
2646 *,
2647 parent: Nullable[Workflow] = None
2648 ) -> None:
2649 """
2650 Initializes an input of a reusable workflow.
2652 :param name: Name of the input.
2653 :param line: Line the input's name is written at, starting at 1.
2654 :param inputType: Type of the input.
2655 :param required: Optional, ``True``, if a caller has to pass the input. Default: ``False``.
2656 :param default: Optional, value of the input, if a caller doesn't pass it. Default: ``None``.
2657 :param description: Optional, description of the input. Default: ``None``.
2658 :param parent: Optional, reference to the workflow declaring it. Default: ``None``.
2659 :raises ValueError: If parameter 'inputType' is ``None``.
2660 :raises TypeError: If parameter 'inputType' is not of type :class:`InputType`.
2661 :raises TypeError: If parameter 'required' is not of type :class:`bool`.
2662 """
2663 super().__init__(name, line, description, parent=parent)
2665 if inputType is None:
2666 raise ValueError("Parameter 'inputType' is None.")
2667 elif not isinstance(inputType, InputType):
2668 ex = TypeError("Parameter 'inputType' is not of type 'InputType'.")
2669 ex.add_note(f"Got type '{getFullyQualifiedName(inputType)}'.")
2670 raise ex
2672 if not isinstance(required, bool): 2672 ↛ 2673line 2672 didn't jump to line 2673 because the condition on line 2672 was never true
2673 ex = TypeError("Parameter 'required' is not of type 'bool'.")
2674 ex.add_note(f"Got type '{getFullyQualifiedName(required)}'.")
2675 raise ex
2677 self._type = inputType
2678 self._required = required
2679 self._default = default
2681 if parent is not None: 2681 ↛ 2682line 2681 didn't jump to line 2682 because the condition on line 2681 was never true
2682 parent._inputs[name] = self
2684 @readonly
2685 def Type(self) -> InputType:
2686 """
2687 Read-only property to access the input's type (:attr:`_type`).
2689 :returns: Type of the input.
2690 """
2691 return self._type
2693 @readonly
2694 def Required(self) -> bool:
2695 """
2696 Read-only property to access whether a caller has to pass the input (:attr:`_required`).
2698 :returns: ``True``, if the input is required.
2699 """
2700 return self._required
2702 @readonly
2703 def Default(self) -> ValueT:
2704 """
2705 Read-only property to access the input's value, if a caller doesn't pass it (:attr:`_default`).
2707 The value keeps the type it is written with, as ``'3.14'`` or ``false``, and a multi-line value keeps its line
2708 breaks.
2710 :returns: The default value, or ``None`` if the workflow gives none.
2711 """
2712 return self._default
2714 @classmethod
2715 def _FromYAML(cls, name: str, declaration: Any, path: Path, line: int) -> Self:
2716 """
2717 Read an input's declaration below ``on.workflow_call.inputs``.
2720 :param name: Name of the input.
2721 :param declaration: The declaration.
2722 :param path: Path to the workflow file.
2723 :param line: Line the input's name is written at, starting at 1.
2724 :returns: The input.
2725 :raises WorkflowError: If the declaration is not a mapping.
2726 :raises WorkflowError: If key ``required`` is not a boolean.
2727 :raises WorkflowError: If the declaration has no ``type`` key.
2728 :raises WorkflowError: If key ``type`` is not an input type. |br|
2729 The note lists the allowed values.
2730 """
2731 if declaration is None:
2732 raise WorkflowError(f"Input '{name}' has no 'type' key.", path, line)
2733 elif not isinstance(declaration, CommentedMap):
2734 ex = WorkflowError(f"Declaration of '{name}' is not a mapping.", path, line)
2735 ex.add_note(f"Got type '{getFullyQualifiedName(declaration)}'.")
2736 raise ex
2738 description = declaration.get("description", None)
2739 required = Base._ToPython(declaration.get("required", False))
2740 if not isinstance(required, bool): 2740 ↛ 2741line 2740 didn't jump to line 2741 because the condition on line 2740 was never true
2741 ex = WorkflowError(f"Key 'required' of '{name}' is not a boolean.", path, line)
2742 ex.add_note(f"Got '{required}'.")
2743 raise ex
2745 if (inputType := declaration.get("type", None)) is None:
2746 raise WorkflowError(f"Input '{name}' has no 'type' key.", path, line)
2748 try:
2749 inputType = InputType.Parse(str(inputType))
2750 except ValueError as cause:
2751 ex = WorkflowError(f"Key 'type' of input '{name}' is not an input type.", path, line)
2752 ex.add_note(f"Got '{inputType}'.")
2753 ex.add_note(f"Allowed values: {', '.join(member.value for member in InputType)}.")
2754 raise ex from cause
2756 default = Base._ToPython(declaration.get("default", None))
2758 return cls(name, line, inputType, required, default, None if description is None else str(description))
2761@export
2762class Output(Parameter):
2763 """An output of a reusable workflow, declared in ``on.workflow_call.outputs``."""
2765 _value: str #: Expression the output's value is taken from.
2767 def __init__(
2768 self,
2769 name: str,
2770 line: int,
2771 value: str,
2772 description: Nullable[str] = None,
2773 *,
2774 parent: Nullable[Workflow] = None
2775 ) -> None:
2776 """
2777 Initializes an output of a reusable workflow.
2779 :param name: Name of the output.
2780 :param line: Line the output's name is written at, starting at 1.
2781 :param value: Expression the output's value is taken from, as ``${{ jobs.Build.outputs.version }}``.
2782 :param description: Optional, description of the output. Default: ``None``.
2783 :param parent: Optional, reference to the workflow declaring it. Default: ``None``.
2784 :raises ValueError: If parameter 'value' is ``None``.
2785 :raises TypeError: If parameter 'value' is not of type :class:`str`.
2786 """
2787 super().__init__(name, line, description, parent=parent)
2789 if value is None: 2789 ↛ 2790line 2789 didn't jump to line 2790 because the condition on line 2789 was never true
2790 raise ValueError("Parameter 'value' is None.")
2791 elif not isinstance(value, str): 2791 ↛ 2792line 2791 didn't jump to line 2792 because the condition on line 2791 was never true
2792 ex = TypeError("Parameter 'value' is not of type 'str'.")
2793 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
2794 raise ex
2796 self._value = value
2798 if parent is not None: 2798 ↛ 2799line 2798 didn't jump to line 2799 because the condition on line 2798 was never true
2799 parent._outputs[name] = self
2801 @readonly
2802 def Value(self) -> str:
2803 """
2804 Read-only property to access the expression the output's value is taken from (:attr:`_value`).
2806 :returns: The expression, as ``${{ jobs.Build.outputs.version }}``.
2807 """
2808 return self._value
2810 @classmethod
2811 def _FromYAML(cls, name: str, declaration: Any, path: Path, line: int) -> Self:
2812 """
2813 Read an output's declaration below ``on.workflow_call.outputs``.
2816 :param name: Name of the output.
2817 :param declaration: The declaration.
2818 :param path: Path to the workflow file.
2819 :param line: Line the output's name is written at, starting at 1.
2820 :returns: The output.
2821 :raises WorkflowError: If the declaration is not a mapping.
2822 :raises WorkflowError: If the declaration has no ``value`` key.
2823 """
2824 if declaration is None:
2825 raise WorkflowError(f"Output '{name}' has no 'value' key.", path, line)
2826 elif not isinstance(declaration, CommentedMap): 2826 ↛ 2827line 2826 didn't jump to line 2827 because the condition on line 2826 was never true
2827 ex = WorkflowError(f"Declaration of '{name}' is not a mapping.", path, line)
2828 ex.add_note(f"Got type '{getFullyQualifiedName(declaration)}'.")
2829 raise ex
2831 description = declaration.get("description", None)
2833 if (value := declaration.get("value", None)) is None:
2834 raise WorkflowError(f"Output '{name}' has no 'value' key.", path, line)
2836 return cls(name, line, str(value), None if description is None else str(description))
2839@export
2840class Secret(Parameter):
2841 """A secret of a reusable workflow, declared in ``on.workflow_call.secrets``."""
2843 _required: bool #: ``True``, if a caller has to pass the secret.
2845 def __init__(
2846 self,
2847 name: str,
2848 line: int,
2849 required: bool = False,
2850 description: Nullable[str] = None,
2851 *,
2852 parent: Nullable[Workflow] = None
2853 ) -> None:
2854 """
2855 Initializes a secret of a reusable workflow.
2857 :param name: Name of the secret.
2858 :param line: Line the secret's name is written at, starting at 1.
2859 :param required: Optional, ``True``, if a caller has to pass the secret. Default: ``False``.
2860 :param description: Optional, description of the secret. Default: ``None``.
2861 :param parent: Optional, reference to the workflow declaring it. Default: ``None``.
2862 :raises TypeError: If parameter 'required' is not of type :class:`bool`.
2863 """
2864 super().__init__(name, line, description, parent=parent)
2866 if not isinstance(required, bool): 2866 ↛ 2867line 2866 didn't jump to line 2867 because the condition on line 2866 was never true
2867 ex = TypeError("Parameter 'required' is not of type 'bool'.")
2868 ex.add_note(f"Got type '{getFullyQualifiedName(required)}'.")
2869 raise ex
2871 self._required = required
2873 if parent is not None: 2873 ↛ 2874line 2873 didn't jump to line 2874 because the condition on line 2873 was never true
2874 parent._secrets[name] = self
2876 @readonly
2877 def Required(self) -> bool:
2878 """
2879 Read-only property to access whether a caller has to pass the secret (:attr:`_required`).
2881 :returns: ``True``, if the secret is required.
2882 """
2883 return self._required
2885 @classmethod
2886 def _FromYAML(cls, name: str, declaration: Any, path: Path, line: int) -> Self:
2887 """
2888 Read a secret's declaration below ``on.workflow_call.secrets``.
2891 :param name: Name of the secret.
2892 :param declaration: The declaration.
2893 :param path: Path to the workflow file.
2894 :param line: Line the secret's name is written at, starting at 1.
2895 :returns: The secret.
2896 :raises WorkflowError: If the declaration is not a mapping.
2897 :raises WorkflowError: If key ``required`` is not a boolean.
2898 """
2899 if declaration is None:
2900 return cls(name, line)
2901 elif not isinstance(declaration, CommentedMap): 2901 ↛ 2902line 2901 didn't jump to line 2902 because the condition on line 2901 was never true
2902 ex = WorkflowError(f"Declaration of '{name}' is not a mapping.", path, line)
2903 ex.add_note(f"Got type '{getFullyQualifiedName(declaration)}'.")
2904 raise ex
2906 description = declaration.get("description", None)
2907 required = Base._ToPython(declaration.get("required", False))
2908 if not isinstance(required, bool):
2909 ex = WorkflowError(f"Key 'required' of '{name}' is not a boolean.", path, line)
2910 ex.add_note(f"Got '{required}'.")
2911 raise ex
2913 return cls(name, line, required, None if description is None else str(description))
2916@export
2917class WorkflowResolver(metaclass=ExtendedType, slots=True):
2918 """
2919 Reads the reusable workflows jobs call, and the actions steps run, as far as they are in a local directory.
2921 A repository is mapped to the directory holding its workflow files, so a reference like
2922 ``pyTooling/Actions/.github/workflows/Package.yml@r8`` reads ``Package.yml`` from that directory, whatever its ref.
2923 A local reference like ``./.github/workflows/Package.yml`` reads the file next to the calling workflow's file.
2925 An action of a mapped repository, like ``pyTooling/Actions/.github/actions/ComputeRequirements@r8``, is read from
2926 the repository's root - the directory holding the ``.github`` directory the mapped directory is in. A local action,
2927 like ``./.github/actions/ComputeRequirements``, is read from the root of the calling workflow's or action's
2928 repository.
2930 Every file is read once; asking for it again returns the same :class:`Workflow` or :class:`Action`.
2931 """
2933 _repositories: dict[str, Path] #: Directories holding the workflow files, by repository in lower case.
2934 _workflows: dict[Path, Workflow] #: Workflows already read, by resolved path.
2935 _actions: dict[Path, Action] #: Actions already read, by resolved path.
2937 def __init__(self, repositories: Nullable[Mapping[str, Path]] = None) -> None:
2938 """
2939 Initializes a resolver.
2941 :param repositories: Optional, directories holding the workflow files, by repository, as
2942 ``{"pyTooling/Actions": Path(".github/workflows")}``. Default: ``None``.
2943 :raises TypeError: If parameter 'repositories' is not a mapping.
2944 :raises TypeError: If a key of parameter 'repositories' is not of type :class:`str`.
2945 :raises ValueError: If a key of parameter 'repositories' is not of the form ``owner/repo``.
2946 :raises TypeError: If a value of parameter 'repositories' is not of type :class:`~pathlib.Path`.
2947 """
2948 self._repositories = {}
2949 self._workflows = {}
2950 self._actions = {}
2952 if repositories is None:
2953 return
2954 elif not isinstance(repositories, Mapping): 2954 ↛ 2955line 2954 didn't jump to line 2955 because the condition on line 2954 was never true
2955 ex = TypeError("Parameter 'repositories' is not a mapping.")
2956 ex.add_note(f"Got type '{getFullyQualifiedName(repositories)}'.")
2957 raise ex
2959 for repository, directory in repositories.items():
2960 if not isinstance(repository, str): 2960 ↛ 2961line 2960 didn't jump to line 2961 because the condition on line 2960 was never true
2961 ex = TypeError("Key of parameter 'repositories' is not of type 'str'.")
2962 ex.add_note(f"Got type '{getFullyQualifiedName(repository)}'.")
2963 raise ex
2964 elif repository.count("/") != 1 or repository.startswith("/") or repository.endswith("/"):
2965 ex = ValueError("Key of parameter 'repositories' is not of the form 'owner/repo'.")
2966 ex.add_note(f"Got '{repository}'.")
2967 raise ex
2968 elif not isinstance(directory, Path):
2969 ex = TypeError(f"Value of parameter 'repositories' for '{repository}' is not of type 'Path'.")
2970 ex.add_note(f"Got type '{getFullyQualifiedName(directory)}'.")
2971 raise ex
2973 self._repositories[repository.lower()] = directory
2975 @readonly
2976 def Repositories(self) -> dict[str, Path]:
2977 """
2978 Read-only property to access the directories holding the workflow files (:attr:`_repositories`).
2980 :returns: The directories, by repository in lower case.
2981 """
2982 return self._repositories
2984 def CanResolve(self, uses: UsesReference) -> bool:
2985 """
2986 Return whether a reference names a file the resolver reads: a local one, or one of a mapped repository.
2988 :param uses: The reference, as :attr:`Job.Uses`.
2989 :returns: ``True``, if the reference is local, or its repository is in :attr:`Repositories`.
2990 :raises ValueError: If parameter 'uses' is ``None``.
2991 :raises TypeError: If parameter 'uses' is not of type :class:`UsesReference`.
2992 """
2993 if uses is None:
2994 raise ValueError("Parameter 'uses' is None.")
2995 elif not isinstance(uses, UsesReference):
2996 ex = TypeError("Parameter 'uses' is not of type 'UsesReference'.")
2997 ex.add_note(f"Got type '{getFullyQualifiedName(uses)}'.")
2998 raise ex
3000 return uses._isLocal or (uses._repository is not None and uses._repository.lower() in self._repositories)
3002 def Load(self, path: Path) -> Workflow:
3003 """
3004 Read a workflow file, or return it if it was read before.
3006 :param path: Path to the workflow file.
3007 :returns: The workflow.
3008 :raises ValueError: If parameter 'path' is ``None``.
3009 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`.
3010 :raises WorkflowError: If the file doesn't exist, can't be read, or is not a well-formed workflow.
3011 """
3012 if path is None: 3012 ↛ 3013line 3012 didn't jump to line 3013 because the condition on line 3012 was never true
3013 raise ValueError("Parameter 'path' is None.")
3014 elif not isinstance(path, Path): 3014 ↛ 3015line 3014 didn't jump to line 3015 because the condition on line 3014 was never true
3015 ex = TypeError("Parameter 'path' is not of type 'Path'.")
3016 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
3017 raise ex
3019 key = path.resolve()
3020 if (workflow := self._workflows.get(key, None)) is None:
3021 workflow = Workflow.FromFile(path)
3022 self._workflows[key] = workflow
3024 return workflow
3026 def Resolve(self, uses: UsesReference) -> Nullable[Workflow]:
3027 """
3028 Return the reusable workflow a reference names, if its file is in a local directory.
3030 :param uses: The reference, as :attr:`Job.Uses`.
3031 :returns: The workflow, or ``None`` if the reference names an action, or a repository without a
3032 directory.
3033 :raises ValueError: If parameter 'uses' is ``None``.
3034 :raises TypeError: If parameter 'uses' is not of type :class:`UsesReference`.
3035 :raises ValueError: If parameter 'uses' is a local reference outside a workflow.
3036 :raises WorkflowError: If the workflow file doesn't exist in the directory. |br|
3037 The note names the reference's location.
3038 :raises WorkflowError: If the file is not a well-formed workflow.
3039 """
3040 if uses is None: 3040 ↛ 3041line 3040 didn't jump to line 3041 because the condition on line 3040 was never true
3041 raise ValueError("Parameter 'uses' is None.")
3042 elif not isinstance(uses, UsesReference): 3042 ↛ 3043line 3042 didn't jump to line 3043 because the condition on line 3042 was never true
3043 ex = TypeError("Parameter 'uses' is not of type 'UsesReference'.")
3044 ex.add_note(f"Got type '{getFullyQualifiedName(uses)}'.")
3045 raise ex
3047 if not uses.IsWorkflow:
3048 return None
3049 elif uses._isLocal:
3050 if uses._workflow is None: 3050 ↛ 3051line 3050 didn't jump to line 3051 because the condition on line 3050 was never true
3051 ex = ValueError("Parameter 'uses' is a local reference outside a workflow.")
3052 ex.add_note(f"Got '{uses}'.")
3053 raise ex
3055 directory = uses._workflow._path.parent
3056 elif (directory := self._repositories.get(uses._repository.lower(), None)) is None:
3057 return None
3059 path = directory / uses.FileName
3060 if not path.exists():
3061 ex = WorkflowError(
3062 f"Workflow '{uses.FileName}' doesn't exist in '{directory}'.",
3063 uses._file,
3064 uses._line
3065 )
3066 ex.add_note(f"Called as '{uses}'.")
3067 raise ex
3069 return self.Load(path)
3071 def LoadAction(self, path: Path) -> Action:
3072 """
3073 Read an action's file, or return it if it was read before.
3075 :param path: Path to the action's file.
3076 :returns: The action.
3077 :raises ValueError: If parameter 'path' is ``None``.
3078 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`.
3079 :raises WorkflowError: If the file doesn't exist, can't be read, or is not a well-formed action.
3080 """
3081 if path is None: 3081 ↛ 3082line 3081 didn't jump to line 3082 because the condition on line 3081 was never true
3082 raise ValueError("Parameter 'path' is None.")
3083 elif not isinstance(path, Path): 3083 ↛ 3084line 3083 didn't jump to line 3084 because the condition on line 3083 was never true
3084 ex = TypeError("Parameter 'path' is not of type 'Path'.")
3085 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
3086 raise ex
3088 key = path.resolve()
3089 if (action := self._actions.get(key, None)) is None:
3090 action = Action.FromFile(path)
3091 self._actions[key] = action
3093 return action
3095 def ResolveAction(self, uses: UsesReference) -> Nullable[Action]:
3096 """
3097 Return the action a step's reference names, if its file is in a local directory.
3099 :param uses: The reference, as :attr:`Step.Uses`.
3100 :returns: The action, or ``None`` if the reference names a reusable workflow, a Docker image, a
3101 repository without a directory, or a local action outside a repository's ``.github``
3102 directory.
3103 :raises ValueError: If parameter 'uses' is ``None``.
3104 :raises TypeError: If parameter 'uses' is not of type :class:`UsesReference`.
3105 :raises WorkflowError: If the directory has neither an ``action.yml`` nor an ``action.yaml``. |br|
3106 The note names the reference's location.
3107 :raises WorkflowError: If the file is not a well-formed action.
3108 """
3109 if uses is None: 3109 ↛ 3110line 3109 didn't jump to line 3110 because the condition on line 3109 was never true
3110 raise ValueError("Parameter 'uses' is None.")
3111 elif not isinstance(uses, UsesReference): 3111 ↛ 3112line 3111 didn't jump to line 3112 because the condition on line 3111 was never true
3112 ex = TypeError("Parameter 'uses' is not of type 'UsesReference'.")
3113 ex.add_note(f"Got type '{getFullyQualifiedName(uses)}'.")
3114 raise ex
3116 if uses.IsWorkflow or uses._isDocker:
3117 return None
3119 if uses._isLocal:
3120 if uses._file is None:
3121 return None
3123 base = uses._file.parent
3124 elif (base := self._repositories.get(uses._repository.lower(), None)) is None:
3125 return None
3127 root = next((directory.parent for directory in (base, *base.parents) if directory.name == ".github"), None)
3128 if root is None:
3129 return None
3131 directory = root / uses._path
3132 for fileName in ("action.yml", "action.yaml"):
3133 if (path := directory / fileName).exists():
3134 return self.LoadAction(path)
3136 ex = WorkflowError(f"Action '{uses._path}' has no 'action.yml' in '{directory}'.", uses._file, uses._line)
3137 ex.add_note(f"Called as '{uses}'.")
3138 raise ex
3141@export
3142class DefinitionMixin(Generic[DefinitionType], metaclass=ExtendedType, mixin=True, expects=("_DEFINITION_TYPE",)):
3143 """
3144 Mixin-class for an element of :mod:`pyTooling.CI` built from a workflow file, linking it to its definition.
3146 :meth:`Workflow.ToPipeline` builds the elements, so a consumer of the generic model still reaches the facts only
3147 the file has: the line an element is written at, the reference a job calls, its permissions.
3148 """
3150 _definition: DefinitionType #: The element of the workflow file this element was built from.
3152 @classmethod
3153 def _CheckDefinition(cls, definition: DefinitionType) -> None:
3154 """
3155 Check a definition before the element is built from it.
3157 The host class names the element by its definition, so it checks the definition before calling
3158 ``super().__init__()``.
3160 :param definition: The element of the workflow file the element is built from.
3161 :raises ValueError: If parameter 'definition' is ``None``.
3162 :raises TypeError: If parameter 'definition' is not of the type the host class declares in
3163 :attr:`_DEFINITION_TYPE`.
3164 """
3165 if definition is None:
3166 raise ValueError("Parameter 'definition' is None.")
3167 elif not isinstance(definition, cls._DEFINITION_TYPE):
3168 ex = TypeError(f"Parameter 'definition' is not of type '{cls._DEFINITION_TYPE.__name__}'.")
3169 ex.add_note(f"Got type '{getFullyQualifiedName(definition)}'.")
3170 raise ex
3172 def __init__(self, definition: DefinitionType) -> None:
3173 """
3174 Initializes the link of an element to its definition, which :meth:`_CheckDefinition` checked.
3176 :param definition: The element of the workflow file this element is built from.
3177 """
3178 self._definition = definition
3180 @readonly
3181 def Definition(self) -> DefinitionType:
3182 """
3183 Read-only property to access the element of the workflow file this element was built from (:attr:`_definition`).
3185 :returns: The :class:`Workflow` of a pipeline, the :class:`Job` of a called workflow, a matrix, a matrix instance
3186 and a job, or the :class:`Step` of a step.
3187 """
3188 return self._definition
3191@export
3192class CallMixin(metaclass=ExtendedType, mixin=True):
3193 """Mixin-class for a called workflow built from a workflow file, holding the workflow file it was expanded from."""
3195 _calledWorkflow: Nullable[Workflow] #: The workflow file the called workflow's elements were built from.
3197 def __init__(self, calledWorkflow: Nullable[Workflow] = None) -> None:
3198 """
3199 Initializes the called workflow file.
3201 :param calledWorkflow: Optional, the workflow file the called workflow's elements are built from. Default:
3202 ``None``.
3203 :raises TypeError: If parameter 'calledWorkflow' is not of type :class:`Workflow`.
3204 """
3205 if calledWorkflow is not None and not isinstance(calledWorkflow, Workflow): 3205 ↛ 3206line 3205 didn't jump to line 3206 because the condition on line 3205 was never true
3206 ex = TypeError("Parameter 'calledWorkflow' is not of type 'Workflow'.")
3207 ex.add_note(f"Got type '{getFullyQualifiedName(calledWorkflow)}'.")
3208 raise ex
3210 self._calledWorkflow = calledWorkflow
3212 @readonly
3213 def CalledWorkflow(self) -> Nullable[Workflow]:
3214 """
3215 Read-only property to access the workflow file the called workflow was expanded from (:attr:`_calledWorkflow`).
3217 :returns: The workflow, or ``None`` if the call wasn't expanded - its file isn't at hand, or the depth was used
3218 up.
3219 """
3220 return self._calledWorkflow
3223@export
3224class DefinedPipeline(CIPipeline, DefinitionMixin[Workflow]):
3225 """The pipeline a workflow file defines, as :meth:`Workflow.ToPipeline` builds it."""
3227 _DEFINITION_TYPE: ClassVar[type] = Workflow #: A pipeline is built from a workflow file.
3229 def __init__(
3230 self,
3231 definition: Workflow,
3232 *,
3233 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None
3234 ) -> None:
3235 """
3236 Initializes a pipeline built from a workflow file, named by the file's stem.
3238 :param definition: The workflow file.
3239 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
3240 """
3241 self._CheckDefinition(definition)
3243 super().__init__(definition._name, keyValuePairs=keyValuePairs)
3244 DefinitionMixin.__init__(self, definition)
3247@export
3248class DefinedWorkflow(CIWorkflow, CallMixin, DefinitionMixin[Job]):
3249 """A called workflow built from the job calling it, as :meth:`Workflow.ToPipeline` builds it."""
3251 _DEFINITION_TYPE: ClassVar[type] = Job #: A called workflow is built from the job calling it.
3253 def __init__(
3254 self,
3255 definition: Job,
3256 *,
3257 calledWorkflow: Nullable[Workflow] = None,
3258 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
3259 parent: Nullable[CIWorkflow] = None
3260 ) -> None:
3261 """
3262 Initializes a called workflow built from the job calling it, named by the job's key.
3264 The job's ``uses`` is the workflow's :attr:`~pyTooling.CI.Workflow.Reference`, its ``if`` the
3265 :attr:`~pyTooling.CI.Rule.Condition` of the workflow's one rule in :attr:`~pyTooling.CI.RulesMixin.Rules`.
3267 :param definition: The job calling the workflow.
3268 :param calledWorkflow: Optional, the workflow file the called workflow's elements are built from. Default:
3269 ``None``.
3270 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
3271 :param parent: Optional, reference to the workflow containing the call. Default: ``None``.
3272 :raises ValueError: If parameter 'definition' calls no workflow.
3273 """
3274 self._CheckDefinition(definition)
3276 if definition._uses is None: 3276 ↛ 3277line 3276 didn't jump to line 3277 because the condition on line 3276 was never true
3277 ex = ValueError("Parameter 'definition' calls no workflow.")
3278 ex.add_note(f"Got job '{definition._name}'.")
3279 raise ex
3281 super().__init__(
3282 definition._name, reference=str(definition._uses),
3283 rules=None if definition._condition is None else [Rule(definition._condition)],
3284 keyValuePairs=keyValuePairs, parent=parent
3285 )
3286 DefinitionMixin.__init__(self, definition)
3287 CallMixin.__init__(self, calledWorkflow)
3290@export
3291class DefinedMatrix(CIMatrix, DefinitionMixin[Job]):
3292 """
3293 A matrix built from the job declaring it, as :meth:`Workflow.ToPipeline` builds it.
3295 A dynamic matrix - see :attr:`Matrix.IsDynamic` - holds no instances, since its combinations are known at run time
3296 only.
3297 """
3299 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix is built from the job declaring it.
3301 def __init__(
3302 self,
3303 definition: Job,
3304 *,
3305 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
3306 parent: Nullable[CIWorkflow] = None
3307 ) -> None:
3308 """
3309 Initializes a matrix built from the job declaring it, named by the job's key.
3311 :param definition: The job declaring the matrix.
3312 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
3313 :param parent: Optional, reference to the workflow containing the matrix. Default: ``None``.
3314 :raises ValueError: If parameter 'definition' declares no matrix.
3315 """
3316 self._CheckDefinition(definition)
3318 if definition._matrix is None:
3319 ex = ValueError("Parameter 'definition' declares no matrix.")
3320 ex.add_note(f"Got job '{definition._name}'.")
3321 raise ex
3323 super().__init__(
3324 definition._name,
3325 rules=None if definition._condition is None else [Rule(definition._condition)],
3326 keyValuePairs=keyValuePairs, parent=parent
3327 )
3328 DefinitionMixin.__init__(self, definition)
3331@export
3332class DefinedMatrixWorkflow(CIMatrixWorkflow, CallMixin, DefinitionMixin[Job]):
3333 """One instance of a matrix calling a reusable workflow, as :meth:`Workflow.ToPipeline` builds it."""
3335 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix instance is built from the job declaring the matrix.
3337 def __init__(
3338 self,
3339 definition: Job,
3340 dimensions: Mapping[str, Any],
3341 *,
3342 calledWorkflow: Nullable[Workflow] = None,
3343 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
3344 parent: Nullable[CIMatrix] = None
3345 ) -> None:
3346 """
3347 Initializes one instance of a matrix calling a reusable workflow, named by the job's key.
3349 :param definition: The job declaring the matrix.
3350 :param dimensions: The matrix' combination this instance is called with, the values as GitHub prints them.
3351 :param calledWorkflow: Optional, the workflow file the called workflow's elements are built from. Default:
3352 ``None``.
3353 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
3354 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
3355 :raises ValueError: If parameter 'definition' calls no workflow.
3356 :raises ValueError: If parameter 'dimensions' is ``None``.
3357 """
3358 self._CheckDefinition(definition)
3360 if definition._uses is None: 3360 ↛ 3361line 3360 didn't jump to line 3361 because the condition on line 3360 was never true
3361 ex = ValueError("Parameter 'definition' calls no workflow.")
3362 ex.add_note(f"Got job '{definition._name}'.")
3363 raise ex
3364 elif dimensions is None:
3365 raise ValueError("Parameter 'dimensions' is None.")
3367 super().__init__(
3368 definition._name, dimensions, reference=str(definition._uses),
3369 rules=None if definition._condition is None else [Rule(definition._condition)],
3370 keyValuePairs=keyValuePairs, parent=parent
3371 )
3372 DefinitionMixin.__init__(self, definition)
3373 CallMixin.__init__(self, calledWorkflow)
3376@export
3377class DefinedJob(CIJob, DefinitionMixin[Job]):
3378 """A job running steps, as :meth:`Workflow.ToPipeline` builds it."""
3380 _DEFINITION_TYPE: ClassVar[type] = Job #: A job is built from its job in the workflow file.
3382 def __init__(
3383 self,
3384 definition: Job,
3385 *,
3386 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
3387 parent: Nullable[JobGroup] = None
3388 ) -> None:
3389 """
3390 Initializes a job built from its job in the workflow file, named by the job's key.
3392 :param definition: The job.
3393 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
3394 :param parent: Optional, reference to the group containing the job. Default: ``None``.
3395 """
3396 self._CheckDefinition(definition)
3398 super().__init__(
3399 definition._name,
3400 rules=None if definition._condition is None else [Rule(definition._condition)],
3401 keyValuePairs=keyValuePairs, parent=parent
3402 )
3403 DefinitionMixin.__init__(self, definition)
3406@export
3407class DefinedMatrixJob(CIMatrixJob, DefinitionMixin[Job]):
3408 """One instance of a matrix running steps, as :meth:`Workflow.ToPipeline` builds it."""
3410 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix instance is built from the job declaring the matrix.
3412 def __init__(
3413 self,
3414 definition: Job,
3415 dimensions: Mapping[str, Any],
3416 *,
3417 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
3418 parent: Nullable[CIMatrix] = None
3419 ) -> None:
3420 """
3421 Initializes one instance of a matrix running steps, named by the job's key.
3423 :param definition: The job declaring the matrix.
3424 :param dimensions: The matrix' combination this instance runs with, the values as GitHub prints them.
3425 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
3426 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
3427 :raises ValueError: If parameter 'dimensions' is ``None``.
3428 """
3429 self._CheckDefinition(definition)
3431 if dimensions is None:
3432 raise ValueError("Parameter 'dimensions' is None.")
3434 super().__init__(
3435 definition._name, dimensions,
3436 rules=None if definition._condition is None else [Rule(definition._condition)],
3437 keyValuePairs=keyValuePairs, parent=parent
3438 )
3439 DefinitionMixin.__init__(self, definition)
3442@export
3443class DefinedStep(CIStep, DefinitionMixin[Step]):
3444 """A step of a job, as :meth:`Workflow.ToPipeline` builds it."""
3446 _DEFINITION_TYPE: ClassVar[type] = Step #: A step is built from its step in the workflow file.
3448 def __init__(
3449 self,
3450 definition: Step,
3451 *,
3452 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
3453 parent: Nullable[CIJob] = None
3454 ) -> None:
3455 """
3456 Initializes a step built from its step in the workflow file.
3458 The step is named as GitHub displays it: by its ``name``, or else ``Run`` followed by the action it runs or the
3459 first line of its script.
3461 :param definition: The step.
3462 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
3463 :param parent: Optional, reference to the job containing the step. Default: ``None``.
3464 """
3465 self._CheckDefinition(definition)
3467 if definition._name is not None:
3468 name = definition._name
3469 elif definition._uses is not None:
3470 name = f"Run {definition._uses}"
3471 elif definition._run is not None: 3471 ↛ 3475line 3471 didn't jump to line 3475 because the condition on line 3471 was always true
3472 firstLine = definition._run.strip().partition("\n")[0]
3473 name = f"Run {firstLine}"
3474 else:
3475 name = f"Step at line {definition._line}"
3477 super().__init__(
3478 name,
3479 rules=None if definition._condition is None else [Rule(definition._condition)],
3480 keyValuePairs=keyValuePairs, parent=parent
3481 )
3482 DefinitionMixin.__init__(self, definition)