Coverage for pyTooling/Documentation/Sphinx/GitHubActions/Reference.py: 97%

368 statements  

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

1# ==================================================================================================================== # 

2# _____ _ _ ____ _ _ _ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | _ \ ___ ___ _ _ _ __ ___ ___ _ __ | |_ __ _| |_(_) ___ _ __ # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | | | |/ _ \ / __| | | | '_ ` _ \ / _ \ '_ \| __/ _` | __| |/ _ \| '_ \ # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| |_| | (_) | (__| |_| | | | | | | __/ | | | || (_| | |_| | (_) | | | |# 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/ \___/ \___|\__,_|_| |_| |_|\___|_| |_|\__\__,_|\__|_|\___/|_| |_|# 

7# |_| |___/ |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany # 

15# # 

16# Licensed under the Apache License, Version 2.0 (the "License"); # 

17# you may not use this file except in compliance with the License. # 

18# You may obtain a copy of the License at # 

19# # 

20# http://www.apache.org/licenses/LICENSE-2.0 # 

21# # 

22# Unless required by applicable law or agreed to in writing, software # 

23# distributed under the License is distributed on an "AS IS" BASIS, # 

24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

25# See the License for the specific language governing permissions and # 

26# limitations under the License. # 

27# # 

28# SPDX-License-Identifier: Apache-2.0 # 

29# ==================================================================================================================== # 

30# 

31""" 

32Directives of the ``gha`` domain summarizing the current workflow: its parameters, interface, dependencies and YAML. 

33 

34Each of them reads the workflow of the preceding ``gha:workflow``: 

35 

36.. code-block:: ReST 

37 

38 .. gha:workflow:: Package 

39 

40 .. gha:parameter-table:: 

41 :kinds: inputs secrets 

42 

43 .. gha:interface:: 

44 

45 .. gha:dependencies:: 

46 

47 * pip 

48 

49 .. gha:yaml:: 

50 :job: Package 

51 

52 .. gha:autoinputs:: 

53 

54* ``gha:parameter-table`` - the summary tables of the inputs, secrets and outputs; 

55* ``gha:interface`` - the contract with a caller: the required inputs, the secrets, the outputs, the permissions to 

56 grant; 

57* ``gha:dependencies`` - the templates, actions and container images used, merged with hand-written ones; 

58* ``gha:yaml`` - the workflow file or a part of it, as a code block linked to the file on GitHub; 

59* ``gha:autoinputs`` - an entry for every input the document has no ``gha:input`` for. 

60 

61When a document was read, an input of its workflow without an entry is a ``gha.drift`` warning 

62(:func:`checkUndocumentedInputs`). 

63 

64.. seealso:: 

65 

66 :mod:`pyTooling.Documentation.Sphinx.GitHubActions` 

67 |rarr| The domain ``gha``, its workflows and parameter entries. 

68""" 

69from __future__ import annotations 

70 

71from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional as Nullable 

72from typing import TypeVar 

73 

74from docutils import nodes 

75from docutils.parsers.rst import directives 

76from sphinx import addnodes 

77from sphinx.application import Sphinx 

78from sphinx.directives.code import container_wrapper 

79from sphinx.transforms import SphinxTransform 

80from sphinx.util.logging import getLogger 

81 

82from pyTooling.Decorators import export 

83from pyTooling.Documentation.Sphinx.Directives import BaseDirective, SphinxExtensionError, strip 

84from pyTooling.Documentation.Sphinx.GitHubActions import NO_DEFAULT, WARNING_TYPE, InputDirective, formatValue 

85 

86if TYPE_CHECKING: # pragma: no cover 

87 from pyTooling.CI.GitHub.WorkflowFile import UsesReference, ValueT, Workflow 

88 

89 

90__all__ = ["KINDS", "SECTIONS", "MAX_DEFAULT_LENGTH"] 

91 

92#: The kinds of parameters ``gha:parameter-table`` summarizes, in the order it shows them by default: kind |rarr| the 

93#: columns. 

94KINDS = { 

95 "inputs": ("Parameter Name", "Required", "Type", "Default"), 

96 "secrets": ("Token Name", "Required", "Type", "Default"), 

97 "outputs": ("Result Name", "Description"), 

98} 

99 

100#: The parts of a workflow file ``gha:yaml`` shows by option ``:section:``. 

101SECTIONS = ("inputs", "outputs", "secrets", "jobs") 

102 

103#: The length a default is shortened to in a summary table; a multi-line default is shortened to its first line. 

104MAX_DEFAULT_LENGTH = 120 

105 

106_logger = getLogger(__name__) 

107 

108_ResultType = TypeVar("_ResultType") 

109 

110 

111@export 

112class WorkflowReferenceDirective(BaseDirective): 

113 """ 

114 Base-class of the directives summarizing the current workflow, as set by the preceding ``gha:workflow``. 

115 """ 

116 

117 @staticmethod 

118 def _Reference(objectType: str, target: str, text: str) -> addnodes.pending_xref: 

