Coverage for pyTooling/Documentation/Sphinx/GitHubActions/__init__.py: 98%

280 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-03 23:02 +0000

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.CI.GitHub.WorkflowFile`, so a page states them without copying them. What the file can't say 

36stays hand-written, as the content of a directive: 

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.CI.GitHub.WorkflowFile` 

79 |rarr| The model of a workflow file the domain reads. 

80 :mod:`pyTooling.Documentation.Sphinx.GitHubActions.Graph` 

81 |rarr| The ``gha:pipeline-graph`` directive, drawing a workflow's jobs and their ``needs``. 

82 :mod:`pyTooling.Documentation.Sphinx.GitHubActions.Reference` 

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

84""" 

85from __future__ import annotations 

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.builders import Builder 

95from sphinx.domains import Domain, ObjType 

96from sphinx.environment import BuildEnvironment 

97from sphinx.roles import XRefRole 

98from sphinx.util.logging import getLogger 

99from sphinx.util.nodes import make_id, make_refnode 

100 

101from pyTooling.Common import getFullyQualifiedName 

102from pyTooling.Decorators import export, readonly 

103from pyTooling.Documentation.Sphinx.Directives import BaseDirective 

104 

105if TYPE_CHECKING: # pragma: no cover 

106 from pyTooling.CI.GitHub.WorkflowFile import Input, Output, Parameter, Secret, ValueT, Workflow 

107 from pyTooling.CI.GitHub.WorkflowFile import WorkflowResolver 

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 = ["gha.drift"]`` silences them. 

117WARNING_TYPE = "gha" 

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.CI.GitHub.WorkflowFile.Input.Default>`. 

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

135 """ 

136 if value is None: 

137 return NO_DEFAULT 

138 elif isinstance(value, bool): 

139 return "true" if value else "false" 

140 elif isinstance(value, str): 

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

142 return f"'{escaped}'" 

143 

144 return str(value) 

145 

146 

147@export 

148class WorkflowDirective(BaseDirective): 

149 """ 

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

151 

152 .. code-block:: rst 

153 

154 .. gha: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 ``gha_workflow_directory``. Every ``gha:input``, ``gha:output`` and ``gha:secret`` following it in the document 

159 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 = "gha: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("gha") 

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 'gha_workflow_directory'.", 

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

192 ) 

193 elif not path.exists(): 

194 _logger.warning( 

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

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

197 ) 

198 else: 

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

200 self._Load(domain, name, path) 

201 

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

203 

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

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

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

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

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

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

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 ``gha: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.CI.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["gha:workflow-file"] = path 

251 self.env.current_document.setdefault("gha: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 ``gha-<type>-<Workflow>.<name>``, the section carries the anchor of its label and - unless the 

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

275 """ 

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

290 """ 

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

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

293 _logger.warning( 

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

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

296 ) 

297 return [] 

298 

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

300 parameter = None 

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

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

303 _logger.warning( 

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

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

306 ) 

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"gha-{cls.OBJECT_TYPE}", fullName) 

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

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

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

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.gha_label_prefix) is not None: 

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

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

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

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

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

390 return [ 

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

392 section 

393 ] 

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 ``gha: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 = "gha: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 ``gha: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 = "gha: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 ``gha: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 = "gha: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 ``:gha:workflow:``, ``:gha:input:``, ``:gha:output:`` and ``:gha:secret:``. 

528 

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

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

531 """ 

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["gha:workflow"] = env.ref_context.get("gha:workflow", None) 

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

553 target = target[1:] 

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

555 

556 return title, target 

557 

558 

559@export 

560class GitHubActionsDomain(Domain): 

561 """ 

562 The Sphinx domain ``gha``, 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("gha")``, and use :meth:`GetCurrentWorkflow`, :attr:`Resolver` and :meth:`ResolveWorkflow`. 

567 """ 

568 

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

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

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

572 

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

574 "workflow": ObjType("workflow", "workflow"), 

575 "input": ObjType("input", "input"), 

576 "output": ObjType("output", "output"), 

577 "secret": ObjType("secret", "secret"), 

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

579 

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

581 "workflow": WorkflowDirective, 

582 "input": InputDirective, 

