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

1485 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-04 09:05 +0000

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.GitHub`, which models a workflow *run* as the REST API reports it. 

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

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

63its workflow file declares. 

64""" 

65from __future__ import annotations 

66 

67from functools import cached_property 

68from itertools import product 

69from json import dumps as json_dumps 

70from pathlib import Path, PurePosixPath 

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

72from typing import Self, TypeVar, Union 

73 

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

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

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

77from pyTooling.Common import getFullyQualifiedName, StringEnum 

78from pyTooling.Decorators import export, readonly 

79from pyTooling.MetaClasses import ExtendedType, abstractclass 

80 

81from ruamel.yaml import YAML, YAMLError 

82from ruamel.yaml.comments import CommentedMap, CommentedSeq 

83from ruamel.yaml.scalarbool import ScalarBoolean 

84from ruamel.yaml.scalarfloat import ScalarFloat 

85from ruamel.yaml.scalarint import ScalarInt 

86from ruamel.yaml.scalarstring import ScalarString 

87 

88 

89__all__ = ["ValueT"] 

90 

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

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

93 

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

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

96 

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

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

99 

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

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

102 

103 

104@export 

105class WorkflowError(CIError): 

106 """ 

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

108 

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

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

111 """ 

112 

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

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

115 

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

117 """ 

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

119 

120 :param message: The exception's message. 

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

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

123 """ 

124 super().__init__(message) 

125 

126 self._path = path 

127 self._line = line 

128 

129 if path is not None: 

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

131 

132 @readonly 

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

134 """ 

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

136 

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

138 """ 

139 return self._path 

140 

141 @readonly 

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

143 """ 

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

145 

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

147 """ 

148 return self._line 

149 

150 

151@export 

152class AccessLevel(StringEnum): 

153 """ 

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

155 

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

157 """ 

158 

159 NoAccess = "none" #: No access. 

160 Read = "read" #: Read access. 

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

162 

163 @cached_property 

164 def Rank(self) -> int: 

165 """ 

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

167 

168 It is computed once per member. 

169 

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

171 """ 

172 return list(AccessLevel).index(self) 

173 

174 

175@export 

176class PermissionScope(StringEnum): 

177 """ 

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

179 

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

181 """ 

182 

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

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

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

186 Attestations = "attestations" #: Artifact attestations. 

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

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

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

190 Deployments = "deployments" #: Deployments. 

191 Discussions = "discussions" #: GitHub Discussions. 

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

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

194 Packages = "packages" #: GitHub Packages. 

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

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

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

198 Statuses = "statuses" #: Commit statuses. 

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

200 

201 

202@export 

203class InputType(StringEnum): 

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

205 

206 String = "string" #: A string. 

207 Boolean = "boolean" #: A boolean. 

208 Number = "number" #: A number. 

209 

210 

211@export 

212@abstractclass 

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

214 """ 

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

216 

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

218 it starts at. 

219 """ 

220 

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

222 

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

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

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

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

227 

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

229 """ 

230 Initializes an element of a workflow file. 

231 

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

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

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

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

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

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

238 """ 

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

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

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

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

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

244 raise ex 

245 elif line < 1: 

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

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

248 raise ex 

249 

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

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

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

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

254 raise ex 

255 

256 self._parent = parent 

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

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

259 self._line = line 

260 

261 @property 

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

263 """ 

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

265 

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

267 and so do the elements it contains. 

268 

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

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

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

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

273 """ 

274 return self._parent 

275 

276 @Parent.setter 

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

278 if value is None: 

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

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

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

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

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

284 raise ex 

285 

286 self._parent = value 

287 self._workflow = value._workflow 

288 self._file = value._file 

289 

290 @readonly 

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

292 """ 

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

294 

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

296 """ 

297 return self._workflow 

298 

299 @readonly 

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

301 """ 

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

303 

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

305 """ 

306 return self._file 

307 

308 @readonly 

309 def Line(self) -> int: 

310 """ 

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

312 

313 :returns: The line, starting at 1. 

314 """ 

315 return self._line 

316 

317 @readonly 

318 def Location(self) -> str: 

319 """ 

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

321 

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

323 an element outside a file. 

324 """ 

325 if self._file is None: 

326 return f"line {self._line}" 

327 

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

329 

330 @staticmethod 

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

332 """ 

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

334 

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

336 :param key: The key. 

337 :returns: The line, starting at 1. 

338 """ 

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

340 

341 @staticmethod 

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

343 """ 

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

345 

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

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

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

349 

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

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

352 :class:`float` or ``None``. 

353 """ 

354 if isinstance(value, CommentedMap): 

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

356 elif isinstance(value, CommentedSeq): 

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

358 elif isinstance(value, ScalarBoolean): 

359 return bool(value) 

360 elif isinstance(value, ScalarInt): 

361 return int(value) 

362 elif isinstance(value, ScalarFloat): 

363 return float(value) 

364 elif isinstance(value, ScalarString): 

365 return str(value) 

366 

367 return value 

368 

369 

370@export 

371class Workflow(Base[None]): 

372 """ 

373 A GitHub Actions workflow file. 

374 

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

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

377 """ 

378 

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

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

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

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

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

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

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

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

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

388 

389 def __init__( 

390 self, 

391 path: Path, 

392 displayName: Nullable[str] = None, 

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

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

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

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

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

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

399 ) -> None: 

400 """ 

401 Initializes a workflow. 

402 

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

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

405 

406 :param path: Path to the workflow file. 

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

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

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

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

411 ``None``. 

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

413 ``None``. 

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

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

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

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

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

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

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

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

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

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

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

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

426 """ 

427 super().__init__(1) 

428 

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

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

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

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

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

434 raise ex 

435 

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

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

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

439 raise ex 

440 

441 self._workflow = self 

442 self._file = path 

443 self._path = path 

444 self._name = path.stem 

445 self._displayName = displayName 

446 self._triggers = () 

447 self._inputs = {} 

448 self._outputs = {} 

449 self._secrets = {} 

450 self._permissions = None 

451 self._jobs = {} 

452 

453 if triggers is not None: 

454 self._triggers = tuple(triggers) 

455 for trigger in self._triggers: 

456 if not isinstance(trigger, str): 

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

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

459 raise ex 

460 

461 for parameterName, elements, elementClass, container in ( 

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

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

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

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

466 ): 

467 if elements is None: 

468 continue 

469 

470 for element in elements: 

471 if not isinstance(element, elementClass): 

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

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

474 raise ex 

475 

476 container[element._name] = element 

477 element.Parent = self 

478 

479 if permissions is not None: 

480 self._permissions = {} 

481 for permission in permissions: 

482 if not isinstance(permission, Permission): 

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

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

485 raise ex 

486 

487 self._permissions[permission._scope] = permission 

488 permission.Parent = self 

489 

490 @Base.Parent.setter 

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

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

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

494 raise ex 

495 

496 @readonly 

497 def Path(self) -> Path: 

498 """ 

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

500 

501 :returns: The path. 

502 """ 

503 return self._path 

504 

505 @readonly 

506 def Name(self) -> str: 

507 """ 

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

509 

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

511 """ 

512 return self._name 

513 

514 @readonly 

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

516 """ 

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

518 

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

520 """ 

521 return self._displayName 

522 

523 @readonly 

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

525 """ 

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

527 

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

529 """ 

530 return self._triggers 

531 

532 @readonly 

533 def IsCallable(self) -> bool: 

534 """ 

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

536 

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

538 """ 

539 return "workflow_call" in self._triggers 

540 

541 @readonly 

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

543 """ 

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

545 

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

547 """ 

548 return self._inputs 

549 

550 @readonly 

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

552 """ 

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

554 

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

556 """ 

557 return self._outputs 

558 

559 @readonly 

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

561 """ 

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

563 

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

565 """ 

566 return self._secrets 

567 

568 @readonly 

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

570 """ 

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

572 

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

574 """ 

575 return self._permissions 

576 

577 @readonly 

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

579 """ 

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

581 

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

583 """ 

584 return self._jobs 

585 

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

587 """ 

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

589 

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

591 :attr:`~DefinitionMixin.Definition`: 

592 

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

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

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

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

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

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

599 

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

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

602 

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

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

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

606 every level. Default: ``None``. 

607 :returns: The pipeline. 

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

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

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

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

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

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

614 """ 

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

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

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

618 raise ex 

619 

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

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

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

623 raise ex 

624 elif depth is not None and depth < 0: 

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

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

627 raise ex 

628 

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

630 """ 

631 Nested function for recursion. 

632 

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

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

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

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

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

638 """ 

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

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

641 called = None 

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

643 called = resolver.Resolve(job._uses) 

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

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

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

647 raise ex 

648 

649 if job._matrix is not None: 

650 element = DefinedMatrix(job, parent=group) 

651 if not job._matrix.IsDynamic: 

652 for combination in job._matrix.Combinations: 

653 dimensions = Matrix._FormatCombination(combination) 

654 if job._uses is None: 

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

656 for step in job._steps: 

657 DefinedStep(step, parent=instance) 

658 else: 

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

660 if called is not None: 

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

662 elif job._uses is not None: 

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

664 if called is not None: 

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

666 else: 

667 element = DefinedJob(job, parent=group) 

668 for step in job._steps: 

669 DefinedStep(step, parent=element) 

670 

671 elements[job._name] = element 

672 

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

674 for need in job.Needs: 

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

676 

677 pipeline = DefinedPipeline(self) 

678 addElements(self, pipeline, 0, (self, )) 

679 

680 return pipeline 

681 

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

683 """ 

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

685 

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

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

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

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

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

691 

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

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

694 

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

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

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

698 instance matching no combination, keep the positions. 

699 

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

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

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

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

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

705 up. 

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

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

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

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

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

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

712 """ 

713 if pipeline is None: 

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

715 elif not isinstance(pipeline, CIWorkflow): 

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

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

718 raise ex 

719 

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

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

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

723 raise ex 

724 

725 missing: list[str] = [] 

726 

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

728 """ 

729 Nested function for recursion. 

730 

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

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

733 """ 

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

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

736 if job._displayName is None: 

737 names = (job._name, ) 

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

739 continue 

740 else: 

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

742 

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

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

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

746 continue 

747 

748 element = group.GetElement(name) 

749 elements[job._name] = element 

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

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

752 for instance in element.Instances: 

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

754 continue 

755 

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

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

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

759 

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

761 continue 

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

763 apply(called, element) 

764 elif isinstance(element, CIMatrix): 

765 for instance in element.Instances: 

766 if isinstance(instance, CIWorkflow): 

767 apply(called, instance) 

768 

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

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

771 continue 

772 

773 for need in job.Needs: 

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

775 element.AddNeed(needed) 

776 

777 apply(self, pipeline) 

778 pipeline.Validate() 

779 

780 return missing 

781 

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

783 """ 

784 Iterate the actions the workflow's steps run. 

785 

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

787 

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

789 """ 

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

791 for step in job._steps: 

792 if step._uses is not None: 

793 yield step._uses 

794 

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

796 """ 

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

798 

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

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

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

802 

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

804 not followed. Default: ``None``. 

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

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

807 """ 

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

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

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

811 raise ex 

812 

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

814 visited: set[int] = set() 

815 

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

817 """ 

818 Nested function for recursion. 

819 

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

821 """ 

822 visited.add(id(workflow)) 

823 

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

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

826 if job._permissions is not None: 

827 declarations.append(job._permissions) 

828 

829 for permissions in declarations: 

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

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

832 collected[scope] = permission 

833 

834 if resolver is not None: 

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

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

837 continue 

838 elif id(called) not in visited: 

839 collect(called) 

840 

841 collect(self) 

842 

843 return collected 

844 

845 @readonly 

846 def JobCount(self) -> int: 

847 """ 

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

849 

850 :returns: Number of jobs. 

851 """ 

852 return len(self._jobs) 

853 

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

855 """ 

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

857 

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

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

860 """ 

861 return name in self._jobs 

862 

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

864 """ 

865 Iterate the workflow's jobs. 

866 

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

868 """ 

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

870 

871 def __str__(self) -> str: 

872 """ 

873 Return the workflow's name. 

874 

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

876 """ 

877 return self._name 

878 

879 @classmethod 

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

881 """ 

882 Read a workflow file. 

883 

884 :param path: Path to the workflow file. 

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

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

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

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

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

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

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

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

893 wrong kind. |br| 

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

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

896 a cycle. |br| 

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

898 """ 

899 if path is None: 

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

901 elif not isinstance(path, Path): 

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

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

904 raise ex 

905 elif not path.exists(): 

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

907 

908 try: 

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

910 except OSError as cause: 

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

912 

913 try: 

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

915 except YAMLError as cause: 

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

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

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

919 

920 if document is None: 

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

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

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

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

925 raise ex 

926 elif "on" not in document: 

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

928 elif "jobs" not in document: 

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

930 

931 workflow = cls._Parse(document, path) 

932 workflow._Validate() 

933 

934 return workflow 

935 

936 @classmethod 

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

938 """ 

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

940 

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

942 :param path: Path to the workflow file. 

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

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

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

946 wrong kind. |br| 

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

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

949 """ 

950 on = document["on"] 

951 if isinstance(on, str): 

952 triggers = (on, ) 

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

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

955 else: 

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

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

958 raise ex 

959 

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

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

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

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

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

965 raise ex 

966 

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

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

969 continue 

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

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

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

973 raise ex 

974 

975 parameters[section] = [ 

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

977 for name, declaration in declarations.items() 

978 ] 

979 

980 jobs = document["jobs"] 

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

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

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

984 raise ex 

985 

986 jobList = [] 

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

988 line = Base._KeyLine(jobs, name) 

989 if not isinstance(job, CommentedMap): 

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

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

992 raise ex 

993 

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

995 

996 permissions = None 

997 if "permissions" in document: 

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

999 

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

1001 return cls( 

1002 path, 

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

1004 triggers, 

1005 parameters["inputs"], 

1006 parameters["outputs"], 

1007 parameters["secrets"], 

1008 permissions, 

1009 jobList 

1010 ) 

1011 

1012 def _Validate(self) -> None: 

1013 """ 

1014 Validate the workflow read from a file. 

1015 

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

1017 The note lists the workflow's jobs. 

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

1019 """ 

1020 self._ValidateNeeds() 

1021 self._ValidateAcyclic() 

1022 

1023 def _ValidateNeeds(self) -> None: 

1024 """ 

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

1026 

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

1028 The note lists the workflow's jobs. 

1029 """ 

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

1031 for need in job._needNames: 

1032 if need not in self._jobs: 

1033 ex = WorkflowError( 

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

1035 ) 

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

1037 raise ex 

1038 

1039 def _ValidateAcyclic(self) -> None: 

1040 """ 

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

1042 

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

1044 """ 

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

1046 finished: set[str] = set() 

1047 stack: list[str] = [] 

1048 

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

1050 """ 

1051 Nested function for recursion. 

1052 

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

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

1055 """ 

1056 if job._name in finished: 

1057 return 

1058 elif job._name in stack: 

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

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

1061 

1062 stack.append(job._name) 

1063 for need in job.Needs: 

1064 visit(need) 

1065 stack.pop() 

1066 finished.add(job._name) 

1067 

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

1069 visit(job) 

1070 

1071 

1072@export 

1073class Job(Base[Workflow]): 

1074 """ 

1075 A job of a workflow. 

1076 

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

1078 :attr:`Uses`. 

1079 """ 

1080 

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

1082 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1098 

1099 def __init__( 

1100 self, 

1101 name: str, 

1102 line: int, 

1103 displayName: Nullable[str] = None, 

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

1105 condition: Nullable[str] = None, 

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

1107 container: Nullable[str] = None, 

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

1109 uses: Nullable[UsesReference] = None, 

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

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

1112 inheritsSecrets: bool = False, 

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

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

1115 matrix: Nullable[Matrix] = None, 

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

1117 *, 

1118 parent: Nullable[Workflow] = None 

1119 ) -> None: 

1120 """ 

1121 Initializes a job of a workflow. 

1122 

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

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

1125 parent. 

1126 

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

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

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

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

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

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

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

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

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

1136 ``None``. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1159 """ 

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

1161 

1162 if name is None: 

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

1164 elif not isinstance(name, str): 

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

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

1167 raise ex 

1168 elif name == "": 

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

1170 

1171 for parameterName, value in ( 

1172 ("displayName", displayName), 

1173 ("condition", condition), 

1174 ("container", container) 

1175 ): 

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

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

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

1179 raise ex 

1180 

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

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

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

1184 for value in values: 

1185 if not isinstance(value, str): 

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

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

1188 raise ex 

1189 

1190 for parameterName, value, valueClass in ( 

1191 ("uses", uses, UsesReference), 

1192 ("matrix", matrix, Matrix) 

1193 ): 

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

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

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

1197 raise ex 

1198 

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

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

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

1202 raise ex 

1203 

1204 self._name = name 

1205 self._displayName = displayName 

1206 self._condition = condition 

1207 self._permissions = None 

1208 self._uses = uses 

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

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

1211 self._inheritsSecrets = inheritsSecrets 

1212 self._matrix = matrix 

1213 self._steps = [] 

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

1215 self._container = container 

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

1217 

1218 if uses is not None: 

1219 uses.Parent = self 

1220 

1221 if matrix is not None: 

1222 matrix.Parent = self 

1223 

1224 if permissions is not None: 

1225 self._permissions = {} 

1226 for permission in permissions: 

1227 if not isinstance(permission, Permission): 

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

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

1230 raise ex 

1231 

1232 self._permissions[permission._scope] = permission 

1233 permission.Parent = self 

1234 

1235 if steps is not None: 

1236 for step in steps: 

1237 if not isinstance(step, Step): 

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

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

1240 raise ex 

1241 

1242 self._steps.append(step) 

1243 step.Parent = self 

1244 

1245 if parent is not None: 

1246 parent._jobs[name] = self 

1247 

1248 @Base.Parent.setter 

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

1250 Base.Parent.fset(self, value) 

1251 

1252 if self._uses is not None: 

1253 self._uses.Parent = self 

1254 

1255 if self._matrix is not None: 

1256 self._matrix.Parent = self 

1257 

1258 for step in self._steps: 

1259 step.Parent = self 

1260 

1261 if self._permissions is not None: 

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

1263 permission.Parent = self 

1264 

1265 @readonly 

1266 def Name(self) -> str: 

1267 """ 

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

1269 

1270 :returns: Name of the job. 

1271 """ 

1272 return self._name 

1273 

1274 @readonly 

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

1276 """ 

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

1278 

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

1280 """ 

1281 return self._displayName 

1282 

1283 @readonly 

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

1285 """ 

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

1287 

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

1289 """ 

1290 return self._needNames 

1291 

1292 @readonly 

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

1294 """ 

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

1296 

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

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

1299 

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

1301 """ 

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

1303 return () 

1304 

1305 jobs = self._workflow._jobs 

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

1307 

1308 @readonly 

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

1310 """ 

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

1312 

1313 The expression is not evaluated. 

1314 

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

1316 """ 

1317 return self._condition 

1318 

1319 @readonly 

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

1321 """ 

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

1323 

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

1325 """ 

1326 return self._permissions 

1327 

1328 @readonly 

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

1330 """ 

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

1332 

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

1334 """ 

1335 return self._runsOn 

1336 

1337 @readonly 

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

1339 """ 

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

1341 

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

1343 """ 

1344 return self._uses 

1345 

1346 @readonly 

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

1348 """ 

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

1350 

1351 :returns: The inputs, by name. 

1352 """ 

1353 return self._with 

1354 

1355 @readonly 

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

1357 """ 

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

1359 

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

1361 """ 

1362 return self._secrets 

1363 

1364 @readonly 

1365 def InheritsSecrets(self) -> bool: 

1366 """ 

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

1368 

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

1370 """ 

1371 return self._inheritsSecrets 

1372 

1373 @readonly 

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

1375 """ 

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

1377 

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

1379 """ 

1380 return self._matrix 

1381 

1382 @readonly 

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

1384 """ 

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

1386 

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

1388 """ 

1389 return self._steps 

1390 

1391 @readonly 

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

1393 """ 

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

1395 

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

1397 """ 

1398 return self._outputs 

1399 

1400 @readonly 

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

1402 """ 

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

1404 

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

1406 ``container``. 

1407 """ 

1408 return self._container 

1409 

1410 @readonly 

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

1412 """ 

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

1414 

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

1416 """ 

1417 return self._services 

1418 

1419 @readonly 

1420 def StepCount(self) -> int: 

1421 """ 

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

1423 

1424 :returns: Number of steps. 

1425 """ 

1426 return len(self._steps) 

1427 

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

1429 """ 

1430 Iterate the job's steps. 

1431 

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

1433 """ 

1434 return iter(self._steps) 

1435 

1436 def __str__(self) -> str: 

1437 """ 

1438 Return the job's name. 

1439 

1440 :returns: Name of the job. 

1441 """ 

1442 return self._name 

1443 

1444 @classmethod 

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

1446 """ 

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

1448 

1449 :param name: Name of the job. 

1450 :param mapping: The job's mapping. 

1451 :param path: Path to the workflow file. 

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

1453 :returns: The job. 

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

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

1456 """ 

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

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

1459 

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

1461 if isinstance(needs, str): 

1462 needs = (needs, ) 

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

1464 ex = WorkflowError( 

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

1466 ) 

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

1468 raise ex 

1469 

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

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

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

1473 

1474 if isinstance(runsOn, str): 

1475 runsOn = (runsOn, ) 

1476 

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

1478 

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

1480 inheritsSecrets = secrets == "inherit" 

1481 if inheritsSecrets: 

1482 secrets = None 

1483 elif secrets is not None: 

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

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

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

1487 raise ex 

1488 

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

1490 

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

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

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

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

1495 raise ex 

1496 

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

1498 

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

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

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

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

1503 raise ex 

1504 

1505 withValues = Base._ToPython(withValues) 

1506 

1507 uses = None 

1508 if "uses" in mapping: 

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

1510 

1511 permissions = None 

1512 if "permissions" in mapping: 

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

1514 

1515 matrix = None 

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

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

1518 ex = WorkflowError( 

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

1520 ) 

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

1522 raise ex 

1523 

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

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

1526 

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

1528 if isinstance(container, CommentedMap): 

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

1530 

1531 services = None 

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

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

1534 ex = WorkflowError( 

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

1536 ) 

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

1538 raise ex 

1539 

1540 services = {} 

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

1542 if isinstance(service, CommentedMap): 

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

1544 

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

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

1547 

1548 steps = None 

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

1550 if not isinstance(stepList, CommentedSeq): 

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

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

1553 raise ex 

1554 

1555 steps = [ 

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

1557 for position, step in enumerate(stepList) 

1558 ] 

1559 

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

1561 

1562 return cls( 

1563 name, line, 

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

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

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

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

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

1569 services=services, 

1570 uses=uses, 

1571 withInputs=withValues, 

1572 secrets=secrets, 

1573 inheritsSecrets=inheritsSecrets, 

1574 outputs=outputs, 

1575 permissions=permissions, 

1576 matrix=matrix, 

1577 steps=steps 

1578 ) 

1579 

1580 

1581@export 

1582class Action(Base[None]): 

1583 """ 

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

1585 

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

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

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

1589 """ 

1590 

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

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

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

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

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

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

1597 

1598 def __init__( 

1599 self, 

1600 path: Path, 

1601 using: str, 

1602 displayName: Nullable[str] = None, 

1603 image: Nullable[str] = None, 

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

1605 ) -> None: 

1606 """ 

1607 Initializes an action. 

1608 

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

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

1611 

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

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

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

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

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

1617 ``None``. 

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

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

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

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

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

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

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

1625 """ 

1626 super().__init__(1) 

1627 

1628 if path is None: 

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

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

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

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

1633 raise ex 

1634 

1635 if using is None: 

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

1637 

1638 for parameterName, value in ( 

1639 ("using", using), 

1640 ("displayName", displayName), 

1641 ("image", image) 

1642 ): 

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

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

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

1646 raise ex 

1647 

1648 self._file = path 

1649 self._path = path 

1650 self._name = path.parent.name 

1651 self._displayName = displayName 

1652 self._using = using 

1653 self._image = image 

1654 self._steps = [] 

1655 

1656 if steps is not None: 

1657 for step in steps: 

1658 if not isinstance(step, Step): 

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

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

1661 raise ex 

1662 

1663 self._steps.append(step) 

1664 step.Parent = self 

1665 

1666 @Base.Parent.setter 

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

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

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

1670 raise ex 

1671 

1672 @readonly 

1673 def Path(self) -> Path: 

1674 """ 

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

1676 

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

1678 """ 

1679 return self._path 

1680 

1681 @readonly 

1682 def Name(self) -> str: 

1683 """ 

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

1685 

1686 :returns: Name of the action. 

1687 """ 

1688 return self._name 

1689 

1690 @readonly 

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

1692 """ 

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

1694 

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

1696 """ 

1697 return self._displayName 

1698 

1699 @readonly 

1700 def Using(self) -> str: 

1701 """ 

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

1703 

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

1705 """ 

1706 return self._using 

1707 

1708 @readonly 

1709 def IsComposite(self) -> bool: 

1710 """ 

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

1712 

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

1714 """ 

1715 return self._using == "composite" 

1716 

1717 @readonly 

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

1719 """ 

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

1721 

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

1723 """ 

1724 return self._image 

1725 

1726 @readonly 

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

1728 """ 

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

1730 

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

1732 """ 

1733 return self._steps 

1734 

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

1736 """ 

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

1738 

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

1740 """ 

1741 for step in self._steps: 

1742 if step._uses is not None: 

1743 yield step._uses 

1744 

1745 @readonly 

1746 def StepCount(self) -> int: 

1747 """ 

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

1749 

1750 :returns: Number of steps. 

1751 """ 

1752 return len(self._steps) 

1753 

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

1755 """ 

1756 Iterate the action's steps. 

1757 

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

1759 """ 

1760 return iter(self._steps) 

1761 

1762 def __str__(self) -> str: 

1763 """ 

1764 Return the action's name. 

1765 

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

1767 """ 

1768 return self._name 

1769 

1770 @classmethod 

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

1772 """ 

1773 Read an action's file. 

1774 

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

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

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

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

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

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

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

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

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

1784 """ 

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

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

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

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

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

1790 raise ex 

1791 elif not path.exists(): 

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

1793 

1794 try: 

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

1796 except OSError as cause: 

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

1798 

1799 try: 

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

1801 except YAMLError as cause: 

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

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

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

1805 

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

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

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

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

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

1811 raise ex 

1812 elif "runs" not in document: 

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

1814 

1815 return cls._Parse(document, path) 

1816 

1817 @classmethod 

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

1819 """ 

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

1821 

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

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

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

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

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

1827 """ 

1828 runs = document["runs"] 

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

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

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

1832 raise ex 

1833 elif "using" not in runs: 

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

1835 

1836 steps = None 

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

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

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

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

1841 raise ex 

1842 

1843 steps = [ 

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

1845 for position, step in enumerate(stepList) 

1846 ] 

1847 

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

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

1850 return cls( 

1851 path, 

1852 str(runs["using"]), 

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

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

1855 steps=steps 

1856 ) 

1857 

1858 

1859@export 

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

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

1862 

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

1864 

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

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

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

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

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

1870 

1871 def __init__( 

1872 self, 

1873 line: int, 

1874 name: Nullable[str] = None, 

1875 identifier: Nullable[str] = None, 

1876 condition: Nullable[str] = None, 

1877 run: Nullable[str] = None, 

1878 uses: Nullable[UsesReference] = None, 

1879 *, 

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

1881 ) -> None: 

1882 """ 

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

1884 

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

1886 parent. 

1887 

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

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

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

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

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

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

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

1895 attached to. Default: ``None``. 

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

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

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

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

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

1901 """ 

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

1903 

1904 for parameterName, value in ( 

1905 ("name", name), 

1906 ("identifier", identifier), 

1907 ("condition", condition), 

1908 ("run", run) 

1909 ): 

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

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

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

1913 raise ex 

1914 

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

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

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

1918 raise ex 

1919 

1920 self._name = name 

1921 self._identifier = identifier 

1922 self._condition = condition 

1923 self._uses = uses 

1924 self._run = run 

1925 

1926 if uses is not None: 

1927 uses.Parent = self 

1928 

1929 if parent is not None: 

1930 parent._steps.append(self) 

1931 

1932 @Base.Parent.setter 

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

1934 Base.Parent.fset(self, value) 

1935 

1936 if self._uses is not None: 

1937 self._uses.Parent = self 

1938 

1939 @readonly 

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

1941 """ 

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

1943 

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

1945 """ 

1946 return self._name 

1947 

1948 @readonly 

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

1950 """ 

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

1952 

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

1954 """ 

1955 return self._identifier 

1956 

1957 @readonly 

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

1959 """ 

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

1961 

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

1963 """ 

1964 return self._condition 

1965 

1966 @readonly 

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

1968 """ 

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

1970 

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

1972 """ 

1973 return self._uses 

1974 

1975 @readonly 

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

1977 """ 

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

1979 

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

1981 """ 

1982 return self._run 

1983 

1984 @classmethod 

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

1986 """ 

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

1988 

1989 :param mapping: The step's mapping. 

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

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

1992 ``action 'Setup'``. 

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

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

1995 :returns: The step. 

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

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

1998 """ 

1999 if not isinstance(mapping, CommentedMap): 

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

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

2002 raise ex 

2003 

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

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

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

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

2008 uses = None 

2009 if "uses" in mapping: 

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

2011 

2012 return cls( 

2013 line, 

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

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

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

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

2018 uses=uses 

2019 ) 

2020 

2021 

2022@export 

2023class Matrix(Base[Job]): 

2024 """ 

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

2026 

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

2028 its instances are then known at run time only. 

2029 """ 

2030 

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

2032 

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

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

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

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

2037 

2038 def __init__( 

2039 self, 

2040 line: int, 

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

2042 include: ValueT = None, 

2043 exclude: ValueT = None, 

2044 expression: Nullable[str] = None, 

2045 *, 

2046 parent: Nullable[Job] = None 

2047 ) -> None: 

2048 """ 

2049 Initializes a job's matrix. 

2050 

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

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

2053 Default: ``None``. 

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

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

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

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

2058 Default: ``None``. 

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

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

2061 """ 

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

2063 

2064 if dimensions is not None and not isinstance(dimensions, Mapping): 2064 ↛ 2065line 2064 didn't jump to line 2065 because the condition on line 2064 was never true

2065 ex = TypeError("Parameter 'dimensions' is not a mapping.") 

2066 ex.add_note(f"Got type '{getFullyQualifiedName(dimensions)}'.") 

2067 raise ex 

2068 

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

2070 ex = TypeError("Parameter 'expression' is not of type 'str'.") 

2071 ex.add_note(f"Got type '{getFullyQualifiedName(expression)}'.") 

2072 raise ex 

2073 

2074 self._dimensions = {} if dimensions is None else dict(dimensions) 

2075 self._include = include 

2076 self._exclude = exclude 

2077 self._expression = expression 

2078 

2079 if parent is not None: 

2080 parent._matrix = self 

2081 

2082 @readonly 

2083 def Dimensions(self) -> dict[str, ValueT]: 

2084 """ 

2085 Read-only property to access the matrix' dimensions (:attr:`_dimensions`). 

2086 

2087 :returns: The dimensions, by name; a dimension's value is a list, or an expression producing one. 

2088 """ 

2089 return self._dimensions 

2090 

2091 @readonly 

2092 def Include(self) -> ValueT: 

2093 """ 

2094 Read-only property to access the combinations added to the matrix (:attr:`_include`). 

2095 

2096 :returns: A list of combinations, an expression producing them, or ``None`` if the matrix has no ``include``. 

2097 """ 

2098 return self._include 

2099 

2100 @readonly 

2101 def Exclude(self) -> ValueT: 

2102 """ 

2103 Read-only property to access the combinations removed from the matrix (:attr:`_exclude`). 

2104 

2105 :returns: A list of combinations, an expression producing them, or ``None`` if the matrix has no ``exclude``. 

2106 """ 

2107 return self._exclude 

2108 

2109 @readonly 

2110 def Expression(self) -> Nullable[str]: 

2111 """ 

2112 Read-only property to access the expression the whole matrix is taken from (:attr:`_expression`). 

2113 

2114 :returns: The expression, as ``${{ fromJson(needs.Params.outputs.matrix) }}``, or ``None`` if the matrix is a 

2115 mapping. 

2116 """ 

2117 return self._expression 

2118 

2119 @readonly 

2120 def IsDynamic(self) -> bool: 

2121 """ 

2122 Read-only property to return whether the matrix' instances are known at run time only. 

2123 

2124 :returns: ``True``, if the matrix, its ``include``, its ``exclude`` or one of its dimensions is an expression. 

2125 """ 

2126 return ( 

2127 self._expression is not None or isinstance(self._include, str) or isinstance(self._exclude, str) or 

2128 any(isinstance(value, str) for value in self._dimensions.values()) 

2129 ) 

2130 

2131 @readonly 

2132 def Combinations(self) -> list[dict[str, ValueT]]: 

2133 """ 

2134 Read-only property to return the combinations the matrix produces, as GitHub computes them. 

2135 

2136 The dimensions are combined in the order they are written, the last one varying fastest. Then ``exclude`` 

2137 removes every combination matching all key-value pairs of an entry, and ``include`` extends every remaining 

2138 combination whose dimension values the entry doesn't change - its other keys, and those an earlier entry 

2139 added, it may change. An entry extending no combination is a combination of its own. 

2140 

2141 :returns: The combinations, each a mapping of the dimensions' and included keys' names to values. 

2142 :raises WorkflowError: If the matrix is dynamic, so its combinations are known at run time only. 

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

2144 """ 

2145 path = self._file 

2146 if self.IsDynamic: 

2147 raise WorkflowError("Matrix is dynamic; its combinations are known at run time only.", path, self._line) 

2148 

2149 for key, entries in ( 

2150 ("include", self._include), 

2151 ("exclude", self._exclude) 

2152 ): 

2153 if entries is not None and ( 

2154 not isinstance(entries, list) or not all(isinstance(entry, dict) for entry in entries) 

2155 ): 

2156 raise WorkflowError(f"Key '{key}' of the matrix is not a list of mappings.", path, self._line) 

2157 

2158 combinations = [] 

2159 if len(self._dimensions) > 0: 

2160 dimensions = {name: value if isinstance(value, list) else [value] for name, value in self._dimensions.items()} 

2161 combinations = [dict(zip(dimensions, values)) for values in product(*dimensions.values())] 

2162 

2163 if self._exclude is not None: 

2164 for entry in self._exclude: 

2165 combinations = [ 

2166 combination for combination in combinations 

2167 if not all(combination.get(key, None) == value for key, value in entry.items()) 

2168 ] 

2169 

2170 if self._include is None: 

2171 return combinations 

2172 

2173 originals = [dict(combination) for combination in combinations] 

2174 for entry in self._include: 

2175 extended = False 

2176 for combination, original in zip(combinations, originals): 

2177 if all(original[key] == value for key, value in entry.items() if key in original): 

2178 combination.update(entry) 

2179 extended = True 

2180 

2181 if not extended: 

2182 combinations.append(dict(entry)) 

2183 

2184 return combinations 

2185 

2186 @staticmethod 

2187 def _FormatCombination(combination: Mapping[str, ValueT]) -> dict[str, str]: 

2188 """ 

2189 Format the values of a matrix' combination as GitHub prints them in the name of a matrix instance. 

2190 

2191 A string is printed as it is, any other value as JSON: ``true``, ``3``, ``{"os": "ubuntu"}``. 

2192 

2193 :param combination: The combination, as :attr:`Matrix.Combinations` returns it. 

2194 :returns: The combination's names and formatted values, in the combination's order. 

2195 """ 

2196 return { 

2197 name: value if isinstance(value, str) else json_dumps(value, separators=(", ", ": ")) 

2198 for name, value in combination.items() 

2199 } 

2200 

2201 @classmethod 

2202 def _FromYAML(cls, value: Any, path: Path, line: int) -> Self: 

2203 """ 

2204 Read the value of a job's ``strategy.matrix`` key. 

2205 

2206 :param value: The value of the ``matrix`` key: a mapping, or an expression. 

2207 :param path: Path to the workflow file. 

2208 :param line: Line the key is written at, starting at 1. 

2209 :returns: The matrix. 

2210 :raises WorkflowError: If the value is neither a mapping nor an expression. 

2211 """ 

2212 if isinstance(value, str): 

2213 return cls(line, expression=str(value)) 

2214 elif not isinstance(value, CommentedMap): 

2215 ex = WorkflowError("Key 'strategy.matrix' is not a mapping.", path, line) 

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

2217 raise ex 

2218 

2219 return cls( 

2220 line, 

2221 dimensions={key: Base._ToPython(item) for key, item in value.items() if key not in ("include", "exclude")}, 

2222 include=Base._ToPython(value.get("include", None)), 

2223 exclude=Base._ToPython(value.get("exclude", None)) 

2224 ) 

2225 

2226 

2227@export 

2228class UsesReference(Base[Union[Job, Step]]): 

2229 """ 

2230 The value of a ``uses`` key: a reusable workflow called by a job, or an action run by a step. 

2231 

2232 The forms GitHub accepts are read into their parts: 

2233 

2234 .. code-block:: text 

2235 

2236 pyTooling/Actions/.github/workflows/Package.yml@r8 repository, path and ref 

2237 actions/checkout@v6 an action in a repository's root 

2238 ./.github/workflows/Package.yml a file of the same repository and commit 

2239 docker://alpine:3.22 a Docker image 

2240 """ 

2241 

2242 _PARENT_TYPE: ClassVar[ParentTypes] = (Job, Step) #: A reference is contained in a job or a step. 

2243 

2244 _rawReference: str #: The reference, as written. 

2245 _repository: Nullable[str] #: The repository, as ``owner/repo``. 

2246 _path: str #: The path within the repository. 

2247 _reference: Nullable[str] #: The branch, tag or commit. 

2248 _isLocal: bool #: ``True``, if the reference names a file of the same repository. 

2249 _isDocker: bool #: ``True``, if the reference names a Docker image. 

2250 

2251 def __init__(self, rawReference: str, line: int, *, parent: Nullable[Union[Job, Step]] = None) -> None: 

2252 """ 

2253 Initializes a ``uses`` reference by reading it into its parts. 

2254 

2255 :param rawReference: The reference, as written. 

2256 :param line: Line the reference is written at, starting at 1. 

2257 :param parent: Optional, reference to the job or step containing it, which the reference is attached to. 

2258 Default: ``None``. 

2259 :raises ValueError: If parameter 'rawReference' is ``None``. 

2260 :raises TypeError: If parameter 'rawReference' is not of type :class:`str`. 

2261 :raises ValueError: If parameter 'rawReference' is empty. 

2262 :raises ValueError: If parameter 'rawReference' names a repository without a ref. 

2263 :raises ValueError: If parameter 'rawReference' names no repository as ``owner/repo``. 

2264 """ 

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

2266 

2267 if rawReference is None: 

2268 raise ValueError("Parameter 'rawReference' is None.") 

2269 elif not isinstance(rawReference, str): 2269 ↛ 2270line 2269 didn't jump to line 2270 because the condition on line 2269 was never true

2270 ex = TypeError("Parameter 'rawReference' is not of type 'str'.") 

2271 ex.add_note(f"Got type '{getFullyQualifiedName(rawReference)}'.") 

2272 raise ex 

2273 elif rawReference == "": 

2274 raise ValueError("Parameter 'rawReference' is empty.") 

2275 

2276 self._rawReference = rawReference 

2277 self._isLocal = False 

2278 self._isDocker = False 

2279 self._repository = None 

2280 self._reference = None 

2281 

2282 if rawReference.startswith("docker://"): 

2283 self._isDocker = True 

2284 self._path = rawReference[len("docker://"):] 

2285 elif rawReference.startswith("./"): 

2286 self._isLocal = True 

2287 self._path = rawReference[len("./"):] 

2288 else: 

2289 location, separator, reference = rawReference.partition("@") 

2290 if separator == "" or reference == "": 

2291 ex = ValueError("Parameter 'rawReference' names a repository without a ref.") 

2292 ex.add_note(f"Got '{rawReference}'.") 

2293 raise ex 

2294 

2295 owner, _, remainder = location.partition("/") 

2296 repository, _, path = remainder.partition("/") 

2297 if owner == "" or repository == "": 

2298 ex = ValueError("Parameter 'rawReference' names no repository as 'owner/repo'.") 

2299 ex.add_note(f"Got '{rawReference}'.") 

2300 raise ex 

2301 

2302 self._repository = f"{owner}/{repository}" 

2303 self._path = path 

2304 self._reference = reference 

2305 

2306 if parent is not None: 

2307 parent._uses = self 

2308 

2309 @readonly 

2310 def Repository(self) -> Nullable[str]: 

2311 """ 

2312 Read-only property to access the repository (:attr:`_repository`). 

2313 

2314 :returns: The repository, as ``owner/repo``, or ``None`` for a local reference and a Docker image. 

2315 """ 

2316 return self._repository 

2317 

2318 @readonly 

2319 def Path(self) -> str: 

2320 """ 

2321 Read-only property to access the path within the repository (:attr:`_path`). 

2322 

2323 :returns: The path, as ``.github/workflows/Package.yml``, without the leading ``./`` of a local reference. It 

2324 is empty for an action in a repository's root, and the image for a Docker image. 

2325 """ 

2326 return self._path 

2327 

2328 @readonly 

2329 def Reference(self) -> Nullable[str]: 

2330 """ 

2331 Read-only property to access the branch, tag or commit (:attr:`_reference`). 

2332 

2333 :returns: The branch, tag or commit, as ``r8``, or ``None`` for a local reference and a Docker image. 

2334 """ 

2335 return self._reference 

2336 

2337 @readonly 

2338 def IsLocal(self) -> bool: 

2339 """ 

2340 Read-only property to access whether the reference names a file of the same repository (:attr:`_isLocal`). 

2341 

2342 :returns: ``True``, if the reference starts with ``./``. 

2343 """ 

2344 return self._isLocal 

2345 

2346 @readonly 

2347 def IsDocker(self) -> bool: 

2348 """ 

2349 Read-only property to access whether the reference names a Docker image (:attr:`_isDocker`). 

2350 

2351 :returns: ``True``, if the reference starts with ``docker://``. 

2352 """ 

2353 return self._isDocker 

2354 

2355 @readonly 

2356 def IsWorkflow(self) -> bool: 

2357 """ 

2358 Read-only property to return whether the reference names a reusable workflow rather than an action. 

2359 

2360 :returns: ``True``, if the path names a ``.yml`` or ``.yaml`` file in ``.github/workflows``. 

2361 """ 

2362 path = PurePosixPath(self._path) 

2363 return ( 

2364 not self._isDocker and path.parent == PurePosixPath(".github/workflows") and path.suffix in (".yml", ".yaml") 

2365 ) 

2366 

2367 @readonly 

2368 def FileName(self) -> str: 

2369 """ 

2370 Read-only property to return the last element of the path. 

2371 

2372 :returns: The file name, as ``Package.yml`` for a reusable workflow, or ``""`` for an action in a repository's 

2373 root. 

2374 """ 

2375 return PurePosixPath(self._path).name if not self._isDocker else "" 

2376 

2377 @readonly 

2378 def Stem(self) -> str: 

2379 """ 

2380 Read-only property to return the file name without its extension. 

2381 

2382 For a reusable workflow, it is the name :class:`Workflow` gives the file it reads, e.g. ``Package``. 

2383 

2384 :returns: The file name without its extension, or ``""`` for an action in a repository's root. 

2385 """ 

2386 return PurePosixPath(self._path).stem if not self._isDocker else "" 

2387 

2388 def __str__(self) -> str: 

2389 """ 

2390 Return the reference, as written. 

2391 

2392 :returns: The reference. 

2393 """ 

2394 return self._rawReference 

2395 

2396 @classmethod 

2397 def _FromYAML(cls, mapping: CommentedMap, what: str, path: Path) -> Self: 

2398 """ 

2399 Read the ``uses`` key of a job or step. 

2400 

2401 :param mapping: The mapping of the job or step, which has a ``uses`` key. 

2402 :param what: The job or step, for the exception's message, as ``job 'Build'``. 

2403 :param path: Path to the workflow file. 

2404 :returns: The reference. 

2405 :raises WorkflowError: If the value is not a reference. 

2406 """ 

2407 line = Base._KeyLine(mapping, "uses") 

2408 try: 

2409 return cls(str(mapping["uses"]), line) 

2410 except ValueError as cause: 

2411 raise WorkflowError(f"Key 'uses' of {what} is not a reference.", path, line) from cause 

2412 

2413 

2414@export 

2415class Permission(Base[Union[Workflow, Job]]): 

2416 """ 

2417 A permission a workflow or job declares for the ``GITHUB_TOKEN``, as ``contents: write``. 

2418 

2419 The short forms ``read-all`` and ``write-all`` are read as one permission of scope :attr:`PermissionScope.All`. 

2420 """ 

2421 

2422 _PARENT_TYPE: ClassVar[ParentTypes] = (Workflow, Job) #: A permission is declared by a workflow or a job. 

2423 

2424 _scope: PermissionScope #: The scope, as ``contents``. 

2425 _level: AccessLevel #: The access granted. 

2426 

2427 def __init__( 

2428 self, 

2429 scope: PermissionScope, 

2430 level: AccessLevel, 

2431 line: int, 

2432 *, 

2433 parent: Nullable[Union[Workflow, Job]] = None 

2434 ) -> None: 

2435 """ 

2436 Initializes a permission. 

2437 

2438 :param scope: The scope, as ``contents``. 

2439 :param level: The access granted. 

2440 :param line: Line the permission is written at, starting at 1. 

2441 :param parent: Optional, reference to the workflow or job declaring it, which the permission is attached to. 

2442 Default: ``None``. 

2443 :raises ValueError: If parameter 'scope' is ``None``. 

2444 :raises TypeError: If parameter 'scope' is not of type :class:`PermissionScope`. 

2445 :raises ValueError: If parameter 'level' is ``None``. 

2446 :raises TypeError: If parameter 'level' is not of type :class:`AccessLevel`. 

2447 """ 

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

2449 

2450 if scope is None: 2450 ↛ 2451line 2450 didn't jump to line 2451 because the condition on line 2450 was never true

2451 raise ValueError("Parameter 'scope' is None.") 

2452 elif not isinstance(scope, PermissionScope): 

2453 ex = TypeError("Parameter 'scope' is not of type 'PermissionScope'.") 

2454 ex.add_note(f"Got type '{getFullyQualifiedName(scope)}'.") 

2455 raise ex 

2456 

2457 if level is None: 2457 ↛ 2458line 2457 didn't jump to line 2458 because the condition on line 2457 was never true

2458 raise ValueError("Parameter 'level' is None.") 

2459 elif not isinstance(level, AccessLevel): 

2460 ex = TypeError("Parameter 'level' is not of type 'AccessLevel'.") 

2461 ex.add_note(f"Got type '{getFullyQualifiedName(level)}'.") 

2462 raise ex 

2463 

2464 self._scope = scope 

2465 self._level = level 

2466 

2467 if parent is not None: 

2468 if parent._permissions is None: 2468 ↛ 2471line 2468 didn't jump to line 2471 because the condition on line 2468 was always true

2469 parent._permissions = {} 

2470 

2471 parent._permissions[scope] = self 

2472 

2473 @readonly 

2474 def Scope(self) -> PermissionScope: 

2475 """ 

2476 Read-only property to access the scope (:attr:`_scope`). 

2477 

2478 :returns: The scope, as :attr:`PermissionScope.Contents`, or :attr:`PermissionScope.All` for ``read-all`` and 

2479 ``write-all``. 

2480 """ 

2481 return self._scope 

2482 

2483 @readonly 

2484 def Level(self) -> AccessLevel: 

2485 """ 

2486 Read-only property to access the access granted (:attr:`_level`). 

2487 

2488 :returns: The access level. 

2489 """ 

2490 return self._level 

2491 

2492 def __str__(self) -> str: 

2493 """ 

2494 Return the permission, as written in a workflow file. 

2495 

2496 :returns: The permission, as ``contents: write``, or ``read-all`` for scope :attr:`PermissionScope.All`. 

2497 """ 

2498 if self._scope is PermissionScope.All: 

2499 return f"{self._level.value}-all" 

2500 

2501 return f"{self._scope}: {self._level.value}" 

2502 

2503 @classmethod 

2504 def _FromYAML(cls, value: Any, path: Path, line: int) -> list[Self]: 

2505 """ 

2506 Read the value of a ``permissions`` key into the permissions a workflow or job declares. 

2507 

2508 :param value: The value of the ``permissions`` key. 

2509 :param path: Path to the workflow file. 

2510 :param line: Line the key is written at, starting at 1. 

2511 :returns: The permissions, in file order. 

2512 :raises WorkflowError: If the value is neither ``read-all``, ``write-all`` nor a mapping. 

2513 :raises WorkflowError: If a key is not a permission scope. |br| 

2514 The note lists the allowed values. 

2515 :raises WorkflowError: If a scope's value is not an access level. |br| 

2516 The note lists the allowed values. 

2517 """ 

2518 if value == "read-all": 

2519 return [cls(PermissionScope.All, AccessLevel.Read, line)] 

2520 elif value == "write-all": 

2521 return [cls(PermissionScope.All, AccessLevel.Write, line)] 

2522 elif not isinstance(value, CommentedMap): 

2523 ex = WorkflowError("Key 'permissions' is neither 'read-all', 'write-all' nor a mapping.", path, line) 

2524 ex.add_note(f"Got '{value}'." if isinstance(value, str) else f"Got type '{getFullyQualifiedName(value)}'.") 

2525 raise ex 

2526 

2527 permissions = [] 

2528 for scope, level in value.items(): 

2529 scopeLine = Base._KeyLine(value, scope) 

2530 try: 

2531 permissionScope = PermissionScope(scope) 

2532 except ValueError as cause: 

2533 ex = WorkflowError(f"Key '{scope}' of 'permissions' is not a permission scope.", path, scopeLine) 

2534 scopes = (member.value for member in PermissionScope if member is not PermissionScope.All) 

2535 ex.add_note(f"Allowed values: {', '.join(scopes)}.") 

2536 raise ex from cause 

2537 

2538 try: 

2539 accessLevel = AccessLevel(level) 

2540 except ValueError as cause: 

2541 ex = WorkflowError(f"Permission '{scope}' is not an access level.", path, scopeLine) 

2542 ex.add_note(f"Got '{level}'.") 

2543 ex.add_note(f"Allowed values: {', '.join(member.value for member in AccessLevel)}.") 

2544 raise ex from cause 

2545 

2546 permissions.append(cls(permissionScope, accessLevel, scopeLine)) 

2547 

2548 return permissions 

2549 

2550 

2551@export 

2552@abstractclass 

2553class Parameter(Base[Workflow]): 

2554 """ 

2555 Common behaviour of the inputs, outputs and secrets of a reusable workflow. 

2556 

2557 Every parameter has a name and an optional description, and belongs to a :class:`Workflow`. 

2558 """ 

2559 

2560 _PARENT_TYPE: ClassVar[ParentTypes] = Workflow #: A parameter is declared by a workflow. 

2561 

2562 _name: str #: Name of the parameter. 

2563 _description: Nullable[str] #: Description of the parameter. 

2564 

2565 def __init__( 

2566 self, 

2567 name: str, 

2568 line: int, 

2569 description: Nullable[str] = None, 

2570 *, 

2571 parent: Nullable[Workflow] = None 

2572 ) -> None: 

2573 """ 

2574 Initializes a parameter of a reusable workflow. 

2575 

2576 :param name: Name of the parameter. 

2577 :param line: Line the parameter's name is written at, starting at 1. 

2578 :param description: Optional, description of the parameter. Default: ``None``. 

2579 :param parent: Optional, reference to the workflow declaring it. Default: ``None``. 

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

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

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

2583 :raises TypeError: If parameter 'description' is not of type :class:`str`. 

2584 """ 

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

2586 

2587 if name is None: 2587 ↛ 2588line 2587 didn't jump to line 2588 because the condition on line 2587 was never true

2588 raise ValueError("Parameter 'name' is None.") 

2589 elif not isinstance(name, str): 2589 ↛ 2590line 2589 didn't jump to line 2590 because the condition on line 2589 was never true

2590 ex = TypeError("Parameter 'name' is not of type 'str'.") 

2591 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.") 

2592 raise ex 

2593 elif name == "": 2593 ↛ 2594line 2593 didn't jump to line 2594 because the condition on line 2593 was never true

2594 raise ValueError("Parameter 'name' is empty.") 

2595 

2596 if description is not None and not isinstance(description, str): 2596 ↛ 2597line 2596 didn't jump to line 2597 because the condition on line 2596 was never true

2597 ex = TypeError("Parameter 'description' is not of type 'str'.") 

2598 ex.add_note(f"Got type '{getFullyQualifiedName(description)}'.") 

2599 raise ex 

2600 

2601 self._name = name 

2602 self._description = description 

2603 

2604 @readonly 

2605 def Name(self) -> str: 

2606 """ 

2607 Read-only property to access the parameter's name (:attr:`_name`). 

2608 

2609 :returns: Name of the parameter. 

2610 """ 

2611 return self._name 

2612 

2613 @readonly 

2614 def Description(self) -> Nullable[str]: 

2615 """ 

2616 Read-only property to access the parameter's description (:attr:`_description`). 

2617 

2618 :returns: The description, or ``None`` if the workflow gives none. 

2619 """ 

2620 return self._description 

2621 

2622 def __str__(self) -> str: 

2623 """ 

2624 Return the parameter's name. 

2625 

2626 :returns: Name of the parameter. 

2627 """ 

2628 return self._name 

2629 

2630 

2631@export 

2632class Input(Parameter): 

2633 """An input of a reusable workflow, declared in ``on.workflow_call.inputs``.""" 

2634 

2635 _type: InputType #: Type of the input. 

2636 _required: bool #: ``True``, if a caller has to pass the input. 

2637 _default: ValueT #: Value of the input, if a caller doesn't pass it. 

2638 

2639 def __init__( 

2640 self, 

2641 name: str, 

2642 line: int, 

2643 inputType: InputType, 

2644 required: bool = False, 

2645 default: ValueT = None, 

2646 description: Nullable[str] = None, 

2647 *, 

2648 parent: Nullable[Workflow] = None 

2649 ) -> None: 

2650 """ 

2651 Initializes an input of a reusable workflow. 

2652 

2653 :param name: Name of the input. 

2654 :param line: Line the input's name is written at, starting at 1. 

2655 :param inputType: Type of the input. 

2656 :param required: Optional, ``True``, if a caller has to pass the input. Default: ``False``. 

2657 :param default: Optional, value of the input, if a caller doesn't pass it. Default: ``None``. 

2658 :param description: Optional, description of the input. Default: ``None``. 

2659 :param parent: Optional, reference to the workflow declaring it. Default: ``None``. 

2660 :raises ValueError: If parameter 'inputType' is ``None``. 

2661 :raises TypeError: If parameter 'inputType' is not of type :class:`InputType`. 

2662 :raises TypeError: If parameter 'required' is not of type :class:`bool`. 

2663 """ 

2664 super().__init__(name, line, description, parent=parent) 

2665 

2666 if inputType is None: 

2667 raise ValueError("Parameter 'inputType' is None.") 

2668 elif not isinstance(inputType, InputType): 

2669 ex = TypeError("Parameter 'inputType' is not of type 'InputType'.") 

2670 ex.add_note(f"Got type '{getFullyQualifiedName(inputType)}'.") 

2671 raise ex 

2672 

2673 if not isinstance(required, bool): 2673 ↛ 2674line 2673 didn't jump to line 2674 because the condition on line 2673 was never true

2674 ex = TypeError("Parameter 'required' is not of type 'bool'.") 

2675 ex.add_note(f"Got type '{getFullyQualifiedName(required)}'.") 

2676 raise ex 

2677 

2678 self._type = inputType 

2679 self._required = required 

2680 self._default = default 

2681 

2682 if parent is not None: 2682 ↛ 2683line 2682 didn't jump to line 2683 because the condition on line 2682 was never true

2683 parent._inputs[name] = self 

2684 

2685 @readonly 

2686 def Type(self) -> InputType: 

2687 """ 

2688 Read-only property to access the input's type (:attr:`_type`). 

2689 

2690 :returns: Type of the input. 

2691 """ 

2692 return self._type 

2693 

2694 @readonly 

2695 def Required(self) -> bool: 

2696 """ 

2697 Read-only property to access whether a caller has to pass the input (:attr:`_required`). 

2698 

2699 :returns: ``True``, if the input is required. 

2700 """ 

2701 return self._required 

2702 

2703 @readonly 

2704 def Default(self) -> ValueT: 

2705 """ 

2706 Read-only property to access the input's value, if a caller doesn't pass it (:attr:`_default`). 

2707 

2708 The value keeps the type it is written with, as ``'3.14'`` or ``false``, and a multi-line value keeps its line 

2709 breaks. 

2710 

2711 :returns: The default value, or ``None`` if the workflow gives none. 

2712 """ 

2713 return self._default 

2714 

2715 @classmethod 

2716 def _FromYAML(cls, name: str, declaration: Any, path: Path, line: int) -> Self: 

2717 """ 

2718 Read an input's declaration below ``on.workflow_call.inputs``. 

2719 

2720 

2721 :param name: Name of the input. 

2722 :param declaration: The declaration. 

2723 :param path: Path to the workflow file. 

2724 :param line: Line the input's name is written at, starting at 1. 

2725 :returns: The input. 

2726 :raises WorkflowError: If the declaration is not a mapping. 

2727 :raises WorkflowError: If key ``required`` is not a boolean. 

2728 :raises WorkflowError: If the declaration has no ``type`` key. 

2729 :raises WorkflowError: If key ``type`` is not an input type. |br| 

2730 The note lists the allowed values. 

2731 """ 

2732 if declaration is None: 

2733 raise WorkflowError(f"Input '{name}' has no 'type' key.", path, line) 

2734 elif not isinstance(declaration, CommentedMap): 

2735 ex = WorkflowError(f"Declaration of '{name}' is not a mapping.", path, line) 

2736 ex.add_note(f"Got type '{getFullyQualifiedName(declaration)}'.") 

2737 raise ex 

2738 

2739 description = declaration.get("description", None) 

2740 required = Base._ToPython(declaration.get("required", False)) 

2741 if not isinstance(required, bool): 2741 ↛ 2742line 2741 didn't jump to line 2742 because the condition on line 2741 was never true

2742 ex = WorkflowError(f"Key 'required' of '{name}' is not a boolean.", path, line) 

2743 ex.add_note(f"Got '{required}'.") 

2744 raise ex 

2745 

2746 if (inputType := declaration.get("type", None)) is None: 

2747 raise WorkflowError(f"Input '{name}' has no 'type' key.", path, line) 

2748 

2749 try: 

2750 inputType = InputType.Parse(str(inputType)) 

2751 except ValueError as cause: 

2752 ex = WorkflowError(f"Key 'type' of input '{name}' is not an input type.", path, line) 

2753 ex.add_note(f"Got '{inputType}'.") 

2754 ex.add_note(f"Allowed values: {', '.join(member.value for member in InputType)}.") 

2755 raise ex from cause 

2756 

2757 default = Base._ToPython(declaration.get("default", None)) 

2758 

2759 return cls(name, line, inputType, required, default, None if description is None else str(description)) 

2760 

2761 

2762@export 

2763class Output(Parameter): 

2764 """An output of a reusable workflow, declared in ``on.workflow_call.outputs``.""" 

2765 

2766 _value: str #: Expression the output's value is taken from. 

2767 

2768 def __init__( 

2769 self, 

2770 name: str, 

2771 line: int, 

2772 value: str, 

2773 description: Nullable[str] = None, 

2774 *, 

2775 parent: Nullable[Workflow] = None 

2776 ) -> None: 

2777 """ 

2778 Initializes an output of a reusable workflow. 

2779 

2780 :param name: Name of the output. 

2781 :param line: Line the output's name is written at, starting at 1. 

2782 :param value: Expression the output's value is taken from, as ``${{ jobs.Build.outputs.version }}``. 

2783 :param description: Optional, description of the output. Default: ``None``. 

2784 :param parent: Optional, reference to the workflow declaring it. Default: ``None``. 

2785 :raises ValueError: If parameter 'value' is ``None``. 

2786 :raises TypeError: If parameter 'value' is not of type :class:`str`. 

2787 """ 

2788 super().__init__(name, line, description, parent=parent) 

2789 

2790 if value is None: 2790 ↛ 2791line 2790 didn't jump to line 2791 because the condition on line 2790 was never true

2791 raise ValueError("Parameter 'value' is None.") 

2792 elif not isinstance(value, str): 2792 ↛ 2793line 2792 didn't jump to line 2793 because the condition on line 2792 was never true

2793 ex = TypeError("Parameter 'value' is not of type 'str'.") 

2794 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.") 

2795 raise ex 

2796 

2797 self._value = value 

2798 

2799 if parent is not None: 2799 ↛ 2800line 2799 didn't jump to line 2800 because the condition on line 2799 was never true

2800 parent._outputs[name] = self 

2801 

2802 @readonly 

2803 def Value(self) -> str: 

2804 """ 

2805 Read-only property to access the expression the output's value is taken from (:attr:`_value`). 

2806 

2807 :returns: The expression, as ``${{ jobs.Build.outputs.version }}``. 

2808 """ 

2809 return self._value 

2810 

2811 @classmethod 

2812 def _FromYAML(cls, name: str, declaration: Any, path: Path, line: int) -> Self: 

2813 """ 

2814 Read an output's declaration below ``on.workflow_call.outputs``. 

2815 

2816 

2817 :param name: Name of the output. 

2818 :param declaration: The declaration. 

2819 :param path: Path to the workflow file. 

2820 :param line: Line the output's name is written at, starting at 1. 

2821 :returns: The output. 

2822 :raises WorkflowError: If the declaration is not a mapping. 

2823 :raises WorkflowError: If the declaration has no ``value`` key. 

2824 """ 

2825 if declaration is None: 

2826 raise WorkflowError(f"Output '{name}' has no 'value' key.", path, line) 

2827 elif not isinstance(declaration, CommentedMap): 2827 ↛ 2828line 2827 didn't jump to line 2828 because the condition on line 2827 was never true

2828 ex = WorkflowError(f"Declaration of '{name}' is not a mapping.", path, line) 

2829 ex.add_note(f"Got type '{getFullyQualifiedName(declaration)}'.") 

2830 raise ex 

2831 

2832 description = declaration.get("description", None) 

2833 

2834 if (value := declaration.get("value", None)) is None: 

2835 raise WorkflowError(f"Output '{name}' has no 'value' key.", path, line) 

2836 

2837 return cls(name, line, str(value), None if description is None else str(description)) 

2838 

2839 

2840@export 

2841class Secret(Parameter): 

2842 """A secret of a reusable workflow, declared in ``on.workflow_call.secrets``.""" 

2843 

2844 _required: bool #: ``True``, if a caller has to pass the secret. 

2845 

2846 def __init__( 

2847 self, 

2848 name: str, 

2849 line: int, 

2850 required: bool = False, 

2851 description: Nullable[str] = None, 

2852 *, 

2853 parent: Nullable[Workflow] = None 

2854 ) -> None: 

2855 """ 

2856 Initializes a secret of a reusable workflow. 

2857 

2858 :param name: Name of the secret. 

2859 :param line: Line the secret's name is written at, starting at 1. 

2860 :param required: Optional, ``True``, if a caller has to pass the secret. Default: ``False``. 

2861 :param description: Optional, description of the secret. Default: ``None``. 

2862 :param parent: Optional, reference to the workflow declaring it. Default: ``None``. 

2863 :raises TypeError: If parameter 'required' is not of type :class:`bool`. 

2864 """ 

2865 super().__init__(name, line, description, parent=parent) 

2866 

2867 if not isinstance(required, bool): 2867 ↛ 2868line 2867 didn't jump to line 2868 because the condition on line 2867 was never true

2868 ex = TypeError("Parameter 'required' is not of type 'bool'.") 

2869 ex.add_note(f"Got type '{getFullyQualifiedName(required)}'.") 

2870 raise ex 

2871 

2872 self._required = required 

2873 

2874 if parent is not None: 2874 ↛ 2875line 2874 didn't jump to line 2875 because the condition on line 2874 was never true

2875 parent._secrets[name] = self 

2876 

2877 @readonly 

2878 def Required(self) -> bool: 

2879 """ 

2880 Read-only property to access whether a caller has to pass the secret (:attr:`_required`). 

2881 

2882 :returns: ``True``, if the secret is required. 

2883 """ 

2884 return self._required 

2885 

2886 @classmethod 

2887 def _FromYAML(cls, name: str, declaration: Any, path: Path, line: int) -> Self: 

2888 """ 

2889 Read a secret's declaration below ``on.workflow_call.secrets``. 

2890 

2891 

2892 :param name: Name of the secret. 

2893 :param declaration: The declaration. 

2894 :param path: Path to the workflow file. 

2895 :param line: Line the secret's name is written at, starting at 1. 

2896 :returns: The secret. 

2897 :raises WorkflowError: If the declaration is not a mapping. 

2898 :raises WorkflowError: If key ``required`` is not a boolean. 

2899 """ 

2900 if declaration is None: 

2901 return cls(name, line) 

2902 elif not isinstance(declaration, CommentedMap): 2902 ↛ 2903line 2902 didn't jump to line 2903 because the condition on line 2902 was never true

2903 ex = WorkflowError(f"Declaration of '{name}' is not a mapping.", path, line) 

2904 ex.add_note(f"Got type '{getFullyQualifiedName(declaration)}'.") 

2905 raise ex 

2906 

2907 description = declaration.get("description", None) 

2908 required = Base._ToPython(declaration.get("required", False)) 

2909 if not isinstance(required, bool): 

2910 ex = WorkflowError(f"Key 'required' of '{name}' is not a boolean.", path, line) 

2911 ex.add_note(f"Got '{required}'.") 

2912 raise ex 

2913 

2914 return cls(name, line, required, None if description is None else str(description)) 

2915 

2916 

2917@export 

2918class WorkflowResolver(metaclass=ExtendedType, slots=True): 

2919 """ 

2920 Reads the reusable workflows jobs call, and the actions steps run, as far as they are in a local directory. 

2921 

2922 A repository is mapped to the directory holding its workflow files, so a reference like 

2923 ``pyTooling/Actions/.github/workflows/Package.yml@r8`` reads ``Package.yml`` from that directory, whatever its ref. 

2924 A local reference like ``./.github/workflows/Package.yml`` reads the file next to the calling workflow's file. 

2925 

2926 An action of a mapped repository, like ``pyTooling/Actions/.github/actions/ComputeRequirements@r8``, is read from 

2927 the repository's root - the directory holding the ``.github`` directory the mapped directory is in. A local action, 

2928 like ``./.github/actions/ComputeRequirements``, is read from the root of the calling workflow's or action's 

2929 repository. 

2930 

2931 Every file is read once; asking for it again returns the same :class:`Workflow` or :class:`Action`. 

2932 """ 

2933 

2934 _repositories: dict[str, Path] #: Directories holding the workflow files, by repository in lower case. 

2935 _workflows: dict[Path, Workflow] #: Workflows already read, by resolved path. 

2936 _actions: dict[Path, Action] #: Actions already read, by resolved path. 

2937 

2938 def __init__(self, repositories: Nullable[Mapping[str, Path]] = None) -> None: 

2939 """ 

2940 Initializes a resolver. 

2941 

2942 :param repositories: Optional, directories holding the workflow files, by repository, as 

2943 ``{"pyTooling/Actions": Path(".github/workflows")}``. Default: ``None``. 

2944 :raises TypeError: If parameter 'repositories' is not a mapping. 

2945 :raises TypeError: If a key of parameter 'repositories' is not of type :class:`str`. 

2946 :raises ValueError: If a key of parameter 'repositories' is not of the form ``owner/repo``. 

2947 :raises TypeError: If a value of parameter 'repositories' is not of type :class:`~pathlib.Path`. 

2948 """ 

2949 self._repositories = {} 

2950 self._workflows = {} 

2951 self._actions = {} 

2952 

2953 if repositories is None: 

2954 return 

2955 elif not isinstance(repositories, Mapping): 2955 ↛ 2956line 2955 didn't jump to line 2956 because the condition on line 2955 was never true

2956 ex = TypeError("Parameter 'repositories' is not a mapping.") 

2957 ex.add_note(f"Got type '{getFullyQualifiedName(repositories)}'.") 

2958 raise ex 

2959 

2960 for repository, directory in repositories.items(): 

2961 if not isinstance(repository, str): 2961 ↛ 2962line 2961 didn't jump to line 2962 because the condition on line 2961 was never true

2962 ex = TypeError("Key of parameter 'repositories' is not of type 'str'.") 

2963 ex.add_note(f"Got type '{getFullyQualifiedName(repository)}'.") 

2964 raise ex 

2965 elif repository.count("/") != 1 or repository.startswith("/") or repository.endswith("/"): 

2966 ex = ValueError("Key of parameter 'repositories' is not of the form 'owner/repo'.") 

2967 ex.add_note(f"Got '{repository}'.") 

2968 raise ex 

2969 elif not isinstance(directory, Path): 

2970 ex = TypeError(f"Value of parameter 'repositories' for '{repository}' is not of type 'Path'.") 

2971 ex.add_note(f"Got type '{getFullyQualifiedName(directory)}'.") 

2972 raise ex 

2973 

2974 self._repositories[repository.lower()] = directory 

2975 

2976 @readonly 

2977 def Repositories(self) -> dict[str, Path]: 

2978 """ 

2979 Read-only property to access the directories holding the workflow files (:attr:`_repositories`). 

2980 

2981 :returns: The directories, by repository in lower case. 

2982 """ 

2983 return self._repositories 

2984 

2985 def CanResolve(self, uses: UsesReference) -> bool: 

2986 """ 

2987 Return whether a reference names a file the resolver reads: a local one, or one of a mapped repository. 

2988 

2989 :param uses: The reference, as :attr:`Job.Uses`. 

2990 :returns: ``True``, if the reference is local, or its repository is in :attr:`Repositories`. 

2991 :raises ValueError: If parameter 'uses' is ``None``. 

2992 :raises TypeError: If parameter 'uses' is not of type :class:`UsesReference`. 

2993 """ 

2994 if uses is None: 

2995 raise ValueError("Parameter 'uses' is None.") 

2996 elif not isinstance(uses, UsesReference): 

2997 ex = TypeError("Parameter 'uses' is not of type 'UsesReference'.") 

2998 ex.add_note(f"Got type '{getFullyQualifiedName(uses)}'.") 

2999 raise ex 

3000 

3001 return uses._isLocal or (uses._repository is not None and uses._repository.lower() in self._repositories) 

3002 

3003 def Load(self, path: Path) -> Workflow: 

3004 """ 

3005 Read a workflow file, or return it if it was read before. 

3006 

3007 :param path: Path to the workflow file. 

3008 :returns: The workflow. 

3009 :raises ValueError: If parameter 'path' is ``None``. 

3010 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`. 

