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
« 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.
34Each of them reads the workflow of the preceding ``gha:workflow``:
36.. code-block:: ReST
38 .. gha:workflow:: Package
40 .. gha:parameter-table::
41 :kinds: inputs secrets
43 .. gha:interface::
45 .. gha:dependencies::
47 * pip
49 .. gha:yaml::
50 :job: Package
52 .. gha:autoinputs::
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.
61When a document was read, an input of its workflow without an entry is a ``gha.drift`` warning
62(:func:`checkUndocumentedInputs`).
64.. seealso::
66 :mod:`pyTooling.Documentation.Sphinx.GitHubActions`
67 |rarr| The domain ``gha``, its workflows and parameter entries.
68"""
69from __future__ import annotations
71from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional as Nullable
72from typing import TypeVar
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
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
86if TYPE_CHECKING: # pragma: no cover
87 from pyTooling.CI.GitHub.WorkflowFile import UsesReference, ValueT, Workflow
90__all__ = ["KINDS", "SECTIONS", "MAX_DEFAULT_LENGTH"]
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}
100#: The parts of a workflow file ``gha:yaml`` shows by option ``:section:``.
101SECTIONS = ("inputs", "outputs", "secrets", "jobs")
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
106_logger = getLogger(__name__)
108_ResultType = TypeVar("_ResultType")
111@export
112class WorkflowReferenceDirective(BaseDirective):
113 """
114 Base-class of the directives summarizing the current workflow, as set by the preceding ``gha:workflow``.
115 """
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.
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 )
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.
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
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}"
151 return f"{url}#L{first}-L{last}"
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.
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
167 return self.env.get_domain("gha").GetCurrentWorkflow()
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.
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
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()
189@export
190class ParameterTable(WorkflowReferenceDirective):
191 """
192 The directive ``gha:parameter-table``: summary tables of the current workflow's inputs, secrets and outputs.
194 .. code-block:: ReST
196 .. gha:parameter-table::
197 :kinds: inputs secrets
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.
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 """
208 directiveName: str = "gha:parameter-table" #: Name the directive is invoked by.
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.
218 def run(self) -> list[nodes.Node]:
219 """
220 Create the summary tables.
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)]
230 if (workflow := self._CurrentWorkflow()) is None:
231 return []
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
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())
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)
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))
263 tableBody += row
265 tables.append(tableGroup.parent)
267 return tables
269 def _ParseKinds(self) -> tuple[str, ...]:
270 """
271 Read option ``:kinds:``, a list of kinds separated by spaces or commas.
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)
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 )
287 return kinds
289 @staticmethod
290 def _DefaultParagraph(value: ValueT) -> nodes.paragraph:
291 """
292 Create the paragraph of a table cell showing a default.
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 ``…``.
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)
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]}…"
310 text = formatValue(value)
311 return nodes.paragraph("", "", nodes.literal(text, text))
314@export
315class Interface(WorkflowReferenceDirective):
316 """
317 The directive ``gha:interface``: the contract of the current workflow with its caller, as a field list.
319 .. code-block:: ReST
321 .. gha:interface::
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.
329 The templates and actions the workflow uses are listed by :class:`Dependencies`.
330 """
332 directiveName: str = "gha:interface" #: Name the directive is invoked by.
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.
340 def run(self) -> list[nodes.Node]:
341 """
342 Create the field list.
344 A called workflow whose file is missing or malformed is reported, and the permissions are then those of the
345 current workflow alone.
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 []
352 workflowName = self.env.ref_context["gha:workflow"]
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)"))
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 ])
370 resolver = self.env.get_domain("gha").Resolver
371 permissions = self._ResolveOrWarn(lambda: workflow.CollectPermissions(resolver), workflow.CollectPermissions)
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)))
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)
391 fieldList += self._Field("Permissions", items, bullets=True)
393 return [fieldList]
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*.
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)
416 return nodes.field("", nodes.field_name(text=name), nodes.field_body("", body))
419@export
420class Dependencies(WorkflowReferenceDirective):
421 """
422 The directive ``gha:dependencies``: what the current workflow uses, as a nested bullet list.
424 .. code-block:: ReST
426 .. gha:dependencies::
428 * UnitTesting.yml
430 * pip
432 * Python packages given by :gha:input:`UnitTesting.requirements`.
434 From the workflow file, and the files of the templates and actions it uses, as far as they are known locally:
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.
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 """
449 directiveName: str = "gha:dependencies" #: Name the directive is invoked by.
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.
457 _keys: dict[int, tuple[set[str], nodes.list_item]] #: Names of each derived item, by the item's identity.
459 def run(self) -> list[nodes.Node]:
460 """
461 Create the list: the derived dependencies, merged with the hand-written ones.
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 []
469 self._keys = {}
470 dependencies = self._WorkflowItems(workflow, {id(workflow)})
471 dependencies["classes"].append("gha-dependencies")
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)
480 if len(dependencies) == 0:
481 return [nodes.paragraph("", "", nodes.emphasis(text="none")), *others]
483 return [dependencies, *others]
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.
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)
496 return item
498 def _UsesItem(self, uses: UsesReference) -> nodes.list_item:
499 """
500 Create the item of a template or an action.
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.
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))
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}"
528 return self._Item(nodes.paragraph("", "", nodes.reference(text, "", literal, refuri=url)), keys)
530 def _ImageItem(self, kind: str, image: str, name: str = "") -> nodes.list_item:
531 """
532 Create the item of a container's image.
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)
546 return self._Item(paragraph, keys)
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.
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)
563 if job.Container is not None:
564 containers[job.Container] = None
566 for serviceName, image in job.Services.items():
567 services[(serviceName, image)] = None
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)})")
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
580 bulletList += item
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)
586 return bulletList
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.
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)
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
608 items.append(item)
610 return items
612 def _Merge(self, derived: nodes.bullet_list, handwritten: nodes.bullet_list) -> None:
613 """
614 Merge a hand-written list into a derived one.
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.
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
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
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.
645 .. code-block:: ReST
647 .. gha:yaml::
648 :section: inputs
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.
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 """
658 directiveName: str = "gha:yaml" #: Name the directive is invoked by.
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.
671 def run(self) -> list[nodes.Node]:
672 """
673 Create the code block.
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 )]
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 []
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 []
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 []
708 first, last = self._BlockOf(lines, job.Line)
709 else:
710 first, last = 1, len(lines)
712 indentation = self._Indentation(lines[first - 1])
713 excerpt = [line[min(indentation, self._Indentation(line)):] for line in lines[first - 1:last]]
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)
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)
729 if url is not None:
730 caption = f"`{caption} <{url}>`__"
732 container = container_wrapper(self, literal, caption)
733 self.add_name(container)
735 return [container]
737 @staticmethod
738 def _Indentation(line: str) -> int:
739 """
740 Return the indentation of a line of a YAML file.
742 :param line: The line.
743 :returns: The number of spaces the line starts with.
744 """
745 return len(line) - len(line.lstrip(" "))
747 @staticmethod
748 def _IsBlank(line: str) -> bool:
749 """
750 Return whether a line is empty or a comment.
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("#")
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.
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
773 return 1
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.
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.
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
798 return line, last
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.
807 .. code-block:: ReST
809 .. gha:autoinputs::
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 """
816 directiveName: str = "gha:autoinputs" #: Name the directive is invoked by.
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.
824 def run(self) -> list[nodes.Node]:
825 """
826 Create a placeholder, replaced by the entries once the document was parsed.
828 :returns: The placeholder, or nothing without a current workflow.
829 """
830 if (workflow := self._CurrentWorkflow()) is None:
831 return []
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)
838 return [pending]
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.
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 """
851 default_priority = 400 #: Priority among the transforms: before the local table of contents and the smart quotes.
853 def apply(self, **kwargs: Any) -> None:
854 """
855 Replace the placeholder by the entries.
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"])
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 ))
871 pending.replace_self(entries)
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.
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``.
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
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 )