Coverage for pyTooling/GitHub/Sphinx/__init__.py: 98%

302 statements  

« prev     ^ index     » next       coverage.py v7.17.3, created at 2026-11-14 02:08 +0000

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 

112NO_DEFAULT = "— — — —" 

113"""The text of the field *Default Value* when an input has no default.""" 

114 

115WARNING_TYPE = "ghactions" 

116""" 

117The type of the warnings this domain emits; the drift warnings have the subtype ``drift``, so 

118``suppress_warnings = ["ghactions.drift"]`` silences them. 

119""" 

120 

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

122"""The fields a *Description* taken from the workflow file follows.""" 

123 

124_logger = getLogger(__name__) 

125 

126 

127@export 

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

129 """ 

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

131 

132 A string is quoted as YAML quotes it - ``'3.14'`` -, a boolean is ``true`` or ``false``, and a number is written as 

133 it is. 

134 

135 :param value: The value, as :attr:`Input.Default <pyTooling.GitHub.WorkflowFile.Input.Default>`. 

136 :returns: The value as text, or :data:`NO_DEFAULT` for ``None``. 

137 """ 

138 if value is None: 

139 return NO_DEFAULT 

140 elif isinstance(value, bool): 

141 return "true" if value else "false" 

142 elif isinstance(value, str): 

143 escaped = value.replace("'", "''") 

144 return f"'{escaped}'" 

145 

146 return str(value) 

147 

148 

149@export 

150class WorkflowDirective(BaseDirective): 

151 """ 

152 The directive ``ghactions:workflow``: a workflow's target, its index entry, and the current workflow of the document. 

153 

154 .. code-block:: rst 

155 

156 .. ghactions:workflow:: Parameters 

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

158 

159 The argument is the workflow's name, its file's stem. Without ``:file:``, the file is ``<name>.yml`` in 

160 ``ghactions_workflow_directory``. Every ``ghactions:input``, ``ghactions:output`` and ``ghactions:secret`` following 

161 it in the document belongs to this workflow. 

162 

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

164 """ 

165 

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

167 

168 has_content = False #: The directive has no content. 

169 required_arguments = 1 #: The workflow's name. 

170 option_spec = {"file": directives.unchanged_required} #: Path to the workflow file, relative to the document. 

171 

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

173 """ 

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

175 

176 :returns: An index node and the workflow's target. 

177 """ 

178 name = self.arguments[0].strip() 

179 domain: GitHubActionsDomain = self.env.get_domain("ghactions") 

180 

181 if "file" in self.options: 

182 path = Path(self.env.relfn2path(self.options["file"], self.env.docname)[1]) 

183 elif domain.WorkflowDirectory is not None: 

184 path = domain.WorkflowDirectory / f"{name}.yml" 

185 if not path.exists() and (alternative := path.with_suffix(".yaml")).exists(): 185 ↛ 186line 185 didn't jump to line 186 because the condition on line 185 was never true

186 path = alternative 

187 else: 

188 path = None 

189 

190 if path is None: 

191 _logger.warning( 

192 f"{self.directiveName} '{name}' has no file: give option ':file:' or set 'ghactions_workflow_directory'.", 

193 location=self.get_location(), type=WARNING_TYPE, subtype="workflow" 

194 ) 

195 elif not path.exists(): 

196 _logger.warning( 

197 f"{self.directiveName} '{name}': file '{path}' doesn't exist.", 

198 location=self.get_location(), type=WARNING_TYPE, subtype="workflow" 

199 ) 

200 else: 

201 self.env.note_dependency(str(path)) 

202 self._Load(domain, name, path) 

203 

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

205 

206 nodeID = make_id(self.env, self.state.document, "ghactions-workflow", name) 

207 target = nodes.target("", "", ids=[nodeID]) 

208 if (prefix := self.config.ghactions_label_prefix) is not None: 

209 label = f"{prefix}/{name}" 

210 target["ids"].append(nodes.make_id(label)) 

211 target["names"].append(fully_normalize_name(label)) 

212 

213 self.set_source_info(target) 

214 self.state.document.note_explicit_target(target) 

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

216 

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

218 

219 def _Load(self, domain: GitHubActionsDomain, name: str, path: Path) -> None: 

