Coverage for pyTooling/CI/GitHub/WorkflowFile.py: 88%

1487 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-03 23:02 +0000

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. 

33 

34A workflow file is read once into objects: 

35 

36.. code-block:: text 

37 

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 

49 

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 

53 

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``. 

56 

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. 

59 

60The model is independent of :mod:`pyTooling.CI.GitHub`, which models a workflow *run* as the REST API reports it. 

61:meth:`Workflow.ToPipeline` builds the pipeline a workflow defines as a :mod:`pyTooling.CI` model, whose 

62elements link back to the jobs they were built from, and :meth:`Workflow.ApplyNeeds` gives a run the dependencies 

63its workflow file declares. 

64 

65:raises MissingDependencyError: If the 'github' extra isn't installed. 

66""" 

67from __future__ import annotations 

68 

69from functools import cached_property 

70from itertools import product 

71from json import dumps as json_dumps 

72from pathlib import Path, PurePosixPath 

73from typing import Any, ClassVar, Generic, Hashable, Iterable, Iterator, Mapping, Optional as Nullable 

74from typing import Self, TypeVar, Union 

75 

76from pyTooling.CI import CIError, DependencyMixin, JobGroup, Matrix as CIMatrix, MatrixInstanceMixin 

77from pyTooling.CI import MatrixJob as CIMatrixJob, MatrixWorkflow as CIMatrixWorkflow 

78from pyTooling.CI import Job as CIJob, Pipeline as CIPipeline, Step as CIStep, Workflow as CIWorkflow 

79from pyTooling.Common import getFullyQualifiedName, StringEnum 

80from pyTooling.Decorators import export, readonly 

81from pyTooling.Exceptions import MissingDependencyError 

82from pyTooling.MetaClasses import ExtendedType, abstractclass 

83 

84try: 

85 from ruamel.yaml import YAML, YAMLError 

86 from ruamel.yaml.comments import CommentedMap, CommentedSeq 

87 from ruamel.yaml.scalarbool import ScalarBoolean 

88 from ruamel.yaml.scalarfloat import ScalarFloat 

89 from ruamel.yaml.scalarint import ScalarInt 

90 from ruamel.yaml.scalarstring import ScalarString 

91except ImportError as ex: # pragma: no cover 

92 raise MissingDependencyError(dependency="ruamel.yaml", extra="github") from ex 

93 

94 

95__all__ = ["ValueT"] 

96 

97ValueT = Union[str, bool, int, float, None, list["ValueT"], dict[str, "ValueT"]] 

98"""A value read from a workflow file, converted to plain Python types.""" 

99 

100ParentType = TypeVar("ParentType", bound="Base") 

101"""A type variable for the type of an element's parent.""" 

102 

103ParentTypes = Nullable[Union[type, tuple[type, ...]]] 

104"""The type of :attr:`Base._PARENT_TYPE`: ``None``, a class, or a tuple of classes.""" 

105 

106DefinitionType = TypeVar("DefinitionType", bound="Base") 

107"""A type variable for the type of the workflow file's element an element of :mod:`pyTooling.CI` is built from.""" 

108 

109 

110@export 

111class WorkflowError(CIError): 

112 """ 

113 Base-exception of all exceptions raised by :mod:`pyTooling.CI.GitHub.WorkflowFile`. 

114 

115 The exception is raised for a workflow file that is not a well-formed workflow. It carries the file and the line 

116 the problem was found at in :attr:`Path` and :attr:`Line`, and names both in a note. 

117 """ 

118 

119 _path: Nullable[Path] #: Path to the workflow file. 

120 _line: Nullable[int] #: Line in the workflow file, starting at 1. 

121 

122 def __init__(self, message: str, path: Nullable[Path] = None, line: Nullable[int] = None) -> None: 

123 """ 

124 Initializes a workflow error and names the place it was found at in a note. 

125 

126 :param message: The exception's message. 

127 :param path: Optional, path to the workflow file. Default: ``None``. 

128 :param line: Optional, line in the workflow file, starting at 1. Default: ``None``. 

129 """ 

130 super().__init__(message) 

131 

132 self._path = path 

133 self._line = line 

134 

135 if path is not None: 

136 self.add_note(f"In '{path}'." if line is None else f"In '{path}:{line}'.") 

137 

138 @readonly 

139 def Path(self) -> Nullable[Path]: 

140 """ 

141 Read-only property to access the path to the workflow file (:attr:`_path`). 

142 

143 :returns: The path, or ``None`` if the problem isn't tied to a file. 

144 """ 

145 return self._path 

146 

147 @readonly 

148 def Line(self) -> Nullable[int]: 

149 """ 

150 Read-only property to access the line the problem was found at (:attr:`_line`). 

151 

152 :returns: The line, starting at 1, or ``None`` if the problem isn't tied to a line. 

153 """ 

154 return self._line 

155 

156 

157@export 

158class AccessLevel(StringEnum): 

159 """ 

160 The access a permission grants to the ``GITHUB_TOKEN``. 

161 

162 The members are declared from the least to the most access, so :meth:`Rank` orders them. 

163 """ 

164 

165 NoAccess = "none" #: No access. 

166 Read = "read" #: Read access. 

167 Write = "write" #: Read and write access. 

168 

169 @cached_property 

170 def Rank(self) -> int: 

171 """ 

172 Read-only property to return the member's position in the order of access, so levels can be compared. 

173 

174 It is computed once per member. 

175 

176 :returns: ``0`` for :attr:`NoAccess`, ``1`` for :attr:`Read`, ``2`` for :attr:`Write`. 

177 """ 

178 return list(AccessLevel).index(self) 

179 

180 

181@export 

182class PermissionScope(StringEnum): 

183 """ 

184 The scope a permission grants the ``GITHUB_TOKEN`` access to, as a key of ``permissions``. 

185 

186 :attr:`All` stands for every scope at once, as ``read-all`` and ``write-all`` grant it. 

187 """ 

188 

189 All = "*" #: Every scope, from ``read-all`` or ``write-all``. 

190 Actions = "actions" #: Workflows, runs and artifacts. 

191 ArtifactMetadata = "artifact-metadata" #: Storage records of artifacts. 

192 Attestations = "attestations" #: Artifact attestations. 

193 Checks = "checks" #: Check runs and check suites. 

194 CodeQuality = "code-quality" #: Code quality findings. 

195 Contents = "contents" #: Repository contents, commits, branches, tags and releases. 

196 Deployments = "deployments" #: Deployments. 

197 Discussions = "discussions" #: GitHub Discussions. 

198 IDToken = "id-token" #: An OpenID Connect token. 

199 Issues = "issues" #: Issues and their comments. 

200 Packages = "packages" #: GitHub Packages. 

201 Pages = "pages" #: GitHub Pages builds. 

202 PullRequests = "pull-requests" #: Pull requests. 

203 SecurityEvents = "security-events" #: Code scanning alerts. 

204 Statuses = "statuses" #: Commit statuses. 

205 VulnerabilityAlerts = "vulnerability-alerts" #: Dependabot alerts. 

206 

207 

208@export 

209class InputType(StringEnum): 

210 """The type of an input of a reusable workflow.""" 

211 

212 String = "string" #: A string. 

213 Boolean = "boolean" #: A boolean. 

214 Number = "number" #: A number. 

215 

216 

217@export 

218@abstractclass 

219class Base(Generic[ParentType], metaclass=ExtendedType, slots=True): 

220 """ 

221 Common behaviour of every element of a workflow file or an action's file. 

222 

223 Every element knows the element containing it, the workflow it belongs to, the file it was read from, and the line 

224 it starts at. 

225 """ 

226 

227 _PARENT_TYPE: ClassVar[ParentTypes] = None #: Type a parent must have, or ``None`` when the element has no parent. 

228 

229 _parent: Nullable[ParentType] #: Reference to the containing element. 

230 _workflow: Nullable[Workflow] #: Reference to the workflow this element belongs to. 

231 _file: Nullable[Path] #: Path to the file the element was read from: a workflow's or an action's file. 

232 _line: int #: Line the element starts at in its file, starting at 1. 

233 

234 def __init__(self, line: int, *, parent: Nullable[ParentType] = None) -> None: 

235 """ 

236 Initializes an element of a workflow file. 

237 

238 :param line: Line the element starts at in the workflow file, starting at 1. 

239 :param parent: Optional, reference to the containing element. Default: ``None``. 

240 :raises ValueError: If parameter 'line' is ``None``. 

241 :raises TypeError: If parameter 'line' is not of type :class:`int`. 

242 :raises ValueError: If parameter 'line' is not positive. 

243 :raises TypeError: If parameter 'parent' is not of the type this class declares in :attr:`_PARENT_TYPE`. 

244 """ 

245 if line is None: 245 ↛ 246line 245 didn't jump to line 246 because the condition on line 245 was never true

246 raise ValueError("Parameter 'line' is None.") 

247 elif not isinstance(line, int) or isinstance(line, bool): 

248 ex = TypeError("Parameter 'line' is not of type 'int'.") 

249 ex.add_note(f"Got type '{getFullyQualifiedName(line)}'.") 

250 raise ex 

251 elif line < 1: 

252 ex = ValueError("Parameter 'line' is not positive.") 

253 ex.add_note(f"Got value '{line}'.") 

254 raise ex 

255 

256 if parent is not None and not isinstance(parent, self._PARENT_TYPE): 

257 parentTypes = self._PARENT_TYPE if isinstance(self._PARENT_TYPE, tuple) else (self._PARENT_TYPE, ) 

258 ex = TypeError(f"Parameter 'parent' is not of type {' or '.join(f'{t.__name__!r}' for t in parentTypes)}.") 

259 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.") 

260 raise ex 

261 

262 self._parent = parent 

263 self._workflow = None if parent is None else parent._workflow 

264 self._file = None if parent is None else parent._file 

265 self._line = line 

266 

267 @property 

268 def Parent(self) -> Nullable[ParentType]: 

269 """ 

270 Property to access the containing element (:attr:`_parent`). 

271 

272 Assigning a parent attaches an element constructed before it: the element takes the parent's workflow and file, 

273 and so do the elements it contains. 

274 

275 :returns: The containing element, or ``None`` for a :class:`Workflow`. 

276 :raises ValueError: If ``None`` is assigned. 

277 :raises TypeError: If a parent is assigned to a :class:`Workflow` or an :class:`Action`, which have no parent. 

278 :raises TypeError: If an assigned value is not of the type this class declares in :attr:`_PARENT_TYPE`. 

279 """ 

280 return self._parent 

281 

282 @Parent.setter 

283 def Parent(self, value: ParentType) -> None: 

284 if value is None: 

285 raise ValueError("Parameter 'value' is None.") 

286 elif not isinstance(value, self._PARENT_TYPE): 

287 parentTypes = self._PARENT_TYPE if isinstance(self._PARENT_TYPE, tuple) else (self._PARENT_TYPE, ) 

288 ex = TypeError(f"Parameter 'value' is not of type {' or '.join(f'{t.__name__!r}' for t in parentTypes)}.") 

289 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.") 

290 raise ex 

291 

292 self._parent = value 

293 self._workflow = value._workflow 

294 self._file = value._file 

295 

296 @readonly 

297 def Workflow(self) -> Nullable[Workflow]: 

298 """ 

299 Read-only property to access the workflow this element belongs to (:attr:`_workflow`). 

300 

301 :returns: The workflow, or ``None`` for an element outside one. 

302 """ 

303 return self._workflow 

304 

305 @readonly 

306 def File(self) -> Nullable[Path]: 

307 """ 

308 Read-only property to access the file the element was read from (:attr:`_file`). 

309 

310 :returns: Path to the workflow's or the action's file, or ``None`` for an element outside both. 

311 """ 

312 return self._file 

313 

314 @readonly 

315 def Line(self) -> int: 

316 """ 

317 Read-only property to access the line the element starts at in its file (:attr:`_line`). 

318 

319 :returns: The line, starting at 1. 

320 """ 

321 return self._line 

322 

323 @readonly 

324 def Location(self) -> str: 

325 """ 

326 Read-only property to return the place the element is written at, for a message. 

327 

328 :returns: The file's name and the line, as ``CompletePipeline.yml:552`` or ``action.yml:12``, or ``line 552`` for 

329 an element outside a file. 

330 """ 

331 if self._file is None: 

332 return f"line {self._line}" 

333 

334 return f"{self._file.name}:{self._line}" 

335 

336 @staticmethod 

337 def _KeyLine(mapping: CommentedMap, key: str) -> int: 

338 """ 

339 Return the line a key of a mapping is written at. 

340 

341 :param mapping: The mapping read from the file. 

342 :param key: The key. 

343 :returns: The line, starting at 1. 

344 """ 

345 return mapping.lc.key(key)[0] + 1 

346 

347 @staticmethod 

348 def _ToPython(value: Any) -> ValueT: 

349 """ 

350 Convert a value read by ``ruamel.yaml`` into plain Python types. 

351 

352 The round-trip loader returns its own types for mappings, lists, block scalars, anchored booleans, and numbers 

353 written in another notation than a plain decimal. They derive from the Python types, but keep what they were 

354 read with - an anchored boolean even prints as ``0`` or ``1``. Every other value is a Python type already. 

355 

356 :param value: The value read from the file. 

357 :returns: The value as :class:`dict`, :class:`list`, :class:`str`, :class:`bool`, :class:`int`, 

358 :class:`float` or ``None``. 

359 """ 

360 if isinstance(value, CommentedMap): 

361 return {str(key): Base._ToPython(item) for key, item in value.items()} 

362 elif isinstance(value, CommentedSeq): 

363 return [Base._ToPython(item) for item in value] 

364 elif isinstance(value, ScalarBoolean): 

365 return bool(value) 

366 elif isinstance(value, ScalarInt): 

367 return int(value) 

368 elif isinstance(value, ScalarFloat): 

369 return float(value) 

370 elif isinstance(value, ScalarString): 

371 return str(value) 

372 

373 return value 

374 

375 

376@export 

377class Workflow(Base[None]): 

378 """ 

379 A GitHub Actions workflow file. 

380 

381 The workflow is named by its file's stem - ``CompletePipeline`` for ``CompletePipeline.yml`` - because that is how 

382 a caller names it in ``uses``; the ``name`` key is kept as :attr:`DisplayName`. 

383 """ 

384 

385 _path: Path #: Path to the workflow file. 

386 _name: str #: Name of the workflow, the file's stem. 

387 _displayName: Nullable[str] #: Name of the workflow, as GitHub displays it. 

388 _triggers: tuple[str, ...] #: Events triggering the workflow. 

389 _inputs: dict[str, Input] #: Inputs of ``on.workflow_call``, by name. 

390 _outputs: dict[str, Output] #: Outputs of ``on.workflow_call``, by name. 

391 _secrets: dict[str, Secret] #: Secrets of ``on.workflow_call``, by name. 

392 _permissions: Nullable[dict[PermissionScope, Permission]] #: Permissions the workflow declares, by scope. 

393 _jobs: dict[str, Job] #: Jobs of the workflow, by name, in file order. 

394 

395 def __init__( 

396 self, 

397 path: Path, 

398 displayName: Nullable[str] = None, 

399 triggers: Nullable[Iterable[str]] = None, 

400 inputs: Nullable[Iterable[Input]] = None, 

401 outputs: Nullable[Iterable[Output]] = None, 

402 secrets: Nullable[Iterable[Secret]] = None, 

403 permissions: Nullable[Iterable[Permission]] = None, 

404 jobs: Nullable[Iterable[Job]] = None 

405 ) -> None: 

406 """ 

407 Initializes a workflow. 

408 

409 An input, output, secret, permission or job is attached by passing it, or by constructing it with the workflow as 

410 parent. Use :meth:`FromFile` to read a workflow file. 

411 

412 :param path: Path to the workflow file. 

413 :param displayName: Optional, name of the workflow, as GitHub displays it. Default: ``None``. 

414 :param triggers: Optional, events triggering the workflow, as ``workflow_call``. Default: ``None``. 

415 :param inputs: Optional, inputs of ``on.workflow_call``, which are attached to the workflow. Default: ``None``. 

416 :param outputs: Optional, outputs of ``on.workflow_call``, which are attached to the workflow. Default: 

417 ``None``. 

418 :param secrets: Optional, secrets of ``on.workflow_call``, which are attached to the workflow. Default: 

419 ``None``. 

420 :param permissions: Optional, permissions the workflow declares for all its jobs, which are attached to the 

421 workflow. Default: ``None``, for a workflow without a ``permissions`` key. 

422 :param jobs: Optional, jobs, which are attached to the workflow. Default: ``None``. 

423 :raises ValueError: If parameter 'path' is ``None``. 

424 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`. 

425 :raises TypeError: If parameter 'displayName' is not of type :class:`str`. 

426 :raises TypeError: If an element of parameter 'triggers' is not of type :class:`str`. 

427 :raises TypeError: If an element of parameter 'inputs' is not of type :class:`Input`. 

428 :raises TypeError: If an element of parameter 'outputs' is not of type :class:`Output`. 

429 :raises TypeError: If an element of parameter 'secrets' is not of type :class:`Secret`. 

430 :raises TypeError: If an element of parameter 'permissions' is not of type :class:`Permission`. 

431 :raises TypeError: If an element of parameter 'jobs' is not of type :class:`Job`. 

432 """ 

433 super().__init__(1) 

434 

435 if path is None: 435 ↛ 436line 435 didn't jump to line 436 because the condition on line 435 was never true

436 raise ValueError("Parameter 'path' is None.") 

437 elif not isinstance(path, Path): 437 ↛ 438line 437 didn't jump to line 438 because the condition on line 437 was never true

438 ex = TypeError("Parameter 'path' is not of type 'Path'.") 

439 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.") 

440 raise ex 

441 

442 if displayName is not None and not isinstance(displayName, str): 442 ↛ 443line 442 didn't jump to line 443 because the condition on line 442 was never true

443 ex = TypeError("Parameter 'displayName' is not of type 'str'.") 

444 ex.add_note(f"Got type '{getFullyQualifiedName(displayName)}'.") 

445 raise ex 

446 

447 self._workflow = self 

448 self._file = path 

449 self._path = path 

450 self._name = path.stem 

451 self._displayName = displayName 

452 self._triggers = () 

453 self._inputs = {} 

454 self._outputs = {} 

455 self._secrets = {} 

456 self._permissions = None 

457 self._jobs = {} 

458 

459 if triggers is not None: 

460 self._triggers = tuple(triggers) 

461 for trigger in self._triggers: 

462 if not isinstance(trigger, str): 

463 ex = TypeError("An element of parameter 'triggers' is not of type 'str'.") 

464 ex.add_note(f"Got type '{getFullyQualifiedName(trigger)}'.") 

465 raise ex 

466 

467 for parameterName, elements, elementClass, container in ( 

468 ("inputs", inputs, Input, self._inputs), 

469 ("outputs", outputs, Output, self._outputs), 

470 ("secrets", secrets, Secret, self._secrets), 

471 ("jobs", jobs, Job, self._jobs) 

472 ): 