119 """ 

120 Create a reference to an object of the domain, shown as literal text if the object isn't documented. 

121 

122 :param objectType: The object type, as ``input``. 

123 :param target: The object's name, as ``Package.package_name``. 

124 :param text: The text of the reference. 

125 :returns: The pending reference. 

126 """ 

127 return addnodes.pending_xref( 

128 "", nodes.literal(text, text), refdomain="gha", reftype=objectType, reftarget=target, refexplicit=True, 

129 refwarn=False 

130 ) 

131 

132 def _GitHubURL(self, fileName: str, first: Nullable[int] = None, last: Nullable[int] = None) -> Nullable[str]: 

133 """ 

134 Return the URL of a workflow file of the documented repository on GitHub, at the documented ref. 

135 

136 :param fileName: The workflow file's name, as ``Package.yml``. 

137 :param first: Optional, the first line to mark. Default: ``None``. 

138 :param last: Optional, the last line to mark. Default: ``None``. 

139 :returns: The URL, or ``None`` if ``gha_repository`` or ``gha_ref`` isn't configured. 

140 """ 

141 if self.config.gha_repository is None or self.config.gha_ref is None: 

142 return None 

143 

144 server = self.config.gha_server.rstrip("/") 

145 url = f"{server}/{self.config.gha_repository}/blob/{self.config.gha_ref}/.github/workflows/{fileName}" 

146 if first is None: 146 ↛ 147line 146 didn't jump to line 147 because the condition on line 146 was never true

147 return url 

148 elif last is None or last == first: 

149 return f"{url}#L{first}" 

150 

151 return f"{url}#L{first}-L{last}" 

152 

153 def _CurrentWorkflow(self) -> Nullable[Workflow]: 

154 """ 

155 Return the model of the current workflow, and warn if there is no ``gha:workflow`` before the directive. 

156 

157 :returns: The workflow, or ``None`` if there is no ``gha:workflow`` before the directive, or its file couldn't be 

158 read - which the ``gha:workflow`` directive reported already. 

159 """ 

160 if self.env.ref_context.get("gha:workflow", None) is None: 

161 _logger.warning( 

162 f"{self.directiveName} is not preceded by a gha:workflow.", 

163 location=self.get_location(), type=WARNING_TYPE, subtype="workflow" 

164 ) 

165 return None 

166 

167 return self.env.get_domain("gha").GetCurrentWorkflow() 

168 

169 def _ResolveOrWarn(self, resolve: Callable[[], _ResultType], fallback: Callable[[], _ResultType]) -> _ResultType: 

170 """ 

171 Read what a workflow calls or uses, and report a file that is missing or malformed as a warning. 

172 

173 :param resolve: The operation reading the called or used files, as :meth:`WorkflowResolver.Resolve 

174 <pyTooling.CI.GitHub.WorkflowFile.WorkflowResolver.Resolve>`. 

175 :param fallback: The operation giving the result, when a file couldn't be read. 

176 :returns: The result of ``resolve``, or of ``fallback`` after a warning. 

177 """ 

178 from pyTooling.CI.GitHub.WorkflowFile import WorkflowError 

179 

180 try: 

181 return resolve() 

182 except WorkflowError as ex: 

183 _logger.warning( 

184 f"{self.directiveName}: {ex}", location=self.get_location(), type=WARNING_TYPE, subtype="workflow" 

185 ) 

186 return fallback() 

187 

188 

189@export 

190class ParameterTable(WorkflowReferenceDirective): 

191 """ 

192 The directive ``gha:parameter-table``: summary tables of the current workflow's inputs, secrets and outputs. 

193 

194 .. code-block:: ReST 

195 

196 .. gha:parameter-table:: 

197 :kinds: inputs secrets 

198 

199 A table per kind, in the order ``:kinds:`` names them, by default the order of :data:`KINDS`. A table lists the 

200 parameters in file order, each name linked to its entry. An input's or a secret's row states whether it is 

201 required, its type and its default - a long or multi-line default is shortened, the entry shows it in full. An 

202 output's row states its description from the workflow file. 

203 

204 Without ``:kinds:``, a table is shown for every kind the workflow has parameters of. A kind named explicitly, of 

205 which the workflow has none, is a table with a single row saying so. 

206 """ 

207 

208 directiveName: str = "gha:parameter-table" #: Name the directive is invoked by. 

209 

210 has_content = False #: A boolean; ``True`` if content is allowed. 

211 required_arguments = 0 #: Number of required directive arguments. 

212 optional_arguments = 0 #: Number of optional arguments after the required ones. 

213 final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces. 

214 option_spec: dict[str, Any] = { # type: ignore[misc] 

215 "kinds": strip, 

216 } #: Mapping of option names to validator functions. 

217 

218 def run(self) -> list[nodes.Node]: 

219 """ 

220 Create the summary tables. 

221 

222 :returns: A table per kind, an error node if option ``:kinds:`` names an unknown kind, or nothing without a 

223 current workflow. 

224 """ 