220 """ 

221 Read the workflow file, make it the current one, and warn about inputs that are required and have a default. 

222 

223 A file that isn't a well-formed workflow is reported as a warning at the place in the file, and the document 

224 has no current workflow model then. A workflow read is added to the document's list ``ghactions:workflows`` of 

225 (name, path, location of the directive), which is checked when the document was read. 

226 

227 :param domain: The domain. 

228 :param name: The workflow's name, as given as argument. 

229 :param path: Path to the workflow file. 

230 """ 

231 from pyTooling.GitHub.WorkflowFile import WorkflowError 

232 

233 try: 

234 workflow = domain.Resolver.Load(path) 

235 except WorkflowError as ex: 

236 if ex.Path is None: 236 ↛ 237line 236 didn't jump to line 237 because the condition on line 236 was never true

237 location = self.get_location() 

238 elif ex.Line is None: 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true

239 location = str(ex.Path) 

240 else: 

241 location = f"{ex.Path}:{ex.Line}" 

242 

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

244 return 

245 

246 if workflow.Name != name: 

247 _logger.warning( 

248 f"{self.directiveName} '{name}' reads file '{path.name}', which names workflow '{workflow.Name}'.", 

249 location=self.get_location(), type=WARNING_TYPE, subtype="workflow" 

250 ) 

251 

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

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

254 

255 for parameter in workflow.Inputs.values(): 

256 if parameter.Required and parameter.Default is not None: 

257 _logger.warning( 

258 f"Input '{parameter.Name}' of workflow '{workflow.Name}' is required and has a default, which is never used.", 

259 location=f"{workflow.Path}:{parameter.Line}", type=WARNING_TYPE, subtype="drift" 

260 ) 

261 

262 

263@export 

264class ParameterDirective(BaseDirective): 

265 """ 

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

267 

268 The entry is a section titled by the parameter's name, holding a field list: first the fields read from the 

269 workflow file (:attr:`FACT_FIELDS`), then the fields of the directive's content, in the order written. Without a 

270 hand-written *Description*, the workflow file's ``description`` is used, placed behind the :data:`LEADING_FIELDS`. 

271 Content after the field list follows it. 

272 

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

274 

275 Besides its anchor ``ghactions-<type>-<Workflow>.<name>``, the section carries the anchor of its label and - unless 

276 the document uses it already - the anchor docutils derives from a title, as a hand-written section has. 

277 """ 

278 

279 OBJECT_TYPE: ClassVar[str] #: The domain's object type, as ``input``. 

280 LABEL_KIND: ClassVar[str] #: The kind in a label, as ``Input`` in ``JOBTMPL/Parameters/Input/name``. 

281 COLLECTION: ClassVar[str] #: The workflow's property holding the parameters, as ``Inputs``. 

282 FACT_FIELDS: ClassVar[tuple[str, ...]] #: The fields taken from the workflow file. 

283 

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

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

286 

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

288 """ 

289 Create the parameter's entry and register it. 

290 

291 :returns: An index node and the entry's section, or nothing outside a ``ghactions:workflow``. 

292 """ 

293 name = self.arguments[0].strip() 

294 if (workflowName := self.env.ref_context.get("ghactions:workflow", None)) is None: 

295 _logger.warning( 

296 f"{self.directiveName} '{name}' is not preceded by a ghactions:workflow.", 

297 location=self.get_location(), type=WARNING_TYPE, subtype="workflow" 

298 ) 

299 return [] 

300 

301 domain: GitHubActionsDomain = self.env.get_domain("ghactions") 

302 parameter = None 

303 if (workflow := domain.GetCurrentWorkflow()) is not None: 

304 if (parameter := getattr(workflow, self.COLLECTION).get(name, None)) is None: 

305 _logger.warning( 

306 f"Workflow '{workflowName}' has no {self.OBJECT_TYPE} '{name}' ({workflow.Path.name}).", 

307 location=self.get_location(), type=WARNING_TYPE, subtype="drift" 

308 ) 

309 

310 content = self.parse_content_to_nodes() 

311 handwritten = [] 

312 if len(content) > 0 and isinstance(content[0], nodes.field_list): 

313 handwritten = list(content[0].children) 

314 content = content[1:] 

315 

316 fields = [] 

317 for field in handwritten: 

318 fieldName = field[0].astext().strip() 