3011 :raises WorkflowError: If the file doesn't exist, can't be read, or is not a well-formed workflow. 

3012 """ 

3013 if path is None: 3013 ↛ 3014line 3013 didn't jump to line 3014 because the condition on line 3013 was never true

3014 raise ValueError("Parameter 'path' is None.") 

3015 elif not isinstance(path, Path): 3015 ↛ 3016line 3015 didn't jump to line 3016 because the condition on line 3015 was never true

3016 ex = TypeError("Parameter 'path' is not of type 'Path'.") 

3017 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.") 

3018 raise ex 

3019 

3020 key = path.resolve() 

3021 if (workflow := self._workflows.get(key, None)) is None: 

3022 workflow = Workflow.FromFile(path) 

3023 self._workflows[key] = workflow 

3024 

3025 return workflow 

3026 

3027 def Resolve(self, uses: UsesReference) -> Nullable[Workflow]: 

3028 """ 

3029 Return the reusable workflow a reference names, if its file is in a local directory. 

3030 

3031 :param uses: The reference, as :attr:`Job.Uses`. 

3032 :returns: The workflow, or ``None`` if the reference names an action, or a repository without a 

3033 directory. 

3034 :raises ValueError: If parameter 'uses' is ``None``. 

3035 :raises TypeError: If parameter 'uses' is not of type :class:`UsesReference`. 