473 if elements is None: 

474 continue 

475 

476 for element in elements: 

477 if not isinstance(element, elementClass): 

478 ex = TypeError(f"An element of parameter '{parameterName}' is not of type '{elementClass.__name__}'.") 

479 ex.add_note(f"Got type '{getFullyQualifiedName(element)}'.") 

480 raise ex 

481 

482 container[element._name] = element 

483 element.Parent = self 

484 

485 if permissions is not None: 

486 self._permissions = {} 

487 for permission in permissions: 

488 if not isinstance(permission, Permission): 

489 ex = TypeError("An element of parameter 'permissions' is not of type 'Permission'.") 

490 ex.add_note(f"Got type '{getFullyQualifiedName(permission)}'.") 

491 raise ex 

492 

493 self._permissions[permission._scope] = permission 

494 permission.Parent = self 

495 

496 @Base.Parent.setter 

497 def Parent(self, value: None) -> None: 

498 ex = TypeError(f"A '{getFullyQualifiedName(self)}' has no parent.") 

499 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.") 

500 raise ex 

501 

502 @readonly 

503 def Path(self) -> Path: 

504 """ 

505 Read-only property to access the path to the workflow file (:attr:`_path`). 

506 

507 :returns: The path. 

508 """ 

509 return self._path 

510 

511 @readonly 

512 def Name(self) -> str: 

513 """ 

514 Read-only property to access the workflow's name, its file's stem (:attr:`_name`). 

515 

516 :returns: Name of the workflow, as ``CompletePipeline``. 

517 """ 

518 return self._name 

519 

520 @readonly 

521 def DisplayName(self) -> Nullable[str]: 

522 """ 

523 Read-only property to access the workflow's name, as GitHub displays it (:attr:`_displayName`). 

524 

525 :returns: The ``name`` key, as written, or ``None`` if the workflow gives none. 

526 """ 

527 return self._displayName 

528 

529 @readonly 

530 def Triggers(self) -> tuple[str, ...]: 

531 """ 

532 Read-only property to access the events triggering the workflow (:attr:`_triggers`). 

533 

534 :returns: The events, as ``workflow_call`` or ``push``, in the order the ``on`` key lists them. 

535 """ 

536 return self._triggers 

537 

538 @readonly 

539 def IsCallable(self) -> bool: 

540 """ 

541 Read-only property to return whether the workflow is a reusable workflow. 

542 

543 :returns: ``True``, if the workflow is triggered by ``workflow_call``. 

544 """ 

545 return "workflow_call" in self._triggers 

546 

547 @readonly 

548 def Inputs(self) -> dict[str, Input]: 

549 """ 

550 Read-only property to access the inputs of ``on.workflow_call`` (:attr:`_inputs`). 

551 

552 :returns: The inputs, by name, in file order. 

553 """ 

554 return self._inputs 

555 

556 @readonly 

557 def Outputs(self) -> dict[str, Output]: 

558 """ 

559 Read-only property to access the outputs of ``on.workflow_call`` (:attr:`_outputs`). 

560 

561 :returns: The outputs, by name, in file order. 

562 """ 

563 return self._outputs 

564 

565 @readonly 

566 def Secrets(self) -> dict[str, Secret]: 

567 """ 

568 Read-only property to access the secrets of ``on.workflow_call`` (:attr:`_secrets`). 

569 

570 :returns: The secrets, by name, in file order. 

571 """ 

572 return self._secrets 

573 

574 @readonly 

575 def Permissions(self) -> Nullable[dict[PermissionScope, Permission]]: 

576 """ 

577 Read-only property to access the permissions the workflow declares for all its jobs (:attr:`_permissions`). 

578 

579 :returns: The permissions, by scope, or ``None`` if the workflow has no ``permissions`` key. 

580 """ 

581 return self._permissions 

582 

583 @readonly 

584 def Jobs(self) -> dict[str, Job]: 

585 """ 

586 Read-only property to access the workflow's jobs (:attr:`_jobs`). 

587 

588 :returns: The jobs, by name, in file order. 

589 """ 

590 return self._jobs 

591 

592 def ToPipeline(self, resolver: Nullable[WorkflowResolver] = None, depth: Nullable[int] = None) -> DefinedPipeline: 

593 """ 

594 Build the service-independent model of the pipeline this workflow defines. 

595 

596 Every job becomes an element of :mod:`pyTooling.CI`, named by its key and linked to the job by 

597 :attr:`~DefinitionMixin.Definition`: 

598 

599 * a job running steps becomes a :class:`DefinedJob`, its steps :class:`DefinedStep`\\ s; 

600 * a job calling a reusable workflow becomes a :class:`DefinedWorkflow`, holding the elements of the called 

601 workflow, if the resolver reads it and the depth allows it; 

602 * a job with a ``strategy.matrix`` becomes a :class:`DefinedMatrix`, holding a :class:`DefinedMatrixJob` or a 

603 :class:`DefinedMatrixWorkflow` per combination of :attr:`Matrix.Combinations`. A dynamic matrix holds no 

604 instances, because its combinations are known at run time only. 

605 

606 The ``needs`` of the jobs become the elements' :attr:`~pyTooling.CI.DependencyMixin.Needs`, so 

607 :meth:`~pyTooling.CI.Workflow.ToGraph` converts the result into a graph. 

608 

609 :param resolver: Optional, the resolver reading the workflows the jobs call. Without it, called workflows 

610 are not expanded. Default: ``None``. 

611 :param depth: Optional, how many levels of called workflows to expand; ``0`` expands none, ``None`` 

612 every level. Default: ``None``. 

613 :returns: The pipeline. 

614 :raises TypeError: If parameter 'resolver' is not of type :class:`WorkflowResolver`. 

615 :raises TypeError: If parameter 'depth' is not of type :class:`int`. 

616 :raises ValueError: If parameter 'depth' is negative. 

617 :raises WorkflowError: If a workflow to expand doesn't exist, or is not a well-formed workflow. 

618 :raises WorkflowError: If a workflow to expand calls itself, directly or through others. 

619 :raises WorkflowError: If ``include`` or ``exclude`` of a matrix is not a list of mappings. 

620 """ 

621 if resolver is not None and not isinstance(resolver, WorkflowResolver): 

622 ex = TypeError("Parameter 'resolver' is not of type 'WorkflowResolver'.") 

623 ex.add_note(f"Got type '{getFullyQualifiedName(resolver)}'.") 

624 raise ex 

625 

626 if depth is not None and (not isinstance(depth, int) or isinstance(depth, bool)): 

627 ex = TypeError("Parameter 'depth' is not of type 'int'.") 

628 ex.add_note(f"Got type '{getFullyQualifiedName(depth)}'.") 

629 raise ex 

630 elif depth is not None and depth < 0: 

631 ex = ValueError("Parameter 'depth' is negative.") 

632 ex.add_note(f"Got value '{depth}'.") 

633 raise ex 

634 

635 def addElements(workflow: Workflow, group: CIWorkflow, level: int, callers: tuple[Workflow, ...]) -> None: 

636 """ 

637 Nested function for recursion. 

638 

639 :param workflow: The workflow whose jobs become elements. 

640 :param group: The group the elements are added to. 

641 :param level: How many levels of called workflows are expanded above this one. 

642 :param callers: The workflows expanded above this one, this one last. 

643 :raises WorkflowError: If a workflow to expand calls itself, directly or through others. 

644 """ 

645 elements: dict[str, DependencyMixin] = {} 

646 for job in workflow._jobs.values(): 

647 called = None 

648 if job._uses is not None and resolver is not None and (depth is None or level < depth): 

649 called = resolver.Resolve(job._uses) 

650 if called is not None and any(caller._path.resolve() == called._path.resolve() for caller in callers): 

651 ex = WorkflowError(f"Workflow '{called._name}' calls itself.", workflow._path, job._uses._line) 

652 ex.add_note(f"Calls: {' -> '.join(caller._name for caller in (*callers, called))}.") 

653 raise ex 

654 

655 if job._matrix is not None: 

656 element = DefinedMatrix(job, parent=group) 

657 if not job._matrix.IsDynamic: 

658 for combination in job._matrix.Combinations: 

659 dimensions = Matrix._FormatCombination(combination) 

660 if job._uses is None: 

661 instance = DefinedMatrixJob(job, dimensions, parent=element) 

662 for step in job._steps: 

663 DefinedStep(step, parent=instance) 

664 else: 

665 instance = DefinedMatrixWorkflow(job, dimensions, calledWorkflow=called, parent=element) 

666 if called is not None: 

667 addElements(called, instance, level + 1, (*callers, called)) 

668 elif job._uses is not None: 

669 element = DefinedWorkflow(job, calledWorkflow=called, parent=group) 

670 if called is not None: 

671 addElements(called, element, level + 1, (*callers, called)) 

672 else: 

673 element = DefinedJob(job, parent=group) 

674 for step in job._steps: 

675 DefinedStep(step, parent=element) 

676 

677 elements[job._name] = element 

678 

679 for job in workflow._jobs.values(): 

680 for need in job.Needs: 

681 elements[job._name].AddNeed(elements[need._name]) 

682 

683 pipeline = DefinedPipeline(self) 

684 addElements(self, pipeline, 0, (self, )) 

685 

686 return pipeline 

687 

688 def ApplyNeeds(self, pipeline: CIWorkflow, resolver: Nullable[WorkflowResolver] = None) -> list[str]: 

689 """ 

690 Give a run of this workflow the dependencies its jobs declare with ``needs``. 

691 

692 A run read from a service's API, as :class:`pyTooling.CI.GitHub.Pipeline`, knows no ``needs``. Each job of this 

693 workflow is looked up in the run by its display name, or else by its key, and gets as 

694 :attr:`~pyTooling.CI.DependencyMixin.Needs` the elements the jobs it needs were found as. A job calling 

695 a reusable workflow is followed into the called workflow of the run - into each instance, if it is a matrix -, 

696 as far as the resolver reads the called file. A dependency the run's element has already is kept once. 

697 

698 A job whose display name is an expression, as ``${{ matrix.os }} Tests``, can't be looked up, and is skipped. A 

699 job with a condition may have been skipped in the run, so it isn't reported when it is missing. 

700 

701 A run names a matrix instance's dimensions by position, as ``{"0": "ubuntu-26.04", "1": "3.14"}``. If the job 

702 declares a static matrix, an instance whose values are those of one of :attr:`Matrix.Combinations` gets that 

703 combination's names, ``{"os": "ubuntu-26.04", "python": "3.14"}``. The instances of a dynamic matrix, and an 

704 instance matching no combination, keep the positions. 

705 

706 :param pipeline: The run, or a called workflow of a run. 

707 :param resolver: Optional, the resolver reading the workflows the jobs call. Without it, called 

708 workflows are not followed. Default: ``None``. 

709 :returns: The qualified names of the jobs of this workflow, and of the workflows followed, 

710 missing in the run - as the run would name them -, in the order they were looked 

711 up. 

712 :raises ValueError: If parameter 'pipeline' is ``None``. 

713 :raises TypeError: If parameter 'pipeline' is not of type :class:`pyTooling.CI.Workflow`. 

714 :raises TypeError: If parameter 'resolver' is not of type :class:`WorkflowResolver`. 

715 :raises WorkflowError: If a workflow to follow doesn't exist, or is not a well-formed workflow. 

716 :raises WorkflowError: If ``include`` or ``exclude`` of a matrix is not a list of mappings. 

717 :raises NeedDependencyCycleError: If the needs of the run, with the needs added, form a cycle. 

718 """ 

719 if pipeline is None: 

720 raise ValueError("Parameter 'pipeline' is None.") 

721 elif not isinstance(pipeline, CIWorkflow): 

722 ex = TypeError("Parameter 'pipeline' is not of type 'Workflow'.") 

723 ex.add_note(f"Got type '{getFullyQualifiedName(pipeline)}'.") 

724 raise ex 

725 

726 if resolver is not None and not isinstance(resolver, WorkflowResolver): 

727 ex = TypeError("Parameter 'resolver' is not of type 'WorkflowResolver'.") 

728 ex.add_note(f"Got type '{getFullyQualifiedName(resolver)}'.") 

729 raise ex 

730 

731 missing: list[str] = [] 

732 

733 def apply(workflow: Workflow, group: CIWorkflow) -> None: 

734 """ 

735 Nested function for recursion. 

736 

737 :param workflow: The workflow whose jobs are looked up. 

738 :param group: The group of the run the jobs are looked up in. 

739 """ 

740 elements: dict[str, DependencyMixin] = {} 

741 for job in workflow._jobs.values(): 

742 if job._displayName is None: 

743 names = (job._name, ) 

744 elif "${{" in job._displayName: 

745 continue 

746 else: 

747 names = (job._displayName, job._name) 

748 

749 if (name := next((name for name in names if group.ContainsElement(name)), None)) is None: 

750 if job._condition is None: 750 ↛ 752line 750 didn't jump to line 752 because the condition on line 750 was always true

751 missing.append(names[0] if isinstance(group, CIPipeline) else f"{group.QualifiedName} / {names[0]}") 

752 continue 

753 

754 element = group.GetElement(name) 

755 elements[job._name] = element 

756 if job._matrix is not None and not job._matrix.IsDynamic and isinstance(element, CIMatrix): 

757 combinations = [Matrix._FormatCombination(combination) for combination in job._matrix.Combinations] 

758 for instance in element.Instances: 

759 if not isinstance(instance, MatrixInstanceMixin): 759 ↛ 760line 759 didn't jump to line 760 because the condition on line 759 was never true

760 continue 

761 

762 values = [str(value) for value in instance._dimensions.values()] 

763 if (names := next((c for c in combinations if list(c.values()) == values), None)) is not None: 

764 instance._dimensions = dict(zip(names, instance._dimensions.values())) 

765 

766 if job._uses is None or resolver is None or (called := resolver.Resolve(job._uses)) is None: 

767 continue 

768 elif isinstance(element, CIWorkflow): 768 ↛ 770line 768 didn't jump to line 770 because the condition on line 768 was always true

769 apply(called, element) 

770 elif isinstance(element, CIMatrix): 

771 for instance in element.Instances: 

772 if isinstance(instance, CIWorkflow): 

773 apply(called, instance) 

774 

775 for job in workflow._jobs.values(): 

776 if (element := elements.get(job._name, None)) is None: 

777 continue 

778 

779 for need in job.Needs: 

780 if (needed := elements.get(need._name, None)) is not None and needed not in element._needs: 

781 element.AddNeed(needed) 

782 

783 apply(self, pipeline) 

784 pipeline.Validate() 

785 

786 return missing 

787 

788 def IterateActions(self) -> Iterator[UsesReference]: 

789 """ 

790 Iterate the actions the workflow's steps run. 

791 

792 An action is yielded as often as a step runs it. The reusable workflows the jobs call are in :attr:`Job.Uses`. 

793 

794 :returns: An iterator over the actions, in file order. 

795 """ 

796 for job in self._jobs.values(): 

797 for step in job._steps: 

798 if step._uses is not None: 

799 yield step._uses 

800 

801 def CollectPermissions(self, resolver: Nullable[WorkflowResolver] = None) -> dict[PermissionScope, Permission]: 

802 """ 

803 Collect the permissions the workflow and its jobs declare, and those of the workflows its jobs call. 

804 

805 A called workflow can keep or reduce the permissions of the ``GITHUB_TOKEN``, never raise them, so what a 

806 workflow's jobs declare is what a caller has to grant. When several elements declare a scope, the permission 

807 granting the most access is returned, so its :attr:`~Base.Location` names where that access is asked for. 

808 

809 :param resolver: Optional, the resolver reading the workflows the jobs call. Without it, called workflows are 

810 not followed. Default: ``None``. 

811 :returns: The permissions, by scope, in the order they are first declared. 

812 :raises TypeError: If parameter 'resolver' is not of type :class:`WorkflowResolver`. 

813 """ 

814 if resolver is not None and not isinstance(resolver, WorkflowResolver): 814 ↛ 815line 814 didn't jump to line 815 because the condition on line 814 was never true

815 ex = TypeError("Parameter 'resolver' is not of type 'WorkflowResolver'.") 

816 ex.add_note(f"Got type '{getFullyQualifiedName(resolver)}'.") 

817 raise ex 

818 

819 collected: dict[PermissionScope, Permission] = {} 

820 visited: set[int] = set() 

821 

822 def collect(workflow: Workflow) -> None: 

823 """ 

824 Nested function for recursion. 

825 

826 :param workflow: The workflow whose permissions are collected. 

827 """ 

828 visited.add(id(workflow)) 

829 

830 declarations = [] if workflow._permissions is None else [workflow._permissions] 

831 for job in workflow._jobs.values(): 

832 if job._permissions is not None: 

833 declarations.append(job._permissions) 

834 

835 for permissions in declarations: 

836 for scope, permission in permissions.items(): 

837 if (known := collected.get(scope, None)) is None or permission._level.Rank > known._level.Rank: 

838 collected[scope] = permission 

839 

840 if resolver is not None: 

841 for job in workflow._jobs.values(): 

842 if job._uses is None or (called := resolver.Resolve(job._uses)) is None: 

843 continue 

844 elif id(called) not in visited: 

845 collect(called) 

846 

847 collect(self) 

848 

849 return collected 

850 

851 @readonly 

852 def JobCount(self) -> int: 

853 """ 

854 Read-only property to return the number of jobs of the workflow. 

855 

856 :returns: Number of jobs. 

857 """ 

858 return len(self._jobs) 

859 

860 def ContainsJob(self, name: str) -> bool: 

861 """ 

862 Check whether the workflow has a job of that name. 

863 

864 :param name: Name of the job, the key it is declared under. 

865 :returns: ``True``, if the workflow has a job of that name. 

866 """ 

867 return name in self._jobs 

868 

869 def IterateJobs(self) -> Iterator[Job]: 

870 """ 

871 Iterate the workflow's jobs. 

872 

873 :returns: An iterator over the jobs, in file order. 

874 """ 

875 return iter(self._jobs.values()) 

876 

877 def __str__(self) -> str: 

878 """ 

879 Return the workflow's name. 

880 

881 :returns: Name of the workflow, the file's stem. 

882 """ 

883 return self._name 

884 

885 @classmethod 

886 def FromFile(cls, path: Path) -> Self: 

887 """ 

888 Read a workflow file. 

889 

890 :param path: Path to the workflow file. 

891 :returns: The workflow, with its parameters, permissions and jobs attached. 

892 :raises ValueError: If parameter 'path' is ``None``. 

893 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`. 

894 :raises WorkflowError: If the file doesn't exist. 

895 :raises WorkflowError: If the file can't be read. 

896 :raises WorkflowError: If the file is not a YAML document. 

897 :raises WorkflowError: If the document is not a mapping, or has no ``on`` or ``jobs`` key. 

898 :raises WorkflowError: If a parameter of ``on.workflow_call`` lacks a key GitHub requires, or has a value of the 

899 wrong kind. |br| 

900 For an unknown input type, the note lists the allowed values. 

901 :raises WorkflowError: If a job is malformed, needs a job the workflow doesn't have, or the jobs need each other in 

902 a cycle. |br| 

903 For an unknown job, the note lists the workflow's jobs. 

904 """ 