319 if fieldName in self.FACT_FIELDS: 

320 _logger.warning( 

321 f"{self.directiveName} '{workflowName}.{name}': field '{fieldName}' is taken from the workflow file; " 

322 "remove it.", 

323 location=field, type=WARNING_TYPE, subtype="drift" 

324 ) 

325 if parameter is not None: 325 ↛ 328line 325 didn't jump to line 328 because the condition on line 325 was always true

326 continue 

327 

328 fields.append(field) 

329 

330 source, line = self.get_source_info() 

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

332 

333 @classmethod 

334 def CreateEntry( 

335 cls, 

336 env: BuildEnvironment, 

337 document: nodes.document, 

338 workflowName: str, 

339 name: str, 

340 parameter: Nullable[Parameter], 

341 fields: list[nodes.field], 

342 content: list[Node], 

343 source: str, 

344 line: Nullable[int] 

345 ) -> list[Node]: 

346 """ 

347 Create a parameter's entry and register it in the domain. 

348 

349 :param env: The build environment. 

350 :param document: The document the entry is placed in. 

351 :param workflowName: The workflow's name. 

352 :param name: The parameter's name. 

353 :param parameter: The parameter, as read from the workflow file, or ``None`` if the workflow file wasn't read or 

354 hasn't this parameter. 

355 :param fields: The hand-written fields, without those repeating a fact of the workflow file. 

356 :param content: The nodes following the field list. 

357 :param source: The source file the entry is reported at. 

358 :param line: The line the entry is reported at, or ``None``. 

359 :returns: An index node and the entry's section. 

360 """ 

361 fullName = f"{workflowName}.{name}" 

362 nodeID = make_id(env, document, f"ghactions-{cls.OBJECT_TYPE}", fullName) 

363 section = nodes.section("", nodes.title(name, name), ids=[nodeID]) 

364 if (titleID := nodes.make_id(name)) not in document.ids: 364 ↛ 368line 364 didn't jump to line 368 because the condition on line 364 was always true

365 section["ids"].append(titleID) 

366 

367 # The label's anchor comes last: docutils links a name to a node's last anchor, so a link keeps its target. 

368 if (prefix := env.config.ghactions_label_prefix) is not None: 

369 label = f"{prefix}/{workflowName}/{cls.LABEL_KIND}/{name}" 

370 section["ids"].append(nodes.make_id(label)) 

371 section["names"].append(fully_normalize_name(label)) 

372 

373 section.source = source 

374 section.line = line 

375 document.note_explicit_target(section) 

376 

377 if parameter is not None: 

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

379 

380 fieldNames = [field[0].astext().strip() for field in fields] 

381 if "Description" not in fieldNames and parameter is not None and parameter.Description: 

382 leading = [index + 1 for index, fieldName in enumerate(fieldNames) if fieldName in LEADING_FIELDS] 

383 position = max(leading, default=0) 

384 fields.insert(position, cls._TextField("Description", parameter.Description.strip())) 

385 

386 if len(fields) > 0: 

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

388 section.extend(content) 

389 

390 domain: GitHubActionsDomain = env.get_domain("ghactions") 

391 domain.NoteObject(cls.OBJECT_TYPE, fullName, nodeID, section) 

392 return [ 

393 addnodes.index(entries=[("single", f"{name} ({cls.OBJECT_TYPE} of {workflowName})", nodeID, "", None)]), 

394 section 

395 ] 

396 

397 @staticmethod 

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

399 """ 

400 Create a field of a field list. 

401 

402 :param name: Name of the field, as ``Type``. 

403 :param body: The nodes of the field's body. 

404 :returns: The field. 

405 """ 

406 return nodes.field("", nodes.field_name(name, name), nodes.field_body("", *body)) 

407 

408 @classmethod 

409 def _TextField(cls, name: str, text: str) -> nodes.field: 

410 """ 

411 Create a field of a field list, whose body is a line of text. 

412 

413 :param name: Name of the field, as ``Required``. 

414 :param text: Text of the field's body. 

415 :returns: The field. 

416 """ 

417 return cls._Field(name, nodes.paragraph(text, text)) 

418 

419 @classmethod 

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

421 """ 

422 Create the field *Default Value*. 

423 

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

425 

426 :param value: The default value. 

427 :returns: The field. 

428 """ 