3036 :raises ValueError: If parameter 'uses' is a local reference outside a workflow. 

3037 :raises WorkflowError: If the workflow file doesn't exist in the directory. |br| 

3038 The note names the reference's location. 

3039 :raises WorkflowError: If the file is not a well-formed workflow. 

3040 """ 

3041 if uses is None: 3041 ↛ 3042line 3041 didn't jump to line 3042 because the condition on line 3041 was never true

3042 raise ValueError("Parameter 'uses' is None.") 

3043 elif not isinstance(uses, UsesReference): 3043 ↛ 3044line 3043 didn't jump to line 3044 because the condition on line 3043 was never true

3044 ex = TypeError("Parameter 'uses' is not of type 'UsesReference'.") 

3045 ex.add_note(f"Got type '{getFullyQualifiedName(uses)}'.") 

3046 raise ex 

3047 

3048 if not uses.IsWorkflow: 

3049 return None 

3050 elif uses._isLocal: 

3051 if uses._workflow is None: 3051 ↛ 3052line 3051 didn't jump to line 3052 because the condition on line 3051 was never true

3052 ex = ValueError("Parameter 'uses' is a local reference outside a workflow.") 

3053 ex.add_note(f"Got '{uses}'.") 

3054 raise ex 

3055 

3056 directory = uses._workflow._path.parent 

3057 elif (directory := self._repositories.get(uses._repository.lower(), None)) is None: 

3058 return None 

3059 

3060 path = directory / uses.FileName 

3061 if not path.exists(): 

3062 ex = WorkflowError( 

3063 f"Workflow '{uses.FileName}' doesn't exist in '{directory}'.", 

3064 uses._file, 

3065 uses._line 

3066 ) 

3067 ex.add_note(f"Called as '{uses}'.") 

3068 raise ex 

3069 

3070 return self.Load(path) 

3071 

3072 def LoadAction(self, path: Path) -> Action: 

3073 """ 

