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

1482 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-25 00:39 +0000

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.. tree:: 

37 

38 - :class:`Workflow` | a workflow file, e.g. :file:`.github/workflows/CompletePipeline.yml` 

39 - :class:`Input` | an input of ``on.workflow_call`` 

40 - :class:`Output` | an output of ``on.workflow_call`` 

41 - :class:`Secret` | a secret of ``on.workflow_call`` 

42 - :class:`Permission` | a permission the workflow declares 

43 - :class:`Job` | a job, in file order 

44 - :class:`UsesReference` | the reusable workflow the job calls 

45 - :class:`Permission` | a permission the job declares 

46 - :class:`Matrix` | the job's ``strategy.matrix`` 

47 - :class:`Step` | a step of the job 

48 - :class:`UsesReference` | the action the step runs 

49 

50 - :class:`Action` | an action's file, e.g. :file:`.github/actions/ComputeRequirements/action.yml` 

51 - :class:`Step` | a step of a composite action 

52 - :class:`UsesReference` | the action the step runs 

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, \ 

72 Optional as Nullable, Self, TypeVar, Union 

73 

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

75 MatrixJob as CIMatrixJob, MatrixWorkflow as CIMatrixWorkflow, Rule, Job as CIJob, \ 

76 Pipeline as CIPipeline, Step as CIStep, Workflow as CIWorkflow 

77from pyTooling.Common import getFullyQualifiedName, StringEnum 

78from pyTooling.Decorators import export, readonly 

79from pyTooling.MetaClasses import ExtendedType, abstractclass 

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 = stepList = [] 

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

1215 self._container = container 

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

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 stepList.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 = stepList = [] 

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 stepList.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 the permission grants access to. 

2479 """ 

2480 return self._scope 

2481 

2482 @readonly 

2483 def Level(self) -> AccessLevel: 

2484 """ 

2485 Read-only property to access the access granted (:attr:`_level`). 

2486 

2487 :returns: The access level. 

2488 """ 

2489 return self._level 

2490 

2491 def __str__(self) -> str: 

2492 """ 

2493 Return the permission, as written in a workflow file. 

2494 

2495 :returns: The permission, as ``contents: write``, or ``read-all`` for scope :attr:`PermissionScope.All`. 

2496 """ 

2497 if self._scope is PermissionScope.All: 

2498 return f"{self._level.value}-all" 

2499 

2500 return f"{self._scope}: {self._level.value}" 

2501 

2502 @classmethod 

2503 def _FromYAML(cls, value: Any, path: Path, line: int) -> list[Self]: 

2504 """ 

2505 Read the value of a ``permissions`` key into the permissions a workflow or job declares. 

2506 

2507 :param value: The value of the ``permissions`` key. 

2508 :param path: Path to the workflow file. 

2509 :param line: Line the key is written at, starting at 1. 

2510 :returns: The permissions, in file order. 

2511 :raises WorkflowError: If the value is neither ``read-all``, ``write-all`` nor a mapping. 

2512 :raises WorkflowError: If a key is not a permission scope. |br| 

2513 The note lists the allowed values. 

2514 :raises WorkflowError: If a scope's value is not an access level. |br| 

2515 The note lists the allowed values. 

2516 """ 

2517 if value == "read-all": 

2518 return [cls(PermissionScope.All, AccessLevel.Read, line)] 

2519 elif value == "write-all": 

2520 return [cls(PermissionScope.All, AccessLevel.Write, line)] 

2521 elif not isinstance(value, CommentedMap): 

2522 ex = WorkflowError("Key 'permissions' is neither 'read-all', 'write-all' nor a mapping.", path, line) 

2523 ex.add_note(f"Got '{value}'." if isinstance(value, str) else f"Got type '{getFullyQualifiedName(value)}'.") 

2524 raise ex 

2525 

2526 permissions = [] 

2527 for scope, level in value.items(): 

2528 scopeLine = Base._KeyLine(value, scope) 

2529 try: 

2530 permissionScope = PermissionScope(scope) 

2531 except ValueError as cause: 

2532 ex = WorkflowError(f"Key '{scope}' of 'permissions' is not a permission scope.", path, scopeLine) 

2533 scopes = (member.value for member in PermissionScope if member is not PermissionScope.All) 

2534 ex.add_note(f"Allowed values: {', '.join(scopes)}.") 

2535 raise ex from cause 

2536 

2537 try: 

2538 accessLevel = AccessLevel(level) 

2539 except ValueError as cause: 

2540 ex = WorkflowError(f"Permission '{scope}' is not an access level.", path, scopeLine) 

2541 ex.add_note(f"Got '{level}'.") 

2542 ex.add_note(f"Allowed values: {', '.join(member.value for member in AccessLevel)}.") 

2543 raise ex from cause 

2544 

2545 permissions.append(cls(permissionScope, accessLevel, scopeLine)) 

2546 

2547 return permissions 

2548 

2549 

2550@export 

2551@abstractclass 

2552class Parameter(Base[Workflow]): 

2553 """ 

2554 Common behaviour of the inputs, outputs and secrets of a reusable workflow. 

2555 

2556 Every parameter has a name and an optional description, and belongs to a :class:`Workflow`. 

2557 """ 

2558 

2559 _PARENT_TYPE: ClassVar[ParentTypes] = Workflow #: A parameter is declared by a workflow. 

2560 

2561 _name: str #: Name of the parameter. 

2562 _description: Nullable[str] #: Description of the parameter. 

2563 

2564 def __init__( 

2565 self, 

2566 name: str, 

2567 line: int, 

2568 description: Nullable[str] = None, 

2569 *, 

2570 parent: Nullable[Workflow] = None 

2571 ) -> None: 

2572 """ 