429 if value is None: 

430 return cls._TextField("Default Value", NO_DEFAULT) 

431 elif isinstance(value, str) and "\n" in value: 

432 return cls._Field("Default Value", nodes.literal_block(value, value, language="text")) 

433 

434 text = formatValue(value) 

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

436 

437 @classmethod 

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

439 """ 

440 Create the fields taken from the workflow file. 

441 

442 :param parameter: The parameter, as read from the workflow file. 

443 :returns: The fields named in :attr:`FACT_FIELDS`, in that order. 

444 """ 

445 return [] 

446 

447 

448@export 

449class InputDirective(ParameterDirective): 

450 """ 

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

452 

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

454 """ 

455 

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

457 

458 OBJECT_TYPE = "input" #: The domain's object type. 

459 LABEL_KIND = "Input" #: The kind in a label. 

460 COLLECTION = "Inputs" #: The workflow's property holding the parameters. 

461 FACT_FIELDS = ("Type", "Required", "Default Value") #: The fields taken from the workflow file. 

462 

463 @classmethod 

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

465 """ 

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

467 

468 :param parameter: The input, as read from the workflow file. 

469 :returns: The fields. 

470 """ 

471 return [ 

472 cls._TextField("Type", parameter.Type.value), 

473 cls._TextField("Required", "yes" if parameter.Required else "no"), 

474 cls._DefaultField(parameter.Default) 

475 ] 

476 

477 

478@export 

479class SecretDirective(ParameterDirective): 

480 """ 

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

482 

483 The fields *Type* - a secret is a string -, *Required* and *Default Value* - a secret has none - are taken from the 

484 workflow file. 

485 """ 

486 

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

488 

489 OBJECT_TYPE = "secret" #: The domain's object type. 

490 LABEL_KIND = "Secret" #: The kind in a label. 

491 COLLECTION = "Secrets" #: The workflow's property holding the parameters. 

492 FACT_FIELDS = ("Type", "Required", "Default Value") #: The fields taken from the workflow file. 

493 

494 @classmethod 

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

496 """ 

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

498 

499 :param parameter: The secret, as read from the workflow file. 

500 :returns: The fields. 

501 """ 

502 return [ 

503 cls._TextField("Type", "string"), 

504 cls._TextField("Required", "yes" if parameter.Required else "no"), 

505 cls._DefaultField(None) 

506 ] 

507 

508 

509@export 

510class OutputDirective(ParameterDirective): 

511 """ 

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

513 

514 A workflow file states no type and no default for an output, so every field is hand-written; only the 

515 *Description* falls back to the file's ``description``. 

516 """ 

517 

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

519 

520 OBJECT_TYPE = "output" #: The domain's object type. 

521 LABEL_KIND = "Output" #: The kind in a label. 

522 COLLECTION = "Outputs" #: The workflow's property holding the parameters. 

523 FACT_FIELDS = () #: The fields taken from the workflow file. 

524 

525 

526@export 

527class GitHubActionsXRefRole(XRefRole): 

528 """ 

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

530 

531 A parameter is named as ``<Workflow>.<name>``; inside a ``ghactions:workflow``, the name alone refers to the current 

532 workflow's parameter. A leading ``~`` shows only the part behind the last dot. 

533 """ 

534 

535 def process_link( 

536 self, 

537 env: BuildEnvironment, 

538 refnode: Element, 

539 has_explicit_title: bool, 

540 title: str, 

541 target: str 

542 ) -> tuple[str, str]: 

543 """ 

544 Remember the current workflow on the reference, and shorten the title for a leading ``~``. 

545 

546 :param env: The build environment. 

547 :param refnode: The reference node. 

548 :param has_explicit_title: ``True``, if the role was written with a title, as ``text <target>``. 

549 :param title: The title. 

550 :param target: The target. 

551 :returns: The title and the target. 

552 """ 

553 refnode["ghactions:workflow"] = env.ref_context.get("ghactions:workflow", None) 

554 if not has_explicit_title and target.startswith("~"): 

555 target = target[1:] 

556 title = target.rpartition(".")[2] 

557 

558 return title, target 

559 

560 

561@export 

562class GitHubActionsDomain(Domain): 

