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
« 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.
35Each of them reads the workflow of the preceding ``ghactions:workflow``:
37.. code-block:: ReST
39 .. ghactions:workflow:: Package
41 .. ghactions:parameter-table::
42 :kinds: inputs secrets
44 .. ghactions:interface::
46 .. ghactions:dependencies::
48 * pip
50 .. ghactions:yaml::
51 :job: Package
53 .. ghactions:autoinputs::
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.
62When a document was read, an input of its workflow without an entry is a ``ghactions.drift`` warning
63(:func:`checkUndocumentedInputs`).
65.. seealso::
67 :mod:`pyTooling.GitHub.Sphinx`
68 |rarr| The domain ``ghactions``, its workflows and parameter entries.
69"""
70from __future__ import annotations
72from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional as Nullable, 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.Sphinx import BaseDirective, SphinxExtensionError, strip
84from pyTooling.GitHub.Sphinx import NO_DEFAULT, WARNING_TYPE, InputDirective, formatValue
86if TYPE_CHECKING: # pragma: no cover
87 from pyTooling.GitHub.WorkflowFile import UsesReference, ValueT, Workflow
90__all__ = ["KINDS", "SECTIONS", "MAX_DEFAULT_LENGTH"]
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"""
102SECTIONS = ("inputs", "outputs", "secrets", "jobs")
103"""The parts of a workflow file ``ghactions:yaml`` shows by option ``:section:``."""
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."""
108_logger = getLogger(__name__)
110_ResultType = TypeVar("_ResultType")
113@export
114class WorkflowReferenceDirective(BaseDirective):
115 """
116 Base-class of the directives summarizing the current workflow, as set by the preceding ``ghactions:workflow``.
117 """
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.
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 )
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.
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
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}"
153 return f"{url}#L{first}-L{last}"
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.
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
169 return self.env.get_domain("ghactions").GetCurrentWorkflow()
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.
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
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()
191@export
192class ParameterTable(WorkflowReferenceDirective):
193 """
194 The directive ``ghactions:parameter-table``: summary tables of the current workflow's inputs, secrets and outputs.
196 .. code-block:: ReST
198 .. ghactions:parameter-table::
199 :kinds: inputs secrets
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.
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 """
210 directiveName: str = "ghactions:parameter-table" #: Name the directive is invoked by.
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.
220 def run(self) -> list[nodes.Node]:
221 """
222 Create the summary tables.
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)]
232 if (workflow := self._CurrentWorkflow()) is None:
233 return []
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
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())
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)
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))
265 tableBody += row
267 tables.append(tableGroup.parent)
269 return tables
271 def _ParseKinds(self) -> tuple[str, ...]:
272 """
273 Read option ``:kinds:``, a list of kinds separated by spaces or commas.
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)
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 )
289 return kinds
291 @staticmethod
292 def _DefaultParagraph(value: ValueT) -> nodes.paragraph:
293 """
294 Create the paragraph of a table cell showing a default.
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 ``…``.
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)
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]}…"
312 text = formatValue(value)
313 return nodes.paragraph("", "", nodes.literal(text, text))
316@export
317class Interface(WorkflowReferenceDirective):
318 """
319 The directive ``ghactions:interface``: the contract of the current workflow with its caller, as a field list.
321 .. code-block:: ReST
323 .. ghactions:interface::
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.
331 The templates and actions the workflow uses are listed by :class:`Dependencies`.
332 """
334 directiveName: str = "ghactions:interface" #: Name the directive is invoked by.
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.
342 def run(self) -> list[nodes.Node]:
343 """
344 Create the field list.
346 A called workflow whose file is missing or malformed is reported, and the permissions are then those of the
347 current workflow alone.
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 []
354 workflowName = self.env.ref_context["ghactions:workflow"]
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)"))
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 ])
372 resolver = self.env.get_domain("ghactions").Resolver
373 permissions = self._ResolveOrWarn(lambda: workflow.CollectPermissions(resolver), workflow.CollectPermissions)
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)))
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)
393 fieldList += self._Field("Permissions", items, bullets=True)
395 return [fieldList]
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*.
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)
418 return nodes.field("", nodes.field_name(text=name), nodes.field_body("", body))
421@export
422class Dependencies(WorkflowReferenceDirective):
423 """
424 The directive ``ghactions:dependencies``: what the current workflow uses, as a nested bullet list.
426 .. code-block:: ReST
428 .. ghactions:dependencies::
430 * UnitTesting.yml
432 * pip
434 * Python packages given by :ghactions:input:`UnitTesting.requirements`.
436 From the workflow file, and the files of the templates and actions it uses, as far as they are known locally:
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.
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 """
451 directiveName: str = "ghactions:dependencies" #: Name the directive is invoked by.
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.
459 _keys: dict[int, tuple[set[str], nodes.list_item]] #: Names of each derived item, by the item's identity.
461 def run(self) -> list[nodes.Node]:
462 """
463 Create the list: the derived dependencies, merged with the hand-written ones.
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 []
471 self._keys = {}
472 dependencies = self._WorkflowItems(workflow, {id(workflow)})
473 dependencies["classes"].append("ghactions-dependencies")
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)
482 if len(dependencies) == 0:
483 return [nodes.paragraph("", "", nodes.emphasis(text="none")), *others]
485 return [dependencies, *others]
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.
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)
498 return item
500 def _UsesItem(self, uses: UsesReference) -> nodes.list_item:
501 """
502 Create the item of a template or an action.
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.
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))
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}"
530 return self._Item(nodes.paragraph("", "", nodes.reference(text, "", literal, refuri=url)), keys)
532 def _ImageItem(self, kind: str, image: str, name: str = "") -> nodes.list_item:
533 """
534 Create the item of a container's image.
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)
548 return self._Item(paragraph, keys)
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.
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)
565 if job.Container is not None:
566 containers[job.Container] = None
568 for serviceName, image in job.Services.items():
569 services[(serviceName, image)] = None
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)})")
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
582 bulletList += item
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)
588 return bulletList
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.
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)
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
610 items.append(item)
612 return items
614 def _Merge(self, derived: nodes.bullet_list, handwritten: nodes.bullet_list) -> None:
615 """
616 Merge a hand-written list into a derived one.
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.
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
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
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.
647 .. code-block:: ReST
649 .. ghactions:yaml::
650 :section: inputs
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.
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 """
660 directiveName: str = "ghactions:yaml" #: Name the directive is invoked by.
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.
673 def run(self) -> list[nodes.Node]:
674 """
675 Create the code block.
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 )]
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 []
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 []
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 []
710 first, last = self._BlockOf(lines, job.Line)
711 else:
712 first, last = 1, len(lines)
714 indentation = self._Indentation(lines[first - 1])
715 excerpt = [line[min(indentation, self._Indentation(line)):] for line in lines[first - 1:last]]
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)
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)
731 if url is not None:
732 caption = f"`{caption} <{url}>`__"
734 container = container_wrapper(self, literal, caption)
735 self.add_name(container)
737 return [container]
739 @staticmethod
740 def _Indentation(line: str) -> int:
741 """
742 Return the indentation of a line of a YAML file.
744 :param line: The line.
745 :returns: The number of spaces the line starts with.
746 """
747 return len(line) - len(line.lstrip(" "))
749 @staticmethod
750 def _IsBlank(line: str) -> bool:
751 """
752 Return whether a line is empty or a comment.
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("#")
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.
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
775 return 1
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.
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.
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
800 return line, last
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.
809 .. code-block:: ReST
811 .. ghactions:autoinputs::
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 """
818 directiveName: str = "ghactions:autoinputs" #: Name the directive is invoked by.
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.
826 def run(self) -> list[nodes.Node]:
827 """
828 Create a placeholder, replaced by the entries once the document was parsed.
830 :returns: The placeholder, or nothing without a current workflow.
831 """
832 if (workflow := self._CurrentWorkflow()) is None:
833 return []
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)
840 return [pending]
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.
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 """
853 default_priority = 400 #: Priority among the transforms: before the local table of contents and the smart quotes.
855 def apply(self, **kwargs: Any) -> None:
856 """
857 Replace the placeholder by the entries.
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"])
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 ))
873 pending.replace_self(entries)
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.
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``.
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
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 )