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

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. 

33 

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: 

37 

38.. code-block:: rst 

39 

40 .. ghactions:workflow:: Parameters 

41 

42 Parameters 

43 ########## 

44 

45 .. ghactions:input:: package_name 

46 

47 :Possible Values: Any valid Python package name. 

48 :Example: ``myPackage`` 

49 

50.. rubric:: What it registers 

51 

52* Directives 

53 

54 * ``ghactions:workflow`` 

55 * ``ghactions:input`` 

56 * ``ghactions:output`` 

57 * ``ghactions:secret`` 

58 

59* Roles 

60 

61 * ``:ghactions:workflow:`` 

62 * ``:ghactions:input:`` 

63 * ``:ghactions:output:`` 

64 * ``:ghactions:secret:`` 

65 

66* Configuration values, as listed in :attr:`GitHubActionsDomain.configValues` 

67 

68 * ``ghactions_server`` 

69 * ``ghactions_repository`` 

70 * ``ghactions_workflow_directory`` 

71 * ``ghactions_ref`` 

72 * ``ghactions_label_prefix`` 

73 

74The workflow files are read with ``ruamel.yaml`` when a directive runs, not when this module is imported. 

75 

76.. seealso:: 

77 

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 

86 

87from pathlib import Path 

88from typing import TYPE_CHECKING, Any, ClassVar, Iterable, Optional as Nullable 

89 

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 

101 

102from pyTooling.Common import getFullyQualifiedName 

103from pyTooling.Decorators import export, readonly 

104from pyTooling.Sphinx import BaseDirective 

105 

106if TYPE_CHECKING: # pragma: no cover 

107 from pyTooling.GitHub.WorkflowFile import Input, Output, Parameter, Secret, ValueT, Workflow, WorkflowResolver 

108 

109 

110__all__ = ["NO_DEFAULT", "WARNING_TYPE", "LEADING_FIELDS"] 

111 

112#: The text of the field *Default Value* when an input has no default. 

113NO_DEFAULT = "— — — —" 

114 

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" 

118 

119#: The fields a *Description* taken from the workflow file follows. 

120LEADING_FIELDS = ("Type", "Required", "Default Value", "Possible Values") 

121 

122_logger = getLogger(__name__) 

123 

124 

125@export 

126def formatValue(value: ValueT) -> str: 

127 """ 

128 Format a value read from a workflow file the way the file writes it. 

129 

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. 

132 

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}'" 

143 

144 return str(value) 

145 

146 

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. 

151 

152 .. code-block:: rst 

153 

154 .. ghactions:workflow:: Parameters 

155 :file: ../../.github/workflows/Parameters.yml 

156 

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. 

160 

161 The directive writes no visible output. Placed above a page's title, its target is the title, as a label is. 

162 """ 

163 

164 directiveName: str = "ghactions:workflow" #: Name the directive is invoked by. 

165 

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. 

169 

170 def run(self) -> list[Node]: 

171 """ 

172 Register the workflow, read its file, and make it the current workflow. 

173 

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") 

178 

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 

187 

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) 

201 

202 self.env.ref_context["ghactions:workflow"] = name 

203 

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)) 

210 

211 self.set_source_info(target) 

212 self.state.document.note_explicit_target(target) 

213 domain.NoteObject("workflow", name, nodeID, target) 

214 

215 return [addnodes.index(entries=[("single", f"GitHub Actions workflow; {name}", nodeID, "", None)]), target] 

216 

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. 

220 

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. 

224 

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 

230 

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}" 

240 

241 _logger.warning(f"{self.directiveName} '{name}': {ex}", location=location, type=WARNING_TYPE, subtype="workflow") 

242 return 

243 

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 ) 

249 

250 self.env.current_document["ghactions:workflow-file"] = path 

251 self.env.current_document.setdefault("ghactions:workflows", []).append((name, path, self.get_location())) 

252 

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 ) 

259 

260 

261@export 

262class ParameterDirective(BaseDirective): 

263 """ 

264 Base-class of the directives documenting one parameter of the current workflow. 

265 

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. 

270 

271 A hand-written field repeating a fact of the file is a warning, and the file's value is shown. 

272 

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 """ 

276 

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. 

281 

282 has_content = True #: The hand-written fields and text. 

283 required_arguments = 1 #: The parameter's name. 

284 

285 def run(self) -> list[Node]: 

286 """ 

287 Create the parameter's entry and register it. 

288 

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 [] 

298 

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 ) 

307 

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:] 

313 

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 

325 

326 fields.append(field) 

327 

328 source, line = self.get_source_info() 

329 return self.CreateEntry(self.env, self.state.document, workflowName, name, parameter, fields, content, source, line) 

330 

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. 

346 

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) 

364 

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)) 

370 

371 section.source = source 

372 section.line = line 

373 document.note_explicit_target(section) 

374 

375 if parameter is not None: 

376 fields = [*cls._FactFields(parameter), *fields] 

377 

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())) 

383 

384 if len(fields) > 0: 

385 section += nodes.field_list("", *fields) 

386 section.extend(content) 

387 

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 ] 

394 

395 @staticmethod 

396 def _Field(name: str, *body: Node) -> nodes.field: 

397 """ 

398 Create a field of a field list. 

399 

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)) 

405 

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. 

410 

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)) 

416 

417 @classmethod 

418 def _DefaultField(cls, value: ValueT) -> nodes.field: 

419 """ 

420 Create the field *Default Value*. 

421 

422 A multi-line string becomes a literal block, keeping its line breaks; no default becomes :data:`NO_DEFAULT`. 

423 

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")) 

431 

432 text = formatValue(value) 

433 return cls._Field("Default Value", nodes.paragraph("", "", nodes.literal(text, text))) 