563 """ 

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

565 

566 Its objects are keyed by type and name - ``("workflow", "Parameters")``, ``("input", "Parameters.package_name")`` 

567 - and located by document and anchor. Directives of other modules reach the domain by 

568 ``self.env.get_domain("ghactions")``, and use :meth:`GetCurrentWorkflow`, :attr:`Resolver` and 

569 :meth:`ResolveWorkflow`. 

570 """ 

571 

572 name = "ghactions" #: Name of the domain, the prefix of its directives and roles. 

573 label = "GitHub Actions" #: Name of the domain, as displayed. 

574 data_version = 1 #: Version of the data layout; a change discards pickled environments. 

575 

576 object_types: ClassVar[dict[str, ObjType]] = { # type: ignore[misc] 

577 "workflow": ObjType("workflow", "workflow"), 

578 "input": ObjType("input", "input"), 

579 "output": ObjType("output", "output"), 

580 "secret": ObjType("secret", "secret"), 

581 } #: The object types, each referenced by the role of the same name. 

582 

583 directives: ClassVar[dict[str, type]] = { # type: ignore[misc] 

584 "workflow": WorkflowDirective, 

585 "input": InputDirective, 

586 "output": OutputDirective, 

587 "secret": SecretDirective, 

588 } #: The directives, by name. 

589 

590 roles: ClassVar[dict[str, XRefRole]] = { # type: ignore[misc] 

591 "workflow": GitHubActionsXRefRole(innernodeclass=nodes.literal, warn_dangling=True), 

592 "input": GitHubActionsXRefRole(innernodeclass=nodes.literal, warn_dangling=True), 

593 "output": GitHubActionsXRefRole(innernodeclass=nodes.literal, warn_dangling=True), 

594 "secret": GitHubActionsXRefRole(innernodeclass=nodes.literal, warn_dangling=True), 

595 } #: The roles, by name. 

596 

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

598 "objects": {}, 

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

600 

601 #: The configuration values the domain adds to :file:`conf.py`, as ``name: (default, rebuild, types)``. Each is 

602 #: registered with the domain's name as prefix, e.g. ``ghactions_repository``. 

603 #: 

604 #: ``server`` 

605 #: The URL of the GitHub server links point to, ``https://github.com`` by default, or a GitHub Enterprise 

606 #: Server's, as ``https://github.example.com``. 

607 #: ``repository`` 

608 #: The documented repository, as ``owner/repo``. A ``uses`` naming it is read from 

609 #: ``ghactions_workflow_directory``, whatever its ref. 

610 #: ``workflow_directory`` 

611 #: The directory holding the workflow files, relative to the Sphinx source directory, as 

612 #: ``../.github/workflows``. A ``ghactions:workflow`` without ``:file:`` reads ``<name>.yml`` from it. 

613 #: ``ref`` 

614 #: The ref - a branch or tag - of the documented repository the documentation describes, as ``r8``, or ``None``. 

615 #: A directive may warn about a ``uses`` of the documented repository at another ref. 

616 #: ``label_prefix`` 

617 #: The root of the ``:ref:`` labels the directives register besides their domain targets, as 

618 #: ``JOBTMPL/Parameters/Input/package_name``, or ``None`` for none. 

619 configValues: ClassVar[dict[str, tuple[Any, str, Any]]] = { 

620 "server": ("https://github.com", "env", str), 

621 "repository": (None, "env", (str, type(None))), 

622 "workflow_directory": (None, "env", (str, type(None))), 

623 "ref": (None, "env", (str, type(None))), 

624 "label_prefix": ("JOBTMPL", "env", (str, type(None))), 

625 } 

626 

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

628 

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

630 """ 

631 Initializes the domain. 

632 

633 :param env: The build environment. 

634 """ 

635 super().__init__(env) 

636 

637 self._resolver = None 

638 

639 @readonly 

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

641 """ 

642 Read-only property to return the documented objects. 

643 

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

645 """ 

646 return self.data["objects"] 

647 

648 @readonly 

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

650 """ 

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

652 

653 :returns: ``ghactions_workflow_directory`` resolved against the Sphinx source directory, or ``None`` if it 

654 isn't set. 

655 """ 

656 if (directory := self.env.config.ghactions_workflow_directory) is None: 

657 return None 

658 

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