225 try: 

226 kinds = self._ParseKinds() 

227 except SphinxExtensionError as ex: 

228 return [self.state.document.reporter.error(str(ex), line=self.lineno)] 

229 

230 if (workflow := self._CurrentWorkflow()) is None: 

231 return [] 

232 

233 workflowName = self.env.ref_context["gha:workflow"] 

234 tables = [] 

235 for kind in kinds: 

236 parameters = getattr(workflow, kind.capitalize()) 

237 if len(parameters) == 0 and "kinds" not in self.options: 

238 continue 

239 

240 columns = KINDS[kind] 

241 tableGroup = self._CreateSingleRowTableHeader( 

242 columns=[(title, None) for title in columns], 

243 identifier=f"{workflowName}-{kind}", 

244 classes=["gha-parameter-table", f"gha-{kind}"] 

245 ) 

246 tableGroup += (tableBody := nodes.tbody()) 

247 

248 if len(parameters) == 0: 

249 entry = nodes.entry("", nodes.paragraph("", "", nodes.emphasis(text=f"No {kind}")), morecols=len(columns) - 1) 

250 tableBody += nodes.row("", entry) 

251 

252 for name, parameter in parameters.items(): 

253 row = nodes.row() 

254 row += nodes.entry("", nodes.paragraph("", "", self._Reference(kind[:-1], f"{workflowName}.{name}", name))) 

255 if kind == "outputs": 

256 description = "" if parameter.Description is None else parameter.Description.strip() 

257 row += nodes.entry("", nodes.paragraph(description, description)) 

258 else: 

259 row += nodes.entry("", nodes.paragraph(text="yes" if parameter.Required else "no")) 

260 row += nodes.entry("", nodes.paragraph(text=parameter.Type.value if kind == "inputs" else "string")) 

261 row += nodes.entry("", self._DefaultParagraph(parameter.Default if kind == "inputs" else None)) 

262 

263 tableBody += row 

264 

265 tables.append(tableGroup.parent) 

266 

267 return tables 

268 

269 def _ParseKinds(self) -> tuple[str, ...]: 

270 """ 

271 Read option ``:kinds:``, a list of kinds separated by spaces or commas. 

272 

273 :returns: The kinds named, in the order written and each once, or every kind of :data:`KINDS` 

274 without the option. 

275 :raises SphinxExtensionError: If a kind is not one of :data:`KINDS`. 

276 """ 

277 if "kinds" not in self.options: 

278 return tuple(KINDS) 

279 

280 kinds = tuple(dict.fromkeys(self.options["kinds"].replace(",", " ").split())) 

281 for kind in kinds: 

282 if kind not in KINDS: 

283 raise SphinxExtensionError( 

284 f"{self.directiveName}::kinds: '{kind}' is not one of {', '.join(KINDS)}." 

285 ) 

286 

287 return kinds 

288 

289 @staticmethod 

290 def _DefaultParagraph(value: ValueT) -> nodes.paragraph: 

291 """ 

292 Create the paragraph of a table cell showing a default. 

293 

294 A multi-line string is shortened to its first line, and a longer text to :data:`MAX_DEFAULT_LENGTH` characters, 

295 each followed by ``…``. 

296 

297 :param value: The default, as :attr:`Input.Default <pyTooling.CI.GitHub.WorkflowFile.Input.Default>`. 

298 :returns: The paragraph, holding :data:`~pyTooling.Documentation.Sphinx.GitHubActions.NO_DEFAULT` for 

299 ``None``, or the default as literal text. 

300 """ 

301 if value is None: 

302 return nodes.paragraph(NO_DEFAULT, NO_DEFAULT) 

303 

304 if isinstance(value, str): 

305 lines = value.splitlines() 

306 first = lines[0] if len(lines) > 0 else "" 

307 if len(lines) > 1 or len(first) > MAX_DEFAULT_LENGTH: 

308 value = f"{first[:MAX_DEFAULT_LENGTH]}…" 

309 

310 text = formatValue(value) 

311 return nodes.paragraph("", "", nodes.literal(text, text)) 

312 

313 

314@export 

315class Interface(WorkflowReferenceDirective): 

316 """ 

317 The directive ``gha:interface``: the contract of the current workflow with its caller, as a field list. 

318 

319 .. code-block:: ReST 

320 

321 .. gha:interface:: 

322 

323 * *Required Inputs*, *Secrets* and *Outputs* - the parameters, each linked to its entry; a secret a caller has to 

324 pass is marked *required*. 

325 * *Permissions* - the permissions a caller has to grant the ``GITHUB_TOKEN``: what the workflow's jobs and the 

326 jobs of the workflows they call declare, the highest access per scope, each with the job and the place in the 

327 workflow file asking for it. 

328 

329 The templates and actions the workflow uses are listed by :class:`Dependencies`. 

330 """ 

331 