2573 Initializes a parameter of a reusable workflow. 

2574 

2575 :param name: Name of the parameter. 

2576 :param line: Line the parameter's name is written at, starting at 1. 

2577 :param description: Optional, description of the parameter. Default: ``None``. 

2578 :param parent: Optional, reference to the workflow declaring it. Default: ``None``. 

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

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

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

2582 :raises TypeError: If parameter 'description' is not of type :class:`str`. 

2583 """ 

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

2585 

2586 if name is None: 2586 ↛ 2587line 2586 didn't jump to line 2587 because the condition on line 2586 was never true

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

2588 elif not isinstance(name, str): 2588 ↛ 2589line 2588 didn't jump to line 2589 because the condition on line 2588 was never true

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

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

2591 raise ex 

2592 elif name == "": 2592 ↛ 2593line 2592 didn't jump to line 2593 because the condition on line 2592 was never true

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

2594 

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

2596 ex = TypeError("Parameter 'description' is not of type 'str'.") 

2597 ex.add_note(f"Got type '{getFullyQualifiedName(description)}'.") 

2598 raise ex 

2599 

2600 self._name = name 

2601 self._description = description 

2602 

2603 @readonly 

2604 def Name(self) -> str: 

2605 """ 

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

2607 

2608 :returns: Name of the parameter. 

2609 """ 

2610 return self._name 

2611 

2612 @readonly 

2613 def Description(self) -> Nullable[str]: 

2614 """ 

2615 Read-only property to access the parameter's description (:attr:`_description`). 

2616 

2617 :returns: The description, or ``None`` if the workflow gives none. 

2618 """ 

2619 return self._description 

2620 

2621 def __str__(self) -> str: 

2622 """ 

2623 Return the parameter's name. 

2624 

2625 :returns: Name of the parameter. 

2626 """ 

2627 return self._name 

2628 

2629 

2630@export 

2631class Input(Parameter): 

2632 """An input of a reusable workflow, declared in ``on.workflow_call.inputs``.""" 

2633 

2634 _type: InputType #: Type of the input. 

2635 _required: bool #: ``True``, if a caller has to pass the input. 

2636 _default: ValueT #: Value of the input, if a caller doesn't pass it. 

2637 

2638 def __init__( 

2639 self, 

2640 name: str, 

2641 line: int, 

2642 inputType: InputType, 

2643 required: bool = False, 

2644 default: ValueT = None, 

2645 description: Nullable[str] = None, 

2646 *, 

2647 parent: Nullable[Workflow] = None 

2648 ) -> None: 

2649 """ 

2650 Initializes an input of a reusable workflow. 

2651 

2652 :param name: Name of the input. 

2653 :param line: Line the input's name is written at, starting at 1. 

2654 :param inputType: Type of the input. 

2655 :param required: Optional, ``True``, if a caller has to pass the input. Default: ``False``. 

2656 :param default: Optional, value of the input, if a caller doesn't pass it. Default: ``None``. 

2657 :param description: Optional, description of the input. Default: ``None``. 

2658 :param parent: Optional, reference to the workflow declaring it. Default: ``None``. 

2659 :raises ValueError: If parameter 'inputType' is ``None``. 

2660 :raises TypeError: If parameter 'inputType' is not of type :class:`InputType`. 

2661 :raises TypeError: If parameter 'required' is not of type :class:`bool`. 

2662 """ 

2663 super().__init__(name, line, description, parent=parent) 

2664 

2665 if inputType is None: 

2666 raise ValueError("Parameter 'inputType' is None.") 

2667 elif not isinstance(inputType, InputType): 

2668 ex = TypeError("Parameter 'inputType' is not of type 'InputType'.") 

2669 ex.add_note(f"Got type '{getFullyQualifiedName(inputType)}'.") 

2670 raise ex 

2671 

2672 if not isinstance(required, bool): 2672 ↛ 2673line 2672 didn't jump to line 2673 because the condition on line 2672 was never true

2673 ex = TypeError("Parameter 'required' is not of type 'bool'.") 

2674 ex.add_note(f"Got type '{getFullyQualifiedName(required)}'.") 

2675 raise ex 

2676 

2677 self._type = inputType 

2678 self._required = required 

2679 self._default = default 

2680 

2681 if parent is not None: 2681 ↛ 2682line 2681 didn't jump to line 2682 because the condition on line 2681 was never true

2682 parent._inputs[name] = self 

2683 

2684 @readonly 

2685 def Type(self) -> InputType: 

2686 """ 

2687 Read-only property to access the input's type (:attr:`_type`). 

2688 

2689 :returns: Type of the input. 

2690 """ 

2691 return self._type 

2692 

2693 @readonly 

2694 def Required(self) -> bool: 

2695 """ 

2696 Read-only property to access whether a caller has to pass the input (:attr:`_required`). 

2697 

2698 :returns: ``True``, if the input is required. 

2699 """ 

2700 return self._required 

2701 

2702 @readonly 

2703 def Default(self) -> ValueT: 

2704 """ 

2705 Read-only property to access the input's value, if a caller doesn't pass it (:attr:`_default`). 

2706 

2707 The value keeps the type it is written with, as ``'3.14'`` or ``false``, and a multi-line value keeps its line 

2708 breaks. 

2709 

2710 :returns: The default value, or ``None`` if the workflow gives none. 