660 

661 @readonly 

662 def Resolver(self) -> WorkflowResolver: 

663 """ 

664 Read-only property to access the resolver reading the workflow files (:attr:`_resolver`), created when first 

665 needed. 

666 

667 It maps ``ghactions_repository`` to :attr:`WorkflowDirectory`, and reads every file once per build and process - a 

668 parallel build reads a file once in every process that needs it. 

669 

670 :returns: The resolver. 

671 :raises MissingDependencyError: If the ``yaml`` extra isn't installed. 

672 """ 

673 if self._resolver is None: 

674 from pyTooling.GitHub.WorkflowFile import WorkflowResolver 

675 

676 repositories = {} 

677 repository = self.env.config.ghactions_repository 

678 if repository is not None and (directory := self.WorkflowDirectory) is not None: 

679 repositories[repository] = directory 

680 

681 self._resolver = WorkflowResolver(repositories) 

682 

683 return self._resolver 

684 

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

686 """ 

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

688 

689 :returns: The workflow, or ``None`` if the document has no ``ghactions:workflow``, or its file couldn't be read - 

690 which the ``ghactions:workflow`` directive reported already. 

691 """ 

692 if (path := self.env.current_document.get("ghactions:workflow-file", None)) is None: 

693 return None 

694 

695 return self.Resolver.Load(path) 

696 

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

698 """ 

699 Return where a workflow is documented. 

700 

701 :param name: The workflow's name, its file's stem. 

702 :returns: The document and the anchor of its ``ghactions:workflow``, or ``None`` if it isn't documented. 

703 :raises ValueError: If parameter 'name' is ``None``. 

704 :raises TypeError: If parameter 'name' is not of type :class:`str`. 

705 """ 

706 if name is None: 

707 raise ValueError("Parameter 'name' is None.") 

708 elif not isinstance(name, str): 

709 ex = TypeError("Parameter 'name' is not of type 'str'.") 

710 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.") 

711 raise ex 

712 

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

714 

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

716 """ 

717 Register a documented object. 

718 

719 :param objectType: The object type, as ``input``. 

720 :param name: The object's name, as ``Parameters.package_name``. 

721 :param nodeID: The anchor of the object's node. 

722 :param location: Optional, the node a duplicate is reported at. Default: ``None``. 

723 :raises ValueError: If parameter 'objectType' is ``None``. 

724 :raises TypeError: If parameter 'objectType' is not of type :class:`str`. 

725 :raises ValueError: If parameter 'name' is ``None``. 

726 :raises TypeError: If parameter 'name' is not of type :class:`str`. 

727 :raises ValueError: If parameter 'nodeID' is ``None``. 

728 :raises TypeError: If parameter 'nodeID' is not of type :class:`str`. 

729 :raises TypeError: If parameter 'location' is not of type :class:`~docutils.nodes.Node`. 

730 """ 

731 for parameterName, value in ( 

732 ("objectType", objectType), 

733 ("name", name), 

734 ("nodeID", nodeID) 

735 ): 

736 if value is None: 

737 raise ValueError(f"Parameter '{parameterName}' is None.") 

738 elif not isinstance(value, str): 

739 ex = TypeError(f"Parameter '{parameterName}' is not of type 'str'.") 

740 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.") 

741 raise ex 

742 

743 if location is not None and not isinstance(location, Node): 

744 ex = TypeError("Parameter 'location' is not of type 'Node'.") 

745 ex.add_note(f"Got type '{getFullyQualifiedName(location)}'.") 

746 raise ex 

747 

748 objects = self.data["objects"] 

749 if (known := objects.get((objectType, name), None)) is not None: 

750 _logger.warning( 

751 f"Duplicate description of ghactions:{objectType} '{name}', other instance in '{known[0]}'.", 

752 location=location, type=WARNING_TYPE, subtype="duplicate" 

753 ) 

754 

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

756 

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

758 """ 

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

760 

761 :param docname: The document. 

762 """ 

763 objects = self.data["objects"] 

764 for key in [key for key, (document, _) in objects.items() if document == docname]: 

765 del objects[key] 

766 

767 def merge_domaindata(self, docnames: Iterable[str], otherdata: dict[str, Any]) -> None: 