332 directiveName: str = "gha:interface" #: Name the directive is invoked by. 

333 

334 has_content = False #: A boolean; ``True`` if content is allowed. 

335 required_arguments = 0 #: Number of required directive arguments. 

336 optional_arguments = 0 #: Number of optional arguments after the required ones. 

337 final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces. 

338 option_spec: dict[str, Any] = {} #: Mapping of option names to validator functions. 

339 

340 def run(self) -> list[nodes.Node]: 

341 """ 

342 Create the field list. 

343 

344 A called workflow whose file is missing or malformed is reported, and the permissions are then those of the 

345 current workflow alone. 

346 

347 :returns: The field list, or nothing without a current workflow. 

348 """ 

349 if (workflow := self._CurrentWorkflow()) is None: 349 ↛ 350line 349 didn't jump to line 350 because the condition on line 349 was never true

350 return [] 

351 

352 workflowName = self.env.ref_context["gha:workflow"] 

353 

354 fieldList = nodes.field_list(classes=["gha-interface"]) 

355 fieldList += self._Field("Required Inputs", [ 

356 [self._Reference("input", f"{workflowName}.{name}", name)] 

357 for name, parameter in workflow.Inputs.items() if parameter.Required 

358 ]) 

359 secrets = [] 

360 for name, secret in workflow.Secrets.items(): 

361 secrets.append([self._Reference("secret", f"{workflowName}.{name}", name)]) 

362 if secret.Required: 

363 secrets[-1].append(nodes.Text(" (required)")) 

364 

365 fieldList += self._Field("Secrets", secrets) 

366 fieldList += self._Field("Outputs", [ 

367 [self._Reference("output", f"{workflowName}.{name}", name)] for name in workflow.Outputs 

368 ]) 

369 

370 resolver = self.env.get_domain("gha").Resolver 

371 permissions = self._ResolveOrWarn(lambda: workflow.CollectPermissions(resolver), workflow.CollectPermissions) 

372 

373 items = [] 

374 for permission in permissions.values(): 

375 text = str(permission) 

376 item: list[nodes.Node] = [nodes.literal(text, text), nodes.Text(" - ")] 

377 if permission.Parent is permission.Workflow: 377 ↛ 378line 377 didn't jump to line 378 because the condition on line 377 was never true

378 item.append(nodes.Text("the workflow")) 

379 else: 

380 item.extend((nodes.Text("job "), nodes.emphasis(text=permission.Parent.Name))) 

381 

382 location = permission.Location 

383 item.append(nodes.Text(" (")) 

384 if (url := self._GitHubURL(permission.Workflow.Path.name, permission.Line)) is None: 

385 item.append(nodes.Text(location)) 

386 else: 

387 item.append(nodes.reference(location, location, refuri=url)) 

388 item.append(nodes.Text(")")) 

389 items.append(item) 

390 

391 fieldList += self._Field("Permissions", items, bullets=True) 

392 

393 return [fieldList] 

394 

395 @staticmethod 

396 def _Field(name: str, items: list[list[nodes.Node]], bullets: bool = False) -> nodes.field: 

397 """ 

398 Create a field listing items, or saying *none*. 

399 

400 :param name: The field's name. 

401 :param items: The items, each as the nodes of a line. 

402 :param bullets: Optional, ``True`` to list the items as bullets, else separated by commas. Default: ``False``. 

403 :returns: The field. 

404 """ 

405 if len(items) == 0: 

406 body = nodes.paragraph("", "", nodes.emphasis(text="none")) 

407 elif bullets: 

408 body = nodes.bullet_list("", *(nodes.list_item("", nodes.paragraph("", "", *item)) for item in items)) 

409 else: 

410 body = nodes.paragraph() 

411 for index, item in enumerate(items): 

412 if index > 0: 

413 body += nodes.Text(", ") 

414 body.extend(item) 

415 

416 return nodes.field("", nodes.field_name(text=name), nodes.field_body("", body)) 

417 

418 

419@export 

420class Dependencies(WorkflowReferenceDirective): 

421 """ 

422 The directive ``gha:dependencies``: what the current workflow uses, as a nested bullet list. 

423 

424 .. code-block:: ReST 

425 

426 .. gha:dependencies:: 

427 

428 * UnitTesting.yml 

429 

430 * pip 

431 

432 * Python packages given by :gha:input:`UnitTesting.requirements`. 

433 

434 From the workflow file, and the files of the templates and actions it uses, as far as they are known locally: 

435 

436 * the templates the jobs call, each once - with the jobs calling it, if several do -, each with its own 

437 dependencies; 

438 * the actions the steps run, each once; a composite action with the actions its steps run, a Docker action with its 

439 image; 

440 * the images of the containers and service containers the jobs run in. 

441 

442 The content is a bullet list of what a file can't tell, like packages installed by a step. It is merged into the 

443 derived list: an item whose text is the name of a derived item - a template as ``UnitTesting.yml``, an action as 

444 ``actions/checkout``, a container as ``container``, each also as written in the file - adds its nested list to 

445 that item, recursively. Any other item is appended to the list it is in. Content after the bullet list follows the 

446 list. 

447 """ 