2711 """ 

2712 return self._default 

2713 

2714 @classmethod 

2715 def _FromYAML(cls, name: str, declaration: Any, path: Path, line: int) -> Self: 

2716 """ 

2717 Read an input's declaration below ``on.workflow_call.inputs``. 

2718 

2719 

2720 :param name: Name of the input. 

2721 :param declaration: The declaration. 

2722 :param path: Path to the workflow file. 

2723 :param line: Line the input's name is written at, starting at 1. 

2724 :returns: The input. 

2725 :raises WorkflowError: If the declaration is not a mapping. 

2726 :raises WorkflowError: If key ``required`` is not a boolean. 

2727 :raises WorkflowError: If the declaration has no ``type`` key. 

2728 :raises WorkflowError: If key ``type`` is not an input type. |br| 

2729 The note lists the allowed values. 

2730 """ 

2731 if declaration is None: 

2732 raise WorkflowError(f"Input '{name}' has no 'type' key.", path, line) 

2733 elif not isinstance(declaration, CommentedMap): 

2734 ex = WorkflowError(f"Declaration of '{name}' is not a mapping.", path, line) 

2735 ex.add_note(f"Got type '{getFullyQualifiedName(declaration)}'.") 

2736 raise ex 

2737 

2738 description = declaration.get("description", None) 

2739 required = Base._ToPython(declaration.get("required", False)) 

2740 if not isinstance(required, bool): 2740 ↛ 2741line 2740 didn't jump to line 2741 because the condition on line 2740 was never true

2741 ex = WorkflowError(f"Key 'required' of '{name}' is not a boolean.", path, line) 

2742 ex.add_note(f"Got '{required}'.") 

2743 raise ex 

2744 

2745 if (inputType := declaration.get("type", None)) is None: 

2746 raise WorkflowError(f"Input '{name}' has no 'type' key.", path, line) 

2747 

2748 try: 

2749 inputType = InputType.Parse(str(inputType)) 

2750 except ValueError as cause: 

2751 ex = WorkflowError(f"Key 'type' of input '{name}' is not an input type.", path, line) 

2752 ex.add_note(f"Got '{inputType}'.") 

2753 ex.add_note(f"Allowed values: {', '.join(member.value for member in InputType)}.") 

2754 raise ex from cause 

2755 

2756 default = Base._ToPython(declaration.get("default", None)) 

2757 

2758 return cls(name, line, inputType, required, default, None if description is None else str(description)) 

2759 

2760 

2761@export 

2762class Output(Parameter): 

2763 """An output of a reusable workflow, declared in ``on.workflow_call.outputs``.""" 

2764 

2765 _value: str #: Expression the output's value is taken from. 

2766 

2767 def __init__( 

2768 self, 

2769 name: str, 

2770 line: int, 

2771 value: str, 

2772 description: Nullable[str] = None, 

2773 *, 

2774 parent: Nullable[Workflow] = None 

2775 ) -> None: 

2776 """ 

2777 Initializes an output of a reusable workflow. 

2778 

2779 :param name: Name of the output. 

2780 :param line: Line the output's name is written at, starting at 1. 

2781 :param value: Expression the output's value is taken from, as ``${{ jobs.Build.outputs.version }}``. 

2782 :param description: Optional, description of the output. Default: ``None``. 

2783 :param parent: Optional, reference to the workflow declaring it. Default: ``None``. 

2784 :raises ValueError: If parameter 'value' is ``None``. 

2785 :raises TypeError: If parameter 'value' is not of type :class:`str`. 

2786 """ 

2787 super().__init__(name, line, description, parent=parent) 

2788 

2789 if value is None: 2789 ↛ 2790line 2789 didn't jump to line 2790 because the condition on line 2789 was never true

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

2791 elif not isinstance(value, str): 2791 ↛ 2792line 2791 didn't jump to line 2792 because the condition on line 2791 was never true

2792 ex = TypeError("Parameter 'value' is not of type 'str'.") 

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

2794 raise ex 

2795 

2796 self._value = value 

2797 

2798 if parent is not None: 2798 ↛ 2799line 2798 didn't jump to line 2799 because the condition on line 2798 was never true

2799 parent._outputs[name] = self 

2800 

2801 @readonly 

2802 def Value(self) -> str: 

2803 """ 

2804 Read-only property to access the expression the output's value is taken from (:attr:`_value`). 

2805 

2806 :returns: The expression, as ``${{ jobs.Build.outputs.version }}``. 

2807 """ 

2808 return self._value 

2809 

2810 @classmethod 

2811 def _FromYAML(cls, name: str, declaration: Any, path: Path, line: int) -> Self: 

2812 """ 

2813 Read an output's declaration below ``on.workflow_call.outputs``. 

2814 

2815 

2816 :param name: Name of the output. 

2817 :param declaration: The declaration. 

2818 :param path: Path to the workflow file. 

2819 :param line: Line the output's name is written at, starting at 1. 

2820 :returns: The output. 

2821 :raises WorkflowError: If the declaration is not a mapping. 

2822 :raises WorkflowError: If the declaration has no ``value`` key. 