905 if path is None: 

906 raise ValueError("Parameter 'path' is None.") 

907 elif not isinstance(path, Path): 

908 ex = TypeError("Parameter 'path' is not of type 'Path'.") 

909 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.") 

910 raise ex 

911 elif not path.exists(): 

912 raise WorkflowError("Workflow file doesn't exist.", path) from FileNotFoundError(path) 

913 

914 try: 

915 content = path.read_text(encoding="utf-8") 

916 except OSError as cause: 

917 raise WorkflowError("Workflow file can't be read.", path) from cause 

918 

919 try: 

920 document = YAML(typ="rt").load(content) 

921 except YAMLError as cause: 

922 mark = getattr(cause, "problem_mark", None) 

923 line = None if mark is None else mark.line + 1 

924 raise WorkflowError("Workflow file is not a YAML document.", path, line) from cause 

925 

926 if document is None: 

927 raise WorkflowError("Workflow file is empty.", path) 

928 elif not isinstance(document, CommentedMap): 928 ↛ 929line 928 didn't jump to line 929 because the condition on line 928 was never true

929 ex = WorkflowError("Workflow file is not a mapping.", path, 1) 

930 ex.add_note(f"Got type '{getFullyQualifiedName(document)}'.") 

931 raise ex 

932 elif "on" not in document: 

933 raise WorkflowError("Workflow file has no 'on' key.", path) 

934 elif "jobs" not in document: 

935 raise WorkflowError("Workflow file has no 'jobs' key.", path) 

936 

937 workflow = cls._Parse(document, path) 

938 workflow._Validate() 

939 

940 return workflow 

941 

942 @classmethod 

943 def _Parse(cls, document: CommentedMap, path: Path) -> Self: 

944 """ 

945 Build a workflow and the elements it contains from the document read from its file. 

946 

947 :param document: The document, a mapping with an ``on`` and a ``jobs`` key. 

948 :param path: Path to the workflow file. 

949 :returns: The workflow, with its parameters, permissions and jobs attached. 

950 :raises WorkflowError: If key ``on`` is neither an event, a list nor a mapping. 

951 :raises WorkflowError: If a parameter of ``on.workflow_call`` lacks a key GitHub requires, or has a value of the 

952 wrong kind. |br| 

953 For an unknown input type, the note lists the allowed values. 

954 :raises WorkflowError: If key ``jobs`` or a job is not a mapping, or a job is malformed. 

955 """ 

956 on = document["on"] 

957 if isinstance(on, str): 

958 triggers = (on, ) 

959 elif isinstance(on, (CommentedSeq, CommentedMap)): 959 ↛ 962line 959 didn't jump to line 962 because the condition on line 959 was always true

960 triggers = tuple(str(trigger) for trigger in on) 

961 else: 

962 ex = WorkflowError("Key 'on' is neither an event, a list nor a mapping.", path, Base._KeyLine(document, "on")) 

963 ex.add_note(f"Got type '{getFullyQualifiedName(on)}'.") 

964 raise ex 

965 

966 parameters: dict[str, list[Parameter]] = {"inputs": [], "outputs": [], "secrets": []} 

967 if isinstance(on, CommentedMap) and (call := on.get("workflow_call", None)) is not None: 

968 if not isinstance(call, CommentedMap): 968 ↛ 969line 968 didn't jump to line 969 because the condition on line 968 was never true

969 ex = WorkflowError("Key 'on.workflow_call' is not a mapping.", path, Base._KeyLine(on, "workflow_call")) 

970 ex.add_note(f"Got type '{getFullyQualifiedName(call)}'.") 

971 raise ex 

972 

973 for section, parameterClass in (("inputs", Input), ("outputs", Output), ("secrets", Secret)): 

974 if (declarations := call.get(section, None)) is None: 

975 continue 

976 elif not isinstance(declarations, CommentedMap): 976 ↛ 977line 976 didn't jump to line 977 because the condition on line 976 was never true

977 ex = WorkflowError(f"Key 'on.workflow_call.{section}' is not a mapping.", path, Base._KeyLine(call, section)) 

978 ex.add_note(f"Got type '{getFullyQualifiedName(declarations)}'.") 

979 raise ex 

980 

981 parameters[section] = [ 

982 parameterClass._FromYAML(str(name), declaration, path, Base._KeyLine(declarations, name)) 

983 for name, declaration in declarations.items() 

984 ] 

985 

986 jobs = document["jobs"] 

987 if not isinstance(jobs, CommentedMap): 987 ↛ 988line 987 didn't jump to line 988 because the condition on line 987 was never true

988 ex = WorkflowError("Key 'jobs' is not a mapping.", path, Base._KeyLine(document, "jobs")) 

989 ex.add_note(f"Got type '{getFullyQualifiedName(jobs)}'.") 

990 raise ex 

991 

992 jobList = [] 

993 for name, job in jobs.items(): 

994 line = Base._KeyLine(jobs, name) 

995 if not isinstance(job, CommentedMap): 

996 ex = WorkflowError(f"Job '{name}' is not a mapping.", path, line) 

997 ex.add_note(f"Got type '{getFullyQualifiedName(job)}'.") 

998 raise ex 

999 

1000 jobList.append(Job._FromYAML(str(name), job, path, line)) 

1001 

1002 permissions = None 

1003 if "permissions" in document: 

1004 permissions = Permission._FromYAML(document["permissions"], path, Base._KeyLine(document, "permissions")) 

1005 

1006 displayName = document.get("name", None) 

1007 return cls( 

1008 path, 

1009 None if displayName is None else str(displayName), 

1010 triggers, 

1011 parameters["inputs"], 

1012 parameters["outputs"], 

1013 parameters["secrets"], 

1014 permissions, 

1015 jobList 

1016 ) 

1017 

1018 def _Validate(self) -> None: 

1019 """ 

1020 Validate the workflow read from a file. 

1021 

1022 :raises WorkflowError: If a job needs a job the workflow doesn't have. |br| 

1023 The note lists the workflow's jobs. 

1024 :raises WorkflowError: If the jobs need each other in a cycle. 

1025 """ 

1026 self._ValidateNeeds() 

1027 self._ValidateAcyclic() 

1028 

1029 def _ValidateNeeds(self) -> None: 

1030 """ 

1031 Validate that every job names only jobs of the workflow in its ``needs`` key. 

1032 

1033 :raises WorkflowError: If a job needs a job the workflow doesn't have. |br| 

1034 The note lists the workflow's jobs. 

1035 """ 

1036 for job in self._jobs.values(): 

1037 for need in job._needNames: 

1038 if need not in self._jobs: 

1039 ex = WorkflowError( 

1040 f"Job '{job._name}' needs job '{need}', which the workflow doesn't have.", self._path, job._line 

1041 ) 

1042 ex.add_note(f"Jobs: {', '.join(self._jobs)}.") 

1043 raise ex 

1044 

1045 def _ValidateAcyclic(self) -> None: 

1046 """ 

1047 Validate that the jobs don't need each other in a cycle. 

1048 

1049 :raises WorkflowError: If the jobs need each other in a cycle. 

1050 """ 

1051 # Depth-first search: a job still on the stack when it is reached again closes a cycle. 

1052 finished: set[str] = set() 

1053 stack: list[str] = [] 

1054 

1055 def visit(job: Job) -> None: 

1056 """ 

1057 Nested function for recursion. 

1058 

1059 :param job: The job whose needs are followed. 

1060 :raises WorkflowError: If the job is reached again while its needs are followed. 

1061 """ 

1062 if job._name in finished: 

1063 return 

1064 elif job._name in stack: 

1065 cycle = stack[stack.index(job._name):] + [job._name] 

1066 raise WorkflowError(f"Jobs need each other in a cycle: {' -> '.join(cycle)}.", self._path, job._line) 

1067 

1068 stack.append(job._name) 

1069 for need in job.Needs: 

1070 visit(need) 

1071 stack.pop() 

1072 finished.add(job._name) 

1073 

1074 for job in self._jobs.values(): 

1075 visit(job) 

1076 

1077 

1078@export 

1079class Job(Base[Workflow]): 

1080 """ 

1081 A job of a workflow. 

1082 

1083 A job either runs :attr:`Steps` on a runner selected by :attr:`RunsOn`, or calls the reusable workflow named by 

1084 :attr:`Uses`. 

1085 """ 

1086 

1087 _PARENT_TYPE: ClassVar[ParentTypes] = Workflow #: A job is contained in a workflow. 

1088 

1089 _name: str #: Name of the job, the key it is declared under. 

1090 _displayName: Nullable[str] #: Name of the job, as GitHub displays it. 

1091 _needNames: tuple[str, ...] #: Names of the jobs this job needs. 

1092 _condition: Nullable[str] #: Condition under which the job runs. 

1093 _permissions: Nullable[dict[PermissionScope, Permission]] #: Permissions the job declares, by scope. 

1094 _runsOn: tuple[str, ...] #: Labels selecting the runner. 

1095 _uses: Nullable[UsesReference] #: The reusable workflow the job calls. 

1096 _with: dict[str, ValueT] #: Inputs passed to the called workflow, by name. 

1097 _secrets: dict[str, str] #: Secrets passed to the called workflow, by name. 

1098 _inheritsSecrets: bool #: ``True``, if secrets are inherited. 

1099 _matrix: Nullable[Matrix] #: The job's matrix. 

1100 _steps: list[Step] #: Steps of the job. 

1101 _outputs: dict[str, str] #: Outputs of the job, by name. 

1102 _container: Nullable[str] #: Image of the container the job's steps run in. 

1103 _services: dict[str, str] #: Images of the service containers, by service name. 

1104 

1105 def __init__( 

1106 self, 

1107 name: str, 

1108 line: int, 

1109 displayName: Nullable[str] = None, 

1110 needs: Nullable[Iterable[str]] = None, 

1111 condition: Nullable[str] = None, 

1112 runsOn: Nullable[Iterable[str]] = None, 

1113 container: Nullable[str] = None, 

1114 services: Nullable[Mapping[str, str]] = None, 

1115 uses: Nullable[UsesReference] = None, 

1116 withInputs: Nullable[Mapping[str, ValueT]] = None, 

1117 secrets: Nullable[Mapping[str, str]] = None, 

1118 inheritsSecrets: bool = False, 

1119 outputs: Nullable[Mapping[str, str]] = None, 

1120 permissions: Nullable[Iterable[Permission]] = None, 

1121 matrix: Nullable[Matrix] = None, 

1122 steps: Nullable[Iterable[Step]] = None, 

1123 *, 

1124 parent: Nullable[Workflow] = None 

1125 ) -> None: 

1126 """ 

1127 Initializes a job of a workflow. 

1128 

1129 The reusable workflow a job calls, its permissions, its matrix and its steps are attached by passing them, or by 

1130 constructing a :class:`UsesReference`, :class:`Permission`, :class:`Matrix` or :class:`Step` with the job as 

1131 parent. 

1132 

1133 :param name: Name of the job, the key it is declared under. 

1134 :param line: Line the job's name is written at, starting at 1. 

1135 :param displayName: Optional, name of the job, as GitHub displays it. Default: ``None``. 

1136 :param needs: Optional, names of the jobs this job needs. Default: ``None``. 

1137 :param condition: Optional, condition under which the job runs. Default: ``None``. 

1138 :param runsOn: Optional, labels selecting the runner. Default: ``None``. 

1139 :param container: Optional, image of the container the job's steps run in. Default: ``None``. 

1140 :param services: Optional, images of the service containers, by service name. Default: ``None``. 

1141 :param uses: Optional, the reusable workflow the job calls, which is attached to the job. Default: 

1142 ``None``. 

1143 :param withInputs: Optional, inputs passed to the called workflow, by name. Default: ``None``. 

1144 :param secrets: Optional, secrets passed to the called workflow, by name. Default: ``None``. 

1145 :param inheritsSecrets: Optional, ``True``, if the called workflow inherits every secret. Default: ``False``. 

1146 :param outputs: Optional, outputs of the job, by name. Default: ``None``. 

1147 :param permissions: Optional, permissions the job declares, which are attached to the job. Default: ``None``, 

1148 for a job without a ``permissions`` key. 

1149 :param matrix: Optional, the job's matrix, which is attached to the job. Default: ``None``. 

1150 :param steps: Optional, the job's steps, which are attached to the job. Default: ``None``. 

1151 :param parent: Optional, reference to the workflow containing the job. Default: ``None``. 

1152 :raises ValueError: If parameter 'name' is ``None``. 

1153 :raises TypeError: If parameter 'name' is not of type :class:`str`. 

1154 :raises ValueError: If parameter 'name' is empty. 

1155 :raises TypeError: If parameter 'displayName' is not of type :class:`str`. 

1156 :raises TypeError: If parameter 'condition' is not of type :class:`str`. 

1157 :raises TypeError: If parameter 'container' is not of type :class:`str`. 

1158 :raises TypeError: If an element of parameter 'needs' is not of type :class:`str`. 

1159 :raises TypeError: If an element of parameter 'runsOn' is not of type :class:`str`. 

1160 :raises TypeError: If parameter 'uses' is not of type :class:`UsesReference`. 

1161 :raises TypeError: If parameter 'inheritsSecrets' is not of type :class:`bool`. 

1162 :raises TypeError: If an element of parameter 'permissions' is not of type :class:`Permission`. 

1163 :raises TypeError: If parameter 'matrix' is not of type :class:`Matrix`. 

1164 :raises TypeError: If an element of parameter 'steps' is not of type :class:`Step`. 

1165 """ 

1166 super().__init__(line, parent=parent) 

1167 

1168 if name is None: 

1169 raise ValueError("Parameter 'name' is None.") 

1170 elif not isinstance(name, str): 

1171 ex = TypeError("Parameter 'name' is not of type 'str'.") 

1172 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.") 

1173 raise ex 

1174 elif name == "": 

1175 raise ValueError("Parameter 'name' is empty.") 

1176 

1177 for parameterName, value in ( 

1178 ("displayName", displayName), 

1179 ("condition", condition), 

1180 ("container", container) 

1181 ): 

1182 if value is not None and not isinstance(value, str): 

1183 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.") 

1184 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.") 

1185 raise ex 

1186 

1187 self._needNames = () if needs is None else tuple(needs) 

1188 self._runsOn = () if runsOn is None else tuple(runsOn) 

1189 for parameterName, values in (("needs", self._needNames), ("runsOn", self._runsOn)): 

1190 for value in values: 

1191 if not isinstance(value, str): 

1192 ex = TypeError(f"An element of parameter '{parameterName}' is not of type 'str'.") 

1193 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.") 

1194 raise ex 

1195 

1196 for parameterName, value, valueClass in ( 

1197 ("uses", uses, UsesReference), 

1198 ("matrix", matrix, Matrix) 

1199 ): 

1200 if value is not None and not isinstance(value, valueClass): 

1201 ex = TypeError(f"Parameter '{parameterName}' is not of type '{valueClass.__name__}'.") 

1202 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.") 

1203 raise ex 

1204 

1205 if not isinstance(inheritsSecrets, bool): 1205 ↛ 1206line 1205 didn't jump to line 1206 because the condition on line 1205 was never true

1206 ex = TypeError("Parameter 'inheritsSecrets' is not of type 'bool'.") 

1207 ex.add_note(f"Got type '{getFullyQualifiedName(inheritsSecrets)}'.") 

1208 raise ex 

1209 

1210 self._name = name 

1211 self._displayName = displayName 

1212 self._condition = condition 

1213 self._permissions = None 

1214 self._uses = uses 

1215 self._with = {} if withInputs is None else dict(withInputs) 

1216 self._secrets = {} if secrets is None else dict(secrets) 

1217 self._inheritsSecrets = inheritsSecrets 

1218 self._matrix = matrix 

1219 self._steps = [] 

1220 self._outputs = {} if outputs is None else dict(outputs) 

1221 self._container = container 

1222 self._services = {} if services is None else dict(services) 

1223 

1224 if uses is not None: 

1225 uses.Parent = self 

1226 

1227 if matrix is not None: 

1228 matrix.Parent = self 

1229 

1230 if permissions is not None: 

1231 self._permissions = {} 

1232 for permission in permissions: 

1233 if not isinstance(permission, Permission): 

1234 ex = TypeError("An element of parameter 'permissions' is not of type 'Permission'.") 

1235 ex.add_note(f"Got type '{getFullyQualifiedName(permission)}'.") 

1236 raise ex 

1237 

1238 self._permissions[permission._scope] = permission 

1239 permission.Parent = self 

1240 

1241 if steps is not None: 

1242 for step in steps: 

1243 if not isinstance(step, Step): 

1244 ex = TypeError("An element of parameter 'steps' is not of type 'Step'.") 

1245 ex.add_note(f"Got type '{getFullyQualifiedName(step)}'.") 

1246 raise ex 

1247 

1248 self._steps.append(step) 

1249 step.Parent = self 

1250 

1251 if parent is not None: 

1252 parent._jobs[name] = self 

1253 

1254 @Base.Parent.setter 

1255 def Parent(self, value: Workflow) -> None: 

1256 Base.Parent.fset(self, value) 

1257 

1258 if self._uses is not None: 

1259 self._uses.Parent = self 

1260 

1261 if self._matrix is not None: 

1262 self._matrix.Parent = self 

1263 

1264 for step in self._steps: 

1265 step.Parent = self 

1266 

1267 if self._permissions is not None: 

1268 for permission in self._permissions.values(): 

1269 permission.Parent = self 

1270 

1271 @readonly 

1272 def Name(self) -> str: 

1273 """ 

1274 Read-only property to access the job's name, the key it is declared under (:attr:`_name`). 

1275 

1276 :returns: Name of the job. 

1277 """ 

1278 return self._name 

1279 

1280 @readonly 

1281 def DisplayName(self) -> Nullable[str]: 

1282 """ 

1283 Read-only property to access the job's name, as GitHub displays it (:attr:`_displayName`). 

1284 

1285 :returns: The ``name`` key, as written, or ``None`` if the workflow gives none. 

1286 """ 

1287 return self._displayName 

1288 

1289 @readonly 

1290 def NeedNames(self) -> tuple[str, ...]: 

1291 """ 

1292 Read-only property to access the names of the jobs this job needs (:attr:`_needNames`). 

1293 

1294 :returns: The names, in the order the ``needs`` key lists them. 

1295 """ 

1296 return self._needNames 

1297 

1298 @readonly 

1299 def Needs(self) -> tuple[Job, ...]: 

1300 """ 

1301 Read-only property to return the jobs this job needs. 

1302 

1303 The names in :attr:`NeedNames` are looked up in the workflow containing the job. :meth:`Workflow.FromFile` 

1304 rejects a name naming no job, so for a workflow read from a file every name is resolved. 

1305 

1306 :returns: The jobs, in the order the ``needs`` key lists them, skipping names naming no job of the workflow. 

1307 """ 

