Coverage for pyTooling/GitHub/Sphinx/__init__.py: 98%
300 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-04 09:05 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-04 09:05 +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 ``gha`` 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 .. gha:workflow:: Parameters
42 Parameters
43 ##########
45 .. gha:input:: package_name
47 :Possible Values: Any valid Python package name.
48 :Example: ``myPackage``
50.. rubric:: What it registers
52* Directives
54 * ``gha:workflow``
55 * ``gha:input``
56 * ``gha:output``
57 * ``gha:secret``
59* Roles
61 * ``:gha:workflow:``
62 * ``:gha:input:``
63 * ``:gha:output:``
64 * ``:gha:secret:``
66* Configuration values, as listed in :attr:`GitHubActionsDomain.configValues`
68 * ``gha_server``
69 * ``gha_repository``
70 * ``gha_workflow_directory``
71 * ``gha_ref``
72 * ``gha_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 ``gha: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
108 from pyTooling.GitHub.WorkflowFile import WorkflowResolver
111__all__ = ["NO_DEFAULT", "WARNING_TYPE", "LEADING_FIELDS"]
113#: The text of the field *Default Value* when an input has no default.
114NO_DEFAULT = "— — — —"
116#: The type of the warnings this domain emits; the drift warnings have the subtype ``drift``, so
117#: ``suppress_warnings = ["gha.drift"]`` silences them.
118WARNING_TYPE = "gha"
120#: The fields a *Description* taken from the workflow file follows.
121LEADING_FIELDS = ("Type", "Required", "Default Value", "Possible Values")
123_logger = getLogger(__name__)
126@export
127def formatValue(value: ValueT) -> str:
128 """
129 Format a value read from a workflow file the way the file writes it.
131 A string is quoted as YAML quotes it - ``'3.14'`` -, a boolean is ``true`` or ``false``, and a number is written as
132 it is.
134 :param value: The value, as :attr:`Input.Default <pyTooling.GitHub.WorkflowFile.Input.Default>`.
135 :returns: The value as text, or :data:`NO_DEFAULT` for ``None``.
136 """
137 if value is None:
138 return NO_DEFAULT
139 elif isinstance(value, bool):
140 return "true" if value else "false"
141 elif isinstance(value, str):
142 escaped = value.replace("'", "''")
143 return f"'{escaped}'"
145 return str(value)
148@export
149class WorkflowDirective(BaseDirective):
150 """
151 The directive ``gha:workflow``: a workflow's target, its index entry, and the current workflow of the document.
153 .. code-block:: rst
155 .. gha:workflow:: Parameters
156 :file: ../../.github/workflows/Parameters.yml
158 The argument is the workflow's name, its file's stem. Without ``:file:``, the file is ``<name>.yml`` in
159 ``gha_workflow_directory``. Every ``gha:input``, ``gha:output`` and ``gha:secret`` following it in the document
160 belongs to this workflow.
162 The directive writes no visible output. Placed above a page's title, its target is the title, as a label is.
163 """
165 directiveName: str = "gha:workflow" #: Name the directive is invoked by.
167 has_content = False #: The directive has no content.
168 required_arguments = 1 #: The workflow's name.
169 option_spec = {"file": directives.unchanged_required} #: Path to the workflow file, relative to the document.
171 def run(self) -> list[Node]:
172 """
173 Register the workflow, read its file, and make it the current workflow.
175 :returns: An index node and the workflow's target.
176 """
177 name = self.arguments[0].strip()
178 domain: GitHubActionsDomain = self.env.get_domain("gha")
180 if "file" in self.options:
181 path = Path(self.env.relfn2path(self.options["file"], self.env.docname)[1])
182 elif domain.WorkflowDirectory is not None:
183 path = domain.WorkflowDirectory / f"{name}.yml"
184 if not path.exists() and (alternative := path.with_suffix(".yaml")).exists(): 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true
185 path = alternative
186 else:
187 path = None
189 if path is None:
190 _logger.warning(
191 f"{self.directiveName} '{name}' has no file: give option ':file:' or set 'gha_workflow_directory'.",
192 location=self.get_location(), type=WARNING_TYPE, subtype="workflow"
193 )
194 elif not path.exists():
195 _logger.warning(
196 f"{self.directiveName} '{name}': file '{path}' doesn't exist.",
197 location=self.get_location(), type=WARNING_TYPE, subtype="workflow"
198 )
199 else:
200 self.env.note_dependency(str(path))
201 self._Load(domain, name, path)
203 self.env.ref_context["gha:workflow"] = name
205 nodeID = make_id(self.env, self.state.document, "gha-workflow", name)
206 target = nodes.target("", "", ids=[nodeID])
207 if (prefix := self.config.gha_label_prefix) is not None:
208 label = f"{prefix}/{name}"
209 target["ids"].append(nodes.make_id(label))
210 target["names"].append(fully_normalize_name(label))
212 self.set_source_info(target)
213 self.state.document.note_explicit_target(target)
214 domain.NoteObject("workflow", name, nodeID, target)
216 return [addnodes.index(entries=[("single", f"GitHub Actions workflow; {name}", nodeID, "", None)]), target]
218 def _Load(self, domain: GitHubActionsDomain, name: str, path: Path) -> None:
219 """
220 Read the workflow file, make it the current one, and warn about inputs that are required and have a default.
222 A file that isn't a well-formed workflow is reported as a warning at the place in the file, and the document
223 has no current workflow model then. A workflow read is added to the document's list ``gha:workflows`` of
224 (name, path, location of the directive), which is checked when the document was read.
226 :param domain: The domain.
227 :param name: The workflow's name, as given as argument.
228 :param path: Path to the workflow file.
229 """
230 from pyTooling.GitHub.WorkflowFile import WorkflowError
232 try:
233 workflow = domain.Resolver.Load(path)
234 except WorkflowError as ex:
235 if ex.Path is None: 235 ↛ 236line 235 didn't jump to line 236 because the condition on line 235 was never true
236 location = self.get_location()
237 elif ex.Line is None: 237 ↛ 238line 237 didn't jump to line 238 because the condition on line 237 was never true
238 location = str(ex.Path)
239 else:
240 location = f"{ex.Path}:{ex.Line}"
242 _logger.warning(f"{self.directiveName} '{name}': {ex}", location=location, type=WARNING_TYPE, subtype="workflow")
243 return
245 if workflow.Name != name:
246 _logger.warning(
247 f"{self.directiveName} '{name}' reads file '{path.name}', which names workflow '{workflow.Name}'.",
248 location=self.get_location(), type=WARNING_TYPE, subtype="workflow"
249 )
251 self.env.current_document["gha:workflow-file"] = path
252 self.env.current_document.setdefault("gha:workflows", []).append((name, path, self.get_location()))
254 for parameter in workflow.Inputs.values():
255 if parameter.Required and parameter.Default is not None:
256 _logger.warning(
257 f"Input '{parameter.Name}' of workflow '{workflow.Name}' is required and has a default, which is never used.",
258 location=f"{workflow.Path}:{parameter.Line}", type=WARNING_TYPE, subtype="drift"
259 )
262@export
263class ParameterDirective(BaseDirective):
264 """
265 Base-class of the directives documenting one parameter of the current workflow.
267 The entry is a section titled by the parameter's name, holding a field list: first the fields read from the
268 workflow file (:attr:`FACT_FIELDS`), then the fields of the directive's content, in the order written. Without a
269 hand-written *Description*, the workflow file's ``description`` is used, placed behind the :data:`LEADING_FIELDS`.
270 Content after the field list follows it.
272 A hand-written field repeating a fact of the file is a warning, and the file's value is shown.
274 Besides its anchor ``gha-<type>-<Workflow>.<name>``, the section carries the anchor of its label and - unless the
275 document uses it already - the anchor docutils derives from a title, as a hand-written section has.
276 """
278 OBJECT_TYPE: ClassVar[str] #: The domain's object type, as ``input``.
279 LABEL_KIND: ClassVar[str] #: The kind in a label, as ``Input`` in ``JOBTMPL/Parameters/Input/name``.
280 COLLECTION: ClassVar[str] #: The workflow's property holding the parameters, as ``Inputs``.
281 FACT_FIELDS: ClassVar[tuple[str, ...]] #: The fields taken from the workflow file.
283 has_content = True #: The hand-written fields and text.
284 required_arguments = 1 #: The parameter's name.
286 def run(self) -> list[Node]:
287 """
288 Create the parameter's entry and register it.
290 :returns: An index node and the entry's section, or nothing outside a ``gha:workflow``.
291 """
292 name = self.arguments[0].strip()
293 if (workflowName := self.env.ref_context.get("gha:workflow", None)) is None:
294 _logger.warning(
295 f"{self.directiveName} '{name}' is not preceded by a gha:workflow.",
296 location=self.get_location(), type=WARNING_TYPE, subtype="workflow"
297 )
298 return []
300 domain: GitHubActionsDomain = self.env.get_domain("gha")
301 parameter = None
302 if (workflow := domain.GetCurrentWorkflow()) is not None:
303 if (parameter := getattr(workflow, self.COLLECTION).get(name, None)) is None:
304 _logger.warning(
305 f"Workflow '{workflowName}' has no {self.OBJECT_TYPE} '{name}' ({workflow.Path.name}).",
306 location=self.get_location(), type=WARNING_TYPE, subtype="drift"
307 )
309 content = self.parse_content_to_nodes()
310 handwritten = []
311 if len(content) > 0 and isinstance(content[0], nodes.field_list):
312 handwritten = list(content[0].children)
313 content = content[1:]
315 fields = []
316 for field in handwritten:
317 fieldName = field[0].astext().strip()
318 if fieldName in self.FACT_FIELDS:
319 _logger.warning(
320 f"{self.directiveName} '{workflowName}.{name}': field '{fieldName}' is taken from the workflow file; "
321 "remove it.",
322 location=field, type=WARNING_TYPE, subtype="drift"
323 )
324 if parameter is not None: 324 ↛ 327line 324 didn't jump to line 327 because the condition on line 324 was always true
325 continue
327 fields.append(field)
329 source, line = self.get_source_info()
330 return self.CreateEntry(self.env, self.state.document, workflowName, name, parameter, fields, content, source, line)
332 @classmethod
333 def CreateEntry(
334 cls,
335 env: BuildEnvironment,
336 document: nodes.document,
337 workflowName: str,
338 name: str,
339 parameter: Nullable[Parameter],
340 fields: list[nodes.field],
341 content: list[Node],
342 source: str,
343 line: Nullable[int]
344 ) -> list[Node]:
345 """
346 Create a parameter's entry and register it in the domain.
348 :param env: The build environment.
349 :param document: The document the entry is placed in.
350 :param workflowName: The workflow's name.
351 :param name: The parameter's name.
352 :param parameter: The parameter, as read from the workflow file, or ``None`` if the workflow file wasn't read or
353 hasn't this parameter.
354 :param fields: The hand-written fields, without those repeating a fact of the workflow file.
355 :param content: The nodes following the field list.
356 :param source: The source file the entry is reported at.
357 :param line: The line the entry is reported at, or ``None``.
358 :returns: An index node and the entry's section.
359 """
360 fullName = f"{workflowName}.{name}"
361 nodeID = make_id(env, document, f"gha-{cls.OBJECT_TYPE}", fullName)
362 section = nodes.section("", nodes.title(name, name), ids=[nodeID])
363 if (titleID := nodes.make_id(name)) not in document.ids: 363 ↛ 367line 363 didn't jump to line 367 because the condition on line 363 was always true
364 section["ids"].append(titleID)
366 # The label's anchor comes last: docutils links a name to a node's last anchor, so a link keeps its target.
367 if (prefix := env.config.gha_label_prefix) is not None:
368 label = f"{prefix}/{workflowName}/{cls.LABEL_KIND}/{name}"
369 section["ids"].append(nodes.make_id(label))
370 section["names"].append(fully_normalize_name(label))
372 section.source = source
373 section.line = line
374 document.note_explicit_target(section)
376 if parameter is not None:
377 fields = [*cls._FactFields(parameter), *fields]
379 fieldNames = [field[0].astext().strip() for field in fields]
380 if "Description" not in fieldNames and parameter is not None and parameter.Description:
381 leading = [index + 1 for index, fieldName in enumerate(fieldNames) if fieldName in LEADING_FIELDS]
382 position = max(leading, default=0)
383 fields.insert(position, cls._TextField("Description", parameter.Description.strip()))
385 if len(fields) > 0:
386 section += nodes.field_list("", *fields)
387 section.extend(content)
389 domain: GitHubActionsDomain = env.get_domain("gha")
390 domain.NoteObject(cls.OBJECT_TYPE, fullName, nodeID, section)
391 return [
392 addnodes.index(entries=[("single", f"{name} ({cls.OBJECT_TYPE} of {workflowName})", nodeID, "", None)]),
393 section
394 ]
396 @staticmethod
397 def _Field(name: str, *body: Node) -> nodes.field:
398 """
399 Create a field of a field list.
401 :param name: Name of the field, as ``Type``.
402 :param body: The nodes of the field's body.
403 :returns: The field.
404 """
405 return nodes.field("", nodes.field_name(name, name), nodes.field_body("", *body))
407 @classmethod
408 def _TextField(cls, name: str, text: str) -> nodes.field:
409 """
410 Create a field of a field list, whose body is a line of text.
412 :param name: Name of the field, as ``Required``.
413 :param text: Text of the field's body.
414 :returns: The field.
415 """
416 return cls._Field(name, nodes.paragraph(text, text))
418 @classmethod
419 def _DefaultField(cls, value: ValueT) -> nodes.field:
420 """
421 Create the field *Default Value*.
423 A multi-line string becomes a literal block, keeping its line breaks; no default becomes :data:`NO_DEFAULT`.
425 :param value: The default value.
426 :returns: The field.
427 """
428 if value is None:
429 return cls._TextField("Default Value", NO_DEFAULT)
430 elif isinstance(value, str) and "\n" in value:
431 return cls._Field("Default Value", nodes.literal_block(value, value, language="text"))
433 text = formatValue(value)
434 return cls._Field("Default Value", nodes.paragraph("", "", nodes.literal(text, text)))
436 @classmethod
437 def _FactFields(cls, parameter: Parameter) -> list[nodes.field]:
438 """
439 Create the fields taken from the workflow file.
441 :param parameter: The parameter, as read from the workflow file.
442 :returns: The fields named in :attr:`FACT_FIELDS`, in that order.
443 """
444 return []
447@export
448class InputDirective(ParameterDirective):
449 """
450 The directive ``gha:input``: an input of the current workflow.
452 The fields *Type*, *Required* and *Default Value* are taken from the workflow file.
453 """
455 directiveName: str = "gha:input" #: Name the directive is invoked by.
457 OBJECT_TYPE = "input" #: The domain's object type.
458 LABEL_KIND = "Input" #: The kind in a label.
459 COLLECTION = "Inputs" #: The workflow's property holding the parameters.
460 FACT_FIELDS = ("Type", "Required", "Default Value") #: The fields taken from the workflow file.
462 @classmethod
463 def _FactFields(cls, parameter: Input) -> list[nodes.field]:
464 """
465 Create the fields *Type*, *Required* and *Default Value*.
467 :param parameter: The input, as read from the workflow file.
468 :returns: The fields.
469 """
470 return [
471 cls._TextField("Type", parameter.Type.value),
472 cls._TextField("Required", "yes" if parameter.Required else "no"),
473 cls._DefaultField(parameter.Default)
474 ]
477@export
478class SecretDirective(ParameterDirective):
479 """
480 The directive ``gha:secret``: a secret of the current workflow.
482 The fields *Type* - a secret is a string -, *Required* and *Default Value* - a secret has none - are taken from the
483 workflow file.
484 """
486 directiveName: str = "gha:secret" #: Name the directive is invoked by.
488 OBJECT_TYPE = "secret" #: The domain's object type.
489 LABEL_KIND = "Secret" #: The kind in a label.
490 COLLECTION = "Secrets" #: The workflow's property holding the parameters.
491 FACT_FIELDS = ("Type", "Required", "Default Value") #: The fields taken from the workflow file.
493 @classmethod
494 def _FactFields(cls, parameter: Secret) -> list[nodes.field]:
495 """
496 Create the fields *Type*, *Required* and *Default Value*.
498 :param parameter: The secret, as read from the workflow file.
499 :returns: The fields.
500 """
501 return [
502 cls._TextField("Type", "string"),
503 cls._TextField("Required", "yes" if parameter.Required else "no"),
504 cls._DefaultField(None)
505 ]
508@export
509class OutputDirective(ParameterDirective):
510 """
511 The directive ``gha:output``: an output of the current workflow.
513 A workflow file states no type and no default for an output, so every field is hand-written; only the
514 *Description* falls back to the file's ``description``.
515 """
517 directiveName: str = "gha:output" #: Name the directive is invoked by.
519 OBJECT_TYPE = "output" #: The domain's object type.
520 LABEL_KIND = "Output" #: The kind in a label.
521 COLLECTION = "Outputs" #: The workflow's property holding the parameters.
522 FACT_FIELDS = () #: The fields taken from the workflow file.
525@export
526class GitHubActionsXRefRole(XRefRole):
527 """
528 The roles ``:gha:workflow:``, ``:gha:input:``, ``:gha:output:`` and ``:gha:secret:``.
530 A parameter is named as ``<Workflow>.<name>``; inside a ``gha:workflow``, the name alone refers to the current
531 workflow's parameter. A leading ``~`` shows only the part behind the last dot.
532 """
534 def process_link(
535 self,
536 env: BuildEnvironment,
537 refnode: Element,
538 has_explicit_title: bool,
539 title: str,
540 target: str
541 ) -> tuple[str, str]:
542 """
543 Remember the current workflow on the reference, and shorten the title for a leading ``~``.
545 :param env: The build environment.
546 :param refnode: The reference node.
547 :param has_explicit_title: ``True``, if the role was written with a title, as ``text <target>``.
548 :param title: The title.
549 :param target: The target.
550 :returns: The title and the target.
551 """
552 refnode["gha:workflow"] = env.ref_context.get("gha:workflow", None)
553 if not has_explicit_title and target.startswith("~"):
554 target = target[1:]
555 title = target.rpartition(".")[2]
557 return title, target
560@export
561class GitHubActionsDomain(Domain):
562 """
563 The Sphinx domain ``gha``, documenting GitHub Actions workflows.
565 Its objects are keyed by type and name - ``("workflow", "Parameters")``, ``("input", "Parameters.package_name")``
566 - and located by document and anchor. Directives of other modules reach the domain by
567 ``self.env.get_domain("gha")``, and use :meth:`GetCurrentWorkflow`, :attr:`Resolver` and :meth:`ResolveWorkflow`.
568 """
570 name = "gha" #: Name of the domain, the prefix of its directives and roles.
571 label = "GitHub Actions" #: Name of the domain, as displayed.
572 data_version = 1 #: Version of the data layout; a change discards pickled environments.
574 object_types: ClassVar[dict[str, ObjType]] = { # type: ignore[misc]
575 "workflow": ObjType("workflow", "workflow"),
576 "input": ObjType("input", "input"),
577 "output": ObjType("output", "output"),
578 "secret": ObjType("secret", "secret"),
579 } #: The object types, each referenced by the role of the same name.
581 directives: ClassVar[dict[str, type]] = { # type: ignore[misc]
582 "workflow": WorkflowDirective,
583 "input": InputDirective,
584 "output": OutputDirective,
585 "secret": SecretDirective,
586 } #: The directives, by name.
588 roles: ClassVar[dict[str, XRefRole]] = { # type: ignore[misc]
589 "workflow": GitHubActionsXRefRole(innernodeclass=nodes.literal, warn_dangling=True),
590 "input": GitHubActionsXRefRole(innernodeclass=nodes.literal, warn_dangling=True),
591 "output": GitHubActionsXRefRole(innernodeclass=nodes.literal, warn_dangling=True),
592 "secret": GitHubActionsXRefRole(innernodeclass=nodes.literal, warn_dangling=True),
593 } #: The roles, by name.
595 initial_data: ClassVar[dict[str, Any]] = { # type: ignore[misc]
596 "objects": {},
597 } #: The domain's data: ``objects`` maps (type, name) to (document, anchor).
599 #: The configuration values the domain adds to :file:`conf.py`, as ``name: (default, rebuild, types)``. Each is
600 #: registered with the domain's name as prefix, e.g. ``gha_repository``.
601 #:
602 #: ``server``
603 #: The URL of the GitHub server links point to, ``https://github.com`` by default, or a GitHub Enterprise
604 #: Server's, as ``https://github.example.com``.
605 #: ``repository``
606 #: The documented repository, as ``owner/repo``. A ``uses`` naming it is read from ``gha_workflow_directory``,
607 #: whatever its ref.
608 #: ``workflow_directory``
609 #: The directory holding the workflow files, relative to the Sphinx source directory, as
610 #: ``../.github/workflows``. A ``gha:workflow`` without ``:file:`` reads ``<name>.yml`` from it.
611 #: ``ref``
612 #: The ref - a branch or tag - of the documented repository the documentation describes, as ``r8``, or ``None``.
613 #: A directive may warn about a ``uses`` of the documented repository at another ref.
614 #: ``label_prefix``
615 #: The root of the ``:ref:`` labels the directives register besides their domain targets, as
616 #: ``JOBTMPL/Parameters/Input/package_name``, or ``None`` for none.
617 configValues: ClassVar[dict[str, tuple[Any, str, Any]]] = {
618 "server": ("https://github.com", "env", str),
619 "repository": (None, "env", (str, type(None))),
620 "workflow_directory": (None, "env", (str, type(None))),
621 "ref": (None, "env", (str, type(None))),
622 "label_prefix": ("JOBTMPL", "env", (str, type(None))),
623 }
625 _resolver: Nullable[WorkflowResolver] #: Resolver reading the workflow files, created when first needed.
627 def __init__(self, env: BuildEnvironment) -> None:
628 """
629 Initializes the domain.
631 :param env: The build environment.
632 """
633 super().__init__(env)
635 self._resolver = None
637 @readonly
638 def Objects(self) -> dict[tuple[str, str], tuple[str, str]]:
639 """
640 Read-only property to return the documented objects.
642 :returns: A mapping of (object type, name) to (document, anchor).
643 """
644 return self.data["objects"]
646 @readonly
647 def WorkflowDirectory(self) -> Nullable[Path]:
648 """
649 Read-only property to return the directory holding the workflow files.
651 :returns: ``gha_workflow_directory`` resolved against the Sphinx source directory, or ``None`` if it isn't set.
652 """
653 if (directory := self.env.config.gha_workflow_directory) is None:
654 return None
656 return (Path(self.env.srcdir) / directory).resolve()
658 @readonly
659 def Resolver(self) -> WorkflowResolver:
660 """
661 Read-only property to access the resolver reading the workflow files (:attr:`_resolver`), created when first
662 needed.
664 It maps ``gha_repository`` to :attr:`WorkflowDirectory`, and reads every file once per build and process - a
665 parallel build reads a file once in every process that needs it.
667 :returns: The resolver.
668 :raises MissingDependencyError: If the ``yaml`` extra isn't installed.
669 """
670 if self._resolver is None:
671 from pyTooling.GitHub.WorkflowFile import WorkflowResolver
673 repositories = {}
674 repository = self.env.config.gha_repository
675 if repository is not None and (directory := self.WorkflowDirectory) is not None:
676 repositories[repository] = directory
678 self._resolver = WorkflowResolver(repositories)
680 return self._resolver
682 def GetCurrentWorkflow(self) -> Nullable[Workflow]:
683 """
684 Return the model of the current document's workflow, as set by the last ``gha:workflow``.
686 :returns: The workflow, or ``None`` if the document has no ``gha:workflow``, or its file couldn't be read -
687 which the ``gha:workflow`` directive reported already.
688 """
689 if (path := self.env.current_document.get("gha:workflow-file", None)) is None:
690 return None
692 return self.Resolver.Load(path)
694 def ResolveWorkflow(self, name: str) -> Nullable[tuple[str, str]]:
695 """
696 Return where a workflow is documented.
698 :param name: The workflow's name, its file's stem.
699 :returns: The document and the anchor of its ``gha:workflow``, or ``None`` if it isn't documented.
700 :raises ValueError: If parameter 'name' is ``None``.
701 :raises TypeError: If parameter 'name' is not of type :class:`str`.
702 """
703 if name is None:
704 raise ValueError("Parameter 'name' is None.")
705 elif not isinstance(name, str):
706 ex = TypeError("Parameter 'name' is not of type 'str'.")
707 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
708 raise ex
710 return self.data["objects"].get(("workflow", name), None)
712 def NoteObject(self, objectType: str, name: str, nodeID: str, location: Nullable[Node] = None) -> None:
713 """
714 Register a documented object.
716 :param objectType: The object type, as ``input``.
717 :param name: The object's name, as ``Parameters.package_name``.
718 :param nodeID: The anchor of the object's node.
719 :param location: Optional, the node a duplicate is reported at. Default: ``None``.
720 :raises ValueError: If parameter 'objectType' is ``None``.
721 :raises TypeError: If parameter 'objectType' is not of type :class:`str`.
722 :raises ValueError: If parameter 'name' is ``None``.
723 :raises TypeError: If parameter 'name' is not of type :class:`str`.
724 :raises ValueError: If parameter 'nodeID' is ``None``.
725 :raises TypeError: If parameter 'nodeID' is not of type :class:`str`.
726 :raises TypeError: If parameter 'location' is not of type :class:`~docutils.nodes.Node`.
727 """
728 for parameterName, value in (
729 ("objectType", objectType),
730 ("name", name),
731 ("nodeID", nodeID)
732 ):
733 if value is None:
734 raise ValueError(f"Parameter '{parameterName}' is None.")
735 elif not isinstance(value, str):
736 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
737 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
738 raise ex
740 if location is not None and not isinstance(location, Node):
741 ex = TypeError("Parameter 'location' is not of type 'Node'.")
742 ex.add_note(f"Got type '{getFullyQualifiedName(location)}'.")
743 raise ex
745 objects = self.data["objects"]
746 if (known := objects.get((objectType, name), None)) is not None:
747 _logger.warning(
748 f"Duplicate description of gha:{objectType} '{name}', other instance in '{known[0]}'.",
749 location=location, type=WARNING_TYPE, subtype="duplicate"
750 )
752 objects[(objectType, name)] = (self.env.docname, nodeID)
754 def clear_doc(self, docname: str) -> None:
755 """
756 Remove the objects of a document, before it is read again.
758 :param docname: The document.
759 """
760 objects = self.data["objects"]
761 for key in [key for key, (document, _) in objects.items() if document == docname]:
762 del objects[key]
764 def merge_domaindata(self, docnames: Iterable[str], otherdata: dict[str, Any]) -> None:
765 """
766 Merge the objects a parallel reader collected for its documents.
768 :param docnames: The documents the other reader read.
769 :param otherdata: The other reader's data.
770 """
771 documents = set(docnames)
772 objects = self.data["objects"]
773 for key, (document, nodeID) in otherdata["objects"].items():
774 if document in documents: 774 ↛ 773line 774 didn't jump to line 773 because the condition on line 774 was always true
775 objects[key] = (document, nodeID)
777 def resolve_xref(
778 self,
779 env: BuildEnvironment,
780 fromdocname: str,
781 builder: Builder,
782 typ: str,
783 target: str,
784 node: addnodes.pending_xref,
785 contnode: Element
786 ) -> Nullable[nodes.reference]:
787 """
788 Resolve a reference by one of the domain's roles.
790 A parameter's name without a workflow is looked up in the workflow current where the role was written.
792 :param env: The build environment.
793 :param fromdocname: The document containing the reference.
794 :param builder: The builder.
795 :param typ: The role's name, as ``input``.
796 :param target: The target, as ``Parameters.package_name`` or ``package_name``.
797 :param node: The pending reference.
798 :param contnode: The node rendering the reference's title.
799 :returns: The reference, or ``None`` if the target isn't documented.
800 """
801 if typ != "workflow" and "." not in target and (workflowName := node.get("gha:workflow", None)) is not None:
802 target = f"{workflowName}.{target}"
804 if (location := self.data["objects"].get((typ, target), None)) is None:
805 return None
807 return make_refnode(builder, fromdocname, location[0], location[1], contnode, target)
809 def resolve_any_xref(
810 self,
811 env: BuildEnvironment,
812 fromdocname: str,
813 builder: Builder,
814 target: str,
815 node: addnodes.pending_xref,
816 contnode: Element
817 ) -> list[tuple[str, nodes.reference]]:
818 """
819 Resolve a reference by the ``:any:`` role, trying every object type.
821 :param env: The build environment.
822 :param fromdocname: The document containing the reference.
823 :param builder: The builder.
824 :param target: The target.
825 :param node: The pending reference.
826 :param contnode: The node rendering the reference's title.
827 :returns: A pair of role and reference per object type the target resolves for.
828 """
829 results = []
830 for objectType in self.object_types:
831 if (reference := self.resolve_xref(env, fromdocname, builder, objectType, target, node, contnode)) is not None:
832 results.append((f"gha:{objectType}", reference))
834 return results
836 def get_objects(self) -> Iterable[tuple[str, str, str, str, str, int]]:
837 """
838 Iterate the documented objects, for the search index and the inventory.
840 :returns: An iterator of (name, display name, type, document, anchor, priority).
841 """
842 for (objectType, name), (document, nodeID) in self.data["objects"].items():
843 yield name, name, objectType, document, nodeID, 1
846@export
847def setup(sphinx: Sphinx) -> dict[str, Any]:
848 """
849 Register the domain ``gha``, its directives and its configuration values with Sphinx.
851 The directives derive from :class:`~pyTooling.Sphinx.BaseDirective` and draw graphs with
852 :mod:`sphinx.ext.graphviz`, so the extension :mod:`pyTooling.Sphinx` is set up first.
854 :param sphinx: The Sphinx application to register with.
855 :returns: The extension's metadata.
856 """
857 from pyTooling.GitHub import __version__
858 from pyTooling.GitHub.Sphinx.Graph import PipelineGraph, resolveLinks
859 from pyTooling.GitHub.Sphinx.Reference import AutoInputs, Dependencies, Interface, ParameterTable, YAMLExcerpt
860 from pyTooling.GitHub.Sphinx.Reference import checkUndocumentedInputs
862 sphinx.setup_extension("pyTooling.Sphinx")
864 sphinx.add_domain(GitHubActionsDomain)
865 sphinx.add_directive_to_domain("gha", "pipeline-graph", PipelineGraph)
866 sphinx.add_directive_to_domain("gha", "parameter-table", ParameterTable)
867 sphinx.add_directive_to_domain("gha", "interface", Interface)
868 sphinx.add_directive_to_domain("gha", "dependencies", Dependencies)
869 sphinx.add_directive_to_domain("gha", "yaml", YAMLExcerpt)
870 sphinx.add_directive_to_domain("gha", "autoinputs", AutoInputs)
872 for configName, (default, rebuild, types) in GitHubActionsDomain.configValues.items():
873 sphinx.add_config_value(f"{GitHubActionsDomain.name}_{configName}", default, rebuild, types)
875 sphinx.connect("doctree-read", checkUndocumentedInputs)
876 sphinx.connect("doctree-resolved", resolveLinks)
878 return {"version": __version__, "parallel_read_safe": True, "parallel_write_safe": True}