2823 """ 

2824 if declaration is None: 

2825 raise WorkflowError(f"Output '{name}' has no 'value' key.", path, line) 

2826 elif not isinstance(declaration, CommentedMap): 2826 ↛ 2827line 2826 didn't jump to line 2827 because the condition on line 2826 was never true

2827 ex = WorkflowError(f"Declaration of '{name}' is not a mapping.", path, line) 

2828 ex.add_note(f"Got type '{getFullyQualifiedName(declaration)}'.") 

2829 raise ex 

2830 

2831 description = declaration.get("description", None) 

2832 

2833 if (value := declaration.get("value", None)) is None: 

2834 raise WorkflowError(f"Output '{name}' has no 'value' key.", path, line) 

2835 

2836 return cls(name, line, str(value), None if description is None else str(description)) 

2837 

2838 

2839@export 

2840class Secret(Parameter): 

2841 """A secret of a reusable workflow, declared in ``on.workflow_call.secrets``.""" 

2842 

2843 _required: bool #: ``True``, if a caller has to pass the secret. 

2844 

2845 def __init__( 

2846 self, 

2847 name: str, 

2848 line: int, 

2849 required: bool = False, 

2850 description: Nullable[str] = None, 

2851 *, 

2852 parent: Nullable[Workflow] = None 

2853 ) -> None: 

2854 """ 

2855 Initializes a secret of a reusable workflow. 

2856 

2857 :param name: Name of the secret. 

2858 :param line: Line the secret's name is written at, starting at 1. 

2859 :param required: Optional, ``True``, if a caller has to pass the secret. Default: ``False``. 

2860 :param description: Optional, description of the secret. Default: ``None``. 

2861 :param parent: Optional, reference to the workflow declaring it. Default: ``None``. 

2862 :raises TypeError: If parameter 'required' is not of type :class:`bool`. 

2863 """ 

2864 super().__init__(name, line, description, parent=parent) 

2865 

2866 if not isinstance(required, bool): 2866 ↛ 2867line 2866 didn't jump to line 2867 because the condition on line 2866 was never true

2867 ex = TypeError("Parameter 'required' is not of type 'bool'.") 

2868 ex.add_note(f"Got type '{getFullyQualifiedName(required)}'.") 

2869 raise ex 

2870 

2871 self._required = required 

2872 

2873 if parent is not None: 2873 ↛ 2874line 2873 didn't jump to line 2874 because the condition on line 2873 was never true

2874 parent._secrets[name] = self 

2875 

2876 @readonly 

2877 def Required(self) -> bool: 

2878 """ 

2879 Read-only property to access whether a caller has to pass the secret (:attr:`_required`). 

2880 

2881 :returns: ``True``, if the secret is required. 

2882 """ 

2883 return self._required 

2884 

2885 @classmethod 

2886 def _FromYAML(cls, name: str, declaration: Any, path: Path, line: int) -> Self: 

2887 """ 

2888 Read a secret's declaration below ``on.workflow_call.secrets``. 

2889 

2890 

2891 :param name: Name of the secret. 

2892 :param declaration: The declaration. 

2893 :param path: Path to the workflow file. 

2894 :param line: Line the secret's name is written at, starting at 1. 

2895 :returns: The secret. 

2896 :raises WorkflowError: If the declaration is not a mapping. 

2897 :raises WorkflowError: If key ``required`` is not a boolean. 

2898 """ 

2899 if declaration is None: 

2900 return cls(name, line) 

2901 elif not isinstance(declaration, CommentedMap): 2901 ↛ 2902line 2901 didn't jump to line 2902 because the condition on line 2901 was never true

2902 ex = WorkflowError(f"Declaration of '{name}' is not a mapping.", path, line) 

2903 ex.add_note(f"Got type '{getFullyQualifiedName(declaration)}'.") 

2904 raise ex 

2905 

2906 description = declaration.get("description", None) 

2907 required = Base._ToPython(declaration.get("required", False)) 

2908 if not isinstance(required, bool): 

2909 ex = WorkflowError(f"Key 'required' of '{name}' is not a boolean.", path, line) 

2910 ex.add_note(f"Got '{required}'.") 

2911 raise ex 

2912 

2913 return cls(name, line, required, None if description is None else str(description)) 

2914 

2915 

2916@export 

2917class WorkflowResolver(metaclass=ExtendedType, slots=True): 

2918 """ 

2919 Reads the reusable workflows jobs call, and the actions steps run, as far as they are in a local directory. 

2920 

2921 A repository is mapped to the directory holding its workflow files, so a reference like 

2922 ``pyTooling/Actions/.github/workflows/Package.yml@r8`` reads ``Package.yml`` from that directory, whatever its ref. 

2923 A local reference like ``./.github/workflows/Package.yml`` reads the file next to the calling workflow's file. 

2924 

2925 An action of a mapped repository, like ``pyTooling/Actions/.github/actions/ComputeRequirements@r8``, is read from 

2926 the repository's root - the directory holding the ``.github`` directory the mapped directory is in. A local action, 

2927 like ``./.github/actions/ComputeRequirements``, is read from the root of the calling workflow's or action's 

2928 repository. 

2929 

2930 Every file is read once; asking for it again returns the same :class:`Workflow` or :class:`Action`. 

2931 """ 

2932 

2933 _repositories: dict[str, Path] #: Directories holding the workflow files, by repository in lower case. 

2934 _workflows: dict[Path, Workflow] #: Workflows already read, by resolved path. 

2935 _actions: dict[Path, Action] #: Actions already read, by resolved path. 

2936 

2937 def __init__(self, repositories: Nullable[Mapping[str, Path]] = None) -> None: 

2938 """ 

2939 Initializes a resolver. 

2940 

2941 :param repositories: Optional, directories holding the workflow files, by repository, as 

2942 ``{"pyTooling/Actions": Path(".github/workflows")}``. Default: ``None``. 

2943 :raises TypeError: If parameter 'repositories' is not a mapping. 