1308 if self._workflow is None: 1308 ↛ 1309line 1308 didn't jump to line 1309 because the condition on line 1308 was never true

1309 return () 

1310 

1311 jobs = self._workflow._jobs 

1312 return tuple(jobs[name] for name in self._needNames if name in jobs) 

1313 

1314 @readonly 

1315 def Condition(self) -> Nullable[str]: 

1316 """ 

1317 Read-only property to access the condition under which the job runs (:attr:`_condition`). 

1318 

1319 The expression is not evaluated. 

1320 

1321 :returns: The ``if`` expression, as written, or ``None`` if the job has no condition. 

1322 """ 

1323 return self._condition 

1324 

1325 @readonly 

1326 def Permissions(self) -> Nullable[dict[PermissionScope, Permission]]: 

1327 """ 

1328 Read-only property to access the permissions the job declares (:attr:`_permissions`). 

1329 

1330 :returns: The permissions, by scope, or ``None`` if the job has no ``permissions`` key and inherits them. 

1331 """ 

1332 return self._permissions 

1333 

1334 @readonly 

1335 def RunsOn(self) -> tuple[str, ...]: 

1336 """ 

1337 Read-only property to access the labels selecting the runner (:attr:`_runsOn`). 

1338 

1339 :returns: The labels, as written - an expression is not evaluated -, or ``()`` for a job calling a workflow. 

1340 """ 

1341 return self._runsOn 

1342 

1343 @readonly 

1344 def Uses(self) -> Nullable[UsesReference]: 

1345 """ 

1346 Read-only property to access the reusable workflow the job calls (:attr:`_uses`). 

1347 

1348 :returns: The reference, or ``None`` for a job running steps. 

1349 """ 

1350 return self._uses 

1351 

1352 @readonly 

1353 def With(self) -> dict[str, ValueT]: 

1354 """ 

1355 Read-only property to access the inputs passed to the called workflow (:attr:`_with`). 

1356 

1357 :returns: The inputs, by name. 

1358 """ 

1359 return self._with 

1360 

1361 @readonly 

1362 def Secrets(self) -> dict[str, str]: 

1363 """ 

1364 Read-only property to access the secrets passed to the called workflow (:attr:`_secrets`). 

1365 

1366 :returns: The secrets, by name, or an empty dictionary if the job passes none or :attr:`InheritsSecrets`. 

1367 """ 

1368 return self._secrets 

1369 

1370 @readonly 

1371 def InheritsSecrets(self) -> bool: 

1372 """ 

1373 Read-only property to access whether the called workflow inherits every secret (:attr:`_inheritsSecrets`). 

1374 

1375 :returns: ``True``, if the job says ``secrets: inherit``. 

1376 """ 

1377 return self._inheritsSecrets 

1378 

1379 @readonly 

1380 def Matrix(self) -> Nullable[Matrix]: 

1381 """ 

1382 Read-only property to access the job's matrix (:attr:`_matrix`). 

1383 

1384 :returns: The matrix, or ``None`` if the job has no ``strategy.matrix``. 

1385 """ 

1386 return self._matrix 

1387 

1388 @readonly 

1389 def Steps(self) -> list[Step]: 

1390 """ 

1391 Read-only property to access the job's steps (:attr:`_steps`). 

1392 

1393 :returns: The steps, in file order, or an empty list for a job calling a workflow. 

1394 """ 

1395 return self._steps 

1396 

1397 @readonly 

1398 def Outputs(self) -> dict[str, str]: 

1399 """ 

1400 Read-only property to access the job's outputs (:attr:`_outputs`). 

1401 

1402 :returns: The expressions the outputs are taken from, by name. 

1403 """ 

1404 return self._outputs 

1405 

1406 @readonly 

1407 def Container(self) -> Nullable[str]: 

1408 """ 

1409 Read-only property to access the image of the container the job's steps run in (:attr:`_container`). 

1410 

1411 :returns: The image, as written - e.g. ``pytooling/miktex:sphinx`` or an expression -, or ``None`` if the job has no 

1412 ``container``. 

1413 """ 

1414 return self._container 

1415 

1416 @readonly 

1417 def Services(self) -> dict[str, str]: 

1418 """ 

1419 Read-only property to access the images of the job's service containers (:attr:`_services`). 

1420 

1421 :returns: The images, as written, by service name. 

1422 """ 

1423 return self._services 

1424 

1425 @readonly 

1426 def StepCount(self) -> int: 

1427 """ 

1428 Read-only property to return the number of steps of the job. 

1429 

1430 :returns: Number of steps. 

1431 """ 

1432 return len(self._steps) 

1433 

1434 def IterateSteps(self) -> Iterator[Step]: 

1435 """ 

1436 Iterate the job's steps. 

1437 

1438 :returns: An iterator over the steps, in file order. 

1439 """ 

1440 return iter(self._steps) 

1441 

1442 def __str__(self) -> str: 

1443 """ 

1444 Return the job's name. 

1445 

1446 :returns: Name of the job. 

1447 """ 

1448 return self._name 

1449 

1450 @classmethod 

1451 def _FromYAML(cls, name: str, mapping: CommentedMap, path: Path, line: int) -> Self: 

1452 """ 

1453 Build a job and the elements it contains from its mapping in the workflow file. 

1454 

1455 :param name: Name of the job. 

1456 :param mapping: The job's mapping. 

1457 :param path: Path to the workflow file. 

1458 :param line: Line the job's name is written at, starting at 1. 

1459 :returns: The job. 

1460 :raises WorkflowError: If the job has neither ``runs-on`` nor ``uses``, or both. 

1461 :raises WorkflowError: If a key of the job holds a value of the wrong kind. 

1462 """ 

1463 if ("runs-on" in mapping) == ("uses" in mapping): 

1464 raise WorkflowError(f"Job '{name}' needs either 'runs-on' or 'uses'.", path, line) 

1465 

1466 needs = mapping.get("needs", ()) 

1467 if isinstance(needs, str): 

1468 needs = (needs, ) 

1469 elif not isinstance(needs, (list, tuple)): 1469 ↛ 1470line 1469 didn't jump to line 1470 because the condition on line 1469 was never true

1470 ex = WorkflowError( 

1471 f"Key 'needs' of job '{name}' is neither a job name nor a list.", path, Base._KeyLine(mapping, "needs") 

1472 ) 

1473 ex.add_note(f"Got type '{getFullyQualifiedName(needs)}'.") 

1474 raise ex 

1475 

1476 runsOn = mapping.get("runs-on", ()) 

1477 if isinstance(runsOn, dict): 1477 ↛ 1478line 1477 didn't jump to line 1478 because the condition on line 1477 was never true

1478 runsOn = runsOn.get("labels", ()) 

1479 

1480 if isinstance(runsOn, str): 

1481 runsOn = (runsOn, ) 

1482 

1483 condition = mapping.get("if", None) 

1484 

1485 secrets = mapping.get("secrets", None) 

1486 inheritsSecrets = secrets == "inherit" 

1487 if inheritsSecrets: 

1488 secrets = None 

1489 elif secrets is not None: 

1490 if not isinstance(secrets, CommentedMap): 1490 ↛ 1491line 1490 didn't jump to line 1491 because the condition on line 1490 was never true

1491 ex = WorkflowError(f"Key 'secrets' of job '{name}' is not a mapping.", path, Base._KeyLine(mapping, "secrets")) 

1492 ex.add_note(f"Got type '{getFullyQualifiedName(secrets)}'.") 

1493 raise ex 

1494 

1495 secrets = {str(key): str(value) for key, value in secrets.items()} 

1496 

1497 if (outputs := mapping.get("outputs", None)) is not None: 

1498 if not isinstance(outputs, CommentedMap): 1498 ↛ 1499line 1498 didn't jump to line 1499 because the condition on line 1498 was never true

1499 ex = WorkflowError(f"Key 'outputs' of job '{name}' is not a mapping.", path, Base._KeyLine(mapping, "outputs")) 

1500 ex.add_note(f"Got type '{getFullyQualifiedName(outputs)}'.") 

1501 raise ex 

1502 

1503 outputs = {str(key): str(value) for key, value in outputs.items()} 

1504 

1505 if (withValues := mapping.get("with", None)) is not None: 

1506 if not isinstance(withValues, CommentedMap): 1506 ↛ 1507line 1506 didn't jump to line 1507 because the condition on line 1506 was never true

1507 ex = WorkflowError(f"Key 'with' of job '{name}' is not a mapping.", path, Base._KeyLine(mapping, "with")) 

1508 ex.add_note(f"Got type '{getFullyQualifiedName(withValues)}'.") 

1509 raise ex 

1510 

1511 withValues = Base._ToPython(withValues) 

1512 

1513 uses = None 

1514 if "uses" in mapping: 

1515 uses = UsesReference._FromYAML(mapping, f"job '{name}'", path) 

1516 

1517 permissions = None 

1518 if "permissions" in mapping: 

1519 permissions = Permission._FromYAML(mapping["permissions"], path, Base._KeyLine(mapping, "permissions")) 

1520 

1521 matrix = None 

1522 if (strategy := mapping.get("strategy", None)) is not None: 

1523 if not isinstance(strategy, CommentedMap): 1523 ↛ 1524line 1523 didn't jump to line 1524 because the condition on line 1523 was never true

1524 ex = WorkflowError( 

1525 f"Key 'strategy' of job '{name}' is not a mapping.", path, Base._KeyLine(mapping, "strategy") 

1526 ) 

1527 ex.add_note(f"Got type '{getFullyQualifiedName(strategy)}'.") 

1528 raise ex 

1529 

1530 if "matrix" in strategy: 1530 ↛ 1533line 1530 didn't jump to line 1533 because the condition on line 1530 was always true

1531 matrix = Matrix._FromYAML(strategy["matrix"], path, Base._KeyLine(strategy, "matrix")) 

1532 

1533 container = mapping.get("container", None) 

1534 if isinstance(container, CommentedMap): 

1535 container = container.get("image", None) 

1536 

1537 services = None 

1538 if (serviceMap := mapping.get("services", None)) is not None: 

1539 if not isinstance(serviceMap, CommentedMap): 1539 ↛ 1540line 1539 didn't jump to line 1540 because the condition on line 1539 was never true

1540 ex = WorkflowError( 

1541 f"Key 'services' of job '{name}' is not a mapping.", path, Base._KeyLine(mapping, "services") 

1542 ) 

1543 ex.add_note(f"Got type '{getFullyQualifiedName(serviceMap)}'.") 

1544 raise ex 

1545 

1546 services = {} 

1547 for serviceName, service in serviceMap.items(): 

1548 if isinstance(service, CommentedMap): 

1549 service = service.get("image", None) 

1550 

1551 if service is not None: 1551 ↛ 1547line 1551 didn't jump to line 1547 because the condition on line 1551 was always true

1552 services[str(serviceName)] = str(service) 

1553 

1554 steps = None 

1555 if (stepList := mapping.get("steps", None)) is not None: 

1556 if not isinstance(stepList, CommentedSeq): 

1557 ex = WorkflowError(f"Key 'steps' of job '{name}' is not a list.", path, Base._KeyLine(mapping, "steps")) 

1558 ex.add_note(f"Got type '{getFullyQualifiedName(stepList)}'.") 

1559 raise ex 

1560 

1561 steps = [ 

1562 Step._FromYAML(step, position, f"job '{name}'", path, stepList.lc.item(position)[0] + 1) 

1563 for position, step in enumerate(stepList) 

1564 ] 

1565 

1566 displayName = mapping.get("name", None) 

1567 

1568 return cls( 

1569 name, line, 

1570 displayName=None if displayName is None else str(displayName), 

1571 needs=(str(need) for need in needs), 

1572 condition=None if condition is None else str(condition), 

1573 runsOn=(str(label) for label in runsOn), 

1574 container=None if container is None else str(container), 

1575 services=services, 

1576 uses=uses, 

1577 withInputs=withValues, 

1578 secrets=secrets, 

1579 inheritsSecrets=inheritsSecrets, 

1580 outputs=outputs, 

1581 permissions=permissions, 

1582 matrix=matrix, 

1583 steps=steps 

1584 ) 

1585 

1586 

1587@export 

1588class Action(Base[None]): 

1589 """ 

1590 An action's file, ``action.yml``. 

1591 

1592 The action is named by its directory - ``ComputeRequirements`` for ``.github/actions/ComputeRequirements/action.yml`` 

1593 - because that is how a step names it in ``uses``; the ``name`` key is kept as :attr:`DisplayName`. Of a composite 

1594 action, the steps are read, so the actions it runs in turn are known. 

1595 """ 

1596 

1597 _path: Path #: Path to the action's file. 

1598 _name: str #: Name of the action, its directory's name. 

1599 _displayName: Nullable[str] #: Name of the action, as GitHub displays it. 

1600 _using: str #: How the action runs, as ``composite``, ``docker`` or ``node24``. 

1601 _image: Nullable[str] #: The image a Docker action runs, as ``Dockerfile`` or ``docker://alpine:3.22``. 

1602 _steps: list[Step] #: Steps of a composite action. 

1603 

1604 def __init__( 

1605 self, 

1606 path: Path, 

1607 using: str, 

1608 displayName: Nullable[str] = None, 

1609 image: Nullable[str] = None, 

1610 steps: Nullable[Iterable[Step]] = None 

1611 ) -> None: 

1612 """ 

1613 Initializes an action. 

1614 

1615 The steps of a composite action are attached by passing them, or by constructing them with the action as parent. 

1616 Use :meth:`FromFile` to read an action's file. 

1617 

1618 :param path: Path to the action's file. 

1619 :param using: How the action runs, as ``composite``. 

1620 :param displayName: Optional, name of the action, as GitHub displays it. Default: ``None``. 

1621 :param image: Optional, the image a Docker action runs. Default: ``None``. 

1622 :param steps: Optional, the steps of a composite action, which are attached to the action. Default: 

1623 ``None``. 

1624 :raises ValueError: If parameter 'path' is ``None``. 

1625 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`. 

1626 :raises ValueError: If parameter 'using' is ``None``. 

1627 :raises TypeError: If parameter 'using' is not of type :class:`str`. 

1628 :raises TypeError: If parameter 'displayName' is not of type :class:`str`. 

1629 :raises TypeError: If parameter 'image' is not of type :class:`str`. 

1630 :raises TypeError: If an element of parameter 'steps' is not of type :class:`Step`. 

1631 """ 

1632 super().__init__(1) 

1633 

1634 if path is None: 

1635 raise ValueError("Parameter 'path' is None.") 

1636 elif not isinstance(path, Path): 1636 ↛ 1637line 1636 didn't jump to line 1637 because the condition on line 1636 was never true

1637 ex = TypeError("Parameter 'path' is not of type 'Path'.") 

1638 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.") 

1639 raise ex 

1640 

1641 if using is None: 

1642 raise ValueError("Parameter 'using' is None.") 

1643 

1644 for parameterName, value in ( 

1645 ("using", using), 

1646 ("displayName", displayName), 

1647 ("image", image) 

1648 ): 

1649 if value is not None and not isinstance(value, str): 1649 ↛ 1650line 1649 didn't jump to line 1650 because the condition on line 1649 was never true

1650 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.") 

1651 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.") 

1652 raise ex 

1653 

1654 self._file = path 

1655 self._path = path 

1656 self._name = path.parent.name 

1657 self._displayName = displayName 

1658 self._using = using 

1659 self._image = image 

1660 self._steps = [] 

1661 

1662 if steps is not None: 

1663 for step in steps: 

1664 if not isinstance(step, Step): 

1665 ex = TypeError("An element of parameter 'steps' is not of type 'Step'.") 

1666 ex.add_note(f"Got type '{getFullyQualifiedName(step)}'.") 

1667 raise ex 

1668 

1669 self._steps.append(step) 

1670 step.Parent = self 

1671 

1672 @Base.Parent.setter 

1673 def Parent(self, value: None) -> None: 

1674 ex = TypeError(f"A '{getFullyQualifiedName(self)}' has no parent.") 

1675 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.") 

1676 raise ex 

1677 

1678 @readonly 

1679 def Path(self) -> Path: 

1680 """ 

1681 Read-only property to access the path to the action's file (:attr:`_path`). 

1682 

1683 :returns: Path to the action's file. 

1684 """ 

1685 return self._path 

1686 

1687 @readonly 

1688 def Name(self) -> str: 

1689 """ 

1690 Read-only property to access the action's name, its directory's name (:attr:`_name`). 

1691 

1692 :returns: Name of the action. 

1693 """ 

1694 return self._name 

1695 

1696 @readonly 

1697 def DisplayName(self) -> Nullable[str]: 

1698 """ 

1699 Read-only property to access the action's name, as GitHub displays it (:attr:`_displayName`). 

1700 

1701 :returns: The ``name`` key, or ``None`` if the file has none. 

1702 """ 

1703 return self._displayName 

1704 

1705 @readonly 

1706 def Using(self) -> str: 

1707 """ 

1708 Read-only property to access how the action runs (:attr:`_using`). 

1709 

1710 :returns: The ``runs.using`` key, as ``composite``, ``docker`` or ``node24``. 

1711 """ 

1712 return self._using 

1713 

1714 @readonly 

1715 def IsComposite(self) -> bool: 

1716 """ 

1717 Read-only property to return whether the action is a composite action, running steps. 

1718 

1719 :returns: ``True``, if ``runs.using`` is ``composite``. 

1720 """ 

1721 return self._using == "composite" 

1722 

1723 @readonly 

1724 def Image(self) -> Nullable[str]: 

1725 """ 

1726 Read-only property to access the image a Docker action runs (:attr:`_image`). 

1727 

1728 :returns: The ``runs.image`` key, as ``Dockerfile`` or ``docker://alpine:3.22``, or ``None`` for another action. 

1729 """ 

1730 return self._image 

1731 

1732 @readonly 

1733 def Steps(self) -> list[Step]: 

1734 """ 

1735 Read-only property to access the steps of a composite action (:attr:`_steps`). 

1736 

1737 :returns: The steps, in file order, or an empty list for another action. 

1738 """ 

1739 return self._steps 

1740 

1741 def IterateActions(self) -> Iterator[UsesReference]: 

1742 """ 

1743 Iterate the actions the steps of a composite action run. 

1744 

1745 :returns: An iterator over the actions, in file order. 

1746 """ 

1747 for step in self._steps: 

1748 if step._uses is not None: 

1749 yield step._uses 

1750 

1751 @readonly 

1752 def StepCount(self) -> int: 

1753 """ 

1754 Read-only property to return the number of steps of the action. 

1755 

1756 :returns: Number of steps. 

1757 """ 

1758 return len(self._steps) 

1759 

1760 def IterateSteps(self) -> Iterator[Step]: 

1761 """ 

1762 Iterate the action's steps. 

1763 

1764 :returns: An iterator over the steps, in file order. 

1765 """ 