448 

449 directiveName: str = "gha:dependencies" #: Name the directive is invoked by. 

450 

451 has_content = True #: The hand-written dependencies. 

452 required_arguments = 0 #: Number of required directive arguments. 

453 optional_arguments = 0 #: Number of optional arguments after the required ones. 

454 final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces. 

455 option_spec: dict[str, Any] = {} #: Mapping of option names to validator functions. 

456 

457 _keys: dict[int, tuple[set[str], nodes.list_item]] #: Names of each derived item, by the item's identity. 

458 

459 def run(self) -> list[nodes.Node]: 

460 """ 

461 Create the list: the derived dependencies, merged with the hand-written ones. 

462 

463 :returns: The list and the content following it, a paragraph saying *none* if there are no dependencies, or 

464 nothing without a current workflow. 

465 """ 

466 if (workflow := self._CurrentWorkflow()) is None: 466 ↛ 467line 466 didn't jump to line 467 because the condition on line 466 was never true

467 return [] 

468 

469 self._keys = {} 

470 dependencies = self._WorkflowItems(workflow, {id(workflow)}) 

471 dependencies["classes"].append("gha-dependencies") 

472 

473 others = [] 

474 for node in self.parse_content_to_nodes(): 

475 if isinstance(node, nodes.bullet_list): 

476 self._Merge(dependencies, node) 

477 else: 

478 others.append(node) 

479 

480 if len(dependencies) == 0: 

481 return [nodes.paragraph("", "", nodes.emphasis(text="none")), *others] 

482 

483 return [dependencies, *others] 

484 

485 def _Item(self, paragraph: nodes.paragraph, keys: set[str]) -> nodes.list_item: 

486 """ 

487 Create a derived item, known by the names a hand-written item may refer to it by. 

488 

489 :param paragraph: The item's text. 

490 :param keys: The names of the item. 

491 :returns: The item. 

492 """ 

493 item = nodes.list_item("", paragraph) 

494 self._keys[id(item)] = (keys, item) 

495 

496 return item 

497 

498 def _UsesItem(self, uses: UsesReference) -> nodes.list_item: 

499 """ 

500 Create the item of a template or an action. 

501 

502 A template of the documented repository links to its ``gha:workflow``, if that is documented; one of another 

503 repository and an action link to GitHub, as does a local action when ``gha_repository`` and ``gha_ref`` are 

504 configured. 

505 

506 :param uses: The reference, as written in the workflow file. 

507 :returns: The item, known by the reference as written, without its ref, and by a template's file name and 

508 stem. 

509 """ 

510 text = str(uses) 

511 keys = {text, text.partition("@")[0]} 

512 if uses.IsWorkflow: 

513 keys.update((uses.FileName, uses.Stem)) 

514 

515 literal = nodes.literal(text, text) 

516 server = self.config.gha_server.rstrip("/") 

517 if uses.IsWorkflow and self.env.get_domain("gha").Resolver.CanResolve(uses): 

518 return self._Item(nodes.paragraph("", "", self._Reference("workflow", uses.Stem, text)), keys) 

519 elif uses.IsLocal and self.config.gha_repository is not None and self.config.gha_ref is not None: 

520 url = f"{server}/{self.config.gha_repository}/tree/{self.config.gha_ref}/{uses.Path}" 

521 elif uses.Repository is None: 

522 return self._Item(nodes.paragraph("", "", literal), keys) 

523 else: 

524 url = f"{server}/{uses.Repository}" 

525 if uses.Path != "": 

526 url += f"/{'blob' if uses.IsWorkflow else 'tree'}/{uses.Reference}/{uses.Path}" 

527 

528 return self._Item(nodes.paragraph("", "", nodes.reference(text, "", literal, refuri=url)), keys) 

529 

530 def _ImageItem(self, kind: str, image: str, name: str = "") -> nodes.list_item: 

531 """ 

532 Create the item of a container's image. 

533 

534 :param kind: The kind of container, as ``container``, ``service`` or ``image``. 

535 :param image: The image, as written. 

536 :param name: Optional, the service's name. Default: ``""``. 

537 :returns: The item, known by the kind - followed by the service's name -, the service's name and the image. 

538 """ 

539 paragraph = nodes.paragraph("", f"{kind} ") 

540 keys = {kind, image} 

541 if name != "": 

542 paragraph += (nodes.literal(name, name), nodes.Text(": ")) 

543 keys.update((f"{kind} {name}", name)) 

544 paragraph += nodes.literal(image, image) 

545 

546 return self._Item(paragraph, keys) 

547 

548 def _WorkflowItems(self, workflow: Workflow, visited: set[int]) -> nodes.bullet_list: 