3074 Read an action's file, or return it if it was read before. 

3075 

3076 :param path: Path to the action's file. 

3077 :returns: The action. 

3078 :raises ValueError: If parameter 'path' is ``None``. 

3079 :raises TypeError: If parameter 'path' is not of type :class:`~pathlib.Path`. 

3080 :raises WorkflowError: If the file doesn't exist, can't be read, or is not a well-formed action. 

3081 """ 

3082 if path is None: 3082 ↛ 3083line 3082 didn't jump to line 3083 because the condition on line 3082 was never true

3083 raise ValueError("Parameter 'path' is None.") 

3084 elif not isinstance(path, Path): 3084 ↛ 3085line 3084 didn't jump to line 3085 because the condition on line 3084 was never true

3085 ex = TypeError("Parameter 'path' is not of type 'Path'.") 

3086 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.") 

3087 raise ex 

3088 

3089 key = path.resolve() 

3090 if (action := self._actions.get(key, None)) is None: 

3091 action = Action.FromFile(path) 

3092 self._actions[key] = action 

3093 

3094 return action 

3095 

3096 def ResolveAction(self, uses: UsesReference) -> Nullable[Action]: 

3097 """ 

3098 Return the action a step's reference names, if its file is in a local directory. 

3099 

3100 :param uses: The reference, as :attr:`Step.Uses`. 