2944 :raises TypeError: If a key of parameter 'repositories' is not of type :class:`str`. 

2945 :raises ValueError: If a key of parameter 'repositories' is not of the form ``owner/repo``. 

2946 :raises TypeError: If a value of parameter 'repositories' is not of type :class:`~pathlib.Path`. 

2947 """ 

2948 self._repositories = {} 

2949 self._workflows = {} 

2950 self._actions = {} 

2951 

2952 if repositories is None: 

2953 return 

2954 elif not isinstance(repositories, Mapping): 2954 ↛ 2955line 2954 didn't jump to line 2955 because the condition on line 2954 was never true

2955 ex = TypeError("Parameter 'repositories' is not a mapping.") 

2956 ex.add_note(f"Got type '{getFullyQualifiedName(repositories)}'.") 

2957 raise ex 

2958 

2959 for repository, directory in repositories.items(): 

2960 if not isinstance(repository, str): 2960 ↛ 2961line 2960 didn't jump to line 2961 because the condition on line 2960 was never true

2961 ex = TypeError("Key of parameter 'repositories' is not of type 'str'.") 

2962 ex.add_note(f"Got type '{getFullyQualifiedName(repository)}'.") 

2963 raise ex 

2964 elif repository.count("/") != 1 or repository.startswith("/") or repository.endswith("/"): 

2965 ex = ValueError("Key of parameter 'repositories' is not of the form 'owner/repo'.") 

2966 ex.add_note(f"Got '{repository}'.") 

2967 raise ex 

2968 elif not isinstance(directory, Path): 

2969 ex = TypeError(f"Value of parameter 'repositories' for '{repository}' is not of type 'Path'.") 

2970 ex.add_note(f"Got type '{getFullyQualifiedName(directory)}'.") 

2971 raise ex 

2972 

2973 self._repositories[repository.lower()] = directory 

2974 

2975 @readonly 

2976 def Repositories(self) -> dict[str, Path]: 

2977 """ 

2978 Read-only property to access the directories holding the workflow files (:attr:`_repositories`). 

2979 

2980 :returns: The directories, by repository in lower case. 

2981 """ 

2982 return self._repositories 

2983 

2984 def CanResolve(self, uses: UsesReference) -> bool: 

2985 """ 

2986 Return whether a reference names a file the resolver reads: a local one, or one of a mapped repository. 

2987 

2988 :param uses: The reference, as :attr:`Job.Uses`. 

2989 :returns: ``True``, if the reference is local, or its repository is in :attr:`Repositories`. 

2990 :raises ValueError: If parameter 'uses' is ``None``. 

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

2992 """ 

2993 if uses is None: 

2994 raise ValueError("Parameter 'uses' is None.") 

2995 elif not isinstance(uses, UsesReference): 

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

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

2998 raise ex 

2999 

3000 return uses._isLocal or (uses._repository is not None and uses._repository.lower() in self._repositories) 

3001 

3002 def Load(self, path: Path) -> Workflow: 

3003 """ 

3004 Read a workflow file, or return it if it was read before. 

3005 

3006 :param path: Path to the workflow file. 

3007 :returns: The workflow. 

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

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

3010 :raises WorkflowError: If the file doesn't exist, can't be read, or is not a well-formed workflow. 

3011 """ 

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

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

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

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

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

3017 raise ex 

3018 

3019 key = path.resolve() 

3020 if (workflow := self._workflows.get(key, None)) is None: 

3021 workflow = Workflow.FromFile(path) 

3022 self._workflows[key] = workflow 

3023 

3024 return workflow 

3025 

3026 def Resolve(self, uses: UsesReference) -> Nullable[Workflow]: 

3027 """ 

3028 Return the reusable workflow a reference names, if its file is in a local directory. 

3029 

3030 :param uses: The reference, as :attr:`Job.Uses`. 

3031 :returns: The workflow, or ``None`` if the reference names an action, or a repository without a 

3032 directory. 

3033 :raises ValueError: If parameter 'uses' is ``None``. 

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

3035 :raises ValueError: If parameter 'uses' is a local reference outside a workflow. 

3036 :raises WorkflowError: If the workflow file doesn't exist in the directory. |br| 

3037 The note names the reference's location. 

3038 :raises WorkflowError: If the file is not a well-formed workflow. 

3039 """ 

3040 if uses is None: 3040 ↛ 3041line 3040 didn't jump to line 3041 because the condition on line 3040 was never true

3041 raise ValueError("Parameter 'uses' is None.") 

3042 elif not isinstance(uses, UsesReference): 3042 ↛ 3043line 3042 didn't jump to line 3043 because the condition on line 3042 was never true

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

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

3045 raise ex 

3046 

3047 if not uses.IsWorkflow: 

3048 return None 

3049 elif uses._isLocal: 

3050 if uses._workflow is None: 3050 ↛ 3051line 3050 didn't jump to line 3051 because the condition on line 3050 was never true

3051 ex = ValueError("Parameter 'uses' is a local reference outside a workflow.") 

3052 ex.add_note(f"Got '{uses}'.") 

3053 raise ex 

3054 

3055 directory = uses._workflow._path.parent 

3056 elif (directory := self._repositories.get(uses._repository.lower(), None)) is None: 

3057 return None 

3058 

3059 path = directory / uses.FileName 

3060 if not path.exists(): 

3061 ex = WorkflowError( 

3062 f"Workflow '{uses.FileName}' doesn't exist in '{directory}'.", 

3063 uses._file, 

3064 uses._line 

3065 ) 

3066 ex.add_note(f"Called as '{uses}'.") 

3067 raise ex 

3068 

3069 return self.Load(path) 

3070 

3071 def LoadAction(self, path: Path) -> Action: 

3072 """ 