1766 return iter(self._steps) 

1767 

1768 def __str__(self) -> str: 

1769 """ 

1770 Return the action's name. 

1771 

1772 :returns: Name of the action, its directory's name. 

1773 """ 

1774 return self._name 

1775 

1776 @classmethod 

1777 def FromFile(cls, path: Path) -> Self: 

1778 """ 

1779 Read an action's file. 

1780 

1781 :param path: Path to the action's file. 

1782 :returns: The action, with the steps of a composite action attached. 

1783 :raises ValueError: If parameter 'path' is ``None``. 

1784 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`. 

1785 :raises WorkflowError: If the file doesn't exist. 

1786 :raises WorkflowError: If the file can't be read. 

1787 :raises WorkflowError: If the file is not a YAML document. 

1788 :raises WorkflowError: If the document is not a mapping, or has no ``runs`` key. 

1789 :raises WorkflowError: If ``runs`` is not a mapping or has no ``using`` key, or a step is malformed. 

1790 """ 

1791 if path is None: 1791 ↛ 1792line 1791 didn't jump to line 1792 because the condition on line 1791 was never true

1792 raise ValueError("Parameter 'path' is None.") 

1793 elif not isinstance(path, Path): 1793 ↛ 1794line 1793 didn't jump to line 1794 because the condition on line 1793 was never true

1794 ex = TypeError("Parameter 'path' is not of type 'Path'.") 

1795 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.") 

1796 raise ex 

1797 elif not path.exists(): 

1798 raise WorkflowError("Action file doesn't exist.", path) from FileNotFoundError(path) 

1799 

1800 try: 

1801 content = path.read_text(encoding="utf-8") 

1802 except OSError as cause: 

1803 raise WorkflowError("Action file can't be read.", path) from cause 

1804 

1805 try: 

1806 document = YAML(typ="rt").load(content) 

1807 except YAMLError as cause: 

1808 mark = getattr(cause, "problem_mark", None) 

1809 line = None if mark is None else mark.line + 1 

1810 raise WorkflowError("Action file is not a YAML document.", path, line) from cause 

1811 

1812 if document is None: 1812 ↛ 1813line 1812 didn't jump to line 1813 because the condition on line 1812 was never true

1813 raise WorkflowError("Action file is empty.", path) 

1814 elif not isinstance(document, CommentedMap): 1814 ↛ 1815line 1814 didn't jump to line 1815 because the condition on line 1814 was never true

1815 ex = WorkflowError("Action file is not a mapping.", path, 1) 

1816 ex.add_note(f"Got type '{getFullyQualifiedName(document)}'.") 

1817 raise ex 

1818 elif "runs" not in document: 

1819 raise WorkflowError("Action file has no 'runs' key.", path) 

1820 

1821 return cls._Parse(document, path) 

1822 

1823 @classmethod 

1824 def _Parse(cls, document: CommentedMap, path: Path) -> Self: 

1825 """ 

1826 Build an action and the steps it contains from the document read from its file. 

1827 

1828 :param document: The document, a mapping with a ``runs`` key. 

1829 :param path: Path to the action's file. 

1830 :returns: The action, with the steps of a composite action attached. 

1831 :raises WorkflowError: If key ``runs`` is not a mapping, or has no ``using`` key. 

1832 :raises WorkflowError: If key ``runs.steps`` is not a list, or a step is malformed. 

1833 """ 

1834 runs = document["runs"] 

1835 if not isinstance(runs, CommentedMap): 1835 ↛ 1836line 1835 didn't jump to line 1836 because the condition on line 1835 was never true

1836 ex = WorkflowError("Key 'runs' is not a mapping.", path, Base._KeyLine(document, "runs")) 

1837 ex.add_note(f"Got type '{getFullyQualifiedName(runs)}'.") 

1838 raise ex 

1839 elif "using" not in runs: 

1840 raise WorkflowError("Key 'runs' has no 'using' key.", path, Base._KeyLine(document, "runs")) 

1841 

1842 steps = None 

1843 if (stepList := runs.get("steps", None)) is not None: 

1844 if not isinstance(stepList, CommentedSeq): 1844 ↛ 1845line 1844 didn't jump to line 1845 because the condition on line 1844 was never true

1845 ex = WorkflowError("Key 'runs.steps' is not a list.", path, Base._KeyLine(runs, "steps")) 

1846 ex.add_note(f"Got type '{getFullyQualifiedName(stepList)}'.") 

1847 raise ex 

1848 

1849 steps = [ 

1850 Step._FromYAML(step, position, f"action '{path.parent.name}'", path, stepList.lc.item(position)[0] + 1) 

1851 for position, step in enumerate(stepList) 

1852 ] 

1853 

1854 displayName = document.get("name", None) 

1855 image = runs.get("image", None) 

1856 return cls( 

1857 path, 

1858 str(runs["using"]), 

1859 displayName=None if displayName is None else str(displayName), 

1860 image=None if image is None else str(image), 

1861 steps=steps 

1862 ) 

1863 

1864 

1865@export 

1866class Step(Base[Union[Job, Action]]): 

1867 """A step of a job or of a composite action.""" 

1868 

1869 _PARENT_TYPE: ClassVar[ParentTypes] = (Job, Action) #: A step is contained in a job or an action. 

1870 

1871 _name: Nullable[str] #: Name of the step. 

1872 _identifier: Nullable[str] #: Identifier of the step, as referenced by ``steps.<id>``. 

1873 _condition: Nullable[str] #: Condition under which the step runs. 

1874 _uses: Nullable[UsesReference] #: The action the step runs. 

1875 _run: Nullable[str] #: The script the step runs. 

1876 

1877 def __init__( 

1878 self, 

1879 line: int, 

1880 name: Nullable[str] = None, 

1881 identifier: Nullable[str] = None, 

1882 condition: Nullable[str] = None, 

1883 run: Nullable[str] = None, 

1884 uses: Nullable[UsesReference] = None, 

1885 *, 

1886 parent: Nullable[Union[Job, Action]] = None 

1887 ) -> None: 

1888 """ 

1889 Initializes a step of a job or of a composite action. 

1890 

1891 The action a step runs is attached by passing it, or by constructing a :class:`UsesReference` with the step as 

1892 parent. 

1893 

1894 :param line: Line the step starts at, starting at 1. 

1895 :param name: Optional, name of the step. Default: ``None``. 

1896 :param identifier: Optional, identifier of the step. Default: ``None``. 

1897 :param condition: Optional, condition under which the step runs. Default: ``None``. 

1898 :param run: Optional, the script the step runs. Default: ``None``. 

1899 :param uses: Optional, the action the step runs, which is attached to the step. Default: ``None``. 

1900 :param parent: Optional, reference to the job or the composite action containing the step, which the step is 

1901 attached to. Default: ``None``. 

1902 :raises TypeError: If parameter 'name' is not of type :class:`str`. 

1903 :raises TypeError: If parameter 'identifier' is not of type :class:`str`. 

1904 :raises TypeError: If parameter 'condition' is not of type :class:`str`. 

1905 :raises TypeError: If parameter 'run' is not of type :class:`str`. 

1906 :raises TypeError: If parameter 'uses' is not of type :class:`UsesReference`. 

1907 """ 

1908 super().__init__(line, parent=parent) 

1909 

1910 for parameterName, value in ( 

1911 ("name", name), 

1912 ("identifier", identifier), 

1913 ("condition", condition), 

1914 ("run", run) 

1915 ): 

1916 if value is not None and not isinstance(value, str): 1916 ↛ 1917line 1916 didn't jump to line 1917 because the condition on line 1916 was never true

1917 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.") 

1918 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.") 

1919 raise ex 

1920 

1921 if uses is not None and not isinstance(uses, UsesReference): 

1922 ex = TypeError("Parameter 'uses' is not of type 'UsesReference'.") 

1923 ex.add_note(f"Got type '{getFullyQualifiedName(uses)}'.") 

1924 raise ex 

1925 

1926 self._name = name 

1927 self._identifier = identifier 

1928 self._condition = condition 

1929 self._uses = uses 

1930 self._run = run 

1931 

1932 if uses is not None: 

1933 uses.Parent = self 

1934 

1935 if parent is not None: 

1936 parent._steps.append(self) 

1937 

1938 @Base.Parent.setter 

1939 def Parent(self, value: Job) -> None: 

1940 Base.Parent.fset(self, value) 

1941 

1942 if self._uses is not None: 

1943 self._uses.Parent = self 

1944 

1945 @readonly 

1946 def Name(self) -> Nullable[str]: 

1947 """ 

1948 Read-only property to access the step's name (:attr:`_name`). 

1949 

1950 :returns: Name of the step, or ``None`` if the workflow gives none. 

1951 """ 

1952 return self._name 

1953 

1954 @readonly 

1955 def ID(self) -> Nullable[str]: 

1956 """ 

1957 Read-only property to access the step's identifier (:attr:`_identifier`). 

1958 

1959 :returns: Identifier of the step, or ``None`` if the workflow gives none. 

1960 """ 

1961 return self._identifier 

1962 

1963 @readonly 

1964 def Condition(self) -> Nullable[str]: 

1965 """ 

1966 Read-only property to access the condition under which the step runs (:attr:`_condition`). 

1967 

1968 :returns: The ``if`` expression, as written, or ``None`` if the step always runs. 

1969 """ 

1970 return self._condition 

1971 

1972 @readonly 

1973 def Uses(self) -> Nullable[UsesReference]: 

1974 """ 

1975 Read-only property to access the action the step runs (:attr:`_uses`). 

1976 

1977 :returns: The action, or ``None`` for a step running a script. 

1978 """ 

1979 return self._uses 

1980 

1981 @readonly 

1982 def Run(self) -> Nullable[str]: 

1983 """ 

1984 Read-only property to access the script the step runs (:attr:`_run`). 

1985 

1986 :returns: The script, or ``None`` for a step running an action. 

1987 """ 

1988 return self._run 

1989 

1990 @classmethod 

1991 def _FromYAML(cls, mapping: Any, position: int, what: str, path: Path, line: int) -> Self: 

1992 """ 

1993 Read a step from the ``steps`` list of a job or of a composite action. 

1994 

1995 :param mapping: The step's mapping. 

1996 :param position: Position of the step in its list, starting at 0. 

1997 :param what: The job or action containing the step, for the exception's message, as ``job 'Build'`` or 

1998 ``action 'Setup'``. 

1999 :param path: Path to the file, for a message. 

2000 :param line: Line the step starts at, starting at 1. 

2001 :returns: The step. 

2002 :raises WorkflowError: If the step is not a mapping. 

2003 :raises WorkflowError: If the step's ``uses`` is not a reference. 

2004 """ 

2005 if not isinstance(mapping, CommentedMap): 

2006 ex = WorkflowError(f"Step {position + 1} of {what} is not a mapping.", path, line) 

2007 ex.add_note(f"Got type '{getFullyQualifiedName(mapping)}'.") 

2008 raise ex 

2009 

2010 name = mapping.get("name", None) 

2011 identifier = mapping.get("id", None) 

2012 condition = mapping.get("if", None) 

2013 run = mapping.get("run", None) 

2014 uses = None 

2015 if "uses" in mapping: 

2016 uses = UsesReference._FromYAML(mapping, f"step {position + 1} of {what}", path) 

2017 

2018 return cls( 

2019 line, 

2020 name=None if name is None else str(name), 

2021 identifier=None if identifier is None else str(identifier), 

2022 condition=None if condition is None else str(condition), 

2023 run=None if run is None else str(run), 

2024 uses=uses 

2025 ) 

2026 

2027 

2028@export 

2029class Matrix(Base[Job]): 

2030 """ 

2031 The ``strategy.matrix`` of a job. 

2032 

2033 A matrix is *dynamic*, if a part of it is an expression - as ``include: ${{ fromJson(inputs.jobs) }}`` - because 

2034 its instances are then known at run time only. 

2035 """ 

2036 

2037 _PARENT_TYPE: ClassVar[ParentTypes] = Job #: A matrix belongs to a job. 

2038 

2039 _dimensions: dict[str, ValueT] #: The dimensions, by name. 

2040 _include: ValueT #: The combinations added, or an expression producing them. 

2041 _exclude: ValueT #: The combinations removed, or an expression producing them. 

2042 _expression: Nullable[str] #: The expression the whole matrix is taken from. 

2043 

2044 def __init__( 

2045 self, 

2046 line: int, 

2047 dimensions: Nullable[Mapping[str, ValueT]] = None, 

2048 include: ValueT = None, 

2049 exclude: ValueT = None, 

2050 expression: Nullable[str] = None, 

2051 *, 

2052 parent: Nullable[Job] = None 

2053 ) -> None: 

2054 """ 

2055 Initializes a job's matrix. 

2056 

2057 :param line: Line the ``matrix`` key is written at, starting at 1. 

2058 :param dimensions: Optional, the dimensions, by name; a dimension's value is a list or an expression. 

2059 Default: ``None``. 

2060 :param include: Optional, the combinations added, or an expression producing them. Default: ``None``. 

2061 :param exclude: Optional, the combinations removed, or an expression producing them. Default: ``None``. 

2062 :param expression: Optional, the expression the whole matrix is taken from. Default: ``None``. 

2063 :param parent: Optional, reference to the job the matrix belongs to, which the matrix is attached to. 

2064 Default: ``None``. 

2065 :raises TypeError: If parameter 'dimensions' is not a mapping. 

2066 :raises TypeError: If parameter 'expression' is not of type :class:`str`. 

2067 """ 

2068 super().__init__(line, parent=parent) 

2069 

2070 if dimensions is not None and not isinstance(dimensions, Mapping): 2070 ↛ 2071line 2070 didn't jump to line 2071 because the condition on line 2070 was never true

2071 ex = TypeError("Parameter 'dimensions' is not a mapping.") 

2072 ex.add_note(f"Got type '{getFullyQualifiedName(dimensions)}'.") 

2073 raise ex 

2074 

2075 if expression is not None and not isinstance(expression, str): 2075 ↛ 2076line 2075 didn't jump to line 2076 because the condition on line 2075 was never true

2076 ex = TypeError("Parameter 'expression' is not of type 'str'.") 

2077 ex.add_note(f"Got type '{getFullyQualifiedName(expression)}'.") 

2078 raise ex 

2079 

2080 self._dimensions = {} if dimensions is None else dict(dimensions) 

2081 self._include = include 

2082 self._exclude = exclude 

2083 self._expression = expression 

2084 

2085 if parent is not None: 

2086 parent._matrix = self 

2087 

2088 @readonly 

2089 def Dimensions(self) -> dict[str, ValueT]: 

2090 """ 

2091 Read-only property to access the matrix' dimensions (:attr:`_dimensions`). 

2092 

2093 :returns: The dimensions, by name; a dimension's value is a list, or an expression producing one. 

2094 """ 

2095 return self._dimensions 

2096 

2097 @readonly 

2098 def Include(self) -> ValueT: 

2099 """ 

2100 Read-only property to access the combinations added to the matrix (:attr:`_include`). 

2101 

2102 :returns: A list of combinations, an expression producing them, or ``None`` if the matrix has no ``include``. 

2103 """ 

2104 return self._include 

2105 

2106 @readonly 

2107 def Exclude(self) -> ValueT: 

2108 """ 

2109 Read-only property to access the combinations removed from the matrix (:attr:`_exclude`). 

2110 

2111 :returns: A list of combinations, an expression producing them, or ``None`` if the matrix has no ``exclude``. 

2112 """ 

2113 return self._exclude 

2114 

2115 @readonly 

2116 def Expression(self) -> Nullable[str]: 

2117 """ 

2118 Read-only property to access the expression the whole matrix is taken from (:attr:`_expression`). 

2119 

2120 :returns: The expression, as ``${{ fromJson(needs.Params.outputs.matrix) }}``, or ``None`` if the matrix is a 

2121 mapping. 

2122 """ 

2123 return self._expression 

2124 

2125 @readonly 

2126 def IsDynamic(self) -> bool: 

2127 """ 

2128 Read-only property to return whether the matrix' instances are known at run time only. 

2129 

2130 :returns: ``True``, if the matrix, its ``include``, its ``exclude`` or one of its dimensions is an expression. 

2131 """ 

2132 return ( 

2133 self._expression is not None or isinstance(self._include, str) or isinstance(self._exclude, str) or 

2134 any(isinstance(value, str) for value in self._dimensions.values()) 

2135 ) 

2136 

2137 @readonly 

2138 def Combinations(self) -> list[dict[str, ValueT]]: 

2139 """ 

2140 Read-only property to return the combinations the matrix produces, as GitHub computes them. 

2141 

2142 The dimensions are combined in the order they are written, the last one varying fastest. Then ``exclude`` 

2143 removes every combination matching all key-value pairs of an entry, and ``include`` extends every remaining 

2144 combination whose dimension values the entry doesn't change - its other keys, and those an earlier entry 

2145 added, it may change. An entry extending no combination is a combination of its own. 

2146 

2147 :returns: The combinations, each a mapping of the dimensions' and included keys' names to values. 

2148 :raises WorkflowError: If the matrix is dynamic, so its combinations are known at run time only. 

2149 :raises WorkflowError: If ``include`` or ``exclude`` is not a list of mappings. 

2150 """ 

2151 path = self._file 

2152 if self.IsDynamic: 

2153 raise WorkflowError("Matrix is dynamic; its combinations are known at run time only.", path, self._line) 

2154 

2155 for key, entries in ( 

2156 ("include", self._include), 

2157 ("exclude", self._exclude) 

2158 ): 

2159 if entries is not None and ( 

2160 not isinstance(entries, list) or not all(isinstance(entry, dict) for entry in entries) 

2161 ): 

2162 raise WorkflowError(f"Key '{key}' of the matrix is not a list of mappings.", path, self._line) 

2163 

2164 combinations = [] 

2165 if len(self._dimensions) > 0: 

2166 dimensions = {name: value if isinstance(value, list) else [value] for name, value in self._dimensions.items()} 

2167 combinations = [dict(zip(dimensions, values)) for values in product(*dimensions.values())] 

2168 

2169 if self._exclude is not None: 

2170 for entry in self._exclude: 

2171 combinations = [ 

2172 combination for combination in combinations 

2173 if not all(combination.get(key, None) == value for key, value in entry.items()) 

2174 ] 

2175 

2176 if self._include is None: 

2177 return combinations 

2178 

2179 originals = [dict(combination) for combination in combinations] 

2180 for entry in self._include: 

2181 extended = False 

2182 for combination, original in zip(combinations, originals): 

2183 if all(original[key] == value for key, value in entry.items() if key in original): 

2184 combination.update(entry) 

2185 extended = True 

2186 

2187 if not extended: 

2188 combinations.append(dict(entry)) 

2189 

2190 return combinations 

2191 

2192 @staticmethod 

2193 def _FormatCombination(combination: Mapping[str, ValueT]) -> dict[str, str]: 