3101 :returns: The action, or ``None`` if the reference names a reusable workflow, a Docker image, a 

3102 repository without a directory, or a local action outside a repository's ``.github`` 

3103 directory. 

3104 :raises ValueError: If parameter 'uses' is ``None``. 

3105 :raises TypeError: If parameter 'uses' is not of type :class:`UsesReference`. 

3106 :raises WorkflowError: If the directory has neither an ``action.yml`` nor an ``action.yaml``. |br| 

3107 The note names the reference's location. 

3108 :raises WorkflowError: If the file is not a well-formed action. 

3109 """ 

3110 if uses is None: 3110 ↛ 3111line 3110 didn't jump to line 3111 because the condition on line 3110 was never true

3111 raise ValueError("Parameter 'uses' is None.") 

3112 elif not isinstance(uses, UsesReference): 3112 ↛ 3113line 3112 didn't jump to line 3113 because the condition on line 3112 was never true

3113 ex = TypeError("Parameter 'uses' is not of type 'UsesReference'.") 

3114 ex.add_note(f"Got type '{getFullyQualifiedName(uses)}'.") 

3115 raise ex 

3116 

3117 if uses.IsWorkflow or uses._isDocker: 

3118 return None 

3119 

3120 if uses._isLocal: 

3121 if uses._file is None: 

3122 return None 

3123 

3124 base = uses._file.parent 

3125 elif (base := self._repositories.get(uses._repository.lower(), None)) is None: 

3126 return None 

3127 

3128 root = next((directory.parent for directory in (base, *base.parents) if directory.name == ".github"), None) 

3129 if root is None: 

3130 return None 

3131 

3132 directory = root / uses._path 

3133 for fileName in ("action.yml", "action.yaml"): 

3134 if (path := directory / fileName).exists(): 

3135 return self.LoadAction(path) 

3136 

3137 ex = WorkflowError(f"Action '{uses._path}' has no 'action.yml' in '{directory}'.", uses._file, uses._line) 

3138 ex.add_note(f"Called as '{uses}'.") 

3139 raise ex 

3140 

3141 

3142@export 

3143class DefinitionMixin(Generic[DefinitionType], metaclass=ExtendedType, mixin=True, expects=("_DEFINITION_TYPE",)): 

3144 """ 

