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

300 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-04 09:05 +0000

1# ==================================================================================================================== # 

2# _____ _ _ ____ _ _ _ _ _ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___(_) |_| | | |_ _| |__ # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` || | _| | __| |_| | | | | '_ \ # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| || |_| | | |_| _ | |_| | |_) | # 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____|_|\__|_| |_|\__,_|_.__/ # 

7# |_| |___/ |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany # 

15# # 

16# Licensed under the Apache License, Version 2.0 (the "License"); # 

17# you may not use this file except in compliance with the License. # 

18# You may obtain a copy of the License at # 

19# # 

20# http://www.apache.org/licenses/LICENSE-2.0 # 

21# # 

22# Unless required by applicable law or agreed to in writing, software # 

23# distributed under the License is distributed on an "AS IS" BASIS, # 

24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

25# See the License for the specific language governing permissions and # 

26# limitations under the License. # 

27# # 

28# SPDX-License-Identifier: Apache-2.0 # 

29# ==================================================================================================================== # 

30# 

31""" 

32A Sphinx domain ``gha`` documenting GitHub Actions workflows from their YAML files. 

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 .. gha:workflow:: Parameters 

41 

42 Parameters 

43 ########## 

44 

45 .. gha: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 * ``gha:workflow`` 

55 * ``gha:input`` 

56 * ``gha:output`` 

57 * ``gha:secret`` 

58 

59* Roles 

60 

61 * ``:gha:workflow:`` 

62 * ``:gha:input:`` 

63 * ``:gha:output:`` 

64 * ``:gha:secret:`` 

65 

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

67 

68 * ``gha_server`` 

69 * ``gha_repository`` 

70 * ``gha_workflow_directory`` 

71 * ``gha_ref`` 

72 * ``gha_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 ``gha:pipeline-graph`` directive, drawing a workflow's jobs and their ``needs``. 

82 :mod:`pyTooling.GitHub.Sphinx.Reference` 

83 |rarr| The directives summarizing a workflow: its parameters, its interface, its YAML. 

84""" 

85from __future__ import annotations 

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 

108 from pyTooling.GitHub.WorkflowFile import WorkflowResolver 

109 

110 

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

112 

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

114NO_DEFAULT = "— — — —" 

115 

116#: The type of the warnings this domain emits; the drift warnings have the subtype ``drift``, so 

117#: ``suppress_warnings = ["gha.drift"]`` silences them. 

118WARNING_TYPE = "gha" 

119 

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

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

122 

123_logger = getLogger(__name__) 

124 

125 

126@export 

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

128 """ 

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

130 

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

132 it is. 

133 

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

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

136 """ 

137 if value is None: 

138 return NO_DEFAULT 

139 elif isinstance(value, bool): 

140 return "true" if value else "false" 

141 elif isinstance(value, str): 

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

143 return f"'{escaped}'" 

144 

145 return str(value) 

146 

147 

148@export 

149class WorkflowDirective(BaseDirective): 

150 """ 

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

152 

153 .. code-block:: rst 

154 

155 .. gha:workflow:: Parameters 

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

157 

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

159 ``gha_workflow_directory``. Every ``gha:input``, ``gha:output`` and ``gha:secret`` following it in the document 

160 belongs to this workflow. 

161 

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

163 """ 

164 

165 directiveName: str = "gha:workflow" #: Name the directive is invoked by. 

166 

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

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

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

170 

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

172 """ 

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

174 

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

176 """ 

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

178 domain: GitHubActionsDomain = self.env.get_domain("gha") 

179 

180 if "file" in self.options: 

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

182 elif domain.WorkflowDirectory is not None: 

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

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

185 path = alternative 

186 else: 

187 path = None 

188 

189 if path is None: 

190 _logger.warning( 

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

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

193 ) 

194 elif not path.exists(): 

195 _logger.warning( 

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

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

198 ) 

199 else: 

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

201 self._Load(domain, name, path) 

202 

203 self.env.ref_context["gha:workflow"] = name 

204 

205 nodeID = make_id(self.env, self.state.document, "gha-workflow", name) 

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

207 if (prefix := self.config.gha_label_prefix) is not None: 

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

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

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

211 

212 self.set_source_info(target) 

213 self.state.document.note_explicit_target(target) 

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

215 

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

217 

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

219 """ 

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

221 

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

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

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

225 

226 :param domain: The domain. 

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

228 :param path: Path to the workflow file. 

229 """ 

230 from pyTooling.GitHub.WorkflowFile import WorkflowError 

231 

232 try: 

233 workflow = domain.Resolver.Load(path) 

234 except WorkflowError as ex: 

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

236 location = self.get_location() 

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

238 location = str(ex.Path) 

239 else: 

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

241 

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

243 return 

244 

245 if workflow.Name != name: 

246 _logger.warning( 

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

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

249 ) 

250 

251 self.env.current_document["gha:workflow-file"] = path 

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

253 

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

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

256 _logger.warning( 

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

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

259 ) 

260 

261 

262@export 

263class ParameterDirective(BaseDirective): 