2194 """ 

2195 Format the values of a matrix' combination as GitHub prints them in the name of a matrix instance. 

2196 

2197 A string is printed as it is, any other value as JSON: ``true``, ``3``, ``{"os": "ubuntu"}``. 

2198 

2199 :param combination: The combination, as :attr:`Matrix.Combinations` returns it. 

2200 :returns: The combination's names and formatted values, in the combination's order. 

2201 """ 

2202 return { 

2203 name: value if isinstance(value, str) else json_dumps(value, separators=(", ", ": ")) 

2204 for name, value in combination.items() 

2205 } 

2206 

2207 @classmethod 

2208 def _FromYAML(cls, value: Any, path: Path, line: int) -> Self: 

2209 """ 

2210 Read the value of a job's ``strategy.matrix`` key. 

2211 

2212 :param value: The value of the ``matrix`` key: a mapping, or an expression. 

2213 :param path: Path to the workflow file. 

2214 :param line: Line the key is written at, starting at 1. 

2215 :returns: The matrix. 

2216 :raises WorkflowError: If the value is neither a mapping nor an expression. 

2217 """ 

2218 if isinstance(value, str): 

2219 return cls(line, expression=str(value)) 

2220 elif not isinstance(value, CommentedMap): 

2221 ex = WorkflowError("Key 'strategy.matrix' is not a mapping.", path, line) 

2222 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.") 

2223 raise ex 

2224 

2225 return cls( 

2226 line, 

2227 dimensions={key: Base._ToPython(item) for key, item in value.items() if key not in ("include", "exclude")}, 

2228 include=Base._ToPython(value.get("include", None)), 

2229 exclude=Base._ToPython(value.get("exclude", None)) 

2230 ) 

2231 

2232 

2233@export 

2234class UsesReference(Base[Union[Job, Step]]): 

2235 """ 

2236 The value of a ``uses`` key: a reusable workflow called by a job, or an action run by a step. 

2237 

2238 The forms GitHub accepts are read into their parts: 

2239 

2240 .. code-block:: text 

2241 

2242 pyTooling/Actions/.github/workflows/Package.yml@r8 repository, path and ref 

2243 actions/checkout@v6 an action in a repository's root 

2244 ./.github/workflows/Package.yml a file of the same repository and commit 

2245 docker://alpine:3.22 a Docker image 

2246 """ 

2247 

2248 _PARENT_TYPE: ClassVar[ParentTypes] = (Job, Step) #: A reference is contained in a job or a step. 

2249 

2250 _rawReference: str #: The reference, as written. 

2251 _repository: Nullable[str] #: The repository, as ``owner/repo``. 

2252 _path: str #: The path within the repository. 

2253 _reference: Nullable[str] #: The branch, tag or commit. 

2254 _isLocal: bool #: ``True``, if the reference names a file of the same repository. 

2255 _isDocker: bool #: ``True``, if the reference names a Docker image. 

2256 

2257 def __init__(self, rawReference: str, line: int, *, parent: Nullable[Union[Job, Step]] = None) -> None: 

2258 """ 

2259 Initializes a ``uses`` reference by reading it into its parts. 

2260 

2261 :param rawReference: The reference, as written. 

2262 :param line: Line the reference is written at, starting at 1. 

2263 :param parent: Optional, reference to the job or step containing it, which the reference is attached to. 

2264 Default: ``None``. 

2265 :raises ValueError: If parameter 'rawReference' is ``None``. 

2266 :raises TypeError: If parameter 'rawReference' is not of type :class:`str`. 

2267 :raises ValueError: If parameter 'rawReference' is empty. 

2268 :raises ValueError: If parameter 'rawReference' names a repository without a ref. 

2269 :raises ValueError: If parameter 'rawReference' names no repository as ``owner/repo``. 

2270 """ 

2271 super().__init__(line, parent=parent) 

2272 

2273 if rawReference is None: 

2274 raise ValueError("Parameter 'rawReference' is None.") 

2275 elif not isinstance(rawReference, str): 2275 ↛ 2276line 2275 didn't jump to line 2276 because the condition on line 2275 was never true

2276 ex = TypeError("Parameter 'rawReference' is not of type 'str'.") 

2277 ex.add_note(f"Got type '{getFullyQualifiedName(rawReference)}'.") 

2278 raise ex 

2279 elif rawReference == "": 

2280 raise ValueError("Parameter 'rawReference' is empty.") 

2281 

2282 self._rawReference = rawReference 

2283 self._isLocal = False 

2284 self._isDocker = False 

2285 self._repository = None 

2286 self._reference = None 

2287 

2288 if rawReference.startswith("docker://"): 

2289 self._isDocker = True 

2290 self._path = rawReference[len("docker://"):] 

2291 elif rawReference.startswith("./"): 

2292 self._isLocal = True 

2293 self._path = rawReference[len("./"):] 

2294 else: 

2295 location, separator, reference = rawReference.partition("@") 

2296 if separator == "" or reference == "": 

2297 ex = ValueError("Parameter 'rawReference' names a repository without a ref.") 

2298 ex.add_note(f"Got '{rawReference}'.") 

2299 raise ex 

2300 

2301 owner, _, remainder = location.partition("/") 

2302 repository, _, path = remainder.partition("/") 

2303 if owner == "" or repository == "": 

2304 ex = ValueError("Parameter 'rawReference' names no repository as 'owner/repo'.") 

2305 ex.add_note(f"Got '{rawReference}'.") 

2306 raise ex 

2307 

2308 self._repository = f"{owner}/{repository}" 

2309 self._path = path 

2310 self._reference = reference 

2311 

2312 if parent is not None: 

2313 parent._uses = self 

2314 

2315 @readonly 

2316 def Repository(self) -> Nullable[str]: 

2317 """ 

2318 Read-only property to access the repository (:attr:`_repository`). 

2319 

2320 :returns: The repository, as ``owner/repo``, or ``None`` for a local reference and a Docker image. 

2321 """ 

2322 return self._repository 

2323 

2324 @readonly 

2325 def Path(self) -> str: 

2326 """ 

2327 Read-only property to access the path within the repository (:attr:`_path`). 

2328 

2329 :returns: The path, as ``.github/workflows/Package.yml``, without the leading ``./`` of a local reference. It 

2330 is empty for an action in a repository's root, and the image for a Docker image. 

2331 """ 

2332 return self._path 

2333 

2334 @readonly 

2335 def Reference(self) -> Nullable[str]: 

2336 """ 

2337 Read-only property to access the branch, tag or commit (:attr:`_reference`). 

2338 

2339 :returns: The branch, tag or commit, as ``r8``, or ``None`` for a local reference and a Docker image. 

2340 """ 

2341 return self._reference 

2342 

2343 @readonly 

2344 def IsLocal(self) -> bool: 

2345 """ 

2346 Read-only property to access whether the reference names a file of the same repository (:attr:`_isLocal`). 

2347 

2348 :returns: ``True``, if the reference starts with ``./``. 

2349 """ 

2350 return self._isLocal 

2351 

2352 @readonly 

2353 def IsDocker(self) -> bool: 

2354 """ 

2355 Read-only property to access whether the reference names a Docker image (:attr:`_isDocker`). 

2356 

2357 :returns: ``True``, if the reference starts with ``docker://``. 

2358 """ 

2359 return self._isDocker 

2360 

2361 @readonly 

2362 def IsWorkflow(self) -> bool: 

2363 """ 

2364 Read-only property to return whether the reference names a reusable workflow rather than an action. 

2365 

2366 :returns: ``True``, if the path names a ``.yml`` or ``.yaml`` file in ``.github/workflows``. 

2367 """ 

2368 path = PurePosixPath(self._path) 

2369 return ( 

2370 not self._isDocker and path.parent == PurePosixPath(".github/workflows") and path.suffix in (".yml", ".yaml") 

2371 ) 

2372 

2373 @readonly 

2374 def FileName(self) -> str: 

2375 """ 

2376 Read-only property to return the last element of the path. 

2377 

2378 :returns: The file name, as ``Package.yml`` for a reusable workflow, or ``""`` for an action in a repository's 

2379 root. 

2380 """ 

2381 return PurePosixPath(self._path).name if not self._isDocker else "" 

2382 

2383 @readonly 

2384 def Stem(self) -> str: 

2385 """ 

2386 Read-only property to return the file name without its extension. 

2387 

2388 For a reusable workflow, it is the name :class:`Workflow` gives the file it reads, e.g. ``Package``. 

2389 

2390 :returns: The file name without its extension, or ``""`` for an action in a repository's root. 

2391 """ 

2392 return PurePosixPath(self._path).stem if not self._isDocker else "" 

2393 

2394 def __str__(self) -> str: 

2395 """ 

2396 Return the reference, as written. 

2397 

2398 :returns: The reference. 

2399 """ 

2400 return self._rawReference 

2401 

2402 @classmethod 

2403 def _FromYAML(cls, mapping: CommentedMap, what: str, path: Path) -> Self: 

2404 """ 

2405 Read the ``uses`` key of a job or step. 

2406 

2407 :param mapping: The mapping of the job or step, which has a ``uses`` key. 

2408 :param what: The job or step, for the exception's message, as ``job 'Build'``. 

2409 :param path: Path to the workflow file. 

2410 :returns: The reference. 

2411 :raises WorkflowError: If the value is not a reference. 

2412 """ 

2413 line = Base._KeyLine(mapping, "uses") 

2414 try: 

2415 return cls(str(mapping["uses"]), line) 

2416 except ValueError as cause: 

2417 raise WorkflowError(f"Key 'uses' of {what} is not a reference.", path, line) from cause 

2418 

2419 

2420@export 

2421class Permission(Base[Union[Workflow, Job]]): 

2422 """ 

2423 A permission a workflow or job declares for the ``GITHUB_TOKEN``, as ``contents: write``. 

2424 

2425 The short forms ``read-all`` and ``write-all`` are read as one permission of scope :attr:`PermissionScope.All`. 

2426 """ 

2427 

2428 _PARENT_TYPE: ClassVar[ParentTypes] = (Workflow, Job) #: A permission is declared by a workflow or a job. 

2429 

2430 _scope: PermissionScope #: The scope, as ``contents``. 

2431 _level: AccessLevel #: The access granted. 

2432 

2433 def __init__( 

2434 self, 

2435 scope: PermissionScope, 

2436 level: AccessLevel, 

2437 line: int, 

2438 *, 

2439 parent: Nullable[Union[Workflow, Job]] = None 

2440 ) -> None: 

2441 """ 

2442 Initializes a permission. 

2443 

2444 :param scope: The scope, as ``contents``. 

2445 :param level: The access granted. 

2446 :param line: Line the permission is written at, starting at 1. 

2447 :param parent: Optional, reference to the workflow or job declaring it, which the permission is attached to. 

2448 Default: ``None``. 

2449 :raises ValueError: If parameter 'scope' is ``None``. 

2450 :raises TypeError: If parameter 'scope' is not of type :class:`PermissionScope`. 

2451 :raises ValueError: If parameter 'level' is ``None``. 

2452 :raises TypeError: If parameter 'level' is not of type :class:`AccessLevel`. 

2453 """ 

2454 super().__init__(line, parent=parent) 

2455 

2456 if scope is None: 2456 ↛ 2457line 2456 didn't jump to line 2457 because the condition on line 2456 was never true

2457 raise ValueError("Parameter 'scope' is None.") 

2458 elif not isinstance(scope, PermissionScope): 

2459 ex = TypeError("Parameter 'scope' is not of type 'PermissionScope'.") 

2460 ex.add_note(f"Got type '{getFullyQualifiedName(scope)}'.") 

2461 raise ex 

2462 

2463 if level is None: 2463 ↛ 2464line 2463 didn't jump to line 2464 because the condition on line 2463 was never true

2464 raise ValueError("Parameter 'level' is None.") 

2465 elif not isinstance(level, AccessLevel): 

2466 ex = TypeError("Parameter 'level' is not of type 'AccessLevel'.") 

2467 ex.add_note(f"Got type '{getFullyQualifiedName(level)}'.") 

2468 raise ex 

2469 

2470 self._scope = scope 

2471 self._level = level 

2472 

2473 if parent is not None: 

2474 if parent._permissions is None: 2474 ↛ 2477line 2474 didn't jump to line 2477 because the condition on line 2474 was always true

2475 parent._permissions = {} 

2476 

2477 parent._permissions[scope] = self 

2478 

2479 @readonly 

2480 def Scope(self) -> PermissionScope: 

2481 """ 

2482 Read-only property to access the scope (:attr:`_scope`). 

2483 

2484 :returns: The scope, as :attr:`PermissionScope.Contents`, or :attr:`PermissionScope.All` for ``read-all`` and 

2485 ``write-all``. 

2486 """ 

2487 return self._scope 

2488 

2489 @readonly 

2490 def Level(self) -> AccessLevel: 

2491 """ 

2492 Read-only property to access the access granted (:attr:`_level`). 

2493 

2494 :returns: The access level. 

2495 """ 

2496 return self._level 

2497 

2498 def __str__(self) -> str: 

2499 """ 

2500 Return the permission, as written in a workflow file. 

2501 

2502 :returns: The permission, as ``contents: write``, or ``read-all`` for scope :attr:`PermissionScope.All`. 

2503 """ 

2504 if self._scope is PermissionScope.All: 

2505 return f"{self._level.value}-all" 

2506 

2507 return f"{self._scope}: {self._level.value}" 

2508 

2509 @classmethod 

2510 def _FromYAML(cls, value: Any, path: Path, line: int) -> list[Self]: 

2511 """ 

2512 Read the value of a ``permissions`` key into the permissions a workflow or job declares. 

2513 

2514 :param value: The value of the ``permissions`` key. 

2515 :param path: Path to the workflow file. 

2516 :param line: Line the key is written at, starting at 1. 

2517 :returns: The permissions, in file order. 

2518 :raises WorkflowError: If the value is neither ``read-all``, ``write-all`` nor a mapping. 

2519 :raises WorkflowError: If a key is not a permission scope. |br| 

2520 The note lists the allowed values. 

2521 :raises WorkflowError: If a scope's value is not an access level. |br| 

2522 The note lists the allowed values. 

2523 """ 

2524 if value == "read-all": 

2525 return [cls(PermissionScope.All, AccessLevel.Read, line)] 

2526 elif value == "write-all": 

2527 return [cls(PermissionScope.All, AccessLevel.Write, line)] 

2528 elif not isinstance(value, CommentedMap): 

2529 ex = WorkflowError("Key 'permissions' is neither 'read-all', 'write-all' nor a mapping.", path, line) 

2530 ex.add_note(f"Got '{value}'." if isinstance(value, str) else f"Got type '{getFullyQualifiedName(value)}'.") 

2531 raise ex 

2532 

2533 permissions = [] 

2534 for scope, level in value.items(): 

2535 scopeLine = Base._KeyLine(value, scope) 

2536 try: 

2537 permissionScope = PermissionScope(scope) 

2538 except ValueError as cause: 

2539 ex = WorkflowError(f"Key '{scope}' of 'permissions' is not a permission scope.", path, scopeLine) 

2540 scopes = (member.value for member in PermissionScope if member is not PermissionScope.All) 

2541 ex.add_note(f"Allowed values: {', '.join(scopes)}.") 

2542 raise ex from cause 

2543 

2544 try: 

2545 accessLevel = AccessLevel(level) 

2546 except ValueError as cause: 

2547 ex = WorkflowError(f"Permission '{scope}' is not an access level.", path, scopeLine) 

2548 ex.add_note(f"Got '{level}'.") 

2549 ex.add_note(f"Allowed values: {', '.join(member.value for member in AccessLevel)}.") 

2550 raise ex from cause 

2551 

2552 permissions.append(cls(permissionScope, accessLevel, scopeLine)) 

2553 

2554 return permissions 

2555 

2556 

2557@export 

2558@abstractclass 

2559class Parameter(Base[Workflow]): 

2560 """ 

2561 Common behaviour of the inputs, outputs and secrets of a reusable workflow. 

2562 

2563 Every parameter has a name and an optional description, and belongs to a :class:`Workflow`. 

2564 """ 

2565 

2566 _PARENT_TYPE: ClassVar[ParentTypes] = Workflow #: A parameter is declared by a workflow. 

2567 

2568 _name: str #: Name of the parameter. 

2569 _description: Nullable[str] #: Description of the parameter. 

2570 

2571 def __init__( 

2572 self, 

2573 name: str, 

2574 line: int, 

2575 description: Nullable[str] = None, 

2576 *, 

2577 parent: Nullable[Workflow] = None 

2578 ) -> None: 

2579 """ 

2580 Initializes a parameter of a reusable workflow. 

2581 

2582 :param name: Name of the parameter. 

2583 :param line: Line the parameter's name is written at, starting at 1. 

2584 :param description: Optional, description of the parameter. Default: ``None``. 

2585 :param parent: Optional, reference to the workflow declaring it. Default: ``None``. 

2586 :raises ValueError: If parameter 'name' is ``None``. 

2587 :raises TypeError: If parameter 'name' is not of type :class:`str`. 

2588 :raises ValueError: If parameter 'name' is empty. 

2589 :raises TypeError: If parameter 'description' is not of type :class:`str`. 

2590 """ 

2591 super().__init__(line, parent=parent) 

2592 

2593 if name is None: 2593 ↛ 2594line 2593 didn't jump to line 2594 because the condition on line 2593 was never true

2594 raise ValueError("Parameter 'name' is None.") 

2595 elif not isinstance(name, str): 2595 ↛ 2596line 2595 didn't jump to line 2596 because the condition on line 2595 was never true

2596 ex = TypeError("Parameter 'name' is not of type 'str'.") 

2597 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.") 

2598 raise ex 

2599 elif name == "": 2599 ↛ 2600line 2599 didn't jump to line 2600 because the condition on line 2599 was never true

2600 raise ValueError("Parameter 'name' is empty.") 

2601 

2602 if description is not None and not isinstance(description, str): 2602 ↛ 2603line 2602 didn't jump to line 2603 because the condition on line 2602 was never true

2603 ex = TypeError("Parameter 'description' is not of type 'str'.") 

2604 ex.add_note(f"Got type '{getFullyQualifiedName(description)}'.") 

2605 raise ex 

2606 

2607 self._name = name 

2608 self._description = description 

2609 

2610 @readonly 

2611 def Name(self) -> str: 

2612 """ 

2613 Read-only property to access the parameter's name (:attr:`_name`). 

2614 

2615 :returns: Name of the parameter. 

2616 """ 

2617 return self._name 

2618 

2619 @readonly 

2620 def Description(self) -> Nullable[str]: 

2621 """ 

2622 Read-only property to access the parameter's description (:attr:`_description`). 

2623 

2624 :returns: The description, or ``None`` if the workflow gives none. 

2625 """ 

2626 return self._description 

2627 

2628 def __str__(self) -> str: 

2629 """ 

2630 Return the parameter's name. 

2631 

2632 :returns: Name of the parameter. 

2633 """ 

2634 return self._name 

2635 

2636 

2637@export 