3073 Read an action's file, or return it if it was read before. 

3074 

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

3076 :returns: The action. 

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

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

3079 :raises WorkflowError: If the file doesn't exist, can't be read, or is not a well-formed action. 

3080 """ 

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

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

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

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

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

3086 raise ex 

3087 

3088 key = path.resolve() 

3089 if (action := self._actions.get(key, None)) is None: 

3090 action = Action.FromFile(path) 

3091 self._actions[key] = action 

3092 

3093 return action 

3094 

3095 def ResolveAction(self, uses: UsesReference) -> Nullable[Action]: 

3096 """ 

3097 Return the action a step's reference names, if its file is in a local directory. 

3098 

3099 :param uses: The reference, as :attr:`Step.Uses`. 

3100 :returns: The action, or ``None`` if the reference names a reusable workflow, a Docker image, a 

3101 repository without a directory, or a local action outside a repository's ``.github`` 

3102 directory. 

3103 :raises ValueError: If parameter 'uses' is ``None``. 

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

3105 :raises WorkflowError: If the directory has neither an ``action.yml`` nor an ``action.yaml``. |br| 

3106 The note names the reference's location. 

3107 :raises WorkflowError: If the file is not a well-formed action. 

3108 """ 

3109 if uses is None: 3109 ↛ 3110line 3109 didn't jump to line 3110 because the condition on line 3109 was never true

3110 raise ValueError("Parameter 'uses' is None.") 

3111 elif not isinstance(uses, UsesReference): 3111 ↛ 3112line 3111 didn't jump to line 3112 because the condition on line 3111 was never true

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

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

3114 raise ex 

3115 

3116 if uses.IsWorkflow or uses._isDocker: 

3117 return None 

3118 

3119 if uses._isLocal: 

3120 if uses._file is None: 

3121 return None 

3122 

3123 base = uses._file.parent 

3124 elif (base := self._repositories.get(uses._repository.lower(), None)) is None: 

3125 return None 

3126 

3127 root = next((directory.parent for directory in (base, *base.parents) if directory.name == ".github"), None) 

3128 if root is None: 

3129 return None 

3130 

3131 directory = root / uses._path 

3132 for fileName in ("action.yml", "action.yaml"): 

3133 if (path := directory / fileName).exists(): 

3134 return self.LoadAction(path) 

3135 

3136 ex = WorkflowError(f"Action '{uses._path}' has no 'action.yml' in '{directory}'.", uses._file, uses._line) 

3137 ex.add_note(f"Called as '{uses}'.") 

3138 raise ex 

3139 

3140 

3141@export 

3142class DefinitionMixin(Generic[DefinitionType], metaclass=ExtendedType, mixin=True, expects=("_DEFINITION_TYPE",)): 

3143 """ 

3144 Mixin-class for an element of :mod:`pyTooling.CI` built from a workflow file, linking it to its definition. 

3145 

3146 :meth:`Workflow.ToPipeline` builds the elements, so a consumer of the generic model still reaches the facts only 

3147 the file has: the line an element is written at, the reference a job calls, its permissions. 

3148 """ 

3149 

3150 _definition: DefinitionType #: The element of the workflow file this element was built from. 

3151 

3152 @classmethod 

3153 def _CheckDefinition(cls, definition: DefinitionType) -> None: 

3154 """ 

3155 Check a definition before the element is built from it. 

3156 

3157 The host class names the element by its definition, so it checks the definition before calling 

3158 ``super().__init__()``. 

3159 

3160 :param definition: The element of the workflow file the element is built from. 

3161 :raises ValueError: If parameter 'definition' is ``None``. 

3162 :raises TypeError: If parameter 'definition' is not of the type the host class declares in 

3163 :attr:`_DEFINITION_TYPE`. 

3164 """ 

3165 if definition is None: 

3166 raise ValueError("Parameter 'definition' is None.") 

3167 elif not isinstance(definition, cls._DEFINITION_TYPE): 

3168 ex = TypeError(f"Parameter 'definition' is not of type '{cls._DEFINITION_TYPE.__name__}'.") 

3169 ex.add_note(f"Got type '{getFullyQualifiedName(definition)}'.") 

3170 raise ex 

3171 

3172 def __init__(self, definition: DefinitionType) -> None: 

3173 """ 

3174 Initializes the link of an element to its definition, which :meth:`_CheckDefinition` checked. 

3175 

3176 :param definition: The element of the workflow file this element is built from. 

3177 """ 

3178 self._definition = definition 

3179 

3180 @readonly 

3181 def Definition(self) -> DefinitionType: 

3182 """ 

3183 Read-only property to access the element of the workflow file this element was built from (:attr:`_definition`). 

3184 

3185 :returns: The :class:`Workflow` of a pipeline, the :class:`Job` of a called workflow, a matrix, a matrix instance 

3186 and a job, or the :class:`Step` of a step. 

3187 """ 

3188 return self._definition 

3189 

3190 

3191@export 

3192class CallMixin(metaclass=ExtendedType, mixin=True): 

3193 """Mixin-class for a called workflow built from a workflow file, holding the workflow file it was expanded from.""" 

3194 

3195 _calledWorkflow: Nullable[Workflow] #: The workflow file the called workflow's elements were built from. 

3196 

3197 def __init__(self, calledWorkflow: Nullable[Workflow] = None) -> None: 

3198 """ 