583 "output": OutputDirective, 

584 "secret": SecretDirective, 

585 } #: The directives, by name. 

586 

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

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

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

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

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

592 } #: The roles, by name. 

593 

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

595 "objects": {}, 

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

597 

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

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

600 #: 

601 #: ``server`` 

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

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

604 #: ``repository`` 

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

606 #: whatever its ref. 

607 #: ``workflow_directory`` 

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

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

610 #: ``ref`` 

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

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

613 #: ``label_prefix`` 

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

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

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

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

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

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

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

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

622 } 

623 

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

625 

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

627 """ 

628 Initializes the domain. 

629 

630 :param env: The build environment. 

631 """ 

632 super().__init__(env) 

633 

634 self._resolver = None 

635 

636 @readonly 

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

638 """ 

639 Read-only property to return the documented objects. 

640 

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

642 """ 

643 return self.data["objects"] 

644 

645 @readonly 

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

647 """ 

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

649 

650 :returns: ``gha_workflow_directory`` resolved against the Sphinx source directory, or ``None`` if it isn't set. 

651 """ 

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

653 return None 

654 

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

656 

657 @readonly 

658 def Resolver(self) -> WorkflowResolver: 

659 """ 

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

661 needed. 

662 

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

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

665 

666 :returns: The resolver. 

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

668 """ 

669 if self._resolver is None: 

670 from pyTooling.CI.GitHub.WorkflowFile import WorkflowResolver 

671 

672 repositories = {} 

673 repository = self.env.config.gha_repository 

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

675 repositories[repository] = directory 

676 

677 self._resolver = WorkflowResolver(repositories) 

678 

679 return self._resolver 

680 

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

682 """ 

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

684 

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

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

687 """ 

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

689 return None 

690 

691 return self.Resolver.Load(path) 

692 

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

694 """ 

695 Return where a workflow is documented. 

696 

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

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

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

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

701 """ 

702 if name is None: 

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

704 elif not isinstance(name, str): 

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

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

707 raise ex 

708 

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

710 

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

712 """ 

713 Register a documented object. 

714 

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

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

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

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

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

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

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

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

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

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

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

726 """ 

727 for parameterName, value in ( 

728 ("objectType", objectType), 

729 ("name", name), 

730 ("nodeID", nodeID) 

731 ): 

732 if value is None: 

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

734 elif not isinstance(value, str): 

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

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

737 raise ex 

738 

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

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

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

742 raise ex 

743 

744 objects = self.data["objects"] 

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

746 _logger.warning( 

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

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

749 ) 

750 

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

752 

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

754 """ 

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

756 

757 :param docname: The document. 

758 """ 

759 objects = self.data["objects"] 

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

761 del objects[key] 

762 

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

764 """ 

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

766 

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

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

769 """ 

770 documents = set(docnames) 

771 objects = self.data["objects"] 

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

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

774 objects[key] = (document, nodeID) 

775 

776 def resolve_xref( 

777 self, 

778 env: BuildEnvironment, 

779 fromdocname: str, 

780 builder: Builder, 

781 typ: str, 

782 target: str, 

783 node: addnodes.pending_xref, 

784 contnode: Element 

785 ) -> Nullable[nodes.reference]: 

786 """ 

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

788 

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

790 

791 :param env: The build environment. 

792 :param fromdocname: The document containing the reference. 

793 :param builder: The builder. 

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

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

796 :param node: The pending reference. 

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

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

799 """ 

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

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

802 

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

804 return None 

805 

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

807 

808 def resolve_any_xref( 

809 self, 

810 env: BuildEnvironment, 

811 fromdocname: str, 

812 builder: Builder, 

813 target: str, 

814 node: addnodes.pending_xref, 

815 contnode: Element 

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

817 """ 

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

819 

820 :param env: The build environment. 

821 :param fromdocname: The document containing the reference. 

822 :param builder: The builder. 

823 :param target: The target. 

824 :param node: The pending reference. 

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

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

827 """ 

828 results = [] 

829 for objectType in self.object_types: 

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

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

832 

833 return results 

834 

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

836 """ 

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

838 

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

840 """ 

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

842 yield name, name, objectType, document, nodeID, 1