549 """ 

550 List a workflow's dependencies: its templates, actions and container images. 

551 

552 :param workflow: The workflow. 

553 :param visited: The identities of the workflows and actions on the path to this one, which aren't expanded again. 

554 :returns: A bullet list, empty if the workflow uses nothing. 

555 """ 

556 templates: dict[str, tuple[UsesReference, list[str]]] = {} 

557 containers: dict[str, None] = {} 

558 services: dict[tuple[str, str], None] = {} 

559 for job in workflow.IterateJobs(): 

560 if job.Uses is not None and job.Uses.IsWorkflow: 

561 templates.setdefault(str(job.Uses), (job.Uses, []))[1].append(job.Name) 

562 

563 if job.Container is not None: 

564 containers[job.Container] = None 

565 

566 for serviceName, image in job.Services.items(): 

567 services[(serviceName, image)] = None 

568 

569 bulletList = nodes.bullet_list() 

570 for uses, jobNames in templates.values(): 

571 item = self._UsesItem(uses) 

572 if len(jobNames) > 1: 

573 item[0] += nodes.Text(f" (called by {len(jobNames)} jobs: {', '.join(jobNames)})") 

574 

575 called = self._ResolveOrWarn(lambda: self.env.get_domain("gha").Resolver.Resolve(uses), lambda: None) 

576 if called is not None and id(called) not in visited: 

577 if len(nested := self._WorkflowItems(called, visited | {id(called)})) > 0: 577 ↛ 580line 577 didn't jump to line 580 because the condition on line 577 was always true

578 item += nested 

579 

580 bulletList += item 

581 

582 bulletList.extend(self._ActionItems(workflow.IterateActions(), visited)) 

583 bulletList.extend(self._ImageItem("container", image) for image in containers) 

584 bulletList.extend(self._ImageItem("service", image, name) for name, image in services) 

585 

586 return bulletList 

587 

588 def _ActionItems(self, references: Iterable[UsesReference], visited: set[int]) -> list[nodes.list_item]: 

589 """ 

590 List actions, each once; a composite action with the actions its steps run, a Docker action with its image. 

591 

592 :param references: The references to the actions, in file order. 

593 :param visited: The identities of the workflows and actions on the path, which aren't expanded again. 

594 :returns: The items. 

595 """ 

596 items = [] 

597 for uses in {str(uses): uses for uses in references}.values(): 

598 item = self._UsesItem(uses) 

599 action = self._ResolveOrWarn(lambda: self.env.get_domain("gha").Resolver.ResolveAction(uses), lambda: None) 

600 if action is not None and id(action) not in visited: 

601 nested = nodes.bullet_list("", *self._ActionItems(action.IterateActions(), visited | {id(action)})) 

602 if action.Image is not None: 

603 nested += self._ImageItem("image", action.Image) 

604 

605 if len(nested) > 0: 605 ↛ 608line 605 didn't jump to line 608 because the condition on line 605 was always true

606 item += nested 

607 

608 items.append(item) 

609 

610 return items 

611 

612 def _Merge(self, derived: nodes.bullet_list, handwritten: nodes.bullet_list) -> None: 

613 """ 

614 Merge a hand-written list into a derived one. 

615 

616 A hand-written item whose text is a name of a derived item of this list adds its nested lists to that item, 

617 merged recursively; any other item is appended. 

618 

619 :param derived: The derived list. 

620 :param handwritten: The hand-written list. 

621 """ 

622 for item in list(handwritten.children): 

623 text = item[0].astext().strip() if len(item) > 0 and isinstance(item[0], nodes.paragraph) else None 

624 match = next( 

625 (child for child in derived.children if text is not None and text in self._keys.get(id(child), ((), None))[0]), 

626 None 

627 ) 

628 if match is None: 

629 derived += item 

630 continue 

631 

632 for node in item.children[1:]: 

633 nested = next((child for child in match.children if isinstance(child, nodes.bullet_list)), None) 

634 if isinstance(node, nodes.bullet_list) and nested is not None: 

635 self._Merge(nested, node) 

636 else: 

637 match += node 

638 

639 

640@export 

641class YAMLExcerpt(WorkflowReferenceDirective): 

642 """ 

643 The directive ``gha:yaml``: the current workflow's file or a part of it, as a YAML code block. 

644 

645 .. code-block:: ReST 

646 

647 .. gha:yaml:: 

648 :section: inputs 

649 

650 Option ``:section:`` selects the ``inputs``, ``outputs`` or ``secrets`` of ``on.workflow_call``, or the ``jobs``; 

651 option ``:job:`` selects one job. Without either, the whole file is shown. 

652 

653 The lines are numbered as in the file, and the part is shifted left by the indentation of its first line. The 

654 caption names the file and the lines, and links to them on GitHub at ``gha_ref``, if ``gha_repository`` and 

655 ``gha_ref`` are configured. 

656 """ 

657 

658 directiveName: str = "gha:yaml" #: Name the directive is invoked by. 

659 

660 has_content = False #: A boolean; ``True`` if content is allowed. 