3199 Initializes the called workflow file. 

3200 

3201 :param calledWorkflow: Optional, the workflow file the called workflow's elements are built from. Default: 

3202 ``None``. 

3203 :raises TypeError: If parameter 'calledWorkflow' is not of type :class:`Workflow`. 

3204 """ 

3205 if calledWorkflow is not None and not isinstance(calledWorkflow, Workflow): 3205 ↛ 3206line 3205 didn't jump to line 3206 because the condition on line 3205 was never true

3206 ex = TypeError("Parameter 'calledWorkflow' is not of type 'Workflow'.") 

3207 ex.add_note(f"Got type '{getFullyQualifiedName(calledWorkflow)}'.") 

3208 raise ex 

3209 

3210 self._calledWorkflow = calledWorkflow 

3211 

3212 @readonly 

3213 def CalledWorkflow(self) -> Nullable[Workflow]: 

3214 """ 

3215 Read-only property to access the workflow file the called workflow was expanded from (:attr:`_calledWorkflow`). 

3216 

3217 :returns: The workflow, or ``None`` if the call wasn't expanded - its file isn't at hand, or the depth was used 

3218 up. 

3219 """ 

3220 return self._calledWorkflow 

3221 

3222 

3223@export 

3224class DefinedPipeline(CIPipeline, DefinitionMixin[Workflow]): 

3225 """The pipeline a workflow file defines, as :meth:`Workflow.ToPipeline` builds it.""" 

3226 

3227 _DEFINITION_TYPE: ClassVar[type] = Workflow #: A pipeline is built from a workflow file. 

3228 

3229 def __init__( 

3230 self, 

3231 definition: Workflow, 

3232 *, 

3233 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None 

3234 ) -> None: 

3235 """ 

3236 Initializes a pipeline built from a workflow file, named by the file's stem. 

3237 

3238 :param definition: The workflow file. 

3239 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

3240 """ 

3241 self._CheckDefinition(definition) 

3242 

3243 super().__init__(definition._name, keyValuePairs=keyValuePairs) 

3244 DefinitionMixin.__init__(self, definition) 

3245 

3246 

3247@export 

3248class DefinedWorkflow(CIWorkflow, CallMixin, DefinitionMixin[Job]): 

3249 """A called workflow built from the job calling it, as :meth:`Workflow.ToPipeline` builds it.""" 

3250 

3251 _DEFINITION_TYPE: ClassVar[type] = Job #: A called workflow is built from the job calling it. 

3252 

3253 def __init__( 

3254 self, 

3255 definition: Job, 

3256 *, 

3257 calledWorkflow: Nullable[Workflow] = None, 

3258 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

3259 parent: Nullable[CIWorkflow] = None 

3260 ) -> None: 

3261 """ 

3262 Initializes a called workflow built from the job calling it, named by the job's key. 

3263 

3264 The job's ``uses`` is the workflow's :attr:`~pyTooling.CI.Workflow.Reference`, its ``if`` the 

3265 :attr:`~pyTooling.CI.Rule.Condition` of the workflow's one rule in :attr:`~pyTooling.CI.RulesMixin.Rules`. 

3266 

3267 :param definition: The job calling the workflow. 

3268 :param calledWorkflow: Optional, the workflow file the called workflow's elements are built from. Default: 

3269 ``None``. 

3270 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

3271 :param parent: Optional, reference to the workflow containing the call. Default: ``None``. 

3272 :raises ValueError: If parameter 'definition' calls no workflow. 

3273 """ 

3274 self._CheckDefinition(definition) 

3275 

3276 if definition._uses is None: 3276 ↛ 3277line 3276 didn't jump to line 3277 because the condition on line 3276 was never true

3277 ex = ValueError("Parameter 'definition' calls no workflow.") 

3278 ex.add_note(f"Got job '{definition._name}'.") 

3279 raise ex 

3280 

3281 super().__init__( 

3282 definition._name, reference=str(definition._uses), 

3283 rules=None if definition._condition is None else [Rule(definition._condition)], 

3284 keyValuePairs=keyValuePairs, parent=parent 

3285 ) 

3286 DefinitionMixin.__init__(self, definition) 

3287 CallMixin.__init__(self, calledWorkflow) 

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__( 

3324 definition._name, 

3325 rules=None if definition._condition is None else [Rule(definition._condition)], 

3326 keyValuePairs=keyValuePairs, parent=parent 

3327 ) 

3328 DefinitionMixin.__init__(self, definition) 

3329 

3330 

3331@export 

3332class DefinedMatrixWorkflow(CIMatrixWorkflow, CallMixin, DefinitionMixin[Job]): 

3333 """One instance of a matrix calling a reusable workflow, as :meth:`Workflow.ToPipeline` builds it.""" 

3334 

3335 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix instance is built from the job declaring the matrix. 

3336 

3337 def __init__( 

3338 self, 

3339 definition: Job, 

3340 dimensions: Mapping[str, Any], 

3341 *, 

3342 calledWorkflow: Nullable[Workflow] = None, 

3343 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

3344 parent: Nullable[CIMatrix] = None 

3345 ) -> None: 

3346 """ 

3347 Initializes one instance of a matrix calling a reusable workflow, named by the job's key. 

3348 

3349 :param definition: The job declaring the matrix. 

3350 :param dimensions: The matrix' combination this instance is called with, the values as GitHub prints them. 

3351 :param calledWorkflow: Optional, the workflow file the called workflow's elements are built from. Default: 