2638class Input(Parameter): 

2639 """An input of a reusable workflow, declared in ``on.workflow_call.inputs``.""" 

2640 

2641 _type: InputType #: Type of the input. 

2642 _required: bool #: ``True``, if a caller has to pass the input. 

2643 _default: ValueT #: Value of the input, if a caller doesn't pass it. 

2644 

2645 def __init__( 

2646 self, 

2647 name: str, 

2648 line: int, 

2649 inputType: InputType, 

2650 required: bool = False, 

2651 default: ValueT = None, 

2652 description: Nullable[str] = None, 

2653 *, 

2654 parent: Nullable[Workflow] = None 

2655 ) -> None: 

2656 """ 

2657 Initializes an input of a reusable workflow. 

2658 

2659 :param name: Name of the input. 

2660 :param line: Line the input's name is written at, starting at 1. 

2661 :param inputType: Type of the input. 

2662 :param required: Optional, ``True``, if a caller has to pass the input. Default: ``False``. 

2663 :param default: Optional, value of the input, if a caller doesn't pass it. Default: ``None``. 

2664 :param description: Optional, description of the input. Default: ``None``. 

2665 :param parent: Optional, reference to the workflow declaring it. Default: ``None``. 

2666 :raises ValueError: If parameter 'inputType' is ``None``. 

2667 :raises TypeError: If parameter 'inputType' is not of type :class:`InputType`. 

2668 :raises TypeError: If parameter 'required' is not of type :class:`bool`. 

2669 """ 

2670 super().__init__(name, line, description, parent=parent) 

2671 

2672 if inputType is None: 

2673 raise ValueError("Parameter 'inputType' is None.") 

2674 elif not isinstance(inputType, InputType): 

2675 ex = TypeError("Parameter 'inputType' is not of type 'InputType'.") 

2676 ex.add_note(f"Got type '{getFullyQualifiedName(inputType)}'.") 

2677 raise ex 

2678 

2679 if not isinstance(required, bool): 2679 ↛ 2680line 2679 didn't jump to line 2680 because the condition on line 2679 was never true

2680 ex = TypeError("Parameter 'required' is not of type 'bool'.") 

2681 ex.add_note(f"Got type '{getFullyQualifiedName(required)}'.") 

2682 raise ex 

2683 

2684 self._type = inputType 

2685 self._required = required 

2686 self._default = default 

2687 

2688 if parent is not None: 2688 ↛ 2689line 2688 didn't jump to line 2689 because the condition on line 2688 was never true

2689 parent._inputs[name] = self 

2690 

2691 @readonly 

2692 def Type(self) -> InputType: 

2693 """ 

2694 Read-only property to access the input's type (:attr:`_type`). 

2695 

2696 :returns: Type of the input. 

2697 """ 

2698 return self._type 

2699 

2700 @readonly 

2701 def Required(self) -> bool: 

2702 """ 

2703 Read-only property to access whether a caller has to pass the input (:attr:`_required`). 

2704 

2705 :returns: ``True``, if the input is required. 

2706 """ 

2707 return self._required 

2708 

2709 @readonly 

2710 def Default(self) -> ValueT: 

2711 """ 

2712 Read-only property to access the input's value, if a caller doesn't pass it (:attr:`_default`). 

2713 

2714 The value keeps the type it is written with, as ``'3.14'`` or ``false``, and a multi-line value keeps its line 

2715 breaks. 

2716 

2717 :returns: The default value, or ``None`` if the workflow gives none. 

2718 """ 

2719 return self._default 

2720 

2721 @classmethod 

2722 def _FromYAML(cls, name: str, declaration: Any, path: Path, line: int) -> Self: 

2723 """ 

2724 Read an input's declaration below ``on.workflow_call.inputs``. 

2725 

2726 

2727 :param name: Name of the input. 

2728 :param declaration: The declaration. 

2729 :param path: Path to the workflow file. 

2730 :param line: Line the input's name is written at, starting at 1. 

2731 :returns: The input. 

2732 :raises WorkflowError: If the declaration is not a mapping. 

2733 :raises WorkflowError: If key ``required`` is not a boolean. 

2734 :raises WorkflowError: If the declaration has no ``type`` key. 

2735 :raises WorkflowError: If key ``type`` is not an input type. |br| 

2736 The note lists the allowed values. 

2737 """ 

2738 if declaration is None: 

2739 raise WorkflowError(f"Input '{name}' has no 'type' key.", path, line) 

2740 elif not isinstance(declaration, CommentedMap): 

2741 ex = WorkflowError(f"Declaration of '{name}' is not a mapping.", path, line) 

2742 ex.add_note(f"Got type '{getFullyQualifiedName(declaration)}'.") 

2743 raise ex 

2744 

2745 description = declaration.get("description", None) 

2746 required = Base._ToPython(declaration.get("required", False)) 

2747 if not isinstance(required, bool): 2747 ↛ 2748line 2747 didn't jump to line 2748 because the condition on line 2747 was never true

2748 ex = WorkflowError(f"Key 'required' of '{name}' is not a boolean.", path, line) 

2749 ex.add_note(f"Got '{required}'.") 

2750 raise ex 

2751 

2752 if (inputType := declaration.get("type", None)) is None: 

2753 raise WorkflowError(f"Input '{name}' has no 'type' key.", path, line) 

2754 

2755 try: 

2756 inputType = InputType.Parse(str(inputType)) 

2757 except ValueError as cause: 

2758 ex = WorkflowError(f"Key 'type' of input '{name}' is not an input type.", path, line) 

2759 ex.add_note(f"Got '{inputType}'.") 

2760 ex.add_note(f"Allowed values: {', '.join(member.value for member in InputType)}.") 

2761 raise ex from cause 

2762 

2763 default = Base._ToPython(declaration.get("default", None)) 

2764 

2765 return cls(name, line, inputType, required, default, None if description is None else str(description)) 

2766 

2767 

2768@export 

2769class Output(Parameter): 

2770 """An output of a reusable workflow, declared in ``on.workflow_call.outputs``.""" 

2771 

2772 _value: str #: Expression the output's value is taken from. 

2773 

2774 def __init__( 

2775 self, 

2776 name: str, 

2777 line: int, 

2778 value: str, 

2779 description: Nullable[str] = None, 

2780 *, 

2781 parent: Nullable[Workflow] = None 

2782 ) -> None: 

2783 """ 

2784 Initializes an output of a reusable workflow. 

2785 

2786 :param name: Name of the output. 

2787 :param line: Line the output's name is written at, starting at 1. 

2788 :param value: Expression the output's value is taken from, as ``${{ jobs.Build.outputs.version }}``. 

2789 :param description: Optional, description of the output. Default: ``None``. 

2790 :param parent: Optional, reference to the workflow declaring it. Default: ``None``. 

2791 :raises ValueError: If parameter 'value' is ``None``. 

2792 :raises TypeError: If parameter 'value' is not of type :class:`str`. 

2793 """ 

2794 super().__init__(name, line, description, parent=parent) 

2795 

2796 if value is None: 2796 ↛ 2797line 2796 didn't jump to line 2797 because the condition on line 2796 was never true

2797 raise ValueError("Parameter 'value' is None.") 

2798 elif not isinstance(value, str): 2798 ↛ 2799line 2798 didn't jump to line 2799 because the condition on line 2798 was never true

2799 ex = TypeError("Parameter 'value' is not of type 'str'.") 

2800 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.") 

2801 raise ex 

2802 

2803 self._value = value 

2804 

2805 if parent is not None: 2805 ↛ 2806line 2805 didn't jump to line 2806 because the condition on line 2805 was never true

2806 parent._outputs[name] = self 

2807 

2808 @readonly 

2809 def Value(self) -> str: 

2810 """ 

2811 Read-only property to access the expression the output's value is taken from (:attr:`_value`). 

2812 

2813 :returns: The expression, as ``${{ jobs.Build.outputs.version }}``. 

2814 """ 

2815 return self._value 

2816 

2817 @classmethod 

2818 def _FromYAML(cls, name: str, declaration: Any, path: Path, line: int) -> Self: 

2819 """ 

2820 Read an output's declaration below ``on.workflow_call.outputs``. 

2821 

2822 

2823 :param name: Name of the output. 

2824 :param declaration: The declaration. 

2825 :param path: Path to the workflow file. 

2826 :param line: Line the output's name is written at, starting at 1. 

2827 :returns: The output. 

2828 :raises WorkflowError: If the declaration is not a mapping. 

2829 :raises WorkflowError: If the declaration has no ``value`` key. 

2830 """ 

2831 if declaration is None: 

2832 raise WorkflowError(f"Output '{name}' has no 'value' key.", path, line) 

2833 elif not isinstance(declaration, CommentedMap): 2833 ↛ 2834line 2833 didn't jump to line 2834 because the condition on line 2833 was never true

2834 ex = WorkflowError(f"Declaration of '{name}' is not a mapping.", path, line) 

2835 ex.add_note(f"Got type '{getFullyQualifiedName(declaration)}'.") 

2836 raise ex 

2837 

2838 description = declaration.get("description", None) 

2839 

2840 if (value := declaration.get("value", None)) is None: 

2841 raise WorkflowError(f"Output '{name}' has no 'value' key.", path, line) 

2842 

2843 return cls(name, line, str(value), None if description is None else str(description)) 

2844 

2845 

2846@export 

2847class Secret(Parameter): 

2848 """A secret of a reusable workflow, declared in ``on.workflow_call.secrets``.""" 

2849 

2850 _required: bool #: ``True``, if a caller has to pass the secret. 

2851 

2852 def __init__( 

2853 self, 

2854 name: str, 

2855 line: int, 

2856 required: bool = False, 

2857 description: Nullable[str] = None, 

2858 *, 

2859 parent: Nullable[Workflow] = None 

2860 ) -> None: 

2861 """ 

2862 Initializes a secret of a reusable workflow. 

2863 

2864 :param name: Name of the secret. 

2865 :param line: Line the secret's name is written at, starting at 1. 

2866 :param required: Optional, ``True``, if a caller has to pass the secret. Default: ``False``. 

2867 :param description: Optional, description of the secret. Default: ``None``. 

2868 :param parent: Optional, reference to the workflow declaring it. Default: ``None``. 

2869 :raises TypeError: If parameter 'required' is not of type :class:`bool`. 

2870 """ 

2871 super().__init__(name, line, description, parent=parent) 

2872 

2873 if not isinstance(required, bool): 2873 ↛ 2874line 2873 didn't jump to line 2874 because the condition on line 2873 was never true

2874 ex = TypeError("Parameter 'required' is not of type 'bool'.") 

2875 ex.add_note(f"Got type '{getFullyQualifiedName(required)}'.") 

2876 raise ex 

2877 

2878 self._required = required 

2879 

2880 if parent is not None: 2880 ↛ 2881line 2880 didn't jump to line 2881 because the condition on line 2880 was never true

2881 parent._secrets[name] = self 

2882 

2883 @readonly 

2884 def Required(self) -> bool: 

2885 """ 

2886 Read-only property to access whether a caller has to pass the secret (:attr:`_required`). 

2887 

2888 :returns: ``True``, if the secret is required. 

2889 """ 

2890 return self._required 

2891 

2892 @classmethod 

2893 def _FromYAML(cls, name: str, declaration: Any, path: Path, line: int) -> Self: 

2894 """ 

2895 Read a secret's declaration below ``on.workflow_call.secrets``. 

2896 

2897 

2898 :param name: Name of the secret. 

2899 :param declaration: The declaration. 

2900 :param path: Path to the workflow file. 

2901 :param line: Line the secret's name is written at, starting at 1. 

2902 :returns: The secret. 

2903 :raises WorkflowError: If the declaration is not a mapping. 

2904 :raises WorkflowError: If key ``required`` is not a boolean. 

2905 """ 

2906 if declaration is None: 

2907 return cls(name, line) 

2908 elif not isinstance(declaration, CommentedMap): 2908 ↛ 2909line 2908 didn't jump to line 2909 because the condition on line 2908 was never true

2909 ex = WorkflowError(f"Declaration of '{name}' is not a mapping.", path, line) 

2910 ex.add_note(f"Got type '{getFullyQualifiedName(declaration)}'.") 

2911 raise ex 

2912 

2913 description = declaration.get("description", None) 

2914 required = Base._ToPython(declaration.get("required", False)) 

2915 if not isinstance(required, bool): 

2916 ex = WorkflowError(f"Key 'required' of '{name}' is not a boolean.", path, line) 

2917 ex.add_note(f"Got '{required}'.") 

2918 raise ex 

2919 

2920 return cls(name, line, required, None if description is None else str(description)) 

2921 

2922 

2923@export 

2924class WorkflowResolver(metaclass=ExtendedType, slots=True): 

2925 """ 

2926 Reads the reusable workflows jobs call, and the actions steps run, as far as they are in a local directory. 

2927 

2928 A repository is mapped to the directory holding its workflow files, so a reference like 

2929 ``pyTooling/Actions/.github/workflows/Package.yml@r8`` reads ``Package.yml`` from that directory, whatever its ref. 

2930 A local reference like ``./.github/workflows/Package.yml`` reads the file next to the calling workflow's file. 

2931 

2932 An action of a mapped repository, like ``pyTooling/Actions/.github/actions/ComputeRequirements@r8``, is read from 

2933 the repository's root - the directory holding the ``.github`` directory the mapped directory is in. A local action, 

2934 like ``./.github/actions/ComputeRequirements``, is read from the root of the calling workflow's or action's 

2935 repository. 

2936 

2937 Every file is read once; asking for it again returns the same :class:`Workflow` or :class:`Action`. 

2938 """ 

2939 

2940 _repositories: dict[str, Path] #: Directories holding the workflow files, by repository in lower case. 

2941 _workflows: dict[Path, Workflow] #: Workflows already read, by resolved path. 

2942 _actions: dict[Path, Action] #: Actions already read, by resolved path. 

2943 

2944 def __init__(self, repositories: Nullable[Mapping[str, Path]] = None) -> None: 

2945 """ 

2946 Initializes a resolver. 

2947 

2948 :param repositories: Optional, directories holding the workflow files, by repository, as 

2949 ``{"pyTooling/Actions": Path(".github/workflows")}``. Default: ``None``. 

2950 :raises TypeError: If parameter 'repositories' is not a mapping. 

2951 :raises TypeError: If a key of parameter 'repositories' is not of type :class:`str`. 

2952 :raises ValueError: If a key of parameter 'repositories' is not of the form ``owner/repo``. 

2953 :raises TypeError: If a value of parameter 'repositories' is not of type :class:`~pathlib.Path`. 

2954 """ 

2955 self._repositories = {} 

2956 self._workflows = {} 

2957 self._actions = {} 

2958 

2959 if repositories is None: 

2960 return 

2961 elif not isinstance(repositories, Mapping): 2961 ↛ 2962line 2961 didn't jump to line 2962 because the condition on line 2961 was never true

2962 ex = TypeError("Parameter 'repositories' is not a mapping.") 

2963 ex.add_note(f"Got type '{getFullyQualifiedName(repositories)}'.") 

2964 raise ex 

2965 

2966 for repository, directory in repositories.items(): 

2967 if not isinstance(repository, str): 2967 ↛ 2968line 2967 didn't jump to line 2968 because the condition on line 2967 was never true

2968 ex = TypeError("Key of parameter 'repositories' is not of type 'str'.") 

2969 ex.add_note(f"Got type '{getFullyQualifiedName(repository)}'.") 

2970 raise ex 

2971 elif repository.count("/") != 1 or repository.startswith("/") or repository.endswith("/"): 

2972 ex = ValueError("Key of parameter 'repositories' is not of the form 'owner/repo'.") 

2973 ex.add_note(f"Got '{repository}'.") 

2974 raise ex 

2975 elif not isinstance(directory, Path): 

2976 ex = TypeError(f"Value of parameter 'repositories' for '{repository}' is not of type 'Path'.") 

2977 ex.add_note(f"Got type '{getFullyQualifiedName(directory)}'.") 

2978 raise ex 

2979 

2980 self._repositories[repository.lower()] = directory 

2981 

2982 @readonly 

2983 def Repositories(self) -> dict[str, Path]: 

2984 """ 

2985 Read-only property to access the directories holding the workflow files (:attr:`_repositories`). 

2986 

2987 :returns: The directories, by repository in lower case. 

2988 """ 

2989 return self._repositories 

2990 

2991 def CanResolve(self, uses: UsesReference) -> bool: 

2992 """ 

2993 Return whether a reference names a file the resolver reads: a local one, or one of a mapped repository. 

2994 

2995 :param uses: The reference, as :attr:`Job.Uses`. 

2996 :returns: ``True``, if the reference is local, or its repository is in :attr:`Repositories`. 

2997 :raises ValueError: If parameter 'uses' is ``None``. 

2998 :raises TypeError: If parameter 'uses' is not of type :class:`UsesReference`. 

2999 """ 

3000 if uses is None: 

3001 raise ValueError("Parameter 'uses' is None.") 

3002 elif not isinstance(uses, UsesReference): 

3003 ex = TypeError("Parameter 'uses' is not of type 'UsesReference'.") 

3004 ex.add_note(f"Got type '{getFullyQualifiedName(uses)}'.") 

3005 raise ex 

3006 

3007 return uses._isLocal or (uses._repository is not None and uses._repository.lower() in self._repositories) 

3008 

3009 def Load(self, path: Path) -> Workflow: 

3010 """ 

3011 Read a workflow file, or return it if it was read before. 

3012 

3013 :param path: Path to the workflow file. 

3014 :returns: The workflow. 

3015 :raises ValueError: If parameter 'path' is ``None``. 

3016 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`. 

3017 :raises WorkflowError: If the file doesn't exist, can't be read, or is not a well-formed workflow. 

3018 """ 

3019 if path is None: 3019 ↛ 3020line 3019 didn't jump to line 3020 because the condition on line 3019 was never true

3020 raise ValueError("Parameter 'path' is None.") 

3021 elif not isinstance(path, Path): 3021 ↛ 3022line 3021 didn't jump to line 3022 because the condition on line 3021 was never true

3022 ex = TypeError("Parameter 'path' is not of type 'Path'.") 

3023 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.") 

3024 raise ex 

3025 

3026 key = path.resolve() 

3027 if (workflow := self._workflows.get(key, None)) is None: 

3028 workflow = Workflow.FromFile(path) 

3029 self._workflows[key] = workflow 

3030 

3031 return workflow 

3032 

3033 def Resolve(self, uses: UsesReference) -> Nullable[Workflow]: 

3034 """ 

3035 Return the reusable workflow a reference names, if its file is in a local directory. 

3036 

3037 :param uses: The reference, as :attr:`Job.Uses`. 

3038 :returns: The workflow, or ``None`` if the reference names an action, or a repository without a 

3039 directory. 

3040 :raises ValueError: If parameter 'uses' is ``None``. 

3041 :raises TypeError: If parameter 'uses' is not of type :class:`UsesReference`. 

3042 :raises ValueError: If parameter 'uses' is a local reference outside a workflow. 

3043 :raises WorkflowError: If the workflow file doesn't exist in the directory. |br| 

3044 The note names the reference's location. 

3045 :raises WorkflowError: If the file is not a well-formed workflow. 

3046 """ 

