Coverage for pyTooling/GitHub/WorkflowFile.py: 88%
1485 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-04 09:05 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-04 09:05 +0000
1# ==================================================================================================================== #
2# _____ _ _ ____ _ _ _ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___(_) |_| | | |_ _| |__ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` || | _| | __| |_| | | | | '_ \ #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| || |_| | | |_| _ | |_| | |_) | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____|_|\__|_| |_|\__,_|_.__/ #
7# |_| |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32A data model of a GitHub Actions workflow file.
34A workflow file is read once into objects:
36.. code-block:: text
38 Workflow a workflow file, e.g. '.github/workflows/CompletePipeline.yml'
39 +-- Input an input of 'on.workflow_call'
40 +-- Output an output of 'on.workflow_call'
41 +-- Secret a secret of 'on.workflow_call'
42 +-- Permission a permission the workflow declares
43 +-- Job a job, in file order
44 +-- UsesReference the reusable workflow the job calls
45 +-- Permission a permission the job declares
46 +-- Matrix the job's 'strategy.matrix'
47 +-- Step a step of the job
48 +-- UsesReference the action the step runs
50 Action an action's file, e.g. '.github/actions/ComputeRequirements/action.yml'
51 +-- Step a step of a composite action
52 +-- UsesReference the action the step runs
54Every element knows its parent, the workflow it belongs to, the file it was read from - a workflow's or an action's -
55and the line it starts at, so a consumer can name the place a finding comes from, as ``CompletePipeline.yml:552``.
57:class:`WorkflowResolver` reads the reusable workflows a job calls and the actions a step runs, as far as they are in a
58local directory.
60The model is independent of :mod:`pyTooling.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, Optional as Nullable
72from typing import Self, TypeVar, Union
74from pyTooling.CI import CIError, DependencyMixin, JobGroup, Matrix as CIMatrix, MatrixInstanceMixin
75from pyTooling.CI import MatrixJob as CIMatrixJob, MatrixWorkflow as CIMatrixWorkflow
76from pyTooling.CI import Job as CIJob, 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 = []
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 self._steps.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 = []
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 self._steps.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, as :attr:`PermissionScope.Contents`, or :attr:`PermissionScope.All` for ``read-all`` and
2479 ``write-all``.
2480 """
2481 return self._scope
2483 @readonly
2484 def Level(self) -> AccessLevel:
2485 """
2486 Read-only property to access the access granted (:attr:`_level`).
2488 :returns: The access level.
2489 """
2490 return self._level
2492 def __str__(self) -> str:
2493 """
2494 Return the permission, as written in a workflow file.
2496 :returns: The permission, as ``contents: write``, or ``read-all`` for scope :attr:`PermissionScope.All`.
2497 """
2498 if self._scope is PermissionScope.All:
2499 return f"{self._level.value}-all"
2501 return f"{self._scope}: {self._level.value}"
2503 @classmethod
2504 def _FromYAML(cls, value: Any, path: Path, line: int) -> list[Self]:
2505 """
2506 Read the value of a ``permissions`` key into the permissions a workflow or job declares.
2508 :param value: The value of the ``permissions`` key.
2509 :param path: Path to the workflow file.
2510 :param line: Line the key is written at, starting at 1.
2511 :returns: The permissions, in file order.
2512 :raises WorkflowError: If the value is neither ``read-all``, ``write-all`` nor a mapping.
2513 :raises WorkflowError: If a key is not a permission scope. |br|
2514 The note lists the allowed values.
2515 :raises WorkflowError: If a scope's value is not an access level. |br|
2516 The note lists the allowed values.
2517 """
2518 if value == "read-all":
2519 return [cls(PermissionScope.All, AccessLevel.Read, line)]
2520 elif value == "write-all":
2521 return [cls(PermissionScope.All, AccessLevel.Write, line)]
2522 elif not isinstance(value, CommentedMap):
2523 ex = WorkflowError("Key 'permissions' is neither 'read-all', 'write-all' nor a mapping.", path, line)
2524 ex.add_note(f"Got '{value}'." if isinstance(value, str) else f"Got type '{getFullyQualifiedName(value)}'.")
2525 raise ex
2527 permissions = []
2528 for scope, level in value.items():
2529 scopeLine = Base._KeyLine(value, scope)
2530 try:
2531 permissionScope = PermissionScope(scope)
2532 except ValueError as cause:
2533 ex = WorkflowError(f"Key '{scope}' of 'permissions' is not a permission scope.", path, scopeLine)
2534 scopes = (member.value for member in PermissionScope if member is not PermissionScope.All)
2535 ex.add_note(f"Allowed values: {', '.join(scopes)}.")
2536 raise ex from cause
2538 try:
2539 accessLevel = AccessLevel(level)
2540 except ValueError as cause:
2541 ex = WorkflowError(f"Permission '{scope}' is not an access level.", path, scopeLine)
2542 ex.add_note(f"Got '{level}'.")
2543 ex.add_note(f"Allowed values: {', '.join(member.value for member in AccessLevel)}.")
2544 raise ex from cause
2546 permissions.append(cls(permissionScope, accessLevel, scopeLine))
2548 return permissions
2551@export
2552@abstractclass
2553class Parameter(Base[Workflow]):
2554 """
2555 Common behaviour of the inputs, outputs and secrets of a reusable workflow.
2557 Every parameter has a name and an optional description, and belongs to a :class:`Workflow`.
2558 """
2560 _PARENT_TYPE: ClassVar[ParentTypes] = Workflow #: A parameter is declared by a workflow.
2562 _name: str #: Name of the parameter.
2563 _description: Nullable[str] #: Description of the parameter.
2565 def __init__(
2566 self,
2567 name: str,
2568 line: int,
2569 description: Nullable[str] = None,
2570 *,
2571 parent: Nullable[Workflow] = None
2572 ) -> None:
2573 """
2574 Initializes a parameter of a reusable workflow.
2576 :param name: Name of the parameter.
2577 :param line: Line the parameter's name is written at, starting at 1.
2578 :param description: Optional, description of the parameter. Default: ``None``.
2579 :param parent: Optional, reference to the workflow declaring it. Default: ``None``.
2580 :raises ValueError: If parameter 'name' is ``None``.
2581 :raises TypeError: If parameter 'name' is not of type :class:`str`.
2582 :raises ValueError: If parameter 'name' is empty.
2583 :raises TypeError: If parameter 'description' is not of type :class:`str`.
2584 """
2585 super().__init__(line, parent=parent)
2587 if name is None: 2587 ↛ 2588line 2587 didn't jump to line 2588 because the condition on line 2587 was never true
2588 raise ValueError("Parameter 'name' is None.")
2589 elif not isinstance(name, str): 2589 ↛ 2590line 2589 didn't jump to line 2590 because the condition on line 2589 was never true
2590 ex = TypeError("Parameter 'name' is not of type 'str'.")
2591 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
2592 raise ex
2593 elif name == "": 2593 ↛ 2594line 2593 didn't jump to line 2594 because the condition on line 2593 was never true
2594 raise ValueError("Parameter 'name' is empty.")
2596 if description is not None and not isinstance(description, str): 2596 ↛ 2597line 2596 didn't jump to line 2597 because the condition on line 2596 was never true
2597 ex = TypeError("Parameter 'description' is not of type 'str'.")
2598 ex.add_note(f"Got type '{getFullyQualifiedName(description)}'.")
2599 raise ex
2601 self._name = name
2602 self._description = description
2604 @readonly
2605 def Name(self) -> str:
2606 """
2607 Read-only property to access the parameter's name (:attr:`_name`).
2609 :returns: Name of the parameter.
2610 """
2611 return self._name
2613 @readonly
2614 def Description(self) -> Nullable[str]:
2615 """
2616 Read-only property to access the parameter's description (:attr:`_description`).
2618 :returns: The description, or ``None`` if the workflow gives none.
2619 """
2620 return self._description
2622 def __str__(self) -> str:
2623 """
2624 Return the parameter's name.
2626 :returns: Name of the parameter.
2627 """
2628 return self._name
2631@export
2632class Input(Parameter):
2633 """An input of a reusable workflow, declared in ``on.workflow_call.inputs``."""
2635 _type: InputType #: Type of the input.
2636 _required: bool #: ``True``, if a caller has to pass the input.
2637 _default: ValueT #: Value of the input, if a caller doesn't pass it.
2639 def __init__(
2640 self,
2641 name: str,
2642 line: int,
2643 inputType: InputType,
2644 required: bool = False,
2645 default: ValueT = None,
2646 description: Nullable[str] = None,
2647 *,
2648 parent: Nullable[Workflow] = None
2649 ) -> None:
2650 """
2651 Initializes an input of a reusable workflow.
2653 :param name: Name of the input.
2654 :param line: Line the input's name is written at, starting at 1.
2655 :param inputType: Type of the input.
2656 :param required: Optional, ``True``, if a caller has to pass the input. Default: ``False``.
2657 :param default: Optional, value of the input, if a caller doesn't pass it. Default: ``None``.
2658 :param description: Optional, description of the input. Default: ``None``.
2659 :param parent: Optional, reference to the workflow declaring it. Default: ``None``.
2660 :raises ValueError: If parameter 'inputType' is ``None``.
2661 :raises TypeError: If parameter 'inputType' is not of type :class:`InputType`.
2662 :raises TypeError: If parameter 'required' is not of type :class:`bool`.
2663 """
2664 super().__init__(name, line, description, parent=parent)
2666 if inputType is None:
2667 raise ValueError("Parameter 'inputType' is None.")
2668 elif not isinstance(inputType, InputType):
2669 ex = TypeError("Parameter 'inputType' is not of type 'InputType'.")
2670 ex.add_note(f"Got type '{getFullyQualifiedName(inputType)}'.")
2671 raise ex
2673 if not isinstance(required, bool): 2673 ↛ 2674line 2673 didn't jump to line 2674 because the condition on line 2673 was never true
2674 ex = TypeError("Parameter 'required' is not of type 'bool'.")
2675 ex.add_note(f"Got type '{getFullyQualifiedName(required)}'.")
2676 raise ex
2678 self._type = inputType
2679 self._required = required
2680 self._default = default
2682 if parent is not None: 2682 ↛ 2683line 2682 didn't jump to line 2683 because the condition on line 2682 was never true
2683 parent._inputs[name] = self
2685 @readonly
2686 def Type(self) -> InputType:
2687 """
2688 Read-only property to access the input's type (:attr:`_type`).
2690 :returns: Type of the input.
2691 """
2692 return self._type
2694 @readonly
2695 def Required(self) -> bool:
2696 """
2697 Read-only property to access whether a caller has to pass the input (:attr:`_required`).
2699 :returns: ``True``, if the input is required.
2700 """
2701 return self._required
2703 @readonly
2704 def Default(self) -> ValueT:
2705 """
2706 Read-only property to access the input's value, if a caller doesn't pass it (:attr:`_default`).
2708 The value keeps the type it is written with, as ``'3.14'`` or ``false``, and a multi-line value keeps its line
2709 breaks.
2711 :returns: The default value, or ``None`` if the workflow gives none.
2712 """
2713 return self._default
2715 @classmethod
2716 def _FromYAML(cls, name: str, declaration: Any, path: Path, line: int) -> Self:
2717 """
2718 Read an input's declaration below ``on.workflow_call.inputs``.
2721 :param name: Name of the input.
2722 :param declaration: The declaration.
2723 :param path: Path to the workflow file.
2724 :param line: Line the input's name is written at, starting at 1.
2725 :returns: The input.
2726 :raises WorkflowError: If the declaration is not a mapping.
2727 :raises WorkflowError: If key ``required`` is not a boolean.
2728 :raises WorkflowError: If the declaration has no ``type`` key.
2729 :raises WorkflowError: If key ``type`` is not an input type. |br|
2730 The note lists the allowed values.
2731 """
2732 if declaration is None:
2733 raise WorkflowError(f"Input '{name}' has no 'type' key.", path, line)
2734 elif not isinstance(declaration, CommentedMap):
2735 ex = WorkflowError(f"Declaration of '{name}' is not a mapping.", path, line)
2736 ex.add_note(f"Got type '{getFullyQualifiedName(declaration)}'.")
2737 raise ex
2739 description = declaration.get("description", None)
2740 required = Base._ToPython(declaration.get("required", False))
2741 if not isinstance(required, bool): 2741 ↛ 2742line 2741 didn't jump to line 2742 because the condition on line 2741 was never true
2742 ex = WorkflowError(f"Key 'required' of '{name}' is not a boolean.", path, line)
2743 ex.add_note(f"Got '{required}'.")
2744 raise ex
2746 if (inputType := declaration.get("type", None)) is None:
2747 raise WorkflowError(f"Input '{name}' has no 'type' key.", path, line)
2749 try:
2750 inputType = InputType.Parse(str(inputType))
2751 except ValueError as cause:
2752 ex = WorkflowError(f"Key 'type' of input '{name}' is not an input type.", path, line)
2753 ex.add_note(f"Got '{inputType}'.")
2754 ex.add_note(f"Allowed values: {', '.join(member.value for member in InputType)}.")
2755 raise ex from cause
2757 default = Base._ToPython(declaration.get("default", None))
2759 return cls(name, line, inputType, required, default, None if description is None else str(description))
2762@export
2763class Output(Parameter):
2764 """An output of a reusable workflow, declared in ``on.workflow_call.outputs``."""
2766 _value: str #: Expression the output's value is taken from.
2768 def __init__(
2769 self,
2770 name: str,
2771 line: int,
2772 value: str,
2773 description: Nullable[str] = None,
2774 *,
2775 parent: Nullable[Workflow] = None
2776 ) -> None:
2777 """
2778 Initializes an output of a reusable workflow.
2780 :param name: Name of the output.
2781 :param line: Line the output's name is written at, starting at 1.
2782 :param value: Expression the output's value is taken from, as ``${{ jobs.Build.outputs.version }}``.
2783 :param description: Optional, description of the output. Default: ``None``.
2784 :param parent: Optional, reference to the workflow declaring it. Default: ``None``.
2785 :raises ValueError: If parameter 'value' is ``None``.
2786 :raises TypeError: If parameter 'value' is not of type :class:`str`.
2787 """
2788 super().__init__(name, line, description, parent=parent)
2790 if value is None: 2790 ↛ 2791line 2790 didn't jump to line 2791 because the condition on line 2790 was never true
2791 raise ValueError("Parameter 'value' is None.")
2792 elif not isinstance(value, str): 2792 ↛ 2793line 2792 didn't jump to line 2793 because the condition on line 2792 was never true
2793 ex = TypeError("Parameter 'value' is not of type 'str'.")
2794 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
2795 raise ex
2797 self._value = value
2799 if parent is not None: 2799 ↛ 2800line 2799 didn't jump to line 2800 because the condition on line 2799 was never true
2800 parent._outputs[name] = self
2802 @readonly
2803 def Value(self) -> str:
2804 """
2805 Read-only property to access the expression the output's value is taken from (:attr:`_value`).
2807 :returns: The expression, as ``${{ jobs.Build.outputs.version }}``.
2808 """
2809 return self._value
2811 @classmethod
2812 def _FromYAML(cls, name: str, declaration: Any, path: Path, line: int) -> Self:
2813 """
2814 Read an output's declaration below ``on.workflow_call.outputs``.
2817 :param name: Name of the output.
2818 :param declaration: The declaration.
2819 :param path: Path to the workflow file.
2820 :param line: Line the output's name is written at, starting at 1.
2821 :returns: The output.
2822 :raises WorkflowError: If the declaration is not a mapping.
2823 :raises WorkflowError: If the declaration has no ``value`` key.
2824 """
2825 if declaration is None:
2826 raise WorkflowError(f"Output '{name}' has no 'value' key.", path, line)
2827 elif not isinstance(declaration, CommentedMap): 2827 ↛ 2828line 2827 didn't jump to line 2828 because the condition on line 2827 was never true
2828 ex = WorkflowError(f"Declaration of '{name}' is not a mapping.", path, line)
2829 ex.add_note(f"Got type '{getFullyQualifiedName(declaration)}'.")
2830 raise ex
2832 description = declaration.get("description", None)
2834 if (value := declaration.get("value", None)) is None:
2835 raise WorkflowError(f"Output '{name}' has no 'value' key.", path, line)
2837 return cls(name, line, str(value), None if description is None else str(description))
2840@export
2841class Secret(Parameter):
2842 """A secret of a reusable workflow, declared in ``on.workflow_call.secrets``."""
2844 _required: bool #: ``True``, if a caller has to pass the secret.
2846 def __init__(
2847 self,
2848 name: str,
2849 line: int,
2850 required: bool = False,
2851 description: Nullable[str] = None,
2852 *,
2853 parent: Nullable[Workflow] = None
2854 ) -> None:
2855 """
2856 Initializes a secret of a reusable workflow.
2858 :param name: Name of the secret.
2859 :param line: Line the secret's name is written at, starting at 1.
2860 :param required: Optional, ``True``, if a caller has to pass the secret. Default: ``False``.
2861 :param description: Optional, description of the secret. Default: ``None``.
2862 :param parent: Optional, reference to the workflow declaring it. Default: ``None``.
2863 :raises TypeError: If parameter 'required' is not of type :class:`bool`.
2864 """
2865 super().__init__(name, line, description, parent=parent)
2867 if not isinstance(required, bool): 2867 ↛ 2868line 2867 didn't jump to line 2868 because the condition on line 2867 was never true
2868 ex = TypeError("Parameter 'required' is not of type 'bool'.")
2869 ex.add_note(f"Got type '{getFullyQualifiedName(required)}'.")
2870 raise ex
2872 self._required = required
2874 if parent is not None: 2874 ↛ 2875line 2874 didn't jump to line 2875 because the condition on line 2874 was never true
2875 parent._secrets[name] = self
2877 @readonly
2878 def Required(self) -> bool:
2879 """
2880 Read-only property to access whether a caller has to pass the secret (:attr:`_required`).
2882 :returns: ``True``, if the secret is required.
2883 """
2884 return self._required
2886 @classmethod
2887 def _FromYAML(cls, name: str, declaration: Any, path: Path, line: int) -> Self:
2888 """
2889 Read a secret's declaration below ``on.workflow_call.secrets``.
2892 :param name: Name of the secret.
2893 :param declaration: The declaration.
2894 :param path: Path to the workflow file.
2895 :param line: Line the secret's name is written at, starting at 1.
2896 :returns: The secret.
2897 :raises WorkflowError: If the declaration is not a mapping.
2898 :raises WorkflowError: If key ``required`` is not a boolean.
2899 """
2900 if declaration is None:
2901 return cls(name, line)
2902 elif not isinstance(declaration, CommentedMap): 2902 ↛ 2903line 2902 didn't jump to line 2903 because the condition on line 2902 was never true
2903 ex = WorkflowError(f"Declaration of '{name}' is not a mapping.", path, line)
2904 ex.add_note(f"Got type '{getFullyQualifiedName(declaration)}'.")
2905 raise ex
2907 description = declaration.get("description", None)
2908 required = Base._ToPython(declaration.get("required", False))
2909 if not isinstance(required, bool):
2910 ex = WorkflowError(f"Key 'required' of '{name}' is not a boolean.", path, line)
2911 ex.add_note(f"Got '{required}'.")
2912 raise ex
2914 return cls(name, line, required, None if description is None else str(description))
2917@export
2918class WorkflowResolver(metaclass=ExtendedType, slots=True):
2919 """
2920 Reads the reusable workflows jobs call, and the actions steps run, as far as they are in a local directory.
2922 A repository is mapped to the directory holding its workflow files, so a reference like
2923 ``pyTooling/Actions/.github/workflows/Package.yml@r8`` reads ``Package.yml`` from that directory, whatever its ref.
2924 A local reference like ``./.github/workflows/Package.yml`` reads the file next to the calling workflow's file.
2926 An action of a mapped repository, like ``pyTooling/Actions/.github/actions/ComputeRequirements@r8``, is read from
2927 the repository's root - the directory holding the ``.github`` directory the mapped directory is in. A local action,
2928 like ``./.github/actions/ComputeRequirements``, is read from the root of the calling workflow's or action's
2929 repository.
2931 Every file is read once; asking for it again returns the same :class:`Workflow` or :class:`Action`.
2932 """
2934 _repositories: dict[str, Path] #: Directories holding the workflow files, by repository in lower case.
2935 _workflows: dict[Path, Workflow] #: Workflows already read, by resolved path.
2936 _actions: dict[Path, Action] #: Actions already read, by resolved path.
2938 def __init__(self, repositories: Nullable[Mapping[str, Path]] = None) -> None:
2939 """
2940 Initializes a resolver.
2942 :param repositories: Optional, directories holding the workflow files, by repository, as
2943 ``{"pyTooling/Actions": Path(".github/workflows")}``. Default: ``None``.
2944 :raises TypeError: If parameter 'repositories' is not a mapping.
2945 :raises TypeError: If a key of parameter 'repositories' is not of type :class:`str`.
2946 :raises ValueError: If a key of parameter 'repositories' is not of the form ``owner/repo``.
2947 :raises TypeError: If a value of parameter 'repositories' is not of type :class:`~pathlib.Path`.
2948 """
2949 self._repositories = {}
2950 self._workflows = {}
2951 self._actions = {}
2953 if repositories is None:
2954 return
2955 elif not isinstance(repositories, Mapping): 2955 ↛ 2956line 2955 didn't jump to line 2956 because the condition on line 2955 was never true
2956 ex = TypeError("Parameter 'repositories' is not a mapping.")
2957 ex.add_note(f"Got type '{getFullyQualifiedName(repositories)}'.")
2958 raise ex
2960 for repository, directory in repositories.items():
2961 if not isinstance(repository, str): 2961 ↛ 2962line 2961 didn't jump to line 2962 because the condition on line 2961 was never true
2962 ex = TypeError("Key of parameter 'repositories' is not of type 'str'.")
2963 ex.add_note(f"Got type '{getFullyQualifiedName(repository)}'.")
2964 raise ex
2965 elif repository.count("/") != 1 or repository.startswith("/") or repository.endswith("/"):
2966 ex = ValueError("Key of parameter 'repositories' is not of the form 'owner/repo'.")
2967 ex.add_note(f"Got '{repository}'.")
2968 raise ex
2969 elif not isinstance(directory, Path):
2970 ex = TypeError(f"Value of parameter 'repositories' for '{repository}' is not of type 'Path'.")
2971 ex.add_note(f"Got type '{getFullyQualifiedName(directory)}'.")
2972 raise ex
2974 self._repositories[repository.lower()] = directory
2976 @readonly
2977 def Repositories(self) -> dict[str, Path]:
2978 """
2979 Read-only property to access the directories holding the workflow files (:attr:`_repositories`).
2981 :returns: The directories, by repository in lower case.
2982 """
2983 return self._repositories
2985 def CanResolve(self, uses: UsesReference) -> bool:
2986 """
2987 Return whether a reference names a file the resolver reads: a local one, or one of a mapped repository.
2989 :param uses: The reference, as :attr:`Job.Uses`.
2990 :returns: ``True``, if the reference is local, or its repository is in :attr:`Repositories`.
2991 :raises ValueError: If parameter 'uses' is ``None``.
2992 :raises TypeError: If parameter 'uses' is not of type :class:`UsesReference`.
2993 """
2994 if uses is None:
2995 raise ValueError("Parameter 'uses' is None.")
2996 elif not isinstance(uses, UsesReference):
2997 ex = TypeError("Parameter 'uses' is not of type 'UsesReference'.")
2998 ex.add_note(f"Got type '{getFullyQualifiedName(uses)}'.")
2999 raise ex
3001 return uses._isLocal or (uses._repository is not None and uses._repository.lower() in self._repositories)
3003 def Load(self, path: Path) -> Workflow:
3004 """
3005 Read a workflow file, or return it if it was read before.
3007 :param path: Path to the workflow file.
3008 :returns: The workflow.
3009 :raises ValueError: If parameter 'path' is ``None``.
3010 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`.
3011 :raises WorkflowError: If the file doesn't exist, can't be read, or is not a well-formed workflow.
3012 """
3013 if path is None: 3013 ↛ 3014line 3013 didn't jump to line 3014 because the condition on line 3013 was never true
3014 raise ValueError("Parameter 'path' is None.")
3015 elif not isinstance(path, Path): 3015 ↛ 3016line 3015 didn't jump to line 3016 because the condition on line 3015 was never true
3016 ex = TypeError("Parameter 'path' is not of type 'Path'.")
3017 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
3018 raise ex
3020 key = path.resolve()
3021 if (workflow := self._workflows.get(key, None)) is None:
3022 workflow = Workflow.FromFile(path)
3023 self._workflows[key] = workflow
3025 return workflow
3027 def Resolve(self, uses: UsesReference) -> Nullable[Workflow]:
3028 """
3029 Return the reusable workflow a reference names, if its file is in a local directory.
3031 :param uses: The reference, as :attr:`Job.Uses`.
3032 :returns: The workflow, or ``None`` if the reference names an action, or a repository without a
3033 directory.
3034 :raises ValueError: If parameter 'uses' is ``None``.
3035 :raises TypeError: If parameter 'uses' is not of type :class:`UsesReference`.
3036 :raises ValueError: If parameter 'uses' is a local reference outside a workflow.
3037 :raises WorkflowError: If the workflow file doesn't exist in the directory. |br|
3038 The note names the reference's location.
3039 :raises WorkflowError: If the file is not a well-formed workflow.
3040 """
3041 if uses is None: 3041 ↛ 3042line 3041 didn't jump to line 3042 because the condition on line 3041 was never true
3042 raise ValueError("Parameter 'uses' is None.")
3043 elif not isinstance(uses, UsesReference): 3043 ↛ 3044line 3043 didn't jump to line 3044 because the condition on line 3043 was never true
3044 ex = TypeError("Parameter 'uses' is not of type 'UsesReference'.")
3045 ex.add_note(f"Got type '{getFullyQualifiedName(uses)}'.")
3046 raise ex
3048 if not uses.IsWorkflow:
3049 return None
3050 elif uses._isLocal:
3051 if uses._workflow is None: 3051 ↛ 3052line 3051 didn't jump to line 3052 because the condition on line 3051 was never true
3052 ex = ValueError("Parameter 'uses' is a local reference outside a workflow.")
3053 ex.add_note(f"Got '{uses}'.")
3054 raise ex
3056 directory = uses._workflow._path.parent
3057 elif (directory := self._repositories.get(uses._repository.lower(), None)) is None:
3058 return None
3060 path = directory / uses.FileName
3061 if not path.exists():
3062 ex = WorkflowError(
3063 f"Workflow '{uses.FileName}' doesn't exist in '{directory}'.",
3064 uses._file,
3065 uses._line
3066 )
3067 ex.add_note(f"Called as '{uses}'.")
3068 raise ex
3070 return self.Load(path)
3072 def LoadAction(self, path: Path) -> Action:
3073 """
3074 Read an action's file, or return it if it was read before.
3076 :param path: Path to the action's file.
3077 :returns: The action.
3078 :raises ValueError: If parameter 'path' is ``None``.
3079 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`.
3080 :raises WorkflowError: If the file doesn't exist, can't be read, or is not a well-formed action.
3081 """
3082 if path is None: 3082 ↛ 3083line 3082 didn't jump to line 3083 because the condition on line 3082 was never true
3083 raise ValueError("Parameter 'path' is None.")
3084 elif not isinstance(path, Path): 3084 ↛ 3085line 3084 didn't jump to line 3085 because the condition on line 3084 was never true
3085 ex = TypeError("Parameter 'path' is not of type 'Path'.")
3086 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
3087 raise ex
3089 key = path.resolve()
3090 if (action := self._actions.get(key, None)) is None:
3091 action = Action.FromFile(path)
3092 self._actions[key] = action
3094 return action
3096 def ResolveAction(self, uses: UsesReference) -> Nullable[Action]:
3097 """
3098 Return the action a step's reference names, if its file is in a local directory.
3100 :param uses: The reference, as :attr:`Step.Uses`.
3101 :returns: The action, or ``None`` if the reference names a reusable workflow, a Docker image, a
3102 repository without a directory, or a local action outside a repository's ``.github``
3103 directory.
3104 :raises ValueError: If parameter 'uses' is ``None``.
3105 :raises TypeError: If parameter 'uses' is not of type :class:`UsesReference`.
3106 :raises WorkflowError: If the directory has neither an ``action.yml`` nor an ``action.yaml``. |br|
3107 The note names the reference's location.
3108 :raises WorkflowError: If the file is not a well-formed action.
3109 """
3110 if uses is None: 3110 ↛ 3111line 3110 didn't jump to line 3111 because the condition on line 3110 was never true
3111 raise ValueError("Parameter 'uses' is None.")
3112 elif not isinstance(uses, UsesReference): 3112 ↛ 3113line 3112 didn't jump to line 3113 because the condition on line 3112 was never true
3113 ex = TypeError("Parameter 'uses' is not of type 'UsesReference'.")
3114 ex.add_note(f"Got type '{getFullyQualifiedName(uses)}'.")
3115 raise ex
3117 if uses.IsWorkflow or uses._isDocker:
3118 return None
3120 if uses._isLocal:
3121 if uses._file is None:
3122 return None
3124 base = uses._file.parent
3125 elif (base := self._repositories.get(uses._repository.lower(), None)) is None:
3126 return None
3128 root = next((directory.parent for directory in (base, *base.parents) if directory.name == ".github"), None)
3129 if root is None:
3130 return None
3132 directory = root / uses._path
3133 for fileName in ("action.yml", "action.yaml"):
3134 if (path := directory / fileName).exists():
3135 return self.LoadAction(path)
3137 ex = WorkflowError(f"Action '{uses._path}' has no 'action.yml' in '{directory}'.", uses._file, uses._line)
3138 ex.add_note(f"Called as '{uses}'.")
3139 raise ex
3142@export
3143class DefinitionMixin(Generic[DefinitionType], metaclass=ExtendedType, mixin=True, expects=("_DEFINITION_TYPE",)):
3144 """
3145 Mixin-class for an element of :mod:`pyTooling.CI` built from a workflow file, linking it to its definition.
3147 :meth:`Workflow.ToPipeline` builds the elements, so a consumer of the generic model still reaches the facts only
3148 the file has: the line an element is written at, the reference a job calls, its permissions.
3149 """
3151 _definition: DefinitionType #: The element of the workflow file this element was built from.
3153 @classmethod
3154 def _CheckDefinition(cls, definition: DefinitionType) -> None:
3155 """
3156 Check a definition before the element is built from it.
3158 The host class names the element by its definition, so it checks the definition before calling
3159 ``super().__init__()``.
3161 :param definition: The element of the workflow file the element is built from.
3162 :raises ValueError: If parameter 'definition' is ``None``.
3163 :raises TypeError: If parameter 'definition' is not of the type the host class declares in
3164 :attr:`_DEFINITION_TYPE`.
3165 """
3166 if definition is None:
3167 raise ValueError("Parameter 'definition' is None.")
3168 elif not isinstance(definition, cls._DEFINITION_TYPE):
3169 ex = TypeError(f"Parameter 'definition' is not of type '{cls._DEFINITION_TYPE.__name__}'.")
3170 ex.add_note(f"Got type '{getFullyQualifiedName(definition)}'.")
3171 raise ex
3173 def __init__(self, definition: DefinitionType) -> None:
3174 """
3175 Initializes the link of an element to its definition, which :meth:`_CheckDefinition` checked.
3177 :param definition: The element of the workflow file this element is built from.
3178 """
3179 self._definition = definition
3181 @readonly
3182 def Definition(self) -> DefinitionType:
3183 """
3184 Read-only property to access the element of the workflow file this element was built from (:attr:`_definition`).
3186 :returns: The :class:`Workflow` of a pipeline, the :class:`Job` of a called workflow, a matrix, a matrix instance
3187 and a job, or the :class:`Step` of a step.
3188 """
3189 return self._definition
3192@export
3193class CallMixin(metaclass=ExtendedType, mixin=True):
3194 """Mixin-class for a called workflow built from a workflow file, holding the workflow file it was expanded from."""
3196 _calledWorkflow: Nullable[Workflow] #: The workflow file the called workflow's elements were built from.
3198 def __init__(self, calledWorkflow: Nullable[Workflow] = None) -> None:
3199 """
3200 Initializes the called workflow file.
3202 :param calledWorkflow: Optional, the workflow file the called workflow's elements are built from. Default:
3203 ``None``.
3204 :raises TypeError: If parameter 'calledWorkflow' is not of type :class:`Workflow`.
3205 """
3206 if calledWorkflow is not None and not isinstance(calledWorkflow, Workflow): 3206 ↛ 3207line 3206 didn't jump to line 3207 because the condition on line 3206 was never true
3207 ex = TypeError("Parameter 'calledWorkflow' is not of type 'Workflow'.")
3208 ex.add_note(f"Got type '{getFullyQualifiedName(calledWorkflow)}'.")
3209 raise ex
3211 self._calledWorkflow = calledWorkflow
3213 @readonly
3214 def CalledWorkflow(self) -> Nullable[Workflow]:
3215 """
3216 Read-only property to access the workflow file the called workflow was expanded from (:attr:`_calledWorkflow`).
3218 :returns: The workflow, or ``None`` if the call wasn't expanded - its file isn't at hand, or the depth was used
3219 up.
3220 """
3221 return self._calledWorkflow
3224@export
3225class DefinedPipeline(CIPipeline, DefinitionMixin[Workflow]):
3226 """The pipeline a workflow file defines, as :meth:`Workflow.ToPipeline` builds it."""
3228 _DEFINITION_TYPE: ClassVar[type] = Workflow #: A pipeline is built from a workflow file.
3230 def __init__(
3231 self,
3232 definition: Workflow,
3233 *,
3234 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None
3235 ) -> None:
3236 """
3237 Initializes a pipeline built from a workflow file, named by the file's stem.
3239 :param definition: The workflow file.
3240 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
3241 """
3242 self._CheckDefinition(definition)
3244 super().__init__(definition._name, keyValuePairs=keyValuePairs)
3245 DefinitionMixin.__init__(self, definition)
3248@export
3249class DefinedWorkflow(CIWorkflow, CallMixin, DefinitionMixin[Job]):
3250 """A called workflow built from the job calling it, as :meth:`Workflow.ToPipeline` builds it."""
3252 _DEFINITION_TYPE: ClassVar[type] = Job #: A called workflow is built from the job calling it.
3254 def __init__(
3255 self,
3256 definition: Job,
3257 *,
3258 calledWorkflow: Nullable[Workflow] = None,
3259 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
3260 parent: Nullable[CIWorkflow] = None
3261 ) -> None:
3262 """
3263 Initializes a called workflow built from the job calling it, named by the job's key.
3265 The job's ``uses`` is the workflow's :attr:`~pyTooling.CI.Workflow.Reference`, its ``if`` the
3266 workflow's :attr:`~pyTooling.CI.ConditionMixin.Condition`.
3268 :param definition: The job calling the workflow.
3269 :param calledWorkflow: Optional, the workflow file the called workflow's elements are built from. Default:
3270 ``None``.
3271 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
3272 :param parent: Optional, reference to the workflow containing the call. Default: ``None``.
3273 :raises ValueError: If parameter 'definition' calls no workflow.
3274 """
3275 self._CheckDefinition(definition)
3277 if definition._uses is None: 3277 ↛ 3278line 3277 didn't jump to line 3278 because the condition on line 3277 was never true
3278 ex = ValueError("Parameter 'definition' calls no workflow.")
3279 ex.add_note(f"Got job '{definition._name}'.")
3280 raise ex
3282 super().__init__(
3283 definition._name, reference=str(definition._uses), condition=definition._condition, keyValuePairs=keyValuePairs,
3284 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__(definition._name, condition=definition._condition, keyValuePairs=keyValuePairs, parent=parent)
3324 DefinitionMixin.__init__(self, definition)
3327@export
3328class DefinedMatrixWorkflow(CIMatrixWorkflow, CallMixin, DefinitionMixin[Job]):
3329 """One instance of a matrix calling a reusable workflow, as :meth:`Workflow.ToPipeline` builds it."""
3331 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix instance is built from the job declaring the matrix.
3333 def __init__(
3334 self,
3335 definition: Job,
3336 dimensions: Mapping[str, Any],
3337 *,
3338 calledWorkflow: Nullable[Workflow] = None,
3339 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
3340 parent: Nullable[CIMatrix] = None
3341 ) -> None:
3342 """
3343 Initializes one instance of a matrix calling a reusable workflow, named by the job's key.
3345 :param definition: The job declaring the matrix.
3346 :param dimensions: The matrix' combination this instance is called with, the values as GitHub prints them.
3347 :param calledWorkflow: Optional, the workflow file the called workflow's elements are built from. Default:
3348 ``None``.
3349 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
3350 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
3351 :raises ValueError: If parameter 'definition' calls no workflow.
3352 :raises ValueError: If parameter 'dimensions' is ``None``.
3353 """
3354 self._CheckDefinition(definition)
3356 if definition._uses is None: 3356 ↛ 3357line 3356 didn't jump to line 3357 because the condition on line 3356 was never true
3357 ex = ValueError("Parameter 'definition' calls no workflow.")
3358 ex.add_note(f"Got job '{definition._name}'.")
3359 raise ex
3360 elif dimensions is None:
3361 raise ValueError("Parameter 'dimensions' is None.")
3363 super().__init__(
3364 definition._name, dimensions, reference=str(definition._uses), condition=definition._condition,
3365 keyValuePairs=keyValuePairs, parent=parent
3366 )
3367 DefinitionMixin.__init__(self, definition)
3368 CallMixin.__init__(self, calledWorkflow)
3371@export
3372class DefinedJob(CIJob, DefinitionMixin[Job]):
3373 """A job running steps, as :meth:`Workflow.ToPipeline` builds it."""
3375 _DEFINITION_TYPE: ClassVar[type] = Job #: A job is built from its job in the workflow file.
3377 def __init__(
3378 self,
3379 definition: Job,
3380 *,
3381 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
3382 parent: Nullable[JobGroup] = None
3383 ) -> None:
3384 """
3385 Initializes a job built from its job in the workflow file, named by the job's key.
3387 :param definition: The job.
3388 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
3389 :param parent: Optional, reference to the group containing the job. Default: ``None``.
3390 """
3391 self._CheckDefinition(definition)
3393 super().__init__(definition._name, condition=definition._condition, keyValuePairs=keyValuePairs, parent=parent)
3394 DefinitionMixin.__init__(self, definition)
3397@export
3398class DefinedMatrixJob(CIMatrixJob, DefinitionMixin[Job]):
3399 """One instance of a matrix running steps, as :meth:`Workflow.ToPipeline` builds it."""
3401 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix instance is built from the job declaring the matrix.
3403 def __init__(
3404 self,
3405 definition: Job,
3406 dimensions: Mapping[str, Any],
3407 *,
3408 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
3409 parent: Nullable[CIMatrix] = None
3410 ) -> None:
3411 """
3412 Initializes one instance of a matrix running steps, named by the job's key.
3414 :param definition: The job declaring the matrix.
3415 :param dimensions: The matrix' combination this instance runs with, the values as GitHub prints them.
3416 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
3417 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``.
3418 :raises ValueError: If parameter 'dimensions' is ``None``.
3419 """
3420 self._CheckDefinition(definition)
3422 if dimensions is None:
3423 raise ValueError("Parameter 'dimensions' is None.")
3425 super().__init__(
3426 definition._name, dimensions, condition=definition._condition, keyValuePairs=keyValuePairs, parent=parent
3427 )
3428 DefinitionMixin.__init__(self, definition)
3431@export
3432class DefinedStep(CIStep, DefinitionMixin[Step]):
3433 """A step of a job, as :meth:`Workflow.ToPipeline` builds it."""
3435 _DEFINITION_TYPE: ClassVar[type] = Step #: A step is built from its step in the workflow file.
3437 def __init__(
3438 self,
3439 definition: Step,
3440 *,
3441 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None,
3442 parent: Nullable[CIJob] = None
3443 ) -> None:
3444 """
3445 Initializes a step built from its step in the workflow file.
3447 The step is named as GitHub displays it: by its ``name``, or else ``Run`` followed by the action it runs or the
3448 first line of its script.
3450 :param definition: The step.
3451 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``.
3452 :param parent: Optional, reference to the job containing the step. Default: ``None``.
3453 """
3454 self._CheckDefinition(definition)
3456 if definition._name is not None:
3457 name = definition._name
3458 elif definition._uses is not None:
3459 name = f"Run {definition._uses}"
3460 elif definition._run is not None: 3460 ↛ 3464line 3460 didn't jump to line 3464 because the condition on line 3460 was always true
3461 firstLine = definition._run.strip().partition("\n")[0]
3462 name = f"Run {firstLine}"
3463 else:
3464 name = f"Step at line {definition._line}"
3466 super().__init__(name, condition=definition._condition, keyValuePairs=keyValuePairs, parent=parent)
3467 DefinitionMixin.__init__(self, definition)