3352 ``None``. 

3353 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

3354 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``. 

3355 :raises ValueError: If parameter 'definition' calls no workflow. 

3356 :raises ValueError: If parameter 'dimensions' is ``None``. 

3357 """ 

3358 self._CheckDefinition(definition) 

3359 

3360 if definition._uses is None: 3360 ↛ 3361line 3360 didn't jump to line 3361 because the condition on line 3360 was never true

3361 ex = ValueError("Parameter 'definition' calls no workflow.") 

3362 ex.add_note(f"Got job '{definition._name}'.") 

3363 raise ex 

3364 elif dimensions is None: 

3365 raise ValueError("Parameter 'dimensions' is None.") 

3366 

3367 super().__init__( 

3368 definition._name, dimensions, reference=str(definition._uses), 

3369 rules=None if definition._condition is None else [Rule(definition._condition)], 

3370 keyValuePairs=keyValuePairs, parent=parent 

3371 ) 

3372 DefinitionMixin.__init__(self, definition) 

3373 CallMixin.__init__(self, calledWorkflow) 

3374 

3375 

3376@export 

3377class DefinedJob(CIJob, DefinitionMixin[Job]): 

3378 """A job running steps, as :meth:`Workflow.ToPipeline` builds it.""" 

3379 

3380 _DEFINITION_TYPE: ClassVar[type] = Job #: A job is built from its job in the workflow file. 

3381 

3382 def __init__( 

3383 self, 

3384 definition: Job, 

3385 *, 

3386 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

3387 parent: Nullable[JobGroup] = None 

3388 ) -> None: 

3389 """ 

3390 Initializes a job built from its job in the workflow file, named by the job's key. 

3391 

3392 :param definition: The job. 

3393 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

3394 :param parent: Optional, reference to the group containing the job. Default: ``None``. 

3395 """ 

3396 self._CheckDefinition(definition) 

3397 

3398 super().__init__( 

3399 definition._name, 

3400 rules=None if definition._condition is None else [Rule(definition._condition)], 

3401 keyValuePairs=keyValuePairs, parent=parent 

3402 ) 

3403 DefinitionMixin.__init__(self, definition) 

3404 

3405 

3406@export 

3407class DefinedMatrixJob(CIMatrixJob, DefinitionMixin[Job]): 

3408 """One instance of a matrix running steps, as :meth:`Workflow.ToPipeline` builds it.""" 

3409 

3410 _DEFINITION_TYPE: ClassVar[type] = Job #: A matrix instance is built from the job declaring the matrix. 

3411 

3412 def __init__( 

3413 self, 

3414 definition: Job, 

3415 dimensions: Mapping[str, Any], 

3416 *, 

3417 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

3418 parent: Nullable[CIMatrix] = None 

3419 ) -> None: 

3420 """ 

3421 Initializes one instance of a matrix running steps, named by the job's key. 

3422 

3423 :param definition: The job declaring the matrix. 

3424 :param dimensions: The matrix' combination this instance runs with, the values as GitHub prints them. 

3425 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

3426 :param parent: Optional, reference to the matrix containing the instance. Default: ``None``. 

3427 :raises ValueError: If parameter 'dimensions' is ``None``. 

3428 """ 

3429 self._CheckDefinition(definition) 

3430 

3431 if dimensions is None: 

3432 raise ValueError("Parameter 'dimensions' is None.") 

3433 

3434 super().__init__( 

3435 definition._name, dimensions, 

3436 rules=None if definition._condition is None else [Rule(definition._condition)], 

3437 keyValuePairs=keyValuePairs, parent=parent 

3438 ) 

3439 DefinitionMixin.__init__(self, definition) 

3440 

3441 

3442@export 

3443class DefinedStep(CIStep, DefinitionMixin[Step]): 

3444 """A step of a job, as :meth:`Workflow.ToPipeline` builds it.""" 

3445 

3446 _DEFINITION_TYPE: ClassVar[type] = Step #: A step is built from its step in the workflow file. 

3447 

3448 def __init__( 

3449 self, 

3450 definition: Step, 

3451 *, 

3452 keyValuePairs: Nullable[Mapping[Hashable, Any]] = None, 

3453 parent: Nullable[CIJob] = None 

3454 ) -> None: 

3455 """ 

3456 Initializes a step built from its step in the workflow file. 

3457 

3458 The step is named as GitHub displays it: by its ``name``, or else ``Run`` followed by the action it runs or the 

3459 first line of its script. 

3460 

3461 :param definition: The step. 

3462 :param keyValuePairs: Optional, mapping (dictionary) of key-value-pairs. Default: ``None``. 

3463 :param parent: Optional, reference to the job containing the step. Default: ``None``. 

3464 """ 

3465 self._CheckDefinition(definition) 

3466 

3467 if definition._name is not None: 

3468 name = definition._name 

3469 elif definition._uses is not None: 

3470 name = f"Run {definition._uses}" 

3471 elif definition._run is not None: 3471 ↛ 3475line 3471 didn't jump to line 3475 because the condition on line 3471 was always true

3472 firstLine = definition._run.strip().partition("\n")[0] 

3473 name = f"Run {firstLine}" 

3474 else: 

3475 name = f"Step at line {definition._line}" 

3476 

3477 super().__init__( 

3478 name, 

3479 rules=None if definition._condition is None else [Rule(definition._condition)], 

3480 keyValuePairs=keyValuePairs, parent=parent 

3481 ) 

3482 DefinitionMixin.__init__(self, definition)