434 

435 @classmethod 

436 def _FactFields(cls, parameter: Parameter) -> list[nodes.field]: 

437 """ 

438 Create the fields taken from the workflow file. 

439 

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 [] 

444 

445 

446@export 

447class InputDirective(ParameterDirective): 

448 """ 

449 The directive ``ghactions:input``: an input of the current workflow. 

450 

451 The fields *Type*, *Required* and *Default Value* are taken from the workflow file. 

452 """ 

453 

454 directiveName: str = "ghactions:input" #: Name the directive is invoked by. 

455 

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. 

460 

461 @classmethod 

462 def _FactFields(cls, parameter: Input) -> list[nodes.field]: 

463 """ 

464 Create the fields *Type*, *Required* and *Default Value*. 

465 

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 ] 

474 

475 

476@export 

477class SecretDirective(ParameterDirective): 

478 """ 

479 The directive ``ghactions:secret``: a secret of the current workflow. 

480 

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 """ 

484 

485 directiveName: str = "ghactions:secret" #: Name the directive is invoked by. 

486 

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. 

491 

492 @classmethod 

493 def _FactFields(cls, parameter: Secret) -> list[nodes.field]: 

494 """ 

495 Create the fields *Type*, *Required* and *Default Value*. 

496 

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 ] 

505 

506 

507@export 

508class OutputDirective(ParameterDirective): 

509 """ 

510 The directive ``ghactions:output``: an output of the current workflow. 

511 

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 """ 

515 

516 directiveName: str = "ghactions:output" #: Name the directive is invoked by. 

517 

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. 

522 

523 

524@export 

525class GitHubActionsXRefRole(XRefRole): 

526 """ 

527 The roles ``:ghactions:workflow:``, ``:ghactions:input:``, ``:ghactions:output:`` and ``:ghactions:secret:``. 

528 

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 """ 

532 

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 ``~``. 

543 

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] 

555 

556 return title, target 

557 

558 

559@export 

560class GitHubActionsDomain(Domain): 

561 """ 

562 The Sphinx domain ``ghactions``, documenting GitHub Actions workflows. 

563 

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 """ 

569 

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. 

573 

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. 

580 

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. 

587 

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. 

594 

595 initial_data: ClassVar[dict[str, Any]] = { # type: ignore[misc] 

596 "objects": {}, 

597 } #: The domain's data: ``objects`` maps (type, name) to (document, anchor). 

598 

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 } 

624 

625 _resolver: Nullable[WorkflowResolver] #: Resolver reading the workflow files, created when first needed. 

626 

627 def __init__(self, env: BuildEnvironment) -> None: 

628 """ 

629 Initializes the domain. 

630 

631 :param env: The build environment. 

632 """ 

633 super().__init__(env) 

634 

635 self._resolver = None 

636 

637 @readonly 

638 def Objects(self) -> dict[tuple[str, str], tuple[str, str]]: 

639 """ 

640 Read-only property to return the documented objects. 

641 

642 :returns: A mapping of (object type, name) to (document, anchor). 

643 """ 

644 return self.data["objects"] 

645 

646 @readonly 

647 def WorkflowDirectory(self) -> Nullable[Path]: 

648 """ 

649 Read-only property to return the directory holding the workflow files. 

650 

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 

656 

657 return (Path(self.env.srcdir) / directory).resolve() 

658 

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. 

664 

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. 

667 

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 

673 

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 

678 

679 self._resolver = WorkflowResolver(repositories) 

680 

681 return self._resolver 

682 

683 def GetCurrentWorkflow(self) -> Nullable[Workflow]: 

684 """ 

685 Return the model of the current document's workflow, as set by the last ``ghactions:workflow``. 

686 

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 

692 

693 return self.Resolver.Load(path) 

694 

695 def ResolveWorkflow(self, name: str) -> Nullable[tuple[str, str]]: 

696 """ 

697 Return where a workflow is documented. 

698 

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 

710 

711 return self.data["objects"].get(("workflow", name), None) 

712 

713 def NoteObject(self, objectType: str, name: str, nodeID: str, location: Nullable[Node] = None) -> None: 

714 """ 

715 Register a documented object. 

716 

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 

740 

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 

745 

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 ) 

752 

753 objects[(objectType, name)] = (self.env.docname, nodeID) 

754 

755 def clear_doc(self, docname: str) -> None: 

756 """ 

757 Remove the objects of a document, before it is read again. 

758 

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] 

764 

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. 

768 

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) 

777 

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. 

790 

791 A parameter's name without a workflow is looked up in the workflow current where the role was written. 

792 

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}" 

804 

805 if (location := self.data["objects"].get((typ, target), None)) is None: 

806 return None 

807 

808 return make_refnode(builder, fromdocname, location[0], location[1], contnode, target) 

809 

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. 

821 

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)) 

834 

835 return results 

836 

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. 

840 

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 

845 

846 

847@export 

848def setup(sphinx: Sphinx) -> dict[str, Any]: 

849 """ 

850 Register the domain ``ghactions``, its directives and its configuration values with Sphinx. 

851 

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. 

854 

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 

862 

863 sphinx.setup_extension("pyTooling.Sphinx") 

864 

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) 

872 

873 for configName, (default, rebuild, types) in GitHubActionsDomain.configValues.items(): 

874 sphinx.add_config_value(f"{GitHubActionsDomain.name}_{configName}", default, rebuild, types) 

875 

876 sphinx.connect("doctree-read", checkUndocumentedInputs) 

877 sphinx.connect("doctree-resolved", resolveLinks) 

878 

879 return {"version": __version__, "parallel_read_safe": True, "parallel_write_safe": True}