3145 Mixin-class for an element of :mod:`pyTooling.CI` built from a workflow file, linking it to its definition. 

3146 

3147 :meth:`Workflow.ToPipeline` builds the elements, so a consumer of the generic model still reaches the facts only 

3148 the file has: the line an element is written at, the reference a job calls, its permissions. 

3149 """ 

3150 

3151 _definition: DefinitionType #: The element of the workflow file this element was built from. 

3152 

3153 @classmethod 

3154 def _CheckDefinition(cls, definition: DefinitionType) -> None: 

3155 """ 

3156 Check a definition before the element is built from it. 

3157 

3158 The host class names the element by its definition, so it checks the definition before calling 

3159 ``super().__init__()``. 

3160 

3161 :param definition: The element of the workflow file the element is built from. 

3162 :raises ValueError: If parameter 'definition' is ``None``. 

3163 :raises TypeError: If parameter 'definition' is not of the type the host class declares in 

3164 :attr:`_DEFINITION_TYPE`. 

3165 """ 

3166 if definition is None: 

3167 raise ValueError("Parameter 'definition' is None.") 

3168 elif not isinstance(definition, cls._DEFINITION_TYPE): 

3169 ex = TypeError(f"Parameter 'definition' is not of type '{cls._DEFINITION_TYPE.__name__}'.") 

3170 ex.add_note(f"Got type '{getFullyQualifiedName(definition)}'.") 

3171 raise ex 

3172 

3173 def __init__(self, definition: DefinitionType) -> None: 

3174 """ 

