Coverage for pyTooling/GitHub/Sphinx/__init__.py: 98%
299 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 00:39 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 00:39 +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"]
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 = ["ghactions.drift"]`` silences them.
117WARNING_TYPE = "ghactions"
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.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 ``ghactions:workflow``: a workflow's target, its index entry, and the current workflow of the document.
152 .. code-block:: rst
154 .. ghactions: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 ``ghactions_workflow_directory``. Every ``ghactions:input``, ``ghactions:output`` and ``ghactions:secret`` following
159 it in the document 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 = "ghactions: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("ghactions")
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 'ghactions_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["ghactions:workflow"] = name
204 nodeID = make_id(self.env, self.state.document, "ghactions-workflow", name)
205 target = nodes.target("", "", ids=[nodeID])
206 if (prefix := self.config.ghactions_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 ``ghactions: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.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["ghactions:workflow-file"] = path
251 self.env.current_document.setdefault("ghactions: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 ``ghactions-<type>-<Workflow>.<name>``, the section carries the anchor of its label and - unless
274 the 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 ``ghactions:workflow``.
290 """
291 name = self.arguments[0].strip()
292 if (workflowName := self.env.ref_context.get("ghactions:workflow", None)) is None:
293 _logger.warning(
294 f"{self.directiveName} '{name}' is not preceded by a ghactions:workflow.",
295 location=self.get_location(), type=WARNING_TYPE, subtype="workflow"
296 )
297 return []
299 domain: GitHubActionsDomain = self.env.get_domain("ghactions")
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"ghactions-{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.ghactions_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("ghactions")
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 ``ghactions: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 = "ghactions: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 ``ghactions: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 = "ghactions: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 ``ghactions: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 = "ghactions: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 ``:ghactions:workflow:``, ``:ghactions:input:``, ``:ghactions:output:`` and ``:ghactions:secret:``.
529 A parameter is named as ``<Workflow>.<name>``; inside a ``ghactions: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["ghactions:workflow"] = env.ref_context.get("ghactions: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 ``ghactions``, 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("ghactions")``, and use :meth:`GetCurrentWorkflow`, :attr:`Resolver` and
567 :meth:`ResolveWorkflow`.
568 """
570 name = "ghactions" #: 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. ``ghactions_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
607 #: ``ghactions_workflow_directory``, whatever its ref.
608 #: ``workflow_directory``
609 #: The directory holding the workflow files, relative to the Sphinx source directory, as
610 #: ``../.github/workflows``. A ``ghactions: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: ``ghactions_workflow_directory`` resolved against the Sphinx source directory, or ``None`` if it
652 isn't set.
653 """
654 if (directory := self.env.config.ghactions_workflow_directory) is None:
655 return None
657 return (Path(self.env.srcdir) / directory).resolve()
659 @readonly
660 def Resolver(self) -> WorkflowResolver:
661 """
662 Read-only property to access the resolver reading the workflow files (:attr:`_resolver`), created when first
663 needed.
665 It maps ``ghactions_repository`` to :attr:`WorkflowDirectory`, and reads every file once per build and process - a
666 parallel build reads a file once in every process that needs it.
668 :returns: The resolver.
669 :raises MissingDependencyError: If the ``yaml`` extra isn't installed.
670 """
671 if self._resolver is None:
672 from pyTooling.GitHub.WorkflowFile import WorkflowResolver
674 repositories = {}
675 repository = self.env.config.ghactions_repository
676 if repository is not None and (directory := self.WorkflowDirectory) is not None:
677 repositories[repository] = directory
679 self._resolver = WorkflowResolver(repositories)
681 return self._resolver
683 def GetCurrentWorkflow(self) -> Nullable[Workflow]:
684 """
685 Return the model of the current document's workflow, as set by the last ``ghactions:workflow``.
687 :returns: The workflow, or ``None`` if the document has no ``ghactions:workflow``, or its file couldn't be read -
688 which the ``ghactions:workflow`` directive reported already.
689 """
690 if (path := self.env.current_document.get("ghactions:workflow-file", None)) is None:
691 return None
693 return self.Resolver.Load(path)
695 def ResolveWorkflow(self, name: str) -> Nullable[tuple[str, str]]:
696 """
697 Return where a workflow is documented.
699 :param name: The workflow's name, its file's stem.
700 :returns: The document and the anchor of its ``ghactions:workflow``, or ``None`` if it isn't documented.
701 :raises ValueError: If parameter 'name' is ``None``.
702 :raises TypeError: If parameter 'name' is not of type :class:`str`.
703 """
704 if name is None:
705 raise ValueError("Parameter 'name' is None.")
706 elif not isinstance(name, str):
707 ex = TypeError("Parameter 'name' is not of type 'str'.")
708 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
709 raise ex
711 return self.data["objects"].get(("workflow", name), None)
713 def NoteObject(self, objectType: str, name: str, nodeID: str, location: Nullable[Node] = None) -> None:
714 """
715 Register a documented object.
717 :param objectType: The object type, as ``input``.
718 :param name: The object's name, as ``Parameters.package_name``.
719 :param nodeID: The anchor of the object's node.
720 :param location: Optional, the node a duplicate is reported at. Default: ``None``.
721 :raises ValueError: If parameter 'objectType' is ``None``.
722 :raises TypeError: If parameter 'objectType' is not of type :class:`str`.
723 :raises ValueError: If parameter 'name' is ``None``.
724 :raises TypeError: If parameter 'name' is not of type :class:`str`.
725 :raises ValueError: If parameter 'nodeID' is ``None``.
726 :raises TypeError: If parameter 'nodeID' is not of type :class:`str`.
727 :raises TypeError: If parameter 'location' is not of type :class:`~docutils.nodes.Node`.
728 """
729 for parameterName, value in (
730 ("objectType", objectType),
731 ("name", name),
732 ("nodeID", nodeID)
733 ):
734 if value is None:
735 raise ValueError(f"Parameter '{parameterName}' is None.")
736 elif not isinstance(value, str):
737 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.")
738 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
739 raise ex
741 if location is not None and not isinstance(location, Node):
742 ex = TypeError("Parameter 'location' is not of type 'Node'.")
743 ex.add_note(f"Got type '{getFullyQualifiedName(location)}'.")
744 raise ex
746 objects = self.data["objects"]
747 if (known := objects.get((objectType, name), None)) is not None:
748 _logger.warning(
749 f"Duplicate description of ghactions:{objectType} '{name}', other instance in '{known[0]}'.",
750 location=location, type=WARNING_TYPE, subtype="duplicate"
751 )
753 objects[(objectType, name)] = (self.env.docname, nodeID)
755 def clear_doc(self, docname: str) -> None:
756 """
757 Remove the objects of a document, before it is read again.
759 :param docname: The document.
760 """
761 objects = self.data["objects"]
762 for key in [key for key, (document, _) in objects.items() if document == docname]:
763 del objects[key]
765 def merge_domaindata(self, docnames: Iterable[str], otherdata: dict[str, Any]) -> None:
766 """
767 Merge the objects a parallel reader collected for its documents.
769 :param docnames: The documents the other reader read.
770 :param otherdata: The other reader's data.
771 """
772 documents = set(docnames)
773 objects = self.data["objects"]
774 for key, (document, nodeID) in otherdata["objects"].items():
775 if document in documents:
776 objects[key] = (document, nodeID)
778 def resolve_xref(
779 self,
780 env: BuildEnvironment,
781 fromdocname: str,
782 builder: Builder,
783 typ: str,
784 target: str,
785 node: addnodes.pending_xref,
786 contnode: Element
787 ) -> Nullable[nodes.reference]:
788 """
789 Resolve a reference by one of the domain's roles.
791 A parameter's name without a workflow is looked up in the workflow current where the role was written.
793 :param env: The build environment.
794 :param fromdocname: The document containing the reference.
795 :param builder: The builder.
796 :param typ: The role's name, as ``input``.
797 :param target: The target, as ``Parameters.package_name`` or ``package_name``.
798 :param node: The pending reference.
799 :param contnode: The node rendering the reference's title.
800 :returns: The reference, or ``None`` if the target isn't documented.
801 """
802 if typ != "workflow" and "." not in target and (workflowName := node.get("ghactions:workflow", None)) is not None:
803 target = f"{workflowName}.{target}"
805 if (location := self.data["objects"].get((typ, target), None)) is None:
806 return None
808 return make_refnode(builder, fromdocname, location[0], location[1], contnode, target)
810 def resolve_any_xref(
811 self,
812 env: BuildEnvironment,
813 fromdocname: str,
814 builder: Builder,
815 target: str,
816 node: addnodes.pending_xref,
817 contnode: Element
818 ) -> list[tuple[str, nodes.reference]]:
819 """
820 Resolve a reference by the ``:any:`` role, trying every object type.
822 :param env: The build environment.
823 :param fromdocname: The document containing the reference.
824 :param builder: The builder.
825 :param target: The target.
826 :param node: The pending reference.
827 :param contnode: The node rendering the reference's title.
828 :returns: A pair of role and reference per object type the target resolves for.
829 """
830 results = []
831 for objectType in self.object_types:
832 if (reference := self.resolve_xref(env, fromdocname, builder, objectType, target, node, contnode)) is not None:
833 results.append((f"ghactions:{objectType}", reference))
835 return results
837 def get_objects(self) -> Iterable[tuple[str, str, str, str, str, int]]:
838 """
839 Iterate the documented objects, for the search index and the inventory.
841 :returns: An iterator of (name, display name, type, document, anchor, priority).
842 """
843 for (objectType, name), (document, nodeID) in self.data["objects"].items():
844 yield name, name, objectType, document, nodeID, 1
847@export
848def setup(sphinx: Sphinx) -> dict[str, Any]:
849 """
850 Register the domain ``ghactions``, its directives and its configuration values with Sphinx.
852 The directives derive from :class:`~pyTooling.Sphinx.BaseDirective` and draw graphs with
853 :mod:`sphinx.ext.graphviz`, so the extension :mod:`pyTooling.Sphinx` is set up first.
855 :param sphinx: The Sphinx application to register with.
856 :returns: The extension's metadata.
857 """
858 from pyTooling.GitHub import __version__
859 from pyTooling.GitHub.Sphinx.Graph import PipelineGraph, resolveLinks
860 from pyTooling.GitHub.Sphinx.Reference import AutoInputs, Dependencies, Interface, ParameterTable, YAMLExcerpt, \
861 checkUndocumentedInputs
863 sphinx.setup_extension("pyTooling.Sphinx")
865 sphinx.add_domain(GitHubActionsDomain)
866 sphinx.add_directive_to_domain("ghactions", "pipeline-graph", PipelineGraph)
867 sphinx.add_directive_to_domain("ghactions", "parameter-table", ParameterTable)
868 sphinx.add_directive_to_domain("ghactions", "interface", Interface)
869 sphinx.add_directive_to_domain("ghactions", "dependencies", Dependencies)
870 sphinx.add_directive_to_domain("ghactions", "yaml", YAMLExcerpt)
871 sphinx.add_directive_to_domain("ghactions", "autoinputs", AutoInputs)
873 for configName, (default, rebuild, types) in GitHubActionsDomain.configValues.items():
874 sphinx.add_config_value(f"{GitHubActionsDomain.name}_{configName}", default, rebuild, types)
876 sphinx.connect("doctree-read", checkUndocumentedInputs)
877 sphinx.connect("doctree-resolved", resolveLinks)
879 return {"version": __version__, "parallel_read_safe": True, "parallel_write_safe": True}