3047 if uses is None: 3047 ↛ 3048line 3047 didn't jump to line 3048 because the condition on line 3047 was never true

3048 raise ValueError("Parameter 'uses' is None.") 

3049 elif not isinstance(uses, UsesReference): 3049 ↛ 3050line 3049 didn't jump to line 3050 because the condition on line 3049 was never true

3050 ex = TypeError("Parameter 'uses' is not of type 'UsesReference'.") 

3051 ex.add_note(f"Got type '{getFullyQualifiedName(uses)}'.") 

3052 raise ex 

3053 

3054 if not uses.IsWorkflow: 

3055 return None 

3056 elif uses._isLocal: 

3057 if uses._workflow is None: 3057 ↛ 3058line 3057 didn't jump to line 3058 because the condition on line 3057 was never true

3058 ex = ValueError("Parameter 'uses' is a local reference outside a workflow.") 

3059 ex.add_note(f"Got '{uses}'.") 

3060 raise ex 

3061 

3062 directory = uses._workflow._path.parent 

3063 elif (directory := self._repositories.get(uses._repository.lower(), None)) is None: 

3064 return None 

3065 

3066 path = directory / uses.FileName 

3067 if not path.exists(): 

3068 ex = WorkflowError( 

3069 f"Workflow '{uses.FileName}' doesn't exist in '{directory}'.", 

3070 uses._file, 

3071 uses._line 

3072 ) 

3073 ex.add_note(f"Called as '{uses}'.") 

3074 raise ex 

3075 

3076 return self.Load(path) 

3077 

3078 def LoadAction(self, path: Path) -> Action: 

3079 """ 

3080 Read an action's file, or return it if it was read before. 

3081 

3082 :param path: Path to the action's file. 

3083 :returns: The action. 

3084 :raises ValueError: If parameter 'path' is ``None``. 

3085 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`. 

3086 :raises WorkflowError: If the file doesn't exist, can't be read, or is not a well-formed action. 

3087 """ 

3088 if path is None: 3088 ↛ 3089line 3088 didn't jump to line 3089 because the condition on line 3088 was never true

3089 raise ValueError("Parameter 'path' is None.") 

3090 elif not isinstance(path, Path): 3090 ↛ 3091line 3090 didn't jump to line 3091 because the condition on line 3090 was never true

3091 ex = TypeError("Parameter 'path' is not of type 'Path'.") 

3092 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.") 

3093 raise ex 

3094 

3095 key = path.resolve() 

3096 if (action := self._actions.get(key, None)) is None: 

3097 action = Action.FromFile(path) 

3098 self._actions[key] = action 

3099 

3100 return action 

3101 

3102 def ResolveAction(self, uses: UsesReference) -> Nullable[Action]: 

3103 """ 

3104 Return the action a step's reference names, if its file is in a local directory. 

3105 

3106 :param uses: The reference, as :attr:`Step.Uses`. 

3107 :returns: The action, or ``None`` if the reference names a reusable workflow, a Docker image, a 

3108 repository without a directory, or a local action outside a repository's ``.github`` 

3109 directory. 

3110 :raises ValueError: If parameter 'uses' is ``None``. 

3111 :raises TypeError: If parameter 'uses' is not of type :class:`UsesReference`. 

3112 :raises WorkflowError: If the directory has neither an ``action.yml`` nor an ``action.yaml``. |br| 

3113 The note names the reference's location. 

3114 :raises WorkflowError: If the file is not a well-formed action. 

3115 """ 

3116 if uses is None: 3116 ↛ 3117line 3116 didn't jump to line 3117 because the condition on line 3116 was never true

3117 raise ValueError("Parameter 'uses' is None.") 

3118 elif not isinstance(uses, UsesReference): 3118 ↛ 3119line 3118 didn't jump to line 3119 because the condition on line 3118 was never true

3119 ex = TypeError("Parameter 'uses' is not of type 'UsesReference'.") 

3120 ex.add_note(f"Got type '{getFullyQualifiedName(uses)}'.") 

3121 raise ex 

3122 

3123 if uses.IsWorkflow or uses._isDocker: 

3124 return None 

3125 

3126 if uses._isLocal: 

3127 if uses._file is None: 

3128 return None 

3129 

3130 base = uses._file.parent 

3131 elif (base := self._repositories.get(uses._repository.lower(), None)) is None: 

3132 return None 

3133 

3134 root = next((directory.parent for directory in (base, *base.parents) if directory.name == ".github"), None) 

3135 if root is None: 

3136 return None 

3137 

3138 directory = root / uses._path 

3139 for fileName in ("action.yml", "action.yaml"): 

3140 if (path := directory / fileName).exists(): 

3141 return self.LoadAction(path) 

3142 

3143 ex = WorkflowError(f"Action '{uses._path}' has no 'action.yml' in '{directory}'.", uses._file, uses._line) 

3144 ex.add_note(f"Called as '{uses}'.") 

3145 raise ex 

3146 

3147 

3148@export 

3149class DefinitionMixin(Generic[DefinitionType], metaclass=ExtendedType, mixin=True, expects=("_DEFINITION_TYPE",)): 

3150 """ 

3151 Mixin-class for an element of :mod:`pyTooling.CI` built from a workflow file, linking it to its definition. 

3152 

3153 :meth:`Workflow.ToPipeline` builds the elements, so a consumer of the generic model still reaches the facts only 

3154 the file has: the line an element is written at, the reference a job calls, its permissions. 

3155 """ 

3156 

3157 _definition: DefinitionType #: The element of the workflow file this element was built from. 

3158 

3159 @classmethod 

3160 def _CheckDefinition(cls, definition: DefinitionType) -> None: 

3161 """ 

3162 Check a definition before the element is built from it. 

3163 

3164 The host class names the element by its definition, so it checks the definition before calling 

3165 ``super().__init__()``. 

3166 

3167 :param definition: The element of the workflow file the element is built from. 

3168 :raises ValueError: If parameter 'definition' is ``None``. 

3169 :raises TypeError: If parameter 'definition' is not of the type the host class declares in 

3170 :attr:`_DEFINITION_TYPE`. 

3171 """ 

3172 if definition is None: 

3173 raise ValueError("Parameter 'definition' is None.") 

3174 elif not isinstance(definition, cls._DEFINITION_TYPE): 

3175 ex = TypeError(f"Parameter 'definition' is not of type '{cls._DEFINITION_TYPE.__name__}'.") 

3176 ex.add_note(f"Got type '{getFullyQualifiedName(definition)}'.") 

3177 raise ex 

3178 

3179 def __init__(self, definition: DefinitionType) -> None: 

3180 """ 

3181 Initializes the link of an element to its definition, which :meth:`_CheckDefinition` checked. 

3182 

3183 :param definition: The element of the workflow file this element is built from. 

3184 """ 

3185 self._definition = definition 

3186 

3187 @readonly 

3188 def Definition(self) -> DefinitionType: 

3189 """ 

3190 Read-only property to access the element of the workflow file this element was built from (:attr:`_definition`). 

3191 

3192 :returns: The :class:`Workflow` of a pipeline, the :class:`Job` of a called workflow, a matrix, a matrix instance 

3193 and a job, or the :class:`Step` of a step. 

3194 """ 

3195 return self._definition 

3196 

3197 

3198@export 

3199class CallMixin(metaclass=ExtendedType, mixin=True): 

3200 """Mixin-class for a called workflow built from a workflow file, holding the workflow file it was expanded from.""" 

3201 

3202 _calledWorkflow: Nullable[Workflow] #: The workflow file the called workflow's elements were built from. 

3203 

3204 def __init__(self, calledWorkflow: Nullable[Workflow] = None) -> None: 

3205 """ 

3206 Initializes the called workflow file. 

3207 

3208 :param calledWorkflow: Optional, the workflow file the called workflow's elements are built from. Default: 

3209 ``None``. 

3210 :raises TypeError: If parameter 'calledWorkflow' is not of type :class:`Workflow`. 

3211 """ 

3212 if calledWorkflow is not None and not isinstance(calledWorkflow, Workflow): 3212 ↛ 3213line 3212 didn't jump to line 3213 because the condition on line 3212 was never true

3213 ex = TypeError("Parameter 'calledWorkflow' is not of type 'Workflow'.") 

3214 ex.add_note(f"Got type '{getFullyQualifiedName(calledWorkflow)}'.") 

3215 raise ex 

3216 

3217 self._calledWorkflow = calledWorkflow 

3218 

3219 @readonly 

3220 def CalledWorkflow(self) -> Nullable[Workflow]: 

3221 """ 

3222 Read-only property to access the workflow file the called workflow was expanded from (:attr:`_calledWorkflow`). 

3223 

3224 :returns: The workflow, or ``None`` if the call wasn't expanded - its file isn't at hand, or the depth was used 

3225 up. 

3226 """ 

3227 return self._calledWorkflow 

3228 

3229 

3230@export 

3231class DefinedPipeline(CIPipeline, DefinitionMixin[Workflow]): 

3232 """The pipeline a workflow file defines, as :meth:`Workflow.ToPipeline` builds it.""" 

3233 

3234 _DEFINITION_TYPE: ClassVar[type] = Workflow #: A pipeline is built from a workflow file. 

3235 

3236 def __init__( 

3237 self, 

3238 definition: Workflow, 

3239 *, 

3240 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None 

3241 ) -> None: 

3242 """ 

3243 Initializes a pipeline built from a workflow file, named by the file's stem. 

3244 

3245 :param definition: The workflow file. 

3246 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

3247 """ 

3248 self._CheckDefinition(definition) 

3249 

3250 super().__init__(definition._name, keyValuePairs=keyValuePairs) 

3251 DefinitionMixin.__init__(self, definition) 

3252 

3253 

3254@export 

3255class DefinedWorkflow(CIWorkflow, CallMixin, DefinitionMixin[Job]): 

3256 """A called workflow built from the job calling it, as :meth:`Workflow.ToPipeline` builds it.""" 

3257 

3258 _DEFINITION_TYPE: ClassVar[type] = Job #: A called workflow is built from the job calling it. 

3259 

3260 def __init__( 

3261 self, 

3262 definition: Job, 

3263 *, 

3264 calledWorkflow: Nullable[Workflow] = None, 

3265 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

3266 parent: Nullable[CIWorkflow] = None 

3267 ) -> None: 

3268 """ 

3269 Initializes a called workflow built from the job calling it, named by the job's key. 

3270 

3271 The job's ``uses`` is the workflow's :attr:`~pyTooling.CI.Workflow.Reference`, its ``if`` the 

3272 workflow's :attr:`~pyTooling.CI.ConditionMixin.Condition`. 

3273 

3274 :param definition: The job calling the workflow. 

3275 :param calledWorkflow: Optional, the workflow file the called workflow's elements are built from. Default: 

3276 ``None``. 

3277 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

3278 :param parent: Optional, reference to the workflow containing the call. Default: ``None``. 

3279 :raises ValueError: If parameter 'definition' calls no workflow. 

3280 """ 

3281 self._CheckDefinition(definition) 

3282 

3283 if definition._uses is None: 3283 ↛ 3284line 3283 didn't jump to line 3284 because the condition on line 3283 was never true

3284 ex = ValueError("Parameter 'definition' calls no workflow.") 

3285 ex.add_note(f"Got job '{definition._name}'.") 

3286 raise ex 

3287 

3288 super().__init__( 

3289 definition._name, reference=str(definition._uses), condition=definition._condition, keyValuePairs=keyValuePairs, 

3290 parent=parent 

3291 ) 

3292 DefinitionMixin.__init__(self, definition) 

3293 CallMixin.__init__(self, calledWorkflow) 

3294 

3295 

3296@export 

3297class DefinedMatrix(CIMatrix, DefinitionMixin[Job]): 

3298 """ 

3299 A matrix built from the job declaring it, as :meth:`Workflow.ToPipeline` builds it. 

3300 

3301 A dynamic matrix - see :attr:`Matrix.IsDynamic` - holds no instances, since its combinations are known at run time 

3302 only. 

3303 """ 

3304 

3305 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix is built from the job declaring it. 

3306 

3307 def __init__( 

3308 self, 

3309 definition: Job, 

3310 *, 

3311 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

3312 parent: Nullable[CIWorkflow] = None 

3313 ) -> None: 

3314 """ 

3315 Initializes a matrix built from the job declaring it, named by the job's key. 

3316 

3317 :param definition: The job declaring the matrix. 

3318 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

3319 :param parent: Optional, reference to the workflow containing the matrix. Default: ``None``. 

3320 :raises ValueError: If parameter 'definition' declares no matrix. 

3321 """ 

3322 self._CheckDefinition(definition) 

3323 

3324 if definition._matrix is None: 

3325 ex = ValueError("Parameter 'definition' declares no matrix.") 

3326 ex.add_note(f"Got job '{definition._name}'.") 

3327 raise ex 

3328 

3329 super().__init__(definition._name, condition=definition._condition, keyValuePairs=keyValuePairs, parent=parent) 

3330 DefinitionMixin.__init__(self, definition) 

3331 

3332 

3333@export 

3334class DefinedMatrixWorkflow(CIMatrixWorkflow, CallMixin, DefinitionMixin[Job]): 

3335 """One instance of a matrix calling a reusable workflow, as :meth:`Workflow.ToPipeline` builds it.""" 

3336 

3337 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix instance is built from the job declaring the matrix. 

3338 

3339 def __init__( 

3340 self, 

3341 definition: Job, 

3342 dimensions: Mapping[str, Any], 

3343 *, 

3344 calledWorkflow: Nullable[Workflow] = None, 

3345 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

3346 parent: Nullable[CIMatrix] = None 

3347 ) -> None: 

3348 """ 

3349 Initializes one instance of a matrix calling a reusable workflow, named by the job's key. 

3350 

3351 :param definition: The job declaring the matrix. 

3352 :param dimensions: The matrix' combination this instance is called with, the values as GitHub prints them. 

3353 :param calledWorkflow: Optional, the workflow file the called workflow's elements are built from. Default: 

3354 ``None``. 

3355 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

3356 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``. 

3357 :raises ValueError: If parameter 'definition' calls no workflow. 

3358 :raises ValueError: If parameter 'dimensions' is ``None``. 

3359 """ 

3360 self._CheckDefinition(definition) 

3361 

3362 if definition._uses is None: 3362 ↛ 3363line 3362 didn't jump to line 3363 because the condition on line 3362 was never true

3363 ex = ValueError("Parameter 'definition' calls no workflow.") 

3364 ex.add_note(f"Got job '{definition._name}'.") 

3365 raise ex 

3366 elif dimensions is None: 

3367 raise ValueError("Parameter 'dimensions' is None.") 

3368 

3369 super().__init__( 

3370 definition._name, dimensions, reference=str(definition._uses), condition=definition._condition, 

3371 keyValuePairs=keyValuePairs, parent=parent 

3372 ) 

3373 DefinitionMixin.__init__(self, definition) 

3374 CallMixin.__init__(self, calledWorkflow) 

3375 

3376 

3377@export 

3378class DefinedJob(CIJob, DefinitionMixin[Job]): 

3379 """A job running steps, as :meth:`Workflow.ToPipeline` builds it.""" 

3380 

3381 _DEFINITION_TYPE: ClassVar[type] = Job #: A job is built from its job in the workflow file. 

3382 

3383 def __init__( 

3384 self, 

3385 definition: Job, 

3386 *, 

3387 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

3388 parent: Nullable[JobGroup] = None 

3389 ) -> None: 

3390 """ 

3391 Initializes a job built from its job in the workflow file, named by the job's key. 

3392 

3393 :param definition: The job. 

3394 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

3395 :param parent: Optional, reference to the group containing the job. Default: ``None``. 

3396 """ 

3397 self._CheckDefinition(definition) 

3398 

3399 super().__init__(definition._name, condition=definition._condition, keyValuePairs=keyValuePairs, parent=parent) 

3400 DefinitionMixin.__init__(self, definition) 

3401 

3402 

3403@export 

3404class DefinedMatrixJob(CIMatrixJob, DefinitionMixin[Job]): 

3405 """One instance of a matrix running steps, as :meth:`Workflow.ToPipeline` builds it.""" 

3406 

3407 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix instance is built from the job declaring the matrix. 

3408 

3409 def __init__( 

3410 self, 

3411 definition: Job, 

3412 dimensions: Mapping[str, Any], 

3413 *, 

3414 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

3415 parent: Nullable[CIMatrix] = None 

3416 ) -> None: 

3417 """ 

3418 Initializes one instance of a matrix running steps, named by the job's key. 

3419 

3420 :param definition: The job declaring the matrix. 

3421 :param dimensions: The matrix' combination this instance runs with, the values as GitHub prints them. 

3422 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

3423 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``. 

3424 :raises ValueError: If parameter 'dimensions' is ``None``. 

3425 """ 

3426 self._CheckDefinition(definition) 

3427 

3428 if dimensions is None: 

3429 raise ValueError("Parameter 'dimensions' is None.") 

3430 

3431 super().__init__( 

3432 definition._name, dimensions, condition=definition._condition, keyValuePairs=keyValuePairs, parent=parent 

3433 ) 

3434 DefinitionMixin.__init__(self, definition) 

3435 

3436 

3437@export 

3438class DefinedStep(CIStep, DefinitionMixin[Step]): 

3439 """A step of a job, as :meth:`Workflow.ToPipeline` builds it.""" 

3440 

3441 _DEFINITION_TYPE: ClassVar[type] = Step #: A step is built from its step in the workflow file. 

3442 

3443 def __init__( 

3444 self, 

3445 definition: Step, 

3446 *, 

3447 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

3448 parent: Nullable[CIJob] = None 

3449 ) -> None: 

3450 """ 

3451 Initializes a step built from its step in the workflow file. 

3452 

3453 The step is named as GitHub displays it: by its ``name``, or else ``Run`` followed by the action it runs or the 

3454 first line of its script. 

3455 

3456 :param definition: The step. 

3457 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

3458 :param parent: Optional, reference to the job containing the step. Default: ``None``. 

3459 """ 

3460 self._CheckDefinition(definition) 

3461 

3462 if definition._name is not None: 

3463 name = definition._name 

3464 elif definition._uses is not None: 

3465 name = f"Run {definition._uses}" 

3466 elif definition._run is not None: 3466 ↛ 3470line 3466 didn't jump to line 3470 because the condition on line 3466 was always true

3467 firstLine = definition._run.strip().partition("\n")[0] 

3468 name = f"Run {firstLine}" 

3469 else: 

3470 name = f"Step at line {definition._line}" 

3471 

3472 super().__init__(name, condition=definition._condition, keyValuePairs=keyValuePairs, parent=parent) 

3473 DefinitionMixin.__init__(self, definition)