Coverage for pyTooling/GitHub/Sphinx/__init__.py: 98%
302 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"""
32A Sphinx domain ``ghactions`` documenting GitHub Actions workflows from their YAML files.
34The facts of a reusable workflow - an input's type, whether it is required, its default - are read from the workflow
35file by :mod:`pyTooling.GitHub.WorkflowFile`, so a page states them without copying them. What the file can't say
36stays hand-written, as the content of a directive:
38.. code-block:: rst
40 .. ghactions:workflow:: Parameters
42 Parameters
43 ##########
45 .. ghactions:input:: package_name
47 :Possible Values: Any valid Python package name.
48 :Example: ``myPackage``
50.. rubric:: What it registers
52* Directives
54 * ``ghactions:workflow``
55 * ``ghactions:input``
56 * ``ghactions:output``
57 * ``ghactions:secret``
59* Roles
61 * ``:ghactions:workflow:``
62 * ``:ghactions:input:``
63 * ``:ghactions:output:``
64 * ``:ghactions:secret:``
66* Configuration values, as listed in :attr:`GitHubActionsDomain.configValues`
68 * ``ghactions_server``
69 * ``ghactions_repository``
70 * ``ghactions_workflow_directory``
71 * ``ghactions_ref``
72 * ``ghactions_label_prefix``
74The workflow files are read with ``ruamel.yaml`` when a directive runs, not when this module is imported.
76.. seealso::
78 :mod:`pyTooling.GitHub.WorkflowFile`
79 |rarr| The model of a workflow file the domain reads.
80 :mod:`pyTooling.GitHub.Sphinx.Graph`
81 |rarr| The ``ghactions:pipeline-graph`` directive, drawing a workflow's jobs and their ``needs``.
82 :mod:`pyTooling.GitHub.Sphinx.Reference`
83 |rarr| The directives summarizing a workflow: its parameters, its interface, its YAML.
84"""
85from __future__ import annotations
87from pathlib import Path
88from typing import TYPE_CHECKING, Any, ClassVar, Iterable, Optional as Nullable
90from docutils import nodes
91from docutils.nodes import Element, Node, fully_normalize_name
92from docutils.parsers.rst import directives
93from sphinx import addnodes
94from sphinx.application import Sphinx
95from sphinx.builders import Builder
96from sphinx.domains import Domain, ObjType
97from sphinx.environment import BuildEnvironment
98from sphinx.roles import XRefRole
99from sphinx.util.logging import getLogger
100from sphinx.util.nodes import make_id, make_refnode
102from pyTooling.Common import getFullyQualifiedName
103from pyTooling.Decorators import export, readonly
104from pyTooling.Sphinx import BaseDirective
106if TYPE_CHECKING: # pragma: no cover
107 from pyTooling.GitHub.WorkflowFile import Input, Output, Parameter, Secret, ValueT, Workflow, WorkflowResolver
110__all__ = ["NO_DEFAULT", "WARNING_TYPE", "LEADING_FIELDS"]
112NO_DEFAULT = "— — — —"
113"""The text of the field *Default Value* when an input has no default."""
115WARNING_TYPE = "ghactions"
116"""
117The type of the warnings this domain emits; the drift warnings have the subtype ``drift``, so
118``suppress_warnings = ["ghactions.drift"]`` silences them.
119"""
121LEADING_FIELDS = ("Type", "Required", "Default Value", "Possible Values")
122"""The fields a *Description* taken from the workflow file follows."""
124_logger = getLogger(__name__)
127@export
128def formatValue(value: ValueT) -> str:
129 """
130 Format a value read from a workflow file the way the file writes it.
132 A string is quoted as YAML quotes it - ``'3.14'`` -, a boolean is ``true`` or ``false``, and a number is written as
133 it is.
135 :param value: The value, as :attr:`Input.Default <pyTooling.GitHub.WorkflowFile.Input.Default>`.
136 :returns: The value as text, or :data:`NO_DEFAULT` for ``None``.
137 """
138 if value is None:
139 return NO_DEFAULT
140 elif isinstance(value, bool):
141 return "true" if value else "false"
142 elif isinstance(value, str):
143 escaped = value.replace("'", "''")
144 return f"'{escaped}'"
146 return str(value)
149@export
150class WorkflowDirective(BaseDirective):
151 """
152 The directive ``ghactions:workflow``: a workflow's target, its index entry, and the current workflow of the document.
154 .. code-block:: rst
156 .. ghactions:workflow:: Parameters
157 :file: ../../.github/workflows/Parameters.yml
159 The argument is the workflow's name, its file's stem. Without ``:file:``, the file is ``<name>.yml`` in
160 ``ghactions_workflow_directory``. Every ``ghactions:input``, ``ghactions:output`` and ``ghactions:secret`` following
161 it in the document belongs to this workflow.
163 The directive writes no visible output. Placed above a page's title, its target is the title, as a label is.
164 """
166 directiveName: str = "ghactions:workflow" #: Name the directive is invoked by.
168 has_content = False #: The directive has no content.
169 required_arguments = 1 #: The workflow's name.
170 option_spec = {"file": directives.unchanged_required} #: Path to the workflow file, relative to the document.
172 def run(self) -> list[Node]:
173 """
174 Register the workflow, read its file, and make it the current workflow.
176 :returns: An index node and the workflow's target.
177 """
178 name = self.arguments[0].strip()
179 domain: GitHubActionsDomain = self.env.get_domain("ghactions")
181 if "file" in self.options:
182 path = Path(self.env.relfn2path(self.options["file"], self.env.docname)[1])
183 elif domain.WorkflowDirectory is not None:
184 path = domain.WorkflowDirectory / f"{name}.yml"
185 if not path.exists() and (alternative := path.with_suffix(".yaml")).exists(): 185 ↛ 186line 185 didn't jump to line 186 because the condition on line 185 was never true
186 path = alternative
187 else:
188 path = None
190 if path is None:
191 _logger.warning(
192 f"{self.directiveName} '{name}' has no file: give option ':file:' or set 'ghactions_workflow_directory'.",
193 location=self.get_location(), type=WARNING_TYPE, subtype="workflow"
194 )
195 elif not path.exists():
196 _logger.warning(
197 f"{self.directiveName} '{name}': file '{path}' doesn't exist.",
198 location=self.get_location(), type=WARNING_TYPE, subtype="workflow"
199 )
200 else:
201 self.env.note_dependency(str(path))
202 self._Load(domain, name, path)
204 self.env.ref_context["ghactions:workflow"] = name
206 nodeID = make_id(self.env, self.state.document, "ghactions-workflow", name)
207 target = nodes.target("", "", ids=[nodeID])
208 if (prefix := self.config.ghactions_label_prefix) is not None:
209 label = f"{prefix}/{name}"
210 target["ids"].append(nodes.make_id(label))
211 target["names"].append(fully_normalize_name(label))
213 self.set_source_info(target)
214 self.state.document.note_explicit_target(target)
215 domain.NoteObject("workflow", name, nodeID, target)
217 return [addnodes.index(entries=[("single", f"GitHub Actions workflow; {name}", nodeID, "", None)]), target]
219 def _Load(self, domain: GitHubActionsDomain, name: str, path: Path) -> None:
220 """
221 Read the workflow file, make it the current one, and warn about inputs that are required and have a default.
223 A file that isn't a well-formed workflow is reported as a warning at the place in the file, and the document
224 has no current workflow model then. A workflow read is added to the document's list ``ghactions:workflows`` of
225 (name, path, location of the directive), which is checked when the document was read.
227 :param domain: The domain.
228 :param name: The workflow's name, as given as argument.
229 :param path: Path to the workflow file.
230 """
231 from pyTooling.GitHub.WorkflowFile import WorkflowError
233 try:
234 workflow = domain.Resolver.Load(path)
235 except WorkflowError as ex:
236 if ex.Path is None: 236 ↛ 237line 236 didn't jump to line 237 because the condition on line 236 was never true
237 location = self.get_location()
238 elif ex.Line is None: 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true
239 location = str(ex.Path)
240 else:
241 location = f"{ex.Path}:{ex.Line}"
243 _logger.warning(f"{self.directiveName} '{name}': {ex}", location=location, type=WARNING_TYPE, subtype="workflow")
244 return
246 if workflow.Name != name:
247 _logger.warning(
248 f"{self.directiveName} '{name}' reads file '{path.name}', which names workflow '{workflow.Name}'.",
249 location=self.get_location(), type=WARNING_TYPE, subtype="workflow"
250 )
252 self.env.current_document["ghactions:workflow-file"] = path
253 self.env.current_document.setdefault("ghactions:workflows", []).append((name, path, self.get_location()))
255 for parameter in workflow.Inputs.values():
256 if parameter.Required and parameter.Default is not None:
257 _logger.warning(
258 f"Input '{parameter.Name}' of workflow '{workflow.Name}' is required and has a default, which is never used.",
259 location=f"{workflow.Path}:{parameter.Line}", type=WARNING_TYPE, subtype="drift"
260 )
263@export
264class ParameterDirective(BaseDirective):
265 """
266 Base-class of the directives documenting one parameter of the current workflow.
268 The entry is a section titled by the parameter's name, holding a field list: first the fields read from the
269 workflow file (:attr:`FACT_FIELDS`), then the fields of the directive's content, in the order written. Without a
270 hand-written *Description*, the workflow file's ``description`` is used, placed behind the :data:`LEADING_FIELDS`.
271 Content after the field list follows it.
273 A hand-written field repeating a fact of the file is a warning, and the file's value is shown.
275 Besides its anchor ``ghactions-<type>-<Workflow>.<name>``, the section carries the anchor of its label and - unless
276 the document uses it already - the anchor docutils derives from a title, as a hand-written section has.
277 """
279 OBJECT_TYPE: ClassVar[str] #: The domain's object type, as ``input``.
280 LABEL_KIND: ClassVar[str] #: The kind in a label, as ``Input`` in ``JOBTMPL/Parameters/Input/name``.
281 COLLECTION: ClassVar[str] #: The workflow's property holding the parameters, as ``Inputs``.
282 FACT_FIELDS: ClassVar[tuple[str, ...]] #: The fields taken from the workflow file.
284 has_content = True #: The hand-written fields and text.
285 required_arguments = 1 #: The parameter's name.
287 def run(self) -> list[Node]:
288 """
289 Create the parameter's entry and register it.
291 :returns: An index node and the entry's section, or nothing outside a ``ghactions:workflow``.
292 """
293 name = self.arguments[0].strip()
294 if (workflowName := self.env.ref_context.get("ghactions:workflow", None)) is None:
295 _logger.warning(
296 f"{self.directiveName} '{name}' is not preceded by a ghactions:workflow.",
297 location=self.get_location(), type=WARNING_TYPE, subtype="workflow"
298 )
299 return []
301 domain: GitHubActionsDomain = self.env.get_domain("ghactions")
302 parameter = None
303 if (workflow := domain.GetCurrentWorkflow()) is not None:
304 if (parameter := getattr(workflow, self.COLLECTION).get(name, None)) is None:
305 _logger.warning(
306 f"Workflow '{workflowName}' has no {self.OBJECT_TYPE} '{name}' ({workflow.Path.name}).",
307 location=self.get_location(), type=WARNING_TYPE, subtype="drift"
308 )
310 content = self.parse_content_to_nodes()
311 handwritten = []
312 if len(content) > 0 and isinstance(content[0], nodes.field_list):
313 handwritten = list(content[0].children)
314 content = content[1:]
316 fields = []
317 for field in handwritten:
318 fieldName = field[0].astext().strip()
319 if fieldName in self.FACT_FIELDS:
320 _logger.warning(
321 f"{self.directiveName} '{workflowName}.{name}': field '{fieldName}' is taken from the workflow file; "
322 "remove it.",
323 location=field, type=WARNING_TYPE, subtype="drift"
324 )
325 if parameter is not None: 325 ↛ 328line 325 didn't jump to line 328 because the condition on line 325 was always true
326 continue
328 fields.append(field)
330 source, line = self.get_source_info()
331 return self.CreateEntry(self.env, self.state.document, workflowName, name, parameter, fields, content, source, line)
333 @classmethod
334 def CreateEntry(
335 cls,
336 env: BuildEnvironment,
337 document: nodes.document,
338 workflowName: str,
339 name: str,
340 parameter: Nullable[Parameter],
341 fields: list[nodes.field],
342 content: list[Node],
343 source: str,
344 line: Nullable[int]
345 ) -> list[Node]:
346 """
347 Create a parameter's entry and register it in the domain.
349 :param env: The build environment.
350 :param document: The document the entry is placed in.
351 :param workflowName: The workflow's name.
352 :param name: The parameter's name.
353 :param parameter: The parameter, as read from the workflow file, or ``None`` if the workflow file wasn't read or
354 hasn't this parameter.
355 :param fields: The hand-written fields, without those repeating a fact of the workflow file.
356 :param content: The nodes following the field list.
357 :param source: The source file the entry is reported at.
358 :param line: The line the entry is reported at, or ``None``.
359 :returns: An index node and the entry's section.
360 """
361 fullName = f"{workflowName}.{name}"
362 nodeID = make_id(env, document, f"ghactions-{cls.OBJECT_TYPE}", fullName)
363 section = nodes.section("", nodes.title(name, name), ids=[nodeID])
364 if (titleID := nodes.make_id(name)) not in document.ids: 364 ↛ 368line 364 didn't jump to line 368 because the condition on line 364 was always true
365 section["ids"].append(titleID)
367 # The label's anchor comes last: docutils links a name to a node's last anchor, so a link keeps its target.
368 if (prefix := env.config.ghactions_label_prefix) is not None:
369 label = f"{prefix}/{workflowName}/{cls.LABEL_KIND}/{name}"
370 section["ids"].append(nodes.make_id(label))
371 section["names"].append(fully_normalize_name(label))
373 section.source = source
374 section.line = line
375 document.note_explicit_target(section)
377 if parameter is not None:
378 fields = [*cls._FactFields(parameter), *fields]
380 fieldNames = [field[0].astext().strip() for field in fields]
381 if "Description" not in fieldNames and parameter is not None and parameter.Description:
382 leading = [index + 1 for index, fieldName in enumerate(fieldNames) if fieldName in LEADING_FIELDS]
383 position = max(leading, default=0)
384 fields.insert(position, cls._TextField("Description", parameter.Description.strip()))
386 if len(fields) > 0:
387 section += nodes.field_list("", *fields)
388 section.extend(content)
390 domain: GitHubActionsDomain = env.get_domain("ghactions")
391 domain.NoteObject(cls.OBJECT_TYPE, fullName, nodeID, section)
392 return [
393 addnodes.index(entries=[("single", f"{name} ({cls.OBJECT_TYPE} of {workflowName})", nodeID, "", None)]),
394 section
395 ]
397 @staticmethod
398 def _Field(name: str, *body: Node) -> nodes.field:
399 """
400 Create a field of a field list.
402 :param name: Name of the field, as ``Type``.
403 :param body: The nodes of the field's body.
404 :returns: The field.
405 """
406 return nodes.field("", nodes.field_name(name, name), nodes.field_body("", *body))
408 @classmethod
409 def _TextField(cls, name: str, text: str) -> nodes.field:
410 """
411 Create a field of a field list, whose body is a line of text.
413 :param name: Name of the field, as ``Required``.
414 :param text: Text of the field's body.
415 :returns: The field.
416 """
417 return cls._Field(name, nodes.paragraph(text, text))
419 @classmethod
420 def _DefaultField(cls, value: ValueT) -> nodes.field:
421 """
422 Create the field *Default Value*.
424 A multi-line string becomes a literal block, keeping its line breaks; no default becomes :data:`NO_DEFAULT`.
426 :param value: The default value.
427 :returns: The field.
428 """
429 if value is None:
430 return cls._TextField("Default Value", NO_DEFAULT)
431 elif isinstance(value, str) and "\n" in value:
432 return cls._Field("Default Value", nodes.literal_block(value, value, language="text"))
434 text = formatValue(value)
435 return cls._Field("Default Value", nodes.paragraph("", "", nodes.literal(text, text)))
437 @classmethod
438 def _FactFields(cls, parameter: Parameter) -> list[nodes.field]:
439 """
440 Create the fields taken from the workflow file.
442 :param parameter: The parameter, as read from the workflow file.
443 :returns: The fields named in :attr:`FACT_FIELDS`, in that order.
444 """
445 return []
448@export
449class InputDirective(ParameterDirective):
450 """
451 The directive ``ghactions:input``: an input of the current workflow.
453 The fields *Type*, *Required* and *Default Value* are taken from the workflow file.
454 """
456 directiveName: str = "ghactions:input" #: Name the directive is invoked by.
458 OBJECT_TYPE = "input" #: The domain's object type.
459 LABEL_KIND = "Input" #: The kind in a label.
460 COLLECTION = "Inputs" #: The workflow's property holding the parameters.
461 FACT_FIELDS = ("Type", "Required", "Default Value") #: The fields taken from the workflow file.
463 @classmethod
464 def _FactFields(cls, parameter: Input) -> list[nodes.field]:
465 """
466 Create the fields *Type*, *Required* and *Default Value*.
468 :param parameter: The input, as read from the workflow file.
469 :returns: The fields.
470 """
471 return [
472 cls._TextField("Type", parameter.Type.value),
473 cls._TextField("Required", "yes" if parameter.Required else "no"),
474 cls._DefaultField(parameter.Default)
475 ]
478@export
479class SecretDirective(ParameterDirective):
480 """
481 The directive ``ghactions:secret``: a secret of the current workflow.
483 The fields *Type* - a secret is a string -, *Required* and *Default Value* - a secret has none - are taken from the
484 workflow file.
485 """
487 directiveName: str = "ghactions:secret" #: Name the directive is invoked by.
489 OBJECT_TYPE = "secret" #: The domain's object type.
490 LABEL_KIND = "Secret" #: The kind in a label.
491 COLLECTION = "Secrets" #: The workflow's property holding the parameters.
492 FACT_FIELDS = ("Type", "Required", "Default Value") #: The fields taken from the workflow file.
494 @classmethod
495 def _FactFields(cls, parameter: Secret) -> list[nodes.field]:
496 """
497 Create the fields *Type*, *Required* and *Default Value*.
499 :param parameter: The secret, as read from the workflow file.
500 :returns: The fields.
501 """
502 return [
503 cls._TextField("Type", "string"),
504 cls._TextField("Required", "yes" if parameter.Required else "no"),
505 cls._DefaultField(None)
506 ]
509@export
510class OutputDirective(ParameterDirective):
511 """
512 The directive ``ghactions:output``: an output of the current workflow.
514 A workflow file states no type and no default for an output, so every field is hand-written; only the
515 *Description* falls back to the file's ``description``.
516 """
518 directiveName: str = "ghactions:output" #: Name the directive is invoked by.
520 OBJECT_TYPE = "output" #: The domain's object type.
521 LABEL_KIND = "Output" #: The kind in a label.
522 COLLECTION = "Outputs" #: The workflow's property holding the parameters.
523 FACT_FIELDS = () #: The fields taken from the workflow file.
526@export
527class GitHubActionsXRefRole(XRefRole):
528 """
529 The roles ``:ghactions:workflow:``, ``:ghactions:input:``, ``:ghactions:output:`` and ``:ghactions:secret:``.
531 A parameter is named as ``<Workflow>.<name>``; inside a ``ghactions:workflow``, the name alone refers to the current
532 workflow's parameter. A leading ``~`` shows only the part behind the last dot.
533 """
535 def process_link(
536 self,
537 env: BuildEnvironment,
538 refnode: Element,
539 has_explicit_title: bool,
540 title: str,
541 target: str
542 ) -> tuple[str, str]:
543 """
544 Remember the current workflow on the reference, and shorten the title for a leading ``~``.
546 :param env: The build environment.
547 :param refnode: The reference node.
548 :param has_explicit_title: ``True``, if the role was written with a title, as ``text <target>``.
549 :param title: The title.
550 :param target: The target.
551 :returns: The title and the target.
552 """
553 refnode["ghactions:workflow"] = env.ref_context.get("ghactions:workflow", None)
554 if not has_explicit_title and target.startswith("~"):
555 target = target[1:]
556 title = target.rpartition(".")[2]
558 return title, target
561@export
562class GitHubActionsDomain(Domain):
563 """
564 The Sphinx domain ``ghactions``, documenting GitHub Actions workflows.
566 Its objects are keyed by type and name - ``("workflow", "Parameters")``, ``("input", "Parameters.package_name")``
567 - and located by document and anchor. Directives of other modules reach the domain by
568 ``self.env.get_domain("ghactions")``, and use :meth:`GetCurrentWorkflow`, :attr:`Resolver` and
569 :meth:`ResolveWorkflow`.
570 """
572 name = "ghactions" #: Name of the domain, the prefix of its directives and roles.
573 label = "GitHub Actions" #: Name of the domain, as displayed.
574 data_version = 1 #: Version of the data layout; a change discards pickled environments.
576 object_types: ClassVar[dict[str, ObjType]] = { # type: ignore[misc]
577 "workflow": ObjType("workflow", "workflow"),
578 "input": ObjType("input", "input"),
579 "output": ObjType("output", "output"),
580 "secret": ObjType("secret", "secret"),
581 } #: The object types, each referenced by the role of the same name.
583 directives: ClassVar[dict[str, type]] = { # type: ignore[misc]
584 "workflow": WorkflowDirective,
585 "input": InputDirective,
586 "output": OutputDirective,
587 "secret": SecretDirective,
588 } #: The directives, by name.
590 roles: ClassVar[dict[str, XRefRole]] = { # type: ignore[misc]
591 "workflow": GitHubActionsXRefRole(innernodeclass=nodes.literal, warn_dangling=True),
592 "input": GitHubActionsXRefRole(innernodeclass=nodes.literal, warn_dangling=True),
593 "output": GitHubActionsXRefRole(innernodeclass=nodes.literal, warn_dangling=True),
594 "secret": GitHubActionsXRefRole(innernodeclass=nodes.literal, warn_dangling=True),
595 } #: The roles, by name.
597 initial_data: ClassVar[dict[str, Any]] = { # type: ignore[misc]
598 "objects": {},
599 } #: The domain's data: ``objects`` maps (type, name) to (document, anchor).
601 #: The configuration values the domain adds to :file:`conf.py`, as ``name: (default, rebuild, types)``. Each is
602 #: registered with the domain's name as prefix, e.g. ``ghactions_repository``.
603 #:
604 #: ``server``
605 #: The URL of the GitHub server links point to, ``https://github.com`` by default, or a GitHub Enterprise
606 #: Server's, as ``https://github.example.com``.
607 #: ``repository``
608 #: The documented repository, as ``owner/repo``. A ``uses`` naming it is read from
609 #: ``ghactions_workflow_directory``, whatever its ref.
610 #: ``workflow_directory``
611 #: The directory holding the workflow files, relative to the Sphinx source directory, as
612 #: ``../.github/workflows``. A ``ghactions:workflow`` without ``:file:`` reads ``<name>.yml`` from it.
613 #: ``ref``
614 #: The ref - a branch or tag - of the documented repository the documentation describes, as ``r8``, or ``None``.
615 #: A directive may warn about a ``uses`` of the documented repository at another ref.
616 #: ``label_prefix``
617 #: The root of the ``:ref:`` labels the directives register besides their domain targets, as
618 #: ``JOBTMPL/Parameters/Input/package_name``, or ``None`` for none.
619 configValues: ClassVar[dict[str, tuple[Any, str, Any]]] = {
620 "server": ("https://github.com", "env", str),
621 "repository": (None, "env", (str, type(None))),
622 "workflow_directory": (None, "env", (str, type(None))),
623 "ref": (None, "env", (str, type(None))),
624 "label_prefix": ("JOBTMPL", "env", (str, type(None))),
625 }
627 _resolver: Nullable[WorkflowResolver] #: Resolver reading the workflow files, created when first needed.
629 def __init__(self, env: BuildEnvironment) -> None:
630 """
631 Initializes the domain.
633 :param env: The build environment.
634 """
635 super().__init__(env)
637 self._resolver = None
639 @readonly
640 def Objects(self) -> dict[tuple[str, str], tuple[str, str]]:
641 """
642 Read-only property to return the documented objects.
644 :returns: A mapping of (object type, name) to (document, anchor).
645 """
646 return self.data["objects"]
648 @readonly
649 def WorkflowDirectory(self) -> Nullable[Path]:
650 """
651 Read-only property to return the directory holding the workflow files.
653 :returns: ``ghactions_workflow_directory`` resolved against the Sphinx source directory, or ``None`` if it
654 isn't set.
655 """
656 if (directory := self.env.config.ghactions_workflow_directory) is None:
657 return None
659 return (Path(self.env.srcdir) / directory).resolve()
661 @readonly
662 def Resolver(self) -> WorkflowResolver:
663 """
664 Read-only property to access the resolver reading the workflow files (:attr:`_resolver`), created when first
665 needed.
667 It maps ``ghactions_repository`` to :attr:`WorkflowDirectory`, and reads every file once per build and process - a
668 parallel build reads a file once in every process that needs it.
670 :returns: The resolver.
671 :raises MissingDependencyError: If the ``yaml`` extra isn't installed.
672 """
673 if self._resolver is None:
674 from pyTooling.GitHub.WorkflowFile import WorkflowResolver
676 repositories = {}
677 repository = self.env.config.ghactions_repository
678 if repository is not None and (directory := self.WorkflowDirectory) is not None:
679 repositories[repository] = directory
681 self._resolver = WorkflowResolver(repositories)
683 return self._resolver
685 def GetCurrentWorkflow(self) -> Nullable[Workflow]:
686 """
687 Return the model of the current document's workflow, as set by the last ``ghactions:workflow``.
689 :returns: The workflow, or ``None`` if the document has no ``ghactions:workflow``, or its file couldn't be read -
690 which the ``ghactions:workflow`` directive reported already.
691 """
692 if (path := self.env.current_document.get("ghactions:workflow-file", None)) is None:
693 return None
695 return self.Resolver.Load(path)
697 def ResolveWorkflow(self, name: str) -> Nullable[tuple[str, str]]:
698 """
699 Return where a workflow is documented.
701 :param name: The workflow's name, its file's stem.
702 :returns: The document and the anchor of its ``ghactions:workflow``, or ``None`` if it isn't documented.
703 :raises ValueError: If parameter 'name' is ``None``.
704 :raises TypeError: If parameter 'name' is not of type :class:`str`.
705 """
706 if name is None:
707 raise ValueError("Parameter 'name' is None.")
708 elif not isinstance(name, str):
709 ex = TypeError("Parameter 'name' is not of type 'str'.")
710 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
711 raise ex
713 return self.data["objects"].get(("workflow", name), None)
715 def NoteObject(self, objectType: str, name: str, nodeID: str, location: Nullable[Node] = None) -> None:
716 """
717 Register a documented object.
719 :param objectType: The object type, as ``input``.
720 :param name: The object's name, as ``Parameters.package_name``.
721 :param nodeID: The anchor of the object's node.
722 :param location: Optional, the node a duplicate is reported at. Default: ``None``.
723 :raises ValueError: If parameter 'objectType' is ``None``.
724 :raises TypeError: If parameter 'objectType' is not of type :class:`str`.
725 :raises ValueError: If parameter 'name' is ``None``.
726 :raises TypeError: If parameter 'name' is not of type :class:`str`.
727 :raises ValueError: If parameter 'nodeID' is ``None``.
728 :raises TypeError: If parameter 'nodeID' is not of type :class:`str`.
729 :raises TypeError: If parameter 'location' is not of type :class:`~docutils.nodes.Node`.
730 """
731 for parameterName, value in (
732 ("objectType", objectType),
733 ("name", name),
734 ("nodeID", nodeID)
735 ):
736 if value is None:
737 raise ValueError(f"Parameter '{parameterName}' is None.")
738 elif not isinstance(value, str):
739 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
740 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
741 raise ex
743 if location is not None and not isinstance(location, Node):
744 ex = TypeError("Parameter 'location' is not of type 'Node'.")
745 ex.add_note(f"Got type '{getFullyQualifiedName(location)}'.")
746 raise ex
748 objects = self.data["objects"]
749 if (known := objects.get((objectType, name), None)) is not None:
750 _logger.warning(
751 f"Duplicate description of ghactions:{objectType} '{name}', other instance in '{known[0]}'.",
752 location=location, type=WARNING_TYPE, subtype="duplicate"
753 )
755 objects[(objectType, name)] = (self.env.docname, nodeID)
757 def clear_doc(self, docname: str) -> None:
758 """
759 Remove the objects of a document, before it is read again.
761 :param docname: The document.
762 """
763 objects = self.data["objects"]
764 for key in [key for key, (document, _) in objects.items() if document == docname]:
765 del objects[key]
767 def merge_domaindata(self, docnames: Iterable[str], otherdata: dict[str, Any]) -> None:
768 """
769 Merge the objects a parallel reader collected for its documents.
771 :param docnames: The documents the other reader read.
772 :param otherdata: The other reader's data.
773 """
774 documents = set(docnames)
775 objects = self.data["objects"]
776 for key, (document, nodeID) in otherdata["objects"].items():
777 if document in documents:
778 objects[key] = (document, nodeID)
780 def resolve_xref(
781 self,
782 env: BuildEnvironment,
783 fromdocname: str,
784 builder: Builder,
785 typ: str,
786 target: str,
787 node: addnodes.pending_xref,
788 contnode: Element
789 ) -> Nullable[nodes.reference]:
790 """
791 Resolve a reference by one of the domain's roles.
793 A parameter's name without a workflow is looked up in the workflow current where the role was written.
795 :param env: The build environment.
796 :param fromdocname: The document containing the reference.
797 :param builder: The builder.
798 :param typ: The role's name, as ``input``.
799 :param target: The target, as ``Parameters.package_name`` or ``package_name``.
800 :param node: The pending reference.
801 :param contnode: The node rendering the reference's title.
802 :returns: The reference, or ``None`` if the target isn't documented.
803 """
804 if typ != "workflow" and "." not in target and (workflowName := node.get("ghactions:workflow", None)) is not None:
805 target = f"{workflowName}.{target}"
807 if (location := self.data["objects"].get((typ, target), None)) is None:
808 return None
810 return make_refnode(builder, fromdocname, location[0], location[1], contnode, target)
812 def resolve_any_xref(
813 self,
814 env: BuildEnvironment,
815 fromdocname: str,
816 builder: Builder,
817 target: str,
818 node: addnodes.pending_xref,
819 contnode: Element
820 ) -> list[tuple[str, nodes.reference]]:
821 """
822 Resolve a reference by the ``:any:`` role, trying every object type.
824 :param env: The build environment.
825 :param fromdocname: The document containing the reference.
826 :param builder: The builder.
827 :param target: The target.
828 :param node: The pending reference.
829 :param contnode: The node rendering the reference's title.
830 :returns: A pair of role and reference per object type the target resolves for.
831 """
832 results = []
833 for objectType in self.object_types:
834 if (reference := self.resolve_xref(env, fromdocname, builder, objectType, target, node, contnode)) is not None:
835 results.append((f"ghactions:{objectType}", reference))
837 return results
839 def get_objects(self) -> Iterable[tuple[str, str, str, str, str, int]]:
840 """
841 Iterate the documented objects, for the search index and the inventory.
843 :returns: An iterator of (name, display name, type, document, anchor, priority).
844 """
845 for (objectType, name), (document, nodeID) in self.data["objects"].items():
846 yield name, name, objectType, document, nodeID, 1
849@export
850def setup(sphinx: Sphinx) -> dict[str, Any]:
851 """
852 Register the domain ``ghactions``, its directives and its configuration values with Sphinx.
854 The directives derive from :class:`~pyTooling.Sphinx.BaseDirective` and draw graphs with
855 :mod:`sphinx.ext.graphviz`, so the extension :mod:`pyTooling.Sphinx` is set up first.
857 :param sphinx: The Sphinx application to register with.
858 :returns: The extension's metadata.
859 """
860 from pyTooling.GitHub import __version__
861 from pyTooling.GitHub.Sphinx.Graph import PipelineGraph, resolveLinks
862 from pyTooling.GitHub.Sphinx.Reference import AutoInputs, Dependencies, Interface, ParameterTable, YAMLExcerpt, \
863 checkUndocumentedInputs
865 sphinx.setup_extension("pyTooling.Sphinx")
867 sphinx.add_domain(GitHubActionsDomain)
868 sphinx.add_directive_to_domain("ghactions", "pipeline-graph", PipelineGraph)
869 sphinx.add_directive_to_domain("ghactions", "parameter-table", ParameterTable)
870 sphinx.add_directive_to_domain("ghactions", "interface", Interface)
871 sphinx.add_directive_to_domain("ghactions", "dependencies", Dependencies)
872 sphinx.add_directive_to_domain("ghactions", "yaml", YAMLExcerpt)
873 sphinx.add_directive_to_domain("ghactions", "autoinputs", AutoInputs)
875 for configName, (default, rebuild, types) in GitHubActionsDomain.configValues.items():
876 sphinx.add_config_value(f"{GitHubActionsDomain.name}_{configName}", default, rebuild, types)
878 sphinx.connect("doctree-read", checkUndocumentedInputs)
879 sphinx.connect("doctree-resolved", resolveLinks)
881 return {"version": __version__, "parallel_read_safe": True, "parallel_write_safe": True}