3175 Initializes the link of an element to its definition, which :meth:`_CheckDefinition` checked. 

3176 

3177 :param definition: The element of the workflow file this element is built from. 

3178 """ 

3179 self._definition = definition 

3180 

3181 @readonly 

3182 def Definition(self) -> DefinitionType: 

3183 """ 

3184 Read-only property to access the element of the workflow file this element was built from (:attr:`_definition`). 

3185 

3186 :returns: The :class:`Workflow` of a pipeline, the :class:`Job` of a called workflow, a matrix, a matrix instance 

3187 and a job, or the :class:`Step` of a step. 

3188 """ 

3189 return self._definition 

3190 

3191 

3192@export 

3193class CallMixin(metaclass=ExtendedType, mixin=True): 

3194 """Mixin-class for a called workflow built from a workflow file, holding the workflow file it was expanded from.""" 

3195 

3196 _calledWorkflow: Nullable[Workflow] #: The workflow file the called workflow's elements were built from. 

3197 

3198 def __init__(self, calledWorkflow: Nullable[Workflow] = None) -> None: 

3199 """ 

3200 Initializes the called workflow file. 

3201 

3202 :param calledWorkflow: Optional, the workflow file the called workflow's elements are built from. Default: 

3203 ``None``. 

3204 :raises TypeError: If parameter 'calledWorkflow' is not of type :class:`Workflow`. 

