Coverage for pyTooling/Documentation/Sphinx/GitHubActions/__init__.py: 98%
280 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +0000
1# ==================================================================================================================== #
2# _____ _ _ ____ _ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | _ \ ___ ___ _ _ _ __ ___ ___ _ __ | |_ __ _| |_(_) ___ _ __ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | | | |/ _ \ / __| | | | '_ ` _ \ / _ \ '_ \| __/ _` | __| |/ _ \| '_ \ #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| |_| | (_) | (__| |_| | | | | | | __/ | | | || (_| | |_| | (_) | | | |#
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/ \___/ \___|\__,_|_| |_| |_|\___|_| |_|\__\__,_|\__|_|\___/|_| |_|#
7# |_| |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
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.CI.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.CI.GitHub.WorkflowFile`
79 |rarr| The model of a workflow file the domain reads.
80 :mod:`pyTooling.Documentation.Sphinx.GitHubActions.Graph`
81 |rarr| The ``gha:pipeline-graph`` directive, drawing a workflow's jobs and their ``needs``.
82 :mod:`pyTooling.Documentation.Sphinx.GitHubActions.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.builders import Builder
95from sphinx.domains import Domain, ObjType
96from sphinx.environment import BuildEnvironment
97from sphinx.roles import XRefRole
98from sphinx.util.logging import getLogger
99from sphinx.util.nodes import make_id, make_refnode
101from pyTooling.Common import getFullyQualifiedName
102from pyTooling.Decorators import export, readonly
103from pyTooling.Documentation.Sphinx.Directives import BaseDirective
105if TYPE_CHECKING: # pragma: no cover
106 from pyTooling.CI.GitHub.WorkflowFile import Input, Output, Parameter, Secret, ValueT, Workflow
107 from pyTooling.CI.GitHub.WorkflowFile import WorkflowResolver
110__all__ = ["NO_DEFAULT", "WARNING_TYPE", "LEADING_FIELDS"]
112#: The text of the field *Default Value* when an input has no default.
113NO_DEFAULT = "— — — —"
115#: The type of the warnings this domain emits; the drift warnings have the subtype ``drift``, so
116#: ``suppress_warnings = ["gha.drift"]`` silences them.
117WARNING_TYPE = "gha"
119#: The fields a *Description* taken from the workflow file follows.
120LEADING_FIELDS = ("Type", "Required", "Default Value", "Possible Values")
122_logger = getLogger(__name__)
125@export
126def formatValue(value: ValueT) -> str:
127 """
128 Format a value read from a workflow file the way the file writes it.
130 A string is quoted as YAML quotes it - ``'3.14'`` -, a boolean is ``true`` or ``false``, and a number is written as
131 it is.
133 :param value: The value, as :attr:`Input.Default <pyTooling.CI.GitHub.WorkflowFile.Input.Default>`.
134 :returns: The value as text, or :data:`NO_DEFAULT` for ``None``.
135 """
136 if value is None:
137 return NO_DEFAULT
138 elif isinstance(value, bool):
139 return "true" if value else "false"
140 elif isinstance(value, str):
141 escaped = value.replace("'", "''")
142 return f"'{escaped}'"
144 return str(value)
147@export
148class WorkflowDirective(BaseDirective):
149 """
150 The directive ``gha:workflow``: a workflow's target, its index entry, and the current workflow of the document.
152 .. code-block:: rst
154 .. gha:workflow:: Parameters
155 :file: ../../.github/workflows/Parameters.yml
157 The argument is the workflow's name, its file's stem. Without ``:file:``, the file is ``<name>.yml`` in
158 ``gha_workflow_directory``. Every ``gha:input``, ``gha:output`` and ``gha:secret`` following it in the document
159 belongs to this workflow.
161 The directive writes no visible output. Placed above a page's title, its target is the title, as a label is.
162 """
164 directiveName: str = "gha:workflow" #: Name the directive is invoked by.
166 has_content = False #: The directive has no content.
167 required_arguments = 1 #: The workflow's name.
168 option_spec = {"file": directives.unchanged_required} #: Path to the workflow file, relative to the document.
170 def run(self) -> list[Node]:
171 """
172 Register the workflow, read its file, and make it the current workflow.
174 :returns: An index node and the workflow's target.
175 """
176 name = self.arguments[0].strip()
177 domain: GitHubActionsDomain = self.env.get_domain("gha")
179 if "file" in self.options:
180 path = Path(self.env.relfn2path(self.options["file"], self.env.docname)[1])
181 elif domain.WorkflowDirectory is not None:
182 path = domain.WorkflowDirectory / f"{name}.yml"
183 if not path.exists() and (alternative := path.with_suffix(".yaml")).exists(): 183 ↛ 184line 183 didn't jump to line 184 because the condition on line 183 was never true
184 path = alternative
185 else:
186 path = None
188 if path is None:
189 _logger.warning(
190 f"{self.directiveName} '{name}' has no file: give option ':file:' or set 'gha_workflow_directory'.",
191 location=self.get_location(), type=WARNING_TYPE, subtype="workflow"
192 )
193 elif not path.exists():
194 _logger.warning(
195 f"{self.directiveName} '{name}': file '{path}' doesn't exist.",
196 location=self.get_location(), type=WARNING_TYPE, subtype="workflow"
197 )
198 else:
199 self.env.note_dependency(str(path))
200 self._Load(domain, name, path)
202 self.env.ref_context["gha:workflow"] = name
204 nodeID = make_id(self.env, self.state.document, "gha-workflow", name)
205 target = nodes.target("", "", ids=[nodeID])
206 if (prefix := self.config.gha_label_prefix) is not None:
207 label = f"{prefix}/{name}"
208 target["ids"].append(nodes.make_id(label))
209 target["names"].append(fully_normalize_name(label))
211 self.set_source_info(target)
212 self.state.document.note_explicit_target(target)
213 domain.NoteObject("workflow", name, nodeID, target)
215 return [addnodes.index(entries=[("single", f"GitHub Actions workflow; {name}", nodeID, "", None)]), target]
217 def _Load(self, domain: GitHubActionsDomain, name: str, path: Path) -> None:
218 """
219 Read the workflow file, make it the current one, and warn about inputs that are required and have a default.
221 A file that isn't a well-formed workflow is reported as a warning at the place in the file, and the document
222 has no current workflow model then. A workflow read is added to the document's list ``gha:workflows`` of
223 (name, path, location of the directive), which is checked when the document was read.
225 :param domain: The domain.
226 :param name: The workflow's name, as given as argument.
227 :param path: Path to the workflow file.
228 """
229 from pyTooling.CI.GitHub.WorkflowFile import WorkflowError
231 try:
232 workflow = domain.Resolver.Load(path)
233 except WorkflowError as ex:
234 if ex.Path is None: 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true
235 location = self.get_location()
236 elif ex.Line is None: 236 ↛ 237line 236 didn't jump to line 237 because the condition on line 236 was never true
237 location = str(ex.Path)
238 else:
239 location = f"{ex.Path}:{ex.Line}"
241 _logger.warning(f"{self.directiveName} '{name}': {ex}", location=location, type=WARNING_TYPE, subtype="workflow")
242 return
244 if workflow.Name != name:
245 _logger.warning(
246 f"{self.directiveName} '{name}' reads file '{path.name}', which names workflow '{workflow.Name}'.",
247 location=self.get_location(), type=WARNING_TYPE, subtype="workflow"
248 )
250 self.env.current_document["gha:workflow-file"] = path
251 self.env.current_document.setdefault("gha:workflows", []).append((name, path, self.get_location()))
253 for parameter in workflow.Inputs.values():
254 if parameter.Required and parameter.Default is not None:
255 _logger.warning(
256 f"Input '{parameter.Name}' of workflow '{workflow.Name}' is required and has a default, which is never used.",
257 location=f"{workflow.Path}:{parameter.Line}", type=WARNING_TYPE, subtype="drift"
258 )
261@export
262class ParameterDirective(BaseDirective):
263 """
264 Base-class of the directives documenting one parameter of the current workflow.
266 The entry is a section titled by the parameter's name, holding a field list: first the fields read from the
267 workflow file (:attr:`FACT_FIELDS`), then the fields of the directive's content, in the order written. Without a
268 hand-written *Description*, the workflow file's ``description`` is used, placed behind the :data:`LEADING_FIELDS`.
269 Content after the field list follows it.
271 A hand-written field repeating a fact of the file is a warning, and the file's value is shown.
273 Besides its anchor ``gha-<type>-<Workflow>.<name>``, the section carries the anchor of its label and - unless the
274 document uses it already - the anchor docutils derives from a title, as a hand-written section has.
275 """
277 OBJECT_TYPE: ClassVar[str] #: The domain's object type, as ``input``.
278 LABEL_KIND: ClassVar[str] #: The kind in a label, as ``Input`` in ``JOBTMPL/Parameters/Input/name``.
279 COLLECTION: ClassVar[str] #: The workflow's property holding the parameters, as ``Inputs``.
280 FACT_FIELDS: ClassVar[tuple[str, ...]] #: The fields taken from the workflow file.
282 has_content = True #: The hand-written fields and text.
283 required_arguments = 1 #: The parameter's name.
285 def run(self) -> list[Node]:
286 """
287 Create the parameter's entry and register it.
289 :returns: An index node and the entry's section, or nothing outside a ``gha:workflow``.
290 """
291 name = self.arguments[0].strip()
292 if (workflowName := self.env.ref_context.get("gha:workflow", None)) is None:
293 _logger.warning(
294 f"{self.directiveName} '{name}' is not preceded by a gha:workflow.",
295 location=self.get_location(), type=WARNING_TYPE, subtype="workflow"
296 )
297 return []
299 domain: GitHubActionsDomain = self.env.get_domain("gha")
300 parameter = None
301 if (workflow := domain.GetCurrentWorkflow()) is not None:
302 if (parameter := getattr(workflow, self.COLLECTION).get(name, None)) is None:
303 _logger.warning(
304 f"Workflow '{workflowName}' has no {self.OBJECT_TYPE} '{name}' ({workflow.Path.name}).",
305 location=self.get_location(), type=WARNING_TYPE, subtype="drift"
306 )
308 content = self.parse_content_to_nodes()
309 handwritten = []
310 if len(content) > 0 and isinstance(content[0], nodes.field_list):
311 handwritten = list(content[0].children)
312 content = content[1:]
314 fields = []
315 for field in handwritten:
316 fieldName = field[0].astext().strip()
317 if fieldName in self.FACT_FIELDS:
318 _logger.warning(
319 f"{self.directiveName} '{workflowName}.{name}': field '{fieldName}' is taken from the workflow file; "
320 "remove it.",
321 location=field, type=WARNING_TYPE, subtype="drift"
322 )
323 if parameter is not None: 323 ↛ 326line 323 didn't jump to line 326 because the condition on line 323 was always true
324 continue
326 fields.append(field)
328 source, line = self.get_source_info()
329 return self.CreateEntry(self.env, self.state.document, workflowName, name, parameter, fields, content, source, line)
331 @classmethod
332 def CreateEntry(
333 cls,
334 env: BuildEnvironment,
335 document: nodes.document,
336 workflowName: str,
337 name: str,
338 parameter: Nullable[Parameter],
339 fields: list[nodes.field],
340 content: list[Node],
341 source: str,
342 line: Nullable[int]
343 ) -> list[Node]:
344 """
345 Create a parameter's entry and register it in the domain.
347 :param env: The build environment.
348 :param document: The document the entry is placed in.
349 :param workflowName: The workflow's name.
350 :param name: The parameter's name.
351 :param parameter: The parameter, as read from the workflow file, or ``None`` if the workflow file wasn't read or
352 hasn't this parameter.
353 :param fields: The hand-written fields, without those repeating a fact of the workflow file.
354 :param content: The nodes following the field list.
355 :param source: The source file the entry is reported at.
356 :param line: The line the entry is reported at, or ``None``.
357 :returns: An index node and the entry's section.
358 """
359 fullName = f"{workflowName}.{name}"
360 nodeID = make_id(env, document, f"gha-{cls.OBJECT_TYPE}", fullName)
361 section = nodes.section("", nodes.title(name, name), ids=[nodeID])
362 if (titleID := nodes.make_id(name)) not in document.ids: 362 ↛ 366line 362 didn't jump to line 366 because the condition on line 362 was always true
363 section["ids"].append(titleID)
365 # The label's anchor comes last: docutils links a name to a node's last anchor, so a link keeps its target.
366 if (prefix := env.config.gha_label_prefix) is not None:
367 label = f"{prefix}/{workflowName}/{cls.LABEL_KIND}/{name}"
368 section["ids"].append(nodes.make_id(label))
369 section["names"].append(fully_normalize_name(label))
371 section.source = source
372 section.line = line
373 document.note_explicit_target(section)
375 if parameter is not None:
376 fields = [*cls._FactFields(parameter), *fields]
378 fieldNames = [field[0].astext().strip() for field in fields]
379 if "Description" not in fieldNames and parameter is not None and parameter.Description:
380 leading = [index + 1 for index, fieldName in enumerate(fieldNames) if fieldName in LEADING_FIELDS]
381 position = max(leading, default=0)
382 fields.insert(position, cls._TextField("Description", parameter.Description.strip()))
384 if len(fields) > 0:
385 section += nodes.field_list("", *fields)
386 section.extend(content)
388 domain: GitHubActionsDomain = env.get_domain("gha")
389 domain.NoteObject(cls.OBJECT_TYPE, fullName, nodeID, section)
390 return [
391 addnodes.index(entries=[("single", f"{name} ({cls.OBJECT_TYPE} of {workflowName})", nodeID, "", None)]),
392 section
393 ]
395 @staticmethod
396 def _Field(name: str, *body: Node) -> nodes.field:
397 """
398 Create a field of a field list.
400 :param name: Name of the field, as ``Type``.
401 :param body: The nodes of the field's body.
402 :returns: The field.
403 """
404 return nodes.field("", nodes.field_name(name, name), nodes.field_body("", *body))
406 @classmethod
407 def _TextField(cls, name: str, text: str) -> nodes.field:
408 """
409 Create a field of a field list, whose body is a line of text.
411 :param name: Name of the field, as ``Required``.
412 :param text: Text of the field's body.
413 :returns: The field.
414 """
415 return cls._Field(name, nodes.paragraph(text, text))
417 @classmethod
418 def _DefaultField(cls, value: ValueT) -> nodes.field:
419 """
420 Create the field *Default Value*.
422 A multi-line string becomes a literal block, keeping its line breaks; no default becomes :data:`NO_DEFAULT`.
424 :param value: The default value.
425 :returns: The field.
426 """
427 if value is None:
428 return cls._TextField("Default Value", NO_DEFAULT)
429 elif isinstance(value, str) and "\n" in value:
430 return cls._Field("Default Value", nodes.literal_block(value, value, language="text"))
432 text = formatValue(value)
433 return cls._Field("Default Value", nodes.paragraph("", "", nodes.literal(text, text)))
435 @classmethod
436 def _FactFields(cls, parameter: Parameter) -> list[nodes.field]:
437 """
438 Create the fields taken from the workflow file.
440 :param parameter: The parameter, as read from the workflow file.
441 :returns: The fields named in :attr:`FACT_FIELDS`, in that order.
442 """
443 return []
446@export
447class InputDirective(ParameterDirective):
448 """
449 The directive ``gha:input``: an input of the current workflow.
451 The fields *Type*, *Required* and *Default Value* are taken from the workflow file.
452 """
454 directiveName: str = "gha:input" #: Name the directive is invoked by.
456 OBJECT_TYPE = "input" #: The domain's object type.
457 LABEL_KIND = "Input" #: The kind in a label.
458 COLLECTION = "Inputs" #: The workflow's property holding the parameters.
459 FACT_FIELDS = ("Type", "Required", "Default Value") #: The fields taken from the workflow file.
461 @classmethod
462 def _FactFields(cls, parameter: Input) -> list[nodes.field]:
463 """
464 Create the fields *Type*, *Required* and *Default Value*.
466 :param parameter: The input, as read from the workflow file.
467 :returns: The fields.
468 """
469 return [
470 cls._TextField("Type", parameter.Type.value),
471 cls._TextField("Required", "yes" if parameter.Required else "no"),
472 cls._DefaultField(parameter.Default)
473 ]
476@export
477class SecretDirective(ParameterDirective):
478 """
479 The directive ``gha:secret``: a secret of the current workflow.
481 The fields *Type* - a secret is a string -, *Required* and *Default Value* - a secret has none - are taken from the
482 workflow file.
483 """
485 directiveName: str = "gha:secret" #: Name the directive is invoked by.
487 OBJECT_TYPE = "secret" #: The domain's object type.
488 LABEL_KIND = "Secret" #: The kind in a label.
489 COLLECTION = "Secrets" #: The workflow's property holding the parameters.
490 FACT_FIELDS = ("Type", "Required", "Default Value") #: The fields taken from the workflow file.
492 @classmethod
493 def _FactFields(cls, parameter: Secret) -> list[nodes.field]:
494 """
495 Create the fields *Type*, *Required* and *Default Value*.
497 :param parameter: The secret, as read from the workflow file.
498 :returns: The fields.
499 """
500 return [
501 cls._TextField("Type", "string"),
502 cls._TextField("Required", "yes" if parameter.Required else "no"),
503 cls._DefaultField(None)
504 ]
507@export
508class OutputDirective(ParameterDirective):
509 """
510 The directive ``gha:output``: an output of the current workflow.
512 A workflow file states no type and no default for an output, so every field is hand-written; only the
513 *Description* falls back to the file's ``description``.
514 """
516 directiveName: str = "gha:output" #: Name the directive is invoked by.
518 OBJECT_TYPE = "output" #: The domain's object type.
519 LABEL_KIND = "Output" #: The kind in a label.
520 COLLECTION = "Outputs" #: The workflow's property holding the parameters.
521 FACT_FIELDS = () #: The fields taken from the workflow file.
524@export
525class GitHubActionsXRefRole(XRefRole):
526 """
527 The roles ``:gha:workflow:``, ``:gha:input:``, ``:gha:output:`` and ``:gha:secret:``.
529 A parameter is named as ``<Workflow>.<name>``; inside a ``gha:workflow``, the name alone refers to the current
530 workflow's parameter. A leading ``~`` shows only the part behind the last dot.
531 """
533 def process_link(
534 self,
535 env: BuildEnvironment,
536 refnode: Element,
537 has_explicit_title: bool,
538 title: str,
539 target: str
540 ) -> tuple[str, str]:
541 """
542 Remember the current workflow on the reference, and shorten the title for a leading ``~``.
544 :param env: The build environment.
545 :param refnode: The reference node.
546 :param has_explicit_title: ``True``, if the role was written with a title, as ``text <target>``.
547 :param title: The title.
548 :param target: The target.
549 :returns: The title and the target.
550 """
551 refnode["gha:workflow"] = env.ref_context.get("gha:workflow", None)
552 if not has_explicit_title and target.startswith("~"):
553 target = target[1:]
554 title = target.rpartition(".")[2]
556 return title, target
559@export
560class GitHubActionsDomain(Domain):
561 """
562 The Sphinx domain ``gha``, documenting GitHub Actions workflows.
564 Its objects are keyed by type and name - ``("workflow", "Parameters")``, ``("input", "Parameters.package_name")``
565 - and located by document and anchor. Directives of other modules reach the domain by
566 ``self.env.get_domain("gha")``, and use :meth:`GetCurrentWorkflow`, :attr:`Resolver` and :meth:`ResolveWorkflow`.
567 """
569 name = "gha" #: Name of the domain, the prefix of its directives and roles.
570 label = "GitHub Actions" #: Name of the domain, as displayed.
571 data_version = 1 #: Version of the data layout; a change discards pickled environments.
573 object_types: ClassVar[dict[str, ObjType]] = { # type: ignore[misc]
574 "workflow": ObjType("workflow", "workflow"),
575 "input": ObjType("input", "input"),
576 "output": ObjType("output", "output"),
577 "secret": ObjType("secret", "secret"),
578 } #: The object types, each referenced by the role of the same name.
580 directives: ClassVar[dict[str, type]] = { # type: ignore[misc]
581 "workflow": WorkflowDirective,
582 "input": InputDirective,
583 "output": OutputDirective,
584 "secret": SecretDirective,
585 } #: The directives, by name.
587 roles: ClassVar[dict[str, XRefRole]] = { # type: ignore[misc]
588 "workflow": GitHubActionsXRefRole(innernodeclass=nodes.literal, warn_dangling=True),
589 "input": GitHubActionsXRefRole(innernodeclass=nodes.literal, warn_dangling=True),
590 "output": GitHubActionsXRefRole(innernodeclass=nodes.literal, warn_dangling=True),
591 "secret": GitHubActionsXRefRole(innernodeclass=nodes.literal, warn_dangling=True),
592 } #: The roles, by name.
594 initial_data: ClassVar[dict[str, Any]] = { # type: ignore[misc]
595 "objects": {},
596 } #: The domain's data: ``objects`` maps (type, name) to (document, anchor).
598 #: The configuration values the domain adds to :file:`conf.py`, as ``name: (default, rebuild, types)``. Each is
599 #: registered with the domain's name as prefix, e.g. ``gha_repository``.
600 #:
601 #: ``server``
602 #: The URL of the GitHub server links point to, ``https://github.com`` by default, or a GitHub Enterprise
603 #: Server's, as ``https://github.example.com``.
604 #: ``repository``
605 #: The documented repository, as ``owner/repo``. A ``uses`` naming it is read from ``gha_workflow_directory``,
606 #: whatever its ref.
607 #: ``workflow_directory``
608 #: The directory holding the workflow files, relative to the Sphinx source directory, as
609 #: ``../.github/workflows``. A ``gha:workflow`` without ``:file:`` reads ``<name>.yml`` from it.
610 #: ``ref``
611 #: The ref - a branch or tag - of the documented repository the documentation describes, as ``r8``, or ``None``.
612 #: A directive may warn about a ``uses`` of the documented repository at another ref.
613 #: ``label_prefix``
614 #: The root of the ``:ref:`` labels the directives register besides their domain targets, as
615 #: ``JOBTMPL/Parameters/Input/package_name``, or ``None`` for none.
616 configValues: ClassVar[dict[str, tuple[Any, str, Any]]] = {
617 "server": ("https://github.com", "env", str),
618 "repository": (None, "env", (str, type(None))),
619 "workflow_directory": (None, "env", (str, type(None))),
620 "ref": (None, "env", (str, type(None))),
621 "label_prefix": ("JOBTMPL", "env", (str, type(None))),
622 }
624 _resolver: Nullable[WorkflowResolver] #: Resolver reading the workflow files, created when first needed.
626 def __init__(self, env: BuildEnvironment) -> None:
627 """
628 Initializes the domain.
630 :param env: The build environment.
631 """
632 super().__init__(env)
634 self._resolver = None
636 @readonly
637 def Objects(self) -> dict[tuple[str, str], tuple[str, str]]:
638 """
639 Read-only property to return the documented objects.
641 :returns: A mapping of (object type, name) to (document, anchor).
642 """
643 return self.data["objects"]
645 @readonly
646 def WorkflowDirectory(self) -> Nullable[Path]:
647 """
648 Read-only property to return the directory holding the workflow files.
650 :returns: ``gha_workflow_directory`` resolved against the Sphinx source directory, or ``None`` if it isn't set.
651 """
652 if (directory := self.env.config.gha_workflow_directory) is None:
653 return None
655 return (Path(self.env.srcdir) / directory).resolve()
657 @readonly
658 def Resolver(self) -> WorkflowResolver:
659 """
660 Read-only property to access the resolver reading the workflow files (:attr:`_resolver`), created when first
661 needed.
663 It maps ``gha_repository`` to :attr:`WorkflowDirectory`, and reads every file once per build and process - a
664 parallel build reads a file once in every process that needs it.
666 :returns: The resolver.
667 :raises MissingDependencyError: If the ``yaml`` extra isn't installed.
668 """
669 if self._resolver is None:
670 from pyTooling.CI.GitHub.WorkflowFile import WorkflowResolver
672 repositories = {}
673 repository = self.env.config.gha_repository
674 if repository is not None and (directory := self.WorkflowDirectory) is not None:
675 repositories[repository] = directory
677 self._resolver = WorkflowResolver(repositories)
679 return self._resolver
681 def GetCurrentWorkflow(self) -> Nullable[Workflow]:
682 """
683 Return the model of the current document's workflow, as set by the last ``gha:workflow``.
685 :returns: The workflow, or ``None`` if the document has no ``gha:workflow``, or its file couldn't be read -
686 which the ``gha:workflow`` directive reported already.
687 """
688 if (path := self.env.current_document.get("gha:workflow-file", None)) is None:
689 return None
691 return self.Resolver.Load(path)
693 def ResolveWorkflow(self, name: str) -> Nullable[tuple[str, str]]:
694 """
695 Return where a workflow is documented.
697 :param name: The workflow's name, its file's stem.
698 :returns: The document and the anchor of its ``gha:workflow``, or ``None`` if it isn't documented.
699 :raises ValueError: If parameter 'name' is ``None``.
700 :raises TypeError: If parameter 'name' is not of type :class:`str`.
701 """
702 if name is None:
703 raise ValueError("Parameter 'name' is None.")
704 elif not isinstance(name, str):
705 ex = TypeError("Parameter 'name' is not of type 'str'.")
706 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
707 raise ex
709 return self.data["objects"].get(("workflow", name), None)
711 def NoteObject(self, objectType: str, name: str, nodeID: str, location: Nullable[Node] = None) -> None:
712 """
713 Register a documented object.
715 :param objectType: The object type, as ``input``.
716 :param name: The object's name, as ``Parameters.package_name``.
717 :param nodeID: The anchor of the object's node.
718 :param location: Optional, the node a duplicate is reported at. Default: ``None``.
719 :raises ValueError: If parameter 'objectType' is ``None``.
720 :raises TypeError: If parameter 'objectType' is not of type :class:`str`.
721 :raises ValueError: If parameter 'name' is ``None``.
722 :raises TypeError: If parameter 'name' is not of type :class:`str`.
723 :raises ValueError: If parameter 'nodeID' is ``None``.
724 :raises TypeError: If parameter 'nodeID' is not of type :class:`str`.
725 :raises TypeError: If parameter 'location' is not of type :class:`~docutils.nodes.Node`.
726 """
727 for parameterName, value in (
728 ("objectType", objectType),
729 ("name", name),
730 ("nodeID", nodeID)
731 ):
732 if value is None:
733 raise ValueError(f"Parameter '{parameterName}' is None.")
734 elif not isinstance(value, str):
735 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
736 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
737 raise ex
739 if location is not None and not isinstance(location, Node):
740 ex = TypeError("Parameter 'location' is not of type 'Node'.")
741 ex.add_note(f"Got type '{getFullyQualifiedName(location)}'.")
742 raise ex
744 objects = self.data["objects"]
745 if (known := objects.get((objectType, name), None)) is not None:
746 _logger.warning(
747 f"Duplicate description of gha:{objectType} '{name}', other instance in '{known[0]}'.",
748 location=location, type=WARNING_TYPE, subtype="duplicate"
749 )
751 objects[(objectType, name)] = (self.env.docname, nodeID)
753 def clear_doc(self, docname: str) -> None:
754 """
755 Remove the objects of a document, before it is read again.
757 :param docname: The document.
758 """
759 objects = self.data["objects"]
760 for key in [key for key, (document, _) in objects.items() if document == docname]:
761 del objects[key]
763 def merge_domaindata(self, docnames: Iterable[str], otherdata: dict[str, Any]) -> None:
764 """
765 Merge the objects a parallel reader collected for its documents.
767 :param docnames: The documents the other reader read.
768 :param otherdata: The other reader's data.
769 """
770 documents = set(docnames)
771 objects = self.data["objects"]
772 for key, (document, nodeID) in otherdata["objects"].items():
773 if document in documents: 773 ↛ 772line 773 didn't jump to line 772 because the condition on line 773 was always true
774 objects[key] = (document, nodeID)
776 def resolve_xref(
777 self,
778 env: BuildEnvironment,
779 fromdocname: str,
780 builder: Builder,
781 typ: str,
782 target: str,
783 node: addnodes.pending_xref,
784 contnode: Element
785 ) -> Nullable[nodes.reference]:
786 """
787 Resolve a reference by one of the domain's roles.
789 A parameter's name without a workflow is looked up in the workflow current where the role was written.
791 :param env: The build environment.
792 :param fromdocname: The document containing the reference.
793 :param builder: The builder.
794 :param typ: The role's name, as ``input``.
795 :param target: The target, as ``Parameters.package_name`` or ``package_name``.
796 :param node: The pending reference.
797 :param contnode: The node rendering the reference's title.
798 :returns: The reference, or ``None`` if the target isn't documented.
799 """
800 if typ != "workflow" and "." not in target and (workflowName := node.get("gha:workflow", None)) is not None:
801 target = f"{workflowName}.{target}"
803 if (location := self.data["objects"].get((typ, target), None)) is None:
804 return None
806 return make_refnode(builder, fromdocname, location[0], location[1], contnode, target)
808 def resolve_any_xref(
809 self,
810 env: BuildEnvironment,
811 fromdocname: str,
812 builder: Builder,
813 target: str,
814 node: addnodes.pending_xref,
815 contnode: Element
816 ) -> list[tuple[str, nodes.reference]]:
817 """
818 Resolve a reference by the ``:any:`` role, trying every object type.
820 :param env: The build environment.
821 :param fromdocname: The document containing the reference.
822 :param builder: The builder.
823 :param target: The target.
824 :param node: The pending reference.
825 :param contnode: The node rendering the reference's title.
826 :returns: A pair of role and reference per object type the target resolves for.
827 """
828 results = []
829 for objectType in self.object_types:
830 if (reference := self.resolve_xref(env, fromdocname, builder, objectType, target, node, contnode)) is not None:
831 results.append((f"gha:{objectType}", reference))
833 return results
835 def get_objects(self) -> Iterable[tuple[str, str, str, str, str, int]]:
836 """
837 Iterate the documented objects, for the search index and the inventory.
839 :returns: An iterator of (name, display name, type, document, anchor, priority).
840 """
841 for (objectType, name), (document, nodeID) in self.data["objects"].items():
842 yield name, name, objectType, document, nodeID, 1