768 """ 

769 Merge the objects a parallel reader collected for its documents. 

770 

771 :param docnames: The documents the other reader read. 

772 :param otherdata: The other reader's data. 

773 """ 

774 documents = set(docnames) 

775 objects = self.data["objects"] 

776 for key, (document, nodeID) in otherdata["objects"].items(): 

777 if document in documents: 

778 objects[key] = (document, nodeID) 

779 

780 def resolve_xref( 

781 self, 

782 env: BuildEnvironment, 

783 fromdocname: str, 

784 builder: Builder, 

785 typ: str, 

786 target: str, 

787 node: addnodes.pending_xref, 

788 contnode: Element 

789 ) -> Nullable[nodes.reference]: 

790 """ 

791 Resolve a reference by one of the domain's roles. 

792 

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

794 

795 :param env: The build environment. 

796 :param fromdocname: The document containing the reference. 

797 :param builder: The builder. 

798 :param typ: The role's name, as ``input``. 

799 :param target: The target, as ``Parameters.package_name`` or ``package_name``. 

800 :param node: The pending reference. 

801 :param contnode: The node rendering the reference's title. 

802 :returns: The reference, or ``None`` if the target isn't documented. 

803 """ 

804 if typ != "workflow" and "." not in target and (workflowName := node.get("ghactions:workflow", None)) is not None: 

805 target = f"{workflowName}.{target}" 

806 

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

808 return None 

809 

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

811 

812 def resolve_any_xref( 

813 self, 

814 env: BuildEnvironment, 

815 fromdocname: str, 

816 builder: Builder, 

817 target: str, 

818 node: addnodes.pending_xref, 

819 contnode: Element 

820 ) -> list[tuple[str, nodes.reference]]: 

821 """ 

822 Resolve a reference by the ``:any:`` role, trying every object type. 

823 

824 :param env: The build environment. 

825 :param fromdocname: The document containing the reference. 

826 :param builder: The builder. 

827 :param target: The target. 

828 :param node: The pending reference. 

829 :param contnode: The node rendering the reference's title. 

830 :returns: A pair of role and reference per object type the target resolves for. 

831 """ 

832 results = [] 

833 for objectType in self.object_types: 

834 if (reference := self.resolve_xref(env, fromdocname, builder, objectType, target, node, contnode)) is not None: 

835 results.append((f"ghactions:{objectType}", reference)) 

836 

837 return results 

838 

839 def get_objects(self) -> Iterable[tuple[str, str, str, str, str, int]]: 

840 """ 

841 Iterate the documented objects, for the search index and the inventory. 

842 

843 :returns: An iterator of (name, display name, type, document, anchor, priority). 

844 """ 

845 for (objectType, name), (document, nodeID) in self.data["objects"].items(): 

846 yield name, name, objectType, document, nodeID, 1 

847 

848 

849@export 

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

851 """ 

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

853 

854 The directives derive from :class:`~pyTooling.Sphinx.BaseDirective` and draw graphs with 

855 :mod:`sphinx.ext.graphviz`, so the extension :mod:`pyTooling.Sphinx` is set up first. 

856 

857 :param sphinx: The Sphinx application to register with. 

858 :returns: The extension's metadata. 

859 """ 

860 from pyTooling.GitHub import __version__ 

861 from pyTooling.GitHub.Sphinx.Graph import PipelineGraph, resolveLinks 

862 from pyTooling.GitHub.Sphinx.Reference import AutoInputs, Dependencies, Interface, ParameterTable, YAMLExcerpt, \ 

863 checkUndocumentedInputs 

864 

865 sphinx.setup_extension("pyTooling.Sphinx") 

866 

867 sphinx.add_domain(GitHubActionsDomain) 

868 sphinx.add_directive_to_domain("ghactions", "pipeline-graph", PipelineGraph) 

869 sphinx.add_directive_to_domain("ghactions", "parameter-table", ParameterTable) 

870 sphinx.add_directive_to_domain("ghactions", "interface", Interface) 

871 sphinx.add_directive_to_domain("ghactions", "dependencies", Dependencies) 

872 sphinx.add_directive_to_domain("ghactions", "yaml", YAMLExcerpt) 

873 sphinx.add_directive_to_domain("ghactions", "autoinputs", AutoInputs) 

874 

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

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

877 

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

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

880 

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