264 """ 

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

266 

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

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

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

270 Content after the field list follows it. 

271 

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

273 

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

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

276 """ 

277 

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

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

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

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

282 

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

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

285 

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

287 """ 

288 Create the parameter's entry and register it. 

289 

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

291 """ 

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

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

294 _logger.warning( 

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

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

297 ) 

298 return [] 

299 

300 domain: GitHubActionsDomain = self.env.get_domain("gha") 

301 parameter = None 

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

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

304 _logger.warning( 

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

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

307 ) 

308 

309 content = self.parse_content_to_nodes() 

310 handwritten = [] 

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

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

313 content = content[1:] 

314 

315 fields = [] 

316 for field in handwritten: 

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

318 if fieldName in self.FACT_FIELDS: 

319 _logger.warning( 

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

321 "remove it.", 

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

323 ) 

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

325 continue 

326 

327 fields.append(field) 

328 

329 source, line = self.get_source_info() 

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

331 

332 @classmethod 

333 def CreateEntry( 

334 cls, 

335 env: BuildEnvironment, 

336 document: nodes.document, 

337 workflowName: str, 

338 name: str, 

339 parameter: Nullable[Parameter], 

340 fields: list[nodes.field], 

341 content: list[Node], 

342 source: str, 

343 line: Nullable[int] 

344 ) -> list[Node]: 

345 """ 

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

347 

348 :param env: The build environment. 

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

350 :param workflowName: The workflow's name. 

351 :param name: The parameter's name. 

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

353 hasn't this parameter. 

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

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

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

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

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

359 """ 

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

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

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

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

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

365 

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

367 if (prefix := env.config.gha_label_prefix) is not None: 

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

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

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

371 

372 section.source = source 

373 section.line = line 

374 document.note_explicit_target(section) 

375 

376 if parameter is not None: 

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

378 

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

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

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

382 position = max(leading, default=0) 

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

384 

385 if len(fields) > 0: 

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

387 section.extend(content) 

388 

389 domain: GitHubActionsDomain = env.get_domain("gha") 

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

391 return [ 

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

393 section 

394 ] 

395 

396 @staticmethod 

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

398 """ 

399 Create a field of a field list. 

400 

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

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

403 :returns: The field. 

404 """ 

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

406 

407 @classmethod 

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

409 """ 

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

411 

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

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

414 :returns: The field. 

415 """ 

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

417 

418 @classmethod 

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

420 """ 

421 Create the field *Default Value*. 

422 

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

424 

425 :param value: The default value. 

426 :returns: The field. 

427 """ 

428 if value is None: 

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

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

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

432 

433 text = formatValue(value) 

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

435 

436 @classmethod 

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

438 """ 

439 Create the fields taken from the workflow file. 

440 

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

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

443 """ 

444 return [] 

445 

446 

447@export 

448class InputDirective(ParameterDirective): 

449 """ 

450 The directive ``gha:input``: an input of the current workflow. 

451 

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

453 """ 

454 

455 directiveName: str = "gha:input" #: Name the directive is invoked by. 

456 

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

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

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

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

461 

462 @classmethod 

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

464 """ 

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

466 

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

468 :returns: The fields. 

469 """ 

470 return [ 

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

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

473 cls._DefaultField(parameter.Default) 

474 ] 

475 

476 

477@export 

478class SecretDirective(ParameterDirective): 

479 """ 

480 The directive ``gha:secret``: a secret of the current workflow. 

481 

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

483 workflow file. 

484 """ 

485 

486 directiveName: str = "gha:secret" #: Name the directive is invoked by. 

487 

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

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

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

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

492 

493 @classmethod 

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

495 """ 

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

497 

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

499 :returns: The fields. 

500 """ 

501 return [ 

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

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

504 cls._DefaultField(None) 

505 ] 

506 

507 

508@export 

509class OutputDirective(ParameterDirective): 

510 """ 

511 The directive ``gha:output``: an output of the current workflow. 

512 

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

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

515 """ 

516 

517 directiveName: str = "gha:output" #: Name the directive is invoked by. 

518 

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

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

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

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

523 

524 

525@export 

526class GitHubActionsXRefRole(XRefRole): 

527 """ 

528 The roles ``:gha:workflow:``, ``:gha:input:``, ``:gha:output:`` and ``:gha:secret:``. 

529 

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

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

532 """ 

533 

534 def process_link( 

535 self, 

536 env: BuildEnvironment, 

537 refnode: Element, 

538 has_explicit_title: bool, 

539 title: str, 

540 target: str 

541 ) -> tuple[str, str]: 

542 """ 

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

544 

545 :param env: The build environment. 

546 :param refnode: The reference node. 

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

548 :param title: The title. 

549 :param target: The target. 

550 :returns: The title and the target. 

551 """ 

552 refnode["gha:workflow"] = env.ref_context.get("gha:workflow", None) 

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

554 target = target[1:] 

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

556 

557 return title, target 

558 

559 

560@export 

561class GitHubActionsDomain(Domain): 