3205 """ 

3206 if calledWorkflow is not None and not isinstance(calledWorkflow, Workflow): 3206 ↛ 3207line 3206 didn't jump to line 3207 because the condition on line 3206 was never true

3207 ex = TypeError("Parameter 'calledWorkflow' is not of type 'Workflow'.") 

3208 ex.add_note(f"Got type '{getFullyQualifiedName(calledWorkflow)}'.") 

3209 raise ex 

3210 

3211 self._calledWorkflow = calledWorkflow 

3212 

3213 @readonly 

3214 def CalledWorkflow(self) -> Nullable[Workflow]: 

3215 """ 

3216 Read-only property to access the workflow file the called workflow was expanded from (:attr:`_calledWorkflow`). 

3217 

3218 :returns: The workflow, or ``None`` if the call wasn't expanded - its file isn't at hand, or the depth was used 

3219 up. 

3220 """ 

3221 return self._calledWorkflow 

3222 

3223 

3224@export 

3225class DefinedPipeline(CIPipeline, DefinitionMixin[Workflow]): 

3226 """The pipeline a workflow file defines, as :meth:`Workflow.ToPipeline` builds it.""" 

3227 

3228 _DEFINITION_TYPE: ClassVar[type] = Workflow #: A pipeline is built from a workflow file. 

3229 

3230 def __init__( 

3231 self, 

3232 definition: Workflow, 

3233 *, 

3234 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None 

3235 ) -> None: 

3236 """ 

3237 Initializes a pipeline built from a workflow file, named by the file's stem. 

3238 

3239 :param definition: The workflow file. 

3240 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

3241 """ 

3242 self._CheckDefinition(definition) 

3243 

3244 super().__init__(definition._name, keyValuePairs=keyValuePairs) 

3245 DefinitionMixin.__init__(self, definition) 

3246 

3247 

3248@export 

3249class DefinedWorkflow(CIWorkflow, CallMixin, DefinitionMixin[Job]): 

3250 """A called workflow built from the job calling it, as :meth:`Workflow.ToPipeline` builds it.""" 

3251 

3252 _DEFINITION_TYPE: ClassVar[type] = Job #: A called workflow is built from the job calling it. 

3253 

3254 def __init__( 

3255 self, 

3256 definition: Job, 

3257 *, 

3258 calledWorkflow: Nullable[Workflow] = None, 

3259 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

3260 parent: Nullable[CIWorkflow] = None 

3261 ) -> None: 

3262 """ 

3263 Initializes a called workflow built from the job calling it, named by the job's key. 

3264 

3265 The job's ``uses`` is the workflow's :attr:`~pyTooling.CI.Workflow.Reference`, its ``if`` the 

3266 workflow's :attr:`~pyTooling.CI.ConditionMixin.Condition`. 

3267 

3268 :param definition: The job calling the workflow. 

3269 :param calledWorkflow: Optional, the workflow file the called workflow's elements are built from. Default: 

3270 ``None``. 

3271 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

3272 :param parent: Optional, reference to the workflow containing the call. Default: ``None``. 

3273 :raises ValueError: If parameter 'definition' calls no workflow. 

3274 """ 

3275 self._CheckDefinition(definition) 

3276 

3277 if definition._uses is None: 3277 ↛ 3278line 3277 didn't jump to line 3278 because the condition on line 3277 was never true

3278 ex = ValueError("Parameter 'definition' calls no workflow.") 

3279 ex.add_note(f"Got job '{definition._name}'.") 

3280 raise ex 

3281 

3282 super().__init__( 

3283 definition._name, reference=str(definition._uses), condition=definition._condition, keyValuePairs=keyValuePairs, 

3284 parent=parent 

3285 ) 

3286 DefinitionMixin.__init__(self, definition) 

3287 CallMixin.__init__(self, calledWorkflow) 

3288 

3289 

3290@export 

3291class DefinedMatrix(CIMatrix, DefinitionMixin[Job]): 

3292 """ 

3293 A matrix built from the job declaring it, as :meth:`Workflow.ToPipeline` builds it. 

3294 

3295 A dynamic matrix - see :attr:`Matrix.IsDynamic` - holds no instances, since its combinations are known at run time 

3296 only. 

3297 """ 

3298 

3299 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix is built from the job declaring it. 

3300 

3301 def __init__( 

3302 self, 

3303 definition: Job, 

3304 *, 

3305 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

3306 parent: Nullable[CIWorkflow] = None 

3307 ) -> None: 

3308 """ 

3309 Initializes a matrix built from the job declaring it, named by the job's key. 

3310 

3311 :param definition: The job declaring the matrix. 

3312 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

3313 :param parent: Optional, reference to the workflow containing the matrix. Default: ``None``. 

3314 :raises ValueError: If parameter 'definition' declares no matrix. 

3315 """ 

3316 self._CheckDefinition(definition) 

3317 

3318 if definition._matrix is None: 

3319 ex = ValueError("Parameter 'definition' declares no matrix.") 

3320 ex.add_note(f"Got job '{definition._name}'.") 

3321 raise ex 

3322 

3323 super().__init__(definition._name, condition=definition._condition, keyValuePairs=keyValuePairs, parent=parent) 

3324 DefinitionMixin.__init__(self, definition) 

3325 

3326 

3327@export 

3328class DefinedMatrixWorkflow(CIMatrixWorkflow, CallMixin, DefinitionMixin[Job]): 

3329 """One instance of a matrix calling a reusable workflow, as :meth:`Workflow.ToPipeline` builds it.""" 

3330 

3331 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix instance is built from the job declaring the matrix. 

3332 

3333 def __init__( 

3334 self, 

3335 definition: Job, 

3336 dimensions: Mapping[str, Any], 

3337 *, 

3338 calledWorkflow: Nullable[Workflow] = None, 

3339 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

3340 parent: Nullable[CIMatrix] = None 

3341 ) -> None: 

3342 """ 

3343 Initializes one instance of a matrix calling a reusable workflow, named by the job's key. 

3344 

3345 :param definition: The job declaring the matrix. 

3346 :param dimensions: The matrix' combination this instance is called with, the values as GitHub prints them. 

3347 :param calledWorkflow: Optional, the workflow file the called workflow's elements are built from. Default: 

3348 ``None``. 

3349 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

3350 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``. 

3351 :raises ValueError: If parameter 'definition' calls no workflow. 

3352 :raises ValueError: If parameter 'dimensions' is ``None``. 

3353 """ 

3354 self._CheckDefinition(definition) 

3355 

3356 if definition._uses is None: 3356 ↛ 3357line 3356 didn't jump to line 3357 because the condition on line 3356 was never true

3357 ex = ValueError("Parameter 'definition' calls no workflow.") 

3358 ex.add_note(f"Got job '{definition._name}'.") 

3359 raise ex 

3360 elif dimensions is None: 

3361 raise ValueError("Parameter 'dimensions' is None.") 

3362 

3363 super().__init__( 

3364 definition._name, dimensions, reference=str(definition._uses), condition=definition._condition, 

3365 keyValuePairs=keyValuePairs, parent=parent 

3366 ) 

3367 DefinitionMixin.__init__(self, definition) 

3368 CallMixin.__init__(self, calledWorkflow) 

3369 

3370 

3371@export 

3372class DefinedJob(CIJob, DefinitionMixin[Job]): 

3373 """A job running steps, as :meth:`Workflow.ToPipeline` builds it.""" 

3374 

3375 _DEFINITION_TYPE: ClassVar[type] = Job #: A job is built from its job in the workflow file. 

3376 

3377 def __init__( 

3378 self, 

3379 definition: Job, 

3380 *, 

3381 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

3382 parent: Nullable[JobGroup] = None 

3383 ) -> None: 

3384 """ 

3385 Initializes a job built from its job in the workflow file, named by the job's key. 

3386 

3387 :param definition: The job. 

3388 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

3389 :param parent: Optional, reference to the group containing the job. Default: ``None``. 

3390 """ 

3391 self._CheckDefinition(definition) 

3392 

3393 super().__init__(definition._name, condition=definition._condition, keyValuePairs=keyValuePairs, parent=parent) 

3394 DefinitionMixin.__init__(self, definition) 

3395 

3396 

3397@export 

3398class DefinedMatrixJob(CIMatrixJob, DefinitionMixin[Job]): 

3399 """One instance of a matrix running steps, as :meth:`Workflow.ToPipeline` builds it.""" 

3400 

3401 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix instance is built from the job declaring the matrix. 

3402 

3403 def __init__( 

3404 self, 

3405 definition: Job, 

3406 dimensions: Mapping[str, Any], 

3407 *, 

3408 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

3409 parent: Nullable[CIMatrix] = None 

3410 ) -> None: 

3411 """ 

3412 Initializes one instance of a matrix running steps, named by the job's key. 

3413 

3414 :param definition: The job declaring the matrix. 

3415 :param dimensions: The matrix' combination this instance runs with, the values as GitHub prints them. 

3416 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

3417 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``. 

3418 :raises ValueError: If parameter 'dimensions' is ``None``. 

3419 """ 

3420 self._CheckDefinition(definition) 

3421 

3422 if dimensions is None: 

3423 raise ValueError("Parameter 'dimensions' is None.") 

3424 

3425 super().__init__( 

3426 definition._name, dimensions, condition=definition._condition, keyValuePairs=keyValuePairs, parent=parent 

3427 ) 

3428 DefinitionMixin.__init__(self, definition) 

3429 

3430 

3431@export 

3432class DefinedStep(CIStep, DefinitionMixin[Step]): 

3433 """A step of a job, as :meth:`Workflow.ToPipeline` builds it.""" 

3434 

3435 _DEFINITION_TYPE: ClassVar[type] = Step #: A step is built from its step in the workflow file. 

3436 

3437 def __init__( 

3438 self, 

3439 definition: Step, 

3440 *, 

3441 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

3442 parent: Nullable[CIJob] = None 

3443 ) -> None: 

3444 """ 

3445 Initializes a step built from its step in the workflow file. 

3446 

3447 The step is named as GitHub displays it: by its ``name``, or else ``Run`` followed by the action it runs or the 

3448 first line of its script. 

3449 

3450 :param definition: The step. 

3451 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

3452 :param parent: Optional, reference to the job containing the step. Default: ``None``. 

3453 """ 

3454 self._CheckDefinition(definition) 

3455 

3456 if definition._name is not None: 

3457 name = definition._name 

3458 elif definition._uses is not None: 

3459 name = f"Run {definition._uses}" 

3460 elif definition._run is not None: 3460 ↛ 3464line 3460 didn't jump to line 3464 because the condition on line 3460 was always true

3461 firstLine = definition._run.strip().partition("\n")[0] 

3462 name = f"Run {firstLine}" 

3463 else: 

3464 name = f"Step at line {definition._line}" 

3465 

3466 super().__init__(name, condition=definition._condition, keyValuePairs=keyValuePairs, parent=parent) 

3467 DefinitionMixin.__init__(self, definition)