Coverage for pyTooling/GitHub/Sphinx/Reference.py: 97%

370 statements  

« prev     ^ index     » next       coverage.py v7.17.3, created at 2026-11-14 02:08 +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 ``ghactions`` domain summarizing the current workflow: its parameters, interface, dependencies and 

33YAML. 

34 

35Each of them reads the workflow of the preceding ``ghactions:workflow``: 

36 

37.. code-block:: ReST 

38 

39 .. ghactions:workflow:: Package 

40 

41 .. ghactions:parameter-table:: 

42 :kinds: inputs secrets 

43 

44 .. ghactions:interface:: 

45 

46 .. ghactions:dependencies:: 

47 

48 * pip 

49 

50 .. ghactions:yaml:: 

51 :job: Package 

52 

53 .. ghactions:autoinputs:: 

54 

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

56* ``ghactions:interface`` - the contract with a caller: the required inputs, the secrets, the outputs, the permissions 

57 to grant; 

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

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

60* ``ghactions:autoinputs`` - an entry for every input the document has no ``ghactions:input`` for. 

61 

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

63(:func:`checkUndocumentedInputs`). 

64 

65.. seealso:: 

66 

67 :mod:`pyTooling.GitHub.Sphinx` 

68 |rarr| The domain ``ghactions``, its workflows and parameter entries. 

69""" 

70from __future__ import annotations 

71 

72from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional as Nullable, 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.Sphinx import BaseDirective, SphinxExtensionError, strip 

84from pyTooling.GitHub.Sphinx import NO_DEFAULT, WARNING_TYPE, InputDirective, formatValue 

85 

86if TYPE_CHECKING: # pragma: no cover 

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

88 

89 

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

91 

92KINDS = { 

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

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

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

96} 

97""" 

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

99the columns. 

100""" 

101 

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

103"""The parts of a workflow file ``ghactions:yaml`` shows by option ``:section:``.""" 

104 

105MAX_DEFAULT_LENGTH = 120 

106"""The length a default is shortened to in a summary table; a multi-line default is shortened to its first line.""" 

107 

108_logger = getLogger(__name__) 

109 

110_ResultType = TypeVar("_ResultType") 

111 

112 

113@export 

114class WorkflowReferenceDirective(BaseDirective): 

115 """ 

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

117 """ 

118 

119 @staticmethod 

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

121 """ 

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

123 

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

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

126 :param text: The text of the reference. 

127 :returns: The pending reference. 

128 """ 

129 return addnodes.pending_xref( 

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

131 refwarn=False 

132 ) 

133 

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

135 """ 

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

137 

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

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

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

141 :returns: The URL, or ``None`` if ``ghactions_repository`` or ``ghactions_ref`` isn't configured. 

142 """ 

143 if self.config.ghactions_repository is None or self.config.ghactions_ref is None: 

144 return None 

145 

146 server = self.config.ghactions_server.rstrip("/") 

147 url = f"{server}/{self.config.ghactions_repository}/blob/{self.config.ghactions_ref}/.github/workflows/{fileName}" 

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

149 return url 

150 elif last is None or last == first: 

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

152 

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

154 

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

156 """ 

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

158 

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

160 be read - which the ``ghactions:workflow`` directive reported already. 

161 """ 

162 if self.env.ref_context.get("ghactions:workflow", None) is None: 

163 _logger.warning( 

164 f"{self.directiveName} is not preceded by a ghactions:workflow.", 

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

166 ) 

167 return None 

168 

169 return self.env.get_domain("ghactions").GetCurrentWorkflow() 

170 

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

172 """ 

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

174 

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