562 """ 

563 The Sphinx domain ``gha``, documenting GitHub Actions workflows. 

564 

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

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

567 ``self.env.get_domain("gha")``, and use :meth:`GetCurrentWorkflow`, :attr:`Resolver` and :meth:`ResolveWorkflow`. 

568 """ 

569 

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

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

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

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

601 #: 

602 #: ``server`` 

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

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

605 #: ``repository`` 

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

607 #: whatever its ref. 

608 #: ``workflow_directory`` 

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

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

611 #: ``ref`` 

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

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

614 #: ``label_prefix`` 

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

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

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

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

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

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

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

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

623 } 

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: ``gha_workflow_directory`` resolved against the Sphinx source directory, or ``None`` if it isn't set. 

652 """ 

653 if (directory := self.env.config.gha_workflow_directory) is None: 

654 return None 

655 

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

657 

658 @readonly 

659 def Resolver(self) -> WorkflowResolver: 

660 """ 

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

662 needed. 

663 

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

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

666 

667 :returns: The resolver. 

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

669 """ 

670 if self._resolver is None: 

671 from pyTooling.GitHub.WorkflowFile import WorkflowResolver 

672 

673 repositories = {} 

674 repository = self.env.config.gha_repository 

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

676 repositories[repository] = directory 

677 

678 self._resolver = WorkflowResolver(repositories) 

679 

680 return self._resolver 

681 

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

683 """ 

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

685 

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

687 which the ``gha:workflow`` directive reported already. 

688 """ 

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

690 return None 

691 

692 return self.Resolver.Load(path) 

693 

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

695 """ 

696 Return where a workflow is documented. 

697 

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

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

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

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

702 """ 

703 if name is None: 

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

705 elif not isinstance(name, str): 

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

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

708 raise ex 

709 

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

711 

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

713 """ 

714 Register a documented object. 

715 

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

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

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

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

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

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

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

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

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

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

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

727 """ 

728 for parameterName, value in ( 

729 ("objectType", objectType), 

730 ("name", name), 

731 ("nodeID", nodeID) 

732 ): 

733 if value is None: 

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

735 elif not isinstance(value, str): 

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

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

738 raise ex 

739 

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

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

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

743 raise ex 

744 

745 objects = self.data["objects"] 

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

747 _logger.warning( 

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

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

750 ) 

751 

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

753 

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

755 """ 

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

757 

758 :param docname: The document. 

759 """ 

760 objects = self.data["objects"] 

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

762 del objects[key] 

763 

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

765 """ 

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

767 

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

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

770 """ 

771 documents = set(docnames) 

772 objects = self.data["objects"] 

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

774 if document in documents: 774 ↛ 773line 774 didn't jump to line 773 because the condition on line 774 was always true

775 objects[key] = (document, nodeID) 

776 

777 def resolve_xref( 

778 self, 

779 env: BuildEnvironment, 

780 fromdocname: str, 

781 builder: Builder, 

782 typ: str, 

783 target: str, 

784 node: addnodes.pending_xref, 

785 contnode: Element 

786 ) -> Nullable[nodes.reference]: 

787 """ 

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

789 

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

791 

792 :param env: The build environment. 

793 :param fromdocname: The document containing the reference. 

794 :param builder: The builder. 

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

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

797 :param node: The pending reference. 

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

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

800 """ 

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

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

803 

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

805 return None 

806 

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

808 

809 def resolve_any_xref( 

810 self, 

811 env: BuildEnvironment, 

812 fromdocname: str, 

813 builder: Builder, 

814 target: str, 

815 node: addnodes.pending_xref, 

816 contnode: Element 

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

818 """ 

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

820 

821 :param env: The build environment. 

822 :param fromdocname: The document containing the reference. 

823 :param builder: The builder. 

824 :param target: The target. 

825 :param node: The pending reference. 

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

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

828 """ 

829 results = [] 

830 for objectType in self.object_types: 

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

832 results.append((f"gha:{objectType}", reference)) 

833 

834 return results 

835 

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

837 """ 

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

839 

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

841 """ 

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

843 yield name, name, objectType, document, nodeID, 1 

844 

845 

846@export 

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

848 """ 

849 Register the domain ``gha``, its directives and its configuration values with Sphinx. 

850 

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

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

853 

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

855 :returns: The extension's metadata. 

856 """ 

857 from pyTooling.GitHub import __version__ 

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

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

860 from pyTooling.GitHub.Sphinx.Reference import checkUndocumentedInputs 

861 

862 sphinx.setup_extension("pyTooling.Sphinx") 

863 

864 sphinx.add_domain(GitHubActionsDomain) 

865 sphinx.add_directive_to_domain("gha", "pipeline-graph", PipelineGraph) 

866 sphinx.add_directive_to_domain("gha", "parameter-table", ParameterTable) 

867 sphinx.add_directive_to_domain("gha", "interface", Interface) 

868 sphinx.add_directive_to_domain("gha", "dependencies", Dependencies) 

869 sphinx.add_directive_to_domain("gha", "yaml", YAMLExcerpt) 

870 sphinx.add_directive_to_domain("gha", "autoinputs", AutoInputs) 

871 

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

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

874 

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

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

877 

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