661 required_arguments = 0 #: Number of required directive arguments. 

662 optional_arguments = 0 #: Number of optional arguments after the required ones. 

663 final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces. 

664 option_spec: dict[str, Any] = { # type: ignore[misc] 

665 "caption": directives.unchanged_required, 

666 "job": strip, 

667 "name": strip, 

668 "section": strip, 

669 } #: Mapping of option names to validator functions. 

670 

671 def run(self) -> list[nodes.Node]: 

672 """ 

673 Create the code block. 

674 

675 :returns: The code block in a captioned container, an error node for wrong options, or nothing without a current 

676 workflow or when the part doesn't exist. 

677 """ 

678 if "section" in self.options and "job" in self.options: 

679 return [self.state.document.reporter.error( 

680 f"{self.directiveName}: Options ':section:' and ':job:' exclude each other.", line=self.lineno 

681 )] 

682 elif (section := self.options.get("section", None)) is not None and section not in SECTIONS: 

683 return [self.state.document.reporter.error( 

684 f"{self.directiveName}::section: '{section}' is not one of {', '.join(SECTIONS)}.", line=self.lineno 

685 )] 

686 

687 if (workflow := self._CurrentWorkflow()) is None: 687 ↛ 688line 687 didn't jump to line 688 because the condition on line 687 was never true

688 return [] 

689 

690 lines = workflow.Path.read_text(encoding="utf-8").splitlines() 

691 if section is not None: 

692 if len(parameters := getattr(workflow, section.capitalize())) == 0: 

693 _logger.warning( 

694 f"{self.directiveName}: Workflow '{workflow.Name}' has no {section}.", 

695 location=self.get_location(), type=WARNING_TYPE, subtype="drift" 

696 ) 

697 return [] 

698 

699 first, last = self._BlockOf(lines, self._ParentOf(lines, next(iter(parameters.values())).Line)) 

700 elif (jobName := self.options.get("job", None)) is not None: 

701 if (job := workflow.Jobs.get(jobName, None)) is None: 

702 _logger.warning( 

703 f"{self.directiveName}: Workflow '{workflow.Name}' has no job '{jobName}'.", 

704 location=self.get_location(), type=WARNING_TYPE, subtype="drift" 

705 ) 

706 return [] 

707 

708 first, last = self._BlockOf(lines, job.Line) 

709 else: 

710 first, last = 1, len(lines) 

711 

712 indentation = self._Indentation(lines[first - 1]) 

713 excerpt = [line[min(indentation, self._Indentation(line)):] for line in lines[first - 1:last]] 

714 

715 text = "\n".join(excerpt) 

716 literal = nodes.literal_block(text, text, language="yaml", linenos=True) 

717 literal["highlight_args"] = {"linenostart": first} 

718 self.set_source_info(literal) 

719 

720 if (caption := self.options.get("caption", None)) is None: 

721 fileName = workflow.Path.name 

722 if (first, last) == (1, len(lines)): 

723 caption = fileName 

724 url = self._GitHubURL(fileName) 

725 else: 

726 caption = f"{fileName}, lines {first}-{last}" 

727 url = self._GitHubURL(fileName, first, last) 

728 

729 if url is not None: 

730 caption = f"`{caption} <{url}>`__" 

731 

732 container = container_wrapper(self, literal, caption) 

733 self.add_name(container) 

734 

735 return [container] 

736 

737 @staticmethod 

738 def _Indentation(line: str) -> int: 

739 """ 

740 Return the indentation of a line of a YAML file. 

741 

742 :param line: The line. 

743 :returns: The number of spaces the line starts with. 

744 """ 

745 return len(line) - len(line.lstrip(" ")) 

746 

747 @staticmethod 

748 def _IsBlank(line: str) -> bool: 

749 """ 

750 Return whether a line is empty or a comment. 

751 

752 :param line: The line. 

753 :returns: ``True``, if the line holds nothing but whitespace or a comment. 

754 """ 

755 stripped = line.strip() 

756 return stripped == "" or stripped.startswith("#") 

757 

758 @classmethod 

759 def _ParentOf(cls, lines: list[str], line: int) -> int: 

760 """ 

761 Return the line of the key a line is nested in. 

762 

763 :param lines: The lines of the file. 

764 :param line: The nested line, starting at 1. 

765 :returns: The nearest line above it that is indented less, starting at 1, or ``1``. 

766 """ 

767 indentation = cls._Indentation(lines[line - 1]) 

768 for index in range(line - 2, -1, -1): 768 ↛ 773line 768 didn't jump to line 773 because the loop on line 768 didn't complete

769 text = lines[index] 

770 if not cls._IsBlank(text) and cls._Indentation(text) < indentation: 770 ↛ 768line 770 didn't jump to line 768 because the condition on line 770 was always true

771 return index + 1 

772 

773 return 1 

774 

775 @classmethod 

776 def _BlockOf(cls, lines: list[str], line: int) -> tuple[int, int]: 

777 """ 

778 Return the lines of a key and its value. 

779 

780 The block ends before the next line - not empty and not a comment - indented as much as the key or less. A 

781 comment indented as much as the key or less, and empty lines, at the end aren't part of it. 

782 

783 :param lines: The lines of the file. 

784 :param line: The key's line, starting at 1. 

785 :returns: The first and the last line of the block, starting at 1. 

786 """ 

787 indentation = cls._Indentation(lines[line - 1]) 

788 last = line 

789 for index in range(line, len(lines)): 

790 text = lines[index] 

791 if text.strip() == "": 

792 continue 

793 elif cls._Indentation(text) > indentation: 

794 last = index + 1 

795 elif not cls._IsBlank(text): 

796 break 

797 

798 return line, last 

799 

800 

801@export 

802class AutoInputs(WorkflowReferenceDirective): 

803 """ 

804 The directive ``gha:autoinputs``: an entry for every input of the current workflow the document has no 

805 ``gha:input`` for. 

806 

807 .. code-block:: ReST 

808 

809 .. gha:autoinputs:: 

810 

811 An entry is created as a ``gha:input`` without content creates it: the fields *Type*, *Required*, *Default Value* 

812 and the *Description* of the workflow file. Inputs documented by a ``gha:input`` later in the document are left 

813 out, too - the entries are created by :class:`AutoInputsTransform` when the whole document was parsed. 

814 """ 

815 

816 directiveName: str = "gha:autoinputs" #: Name the directive is invoked by. 

817 

818 has_content = False #: A boolean; ``True`` if content is allowed. 

819 required_arguments = 0 #: Number of required directive arguments. 

820 optional_arguments = 0 #: Number of optional arguments after the required ones. 

821 final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces. 

822 option_spec: dict[str, Any] = {} #: Mapping of option names to validator functions. 

823 

824 def run(self) -> list[nodes.Node]: 

825 """ 

826 Create a placeholder, replaced by the entries once the document was parsed. 

827 

828 :returns: The placeholder, or nothing without a current workflow. 

829 """ 

830 if (workflow := self._CurrentWorkflow()) is None: 

831 return [] 

832 

833 details = {"workflow": self.env.ref_context["gha:workflow"], "path": workflow.Path} 

834 pending = nodes.pending(AutoInputsTransform, details) 

835 self.set_source_info(pending) 

836 self.state.document.note_pending(pending) 

837 

838 return [pending] 

839 

840 

841@export 

842class AutoInputsTransform(SphinxTransform): 

843 """ 

844 Replaces the placeholder of a ``gha:autoinputs`` by an entry for every input of its workflow the document has no 

845 ``gha:input`` for. 

846 

847 It runs after the document was parsed - so every ``gha:input`` has registered its entry - and before the entries 

848 are collected for the table of contents and the index. 

849 """ 

850 

851 default_priority = 400 #: Priority among the transforms: before the local table of contents and the smart quotes. 

852 

853 def apply(self, **kwargs: Any) -> None: 

854 """ 

855 Replace the placeholder by the entries. 

856 

857 :param kwargs: Unused. 

858 """ 

859 pending: nodes.pending = self.startnode 

860 workflowName = pending.details["workflow"] 

861 domain = self.env.get_domain("gha") 

862 workflow = domain.Resolver.Load(pending.details["path"]) 

863 

864 entries = [] 

865 for name, parameter in workflow.Inputs.items(): 

866 if domain.Objects.get(("input", f"{workflowName}.{name}"), ("", ""))[0] != self.env.docname: 

867 entries.extend(InputDirective.CreateEntry( 

868 self.env, self.document, workflowName, name, parameter, [], [], pending.source, pending.line 

869 )) 

870 

871 pending.replace_self(entries) 

872 

873 

874@export 

875def checkUndocumentedInputs(sphinx: Sphinx, doctree: nodes.document) -> None: 

876 """ 

877 Call-back for Sphinx' ``doctree-read`` event, warning about inputs the document has no entry for. 

878 

879 Every input of a workflow the document names by ``gha:workflow`` needs an entry in the document, by a ``gha:input`` 

880 or a ``gha:autoinputs``; one without is a warning of type ``gha.drift`` at the ``gha:workflow``. 

881 

882 :param sphinx: The Sphinx application. 

883 :param doctree: The document read. 

884 """ 

885 if (workflows := sphinx.env.current_document.get("gha:workflows", None)) is None: 

886 return 

887 

888 domain = sphinx.env.get_domain("gha") 

889 for workflowName, path, location in workflows: 

890 for name in domain.Resolver.Load(path).Inputs: 

891 if domain.Objects.get(("input", f"{workflowName}.{name}"), ("", ""))[0] != sphinx.env.docname: 

892 _logger.warning( 

893 f"Input '{name}' of workflow '{workflowName}' has no entry: add a gha:input or a gha:autoinputs.", 

894 location=location, type=WARNING_TYPE, subtype="drift" 

895 )