176 <pyTooling.GitHub.WorkflowFile.WorkflowResolver.Resolve>`. 

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

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

179 """ 

180 from pyTooling.GitHub.WorkflowFile import WorkflowError 

181 

182 try: 

183 return resolve() 

184 except WorkflowError as ex: 

185 _logger.warning( 

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

187 ) 

188 return fallback() 

189 

190 

191@export 

192class ParameterTable(WorkflowReferenceDirective): 

193 """ 

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

195 

196 .. code-block:: ReST 

197 

198 .. ghactions:parameter-table:: 

199 :kinds: inputs secrets 

200 

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

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

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

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

205 

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

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

208 """ 

209 

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

211 

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

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

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

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

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

217 "kinds": strip, 

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

219 

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

221 """ 

222 Create the summary tables. 

223 

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

225 current workflow. 

226 """ 

227 try: 

228 kinds = self._ParseKinds() 

229 except SphinxExtensionError as ex: 

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

231 

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

233 return [] 

234 

235 workflowName = self.env.ref_context["ghactions:workflow"] 

236 tables = [] 

237 for kind in kinds: 

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

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

240 continue 

241 

242 columns = KINDS[kind] 

243 tableGroup = self._CreateSingleRowTableHeader( 

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

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

246 classes=["ghactions-parameter-table", f"ghactions-{kind}"] 

247 ) 

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

249 

250 if len(parameters) == 0: 

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

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

253 

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

255 row = nodes.row() 

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

257 if kind == "outputs": 

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

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

260 else: 

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

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

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

264 

265 tableBody += row 

266 

267 tables.append(tableGroup.parent) 

268 

269 return tables 

270 

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

272 """ 

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

274 

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

276 without the option. 

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

278 """ 

279 if "kinds" not in self.options: 

280 return tuple(KINDS) 

281 

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

283 for kind in kinds: 

284 if kind not in KINDS: 

285 raise SphinxExtensionError( 

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

287 ) 

288 

289 return kinds 

290 

291 @staticmethod 

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

293 """ 

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

295 

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

297 each followed by ``…``. 

298 

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

300 :returns: The paragraph, holding :data:`~pyTooling.GitHub.Sphinx.NO_DEFAULT` for 

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

302 """ 

303 if value is None: 

304 return nodes.paragraph(NO_DEFAULT, NO_DEFAULT) 

305 

306 if isinstance(value, str): 

307 lines = value.splitlines() 

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

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

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

311 

312 text = formatValue(value) 

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

314 

315 

316@export 

317class Interface(WorkflowReferenceDirective): 

318 """ 

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

320 

321 .. code-block:: ReST 

322 

323 .. ghactions:interface:: 

324 

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

326 pass is marked *required*. 

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

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

329 workflow file asking for it. 

330 

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

332 """ 

333 

334 directiveName: str = "ghactions:interface" #: Name the directive is invoked by. 

335 

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

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

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

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

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

341 

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

343 """ 

344 Create the field list. 

345 

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

347 current workflow alone. 

348 

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

350 """ 

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

352 return [] 

353 

354 workflowName = self.env.ref_context["ghactions:workflow"] 

355 

356 fieldList = nodes.field_list(classes=["ghactions-interface"]) 

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

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

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

360 ]) 

361 secrets = [] 

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

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

364 if secret.Required: 

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

366 

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

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

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

370 ]) 

371 

372 resolver = self.env.get_domain("ghactions").Resolver 

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

374 

375 items = [] 

376 for permission in permissions.values(): 

377 text = str(permission) 

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

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

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

381 else: 

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

383 

384 location = permission.Location 

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

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

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

388 else: 

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

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

391 items.append(item) 

392 

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

394 

395 return [fieldList] 

396 

397 @staticmethod 

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

399 """ 

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

401 

402 :param name: The field's name. 

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

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

405 :returns: The field. 

406 """ 

407 if len(items) == 0: 

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

409 elif bullets: 

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

411 else: 

412 body = nodes.paragraph() 

413 for index, item in enumerate(items): 

414 if index > 0: 

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

416 body.extend(item) 

417 

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

419 

420 

421@export 

422class Dependencies(WorkflowReferenceDirective): 

423 """ 

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

425 

426 .. code-block:: ReST 

427 

428 .. ghactions:dependencies:: 

429 

430 * UnitTesting.yml 

431 

432 * pip 

433 

434 * Python packages given by :ghactions:input:`UnitTesting.requirements`. 

435 

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

437 

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

439 dependencies; 

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

441 image; 

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

443 

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

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

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

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

448 list. 

449 """ 

450 

451 directiveName: str = "ghactions:dependencies" #: Name the directive is invoked by. 

452 

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

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

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

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

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

458 

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

460 

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

462 """ 

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

464 

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

466 nothing without a current workflow. 

467 """ 

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

469 return [] 

470 

471 self._keys = {} 

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

473 dependencies["classes"].append("ghactions-dependencies") 

474 

475 others = [] 

476 for node in self.parse_content_to_nodes(): 

477 if isinstance(node, nodes.bullet_list): 

478 self._Merge(dependencies, node) 

479 else: 

480 others.append(node) 

481 

482 if len(dependencies) == 0: 

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

484 

485 return [dependencies, *others] 

486 

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

488 """ 

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

490 

491 :param paragraph: The item's text. 

492 :param keys: The names of the item. 

493 :returns: The item. 

494 """ 

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

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

497 

498 return item 

499 

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

501 """ 

502 Create the item of a template or an action. 

503 

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

505 repository and an action link to GitHub, as does a local action when ``ghactions_repository`` and ``ghactions_ref`` 

506 are configured. 

507 

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

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

510 stem. 

511 """ 

512 text = str(uses) 

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

514 if uses.IsWorkflow: 

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

516 

517 literal = nodes.literal(text, text) 

518 server = self.config.ghactions_server.rstrip("/") 

519 if uses.IsWorkflow and self.env.get_domain("ghactions").Resolver.CanResolve(uses): 

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

521 elif uses.IsLocal and self.config.ghactions_repository is not None and self.config.ghactions_ref is not None: 

522 url = f"{server}/{self.config.ghactions_repository}/tree/{self.config.ghactions_ref}/{uses.Path}" 

523 elif uses.Repository is None: 

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

525 else: 

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

527 if uses.Path != "": 

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

529 

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

531 

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

533 """ 

534 Create the item of a container's image. 

535 

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

537 :param image: The image, as written. 

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

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

540 """ 

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

542 keys = {kind, image} 

543 if name != "": 

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

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

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

547 

548 return self._Item(paragraph, keys) 

549 

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

551 """ 

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

553 

554 :param workflow: The workflow. 

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

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

557 """ 

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

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

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

561 for job in workflow.IterateJobs(): 

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

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

564 

565 if job.Container is not None: 

566 containers[job.Container] = None 

567 

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

569 services[(serviceName, image)] = None 

570 

571 bulletList = nodes.bullet_list() 

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

573 item = self._UsesItem(uses) 

574 if len(jobNames) > 1: 

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

576 

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

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

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

580 item += nested 

581 

582 bulletList += item 

583 

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

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

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

587 

588 return bulletList 

589 

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

591 """ 

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

593 

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

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

596 :returns: The items. 

597 """ 

598 items = [] 

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

600 item = self._UsesItem(uses) 

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

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

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

604 if action.Image is not None: 

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

606 

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

608 item += nested 

609 

610 items.append(item) 

611 

612 return items 

613 

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

615 """ 

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

617 

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

619 merged recursively; any other item is appended. 

620 

621 :param derived: The derived list. 

622 :param handwritten: The hand-written list. 

623 """ 

624 for item in list(handwritten.children): 

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

626 match = next( 

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

628 None 

629 ) 

630 if match is None: 

631 derived += item 

632 continue 

633 

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

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

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

637 self._Merge(nested, node) 

638 else: 

639 match += node 

640 

641 

642@export 

643class YAMLExcerpt(WorkflowReferenceDirective): 

644 """ 

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

646 

647 .. code-block:: ReST 

648 

649 .. ghactions:yaml:: 

650 :section: inputs 

651 

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

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

654 

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

656 caption names the file and the lines, and links to them on GitHub at ``ghactions_ref``, if ``ghactions_repository`` 

657 and ``ghactions_ref`` are configured. 

658 """ 

659 

660 directiveName: str = "ghactions:yaml" #: Name the directive is invoked by. 

661 

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

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

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

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

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

667 "caption": directives.unchanged_required, 

668 "job": strip, 

669 "name": strip, 

670 "section": strip, 

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

672 

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

674 """ 

675 Create the code block. 

676 

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

678 workflow or when the part doesn't exist. 

679 """ 

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

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

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

683 )] 

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

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

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

687 )] 

688 

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

690 return [] 

691 

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

693 if section is not None: 

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

695 _logger.warning( 

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

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

698 ) 

699 return [] 

700 

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

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

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

704 _logger.warning( 

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

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

707 ) 

708 return [] 

709 

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

711 else: 

712 first, last = 1, len(lines) 

713 

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

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

716 

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

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

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

720 self.set_source_info(literal) 

721 

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

723 fileName = workflow.Path.name 

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

725 caption = fileName 

726 url = self._GitHubURL(fileName) 

727 else: 

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

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

730 

731 if url is not None: 

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

733 

734 container = container_wrapper(self, literal, caption) 

735 self.add_name(container) 

736 

737 return [container] 

738 

739 @staticmethod 

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

741 """ 

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

743 

744 :param line: The line. 

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

746 """ 

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

748 

749 @staticmethod 

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

751 """ 

752 Return whether a line is empty or a comment. 

753 

754 :param line: The line. 

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

756 """ 

757 stripped = line.strip() 

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

759 

760 @classmethod 

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

762 """ 

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

764 

765 :param lines: The lines of the file. 

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

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

768 """ 

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

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

771 text = lines[index] 

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

773 return index + 1 

774 

775 return 1 

776 

777 @classmethod 

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

779 """ 

780 Return the lines of a key and its value. 

781 

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

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

784 

785 :param lines: The lines of the file. 

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

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

788 """ 

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

790 last = line 

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

792 text = lines[index] 

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

794 continue 

795 elif cls._Indentation(text) > indentation: 

796 last = index + 1 

797 elif not cls._IsBlank(text): 

798 break 

799 

800 return line, last 

801 

802 

803@export 

804class AutoInputs(WorkflowReferenceDirective): 

805 """ 

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

807 ``ghactions:input`` for. 

808 

809 .. code-block:: ReST 

810 

811 .. ghactions:autoinputs:: 

812 

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

814 Value* and the *Description* of the workflow file. Inputs documented by a ``ghactions:input`` later in the document 

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

816 """ 

817 

818 directiveName: str = "ghactions:autoinputs" #: Name the directive is invoked by. 

819 

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

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

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

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

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

825 

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

827 """ 

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

829 

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

831 """ 

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

833 return [] 

834 

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

836 pending = nodes.pending(AutoInputsTransform, details) 

837 self.set_source_info(pending) 

838 self.state.document.note_pending(pending) 

839 

840 return [pending] 

841 

842 

843@export 

844class AutoInputsTransform(SphinxTransform): 

845 """ 

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

847 ``ghactions:input`` for. 

848 

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

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

851 """ 

852 

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

854 

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

856 """ 

857 Replace the placeholder by the entries. 

858 

859 :param kwargs: Unused. 

860 """ 

861 pending: nodes.pending = self.startnode 

862 workflowName = pending.details["workflow"] 

863 domain = self.env.get_domain("ghactions") 

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

865 

866 entries = [] 

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

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

869 entries.extend(InputDirective.CreateEntry( 

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

871 )) 

872 

873 pending.replace_self(entries) 

874 

875 

876@export 

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

878 """ 

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

880 

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

882 ``ghactions:input`` or a ``ghactions:autoinputs``; one without is a warning of type ``ghactions.drift`` at the 

883 ``ghactions:workflow``. 

884 

885 :param sphinx: The Sphinx application. 

886 :param doctree: The document read. 

887 """ 

888 if (workflows := sphinx.env.current_document.get("ghactions:workflows", None)) is None: 

889 return 

890 

891 domain = sphinx.env.get_domain("ghactions") 

892 for workflowName, path, location in workflows: 

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

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

895 _logger.warning( 

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

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

898 )