Coverage for pyTooling/Sphinx/DependencyTable.py: 45%

477 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-04 01:28 +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 directive rendering a project's dependencies as a table, from the requirements rather than by hand. 

33 

34A dependency table states, per package, which version is required, what it is licensed under, and what it drags in. 

35Written by hand it is wrong within a release or two: pyTooling's own documentation table was missing four of the 

36packages :file:`doc/requirements.txt` requires, listed one that isn't required at all, and called Sphinx BSD-3-Clause 

37when it is BSD-2-Clause. 

38 

39**The entrypoints are declared in** :file:`conf.py` **and named by the documents**, the way 

40:mod:`sphinx_reports` declares its reports: 

41 

42.. code-block:: Python 

43 

44 # doc/conf.py 

45 pyTooling_Dependency_PackageOverrides = "Dependency.PackageOverrides.yaml" 

46 pyTooling_Dependency_Requirements = { 

47 "package": {"file": "../requirements.txt"}, 

48 "documentation": {"file": "requirements.txt"}, 

49 "yaml": {"package": "pyTooling[yaml]"} 

50 } 

51 

52.. code-block:: rest 

53 

54 .. dependency-table:: documentation 

55 :caption: Documentation dependencies 

56 

57A requirements file is read - with its ``-r`` includes followed - while :file:`conf.py` is being processed, so a 

58path that doesn't exist ends the build with one clear message instead of an error box in the middle of a page. 

59 

60**The data is fetched live** from the package index, once per build and shared between every table of that build: 

61:file:`requirements.txt`, :file:`tests/requirements.txt` and :file:`doc/requirements.txt` overlap heavily, and a 

62package they share is downloaded once. That still costs real time, so every table reports what it spent, measured 

63with a :class:`~pyTooling.Stopwatch.Stopwatch`, and the build ends with the total. 

64""" 

65from __future__ import annotations 

66 

67from enum import Enum, auto 

68from pathlib import Path 

69from typing import TYPE_CHECKING, Any, ClassVar, Iterable, Literal, Mapping, Optional as Nullable 

70from typing import TypeVar, cast 

71 

72from pyTooling.Common import getFullyQualifiedName 

73from pyTooling.Decorators import export, readonly 

74from pyTooling.Dependency import UnknownLicenseWarning 

75from pyTooling.Exceptions import ConfigurationError, MissingDependencyError 

76from pyTooling.MetaClasses import ExtendedType 

77from pyTooling.Stopwatch import Stopwatch 

78from pyTooling.Warning import WarningCollector 

79 

80from docutils import nodes 

81from docutils.parsers.rst import directives 

82from sphinx.application import Sphinx 

83from sphinx.util import logging 

84 

85if TYPE_CHECKING: # pragma: no cover 

86 # Only this directive needs a package index, so the model is imported when the configuration declares an 

87 # entrypoint rather than when the extension is loaded - otherwise every documentation build using any of these 

88 # roles would need the 'pypi' extra. 

89 from sphinx.config import Config 

90 from packaging.requirements import Requirement 

91 from packaging.specifiers import SpecifierSet 

92 from pyTooling.Dependency.Python import LicenseOverrides, Project, PythonPackageDependencyGraph 

93 from pyTooling.Dependency.Python import PythonPackageIndex, Release, RequirementsFile 

94 

95from pyTooling.Sphinx import BaseDirective, SphinxExtensionError, strip 

96from pyTooling.Sphinx import stripAndNormalize 

97 

98 

99#: URL of the package index the tables are built from, unless :file:`conf.py` names another. 

100DEFAULT_INDEX_URL = "https://pypi.org" 

101 

102#: URL of that index's JSON API. 

103DEFAULT_API_URL = "https://pypi.org/pypi/" 

104 

105#: Levels of sub-dependencies rendered when nothing says otherwise; ``0`` expands until the tree ends. 

106DEFAULT_DEPTH = 0 

107 

108#: Whether a version constraint is reduced to its lower bound when the document doesn't say. 

109DEFAULT_SIMPLIFIED_VERSIONS = True 

110 

111 

112@export 

113class VersionFormat(Enum): 

114 """How many parts of a version number a dependency table prints.""" 

115 

116 Major = auto() #: The major part alone, ``≥9``. 

117 MajorMinor = auto() #: Major and minor, ``≥9.1`` - the default. 

118 MajorMinorPatch = auto() #: Major, minor and patch, ``≥9.1.2``. 

119 All = auto() #: Every part the constraint states, ``≥9.1.2.dev3``. 

120 

121 def __str__(self) -> str: 

122 """ 

123 Return this format's name, as a document writes it. 

124 

125 :returns: The enum member's name. 

126 """ 

127 return self.name 

128 

129 

130@export 

131class DependencyFormat(Enum): 

132 """What a line of a dependency tree states about a package.""" 

133 

134 Package = auto() #: The name alone. 

135 PackageVersion = auto() #: Name and version constraint. 

136 PackageLicense = auto() #: Name and license. 

137 PackageVersionLicense = auto() #: Name, version constraint and license - the default. 

138 

139 @readonly 

140 def ShowsVersion(self) -> bool: 

141 """ 

142 Whether this format states a version constraint. 

143 

144 :returns: ``True`` if the version is printed. 

145 """ 

146 return self in (DependencyFormat.PackageVersion, DependencyFormat.PackageVersionLicense) 

147 

148 @readonly 

149 def ShowsLicense(self) -> bool: 

150 """ 

151 Whether this format states a license. 

152 

153 :returns: ``True`` if the license is printed. 

154 """ 

155 return self in (DependencyFormat.PackageLicense, DependencyFormat.PackageVersionLicense) 

156 

157 def __str__(self) -> str: 

158 """ 

159 Return this format's name, as a document writes it. 

160 

161 :returns: The enum member's name. 

162 """ 

163 return self.name 

164 

165 

166#: How many parts of a version number a table prints when the document doesn't say. 

167DEFAULT_VERSION_FORMAT = VersionFormat.MajorMinor 

168 

169#: What a line of a dependency tree states when the document doesn't say. 

170DEFAULT_DEPENDENCY_FORMAT = DependencyFormat.PackageVersionLicense 

171 

172#: Comparison operators as a reader writes them. Order matters - the two-character forms have to be tried first. 

173OPERATOR_SYMBOLS = (("<=", "≤"), (">=", "≥"), ("!=", "≠"), ("==", "=")) 

174 

175#: Operators a simplified constraint drops: an upper bound and an exclusion say what is *not* required. 

176_DROPPED_OPERATORS = ("<", "<=", "!=") 

177 

178#: The table's columns, as ``(title, relative width)``. 

179TABLE_COLUMNS = (("Package", 3), ("Version", 1), ("License", 2), ("Dependencies", 4)) 

180 

181#: The fields one entrypoint may state in :file:`conf.py`, exactly one of them. 

182#: 

183#: The singular forms take a string and the plural forms an iterable of strings; they are otherwise the same 

184#: statement, and a project writes whichever reads better where it stands. 

185ENTRYPOINT_FIELDS = ("file", "files", "package", "packages") 

186 

187#: Prefix every configuration value of this extension carries in :file:`conf.py`. 

188CONFIG_PREFIX = "pyTooling_Dependency" 

189 

190#: What Sphinx accepts as the rebuild condition of a configuration value - the one this extension uses. 

191_ConfigRebuild = Literal["env"] 

192 

193__all__ = [ 

194 "DEFAULT_INDEX_URL", "DEFAULT_API_URL", "DEFAULT_DEPTH", "DEFAULT_SIMPLIFIED_VERSIONS", "OPERATOR_SYMBOLS", 

195 "TABLE_COLUMNS", "ENTRYPOINT_FIELDS", "CONFIG_PREFIX", 

196 "DEFAULT_VERSION_FORMAT", "DEFAULT_DEPENDENCY_FORMAT" 

197] 

198 

199_logger = logging.getLogger(__name__) 

200 

201#: The two option enumerations, so one parser serves both. 

202_FormatType = TypeVar("_FormatType", VersionFormat, DependencyFormat) 

203 

204 

205def _OneLevelDown(depth: Nullable[int]) -> Nullable[int]: 

206 """ 

207 Return the depth one level deeper, leaving an unlimited depth unlimited. 

208 

209 :param depth: Levels still to expand, or ``None`` for unlimited. 

210 :returns: One level fewer, or ``None``. 

211 """ 

212 return None if depth is None else depth - 1 

213 

214 

215#: The collector of each running build, by the id of its Sphinx application. 

216#: 

217#: It can't live on the build environment, which Sphinx pickles between runs - an open HTTP session and the locks 

218#: guarding lazy loading are not picklable, and a cached view of a package index would be stale anyway. 

219_COLLECTORS: dict[int, "DependencyCollector"] = {} 

220 

221 

222@export 

223class Entrypoint(metaclass=ExtendedType, slots=True): 

224 """ 

225 One entry of ``pyTooling_Dependency_Requirements``: an identifier and the requirements it stands for. 

226 

227 A file entrypoint is read while :file:`conf.py` is being processed and carries its requirements from then on. 

228 A package entrypoint can only be resolved by asking the package index, so it carries the package's name and 

229 extra and is resolved the first time a table names it. 

230 """ 

231 

232 _identifier: str #: Name the documents refer to this entrypoint by. 

233 _files: tuple[Path, ...] #: Every requirements file read, references included. 

234 _packages: tuple[tuple[str, Nullable[str]], ...] #: The packages to read, as name and extra. 

235 _requirements: Nullable[dict[str, Requirement]] #: The resolved requirements, by canonical package name. 

236 

237 def __init__( 

238 self, 

239 identifier: str, 

240 files: tuple[Path, ...] = (), 

241 packages: tuple[tuple[str, Nullable[str]], ...] = (), 

242 requirements: Nullable[dict[str, Requirement]] = None 

243 ) -> None: 

244 """ 

245 Describe one entrypoint. 

246 

247 :param identifier: Name the documents refer to this entrypoint by. 

248 :param files: Optional, every requirements file read, for a file entrypoint. Default: ``()``. 

249 :param packages: Optional, the packages and their extras, for a package entrypoint. Default: ``()``. 

250 :param requirements: Optional, the requirements, if they are known already. Default: ``None``. 

251 """ 

252 self._identifier = identifier 

253 self._files = files 

254 self._packages = packages 

255 self._requirements = requirements 

256 

257 @readonly 

258 def Identifier(self) -> str: 

259 """ 

260 Name the documents refer to this entrypoint by. 

261 

262 :returns: The identifier. 

263 """ 

264 return self._identifier 

265 

266 @readonly 

267 def Files(self) -> tuple[Path, ...]: 

268 """ 

269 The requirements file and every file it includes. 

270 

271 :returns: The files this entrypoint was read from; empty for a package entrypoint. 

272 """ 

273 return self._files 

274 

275 @readonly 

276 def Packages(self) -> tuple[tuple[str, Nullable[str]], ...]: 

277 """ 

278 The packages this entrypoint reads the requirements of, as ``(name, extra)`` pairs. 

279 

280 :returns: The packages, or an empty tuple for a file entrypoint. 

281 """ 

282 return self._packages 

283 

284 @readonly 

285 def Requirements(self) -> Nullable[dict[str, Requirement]]: 

286 """ 

287 The requirements this entrypoint stands for. 

288 

289 :returns: Every required package by its canonical name, or ``None`` if they weren't resolved yet. 

290 """ 

291 return self._requirements 

292 

293 def CacheRequirements(self, requirements: dict[str, Requirement]) -> None: 

294 """ 

295 Remember the requirements the package index answered with. 

296 

297 A package entrypoint can only be resolved by asking the index; remembering the answer is what keeps a second 

298 table naming the same entrypoint from asking again. 

299 

300 :param requirements: Every required package, by its canonical name. 

301 """ 

302 self._requirements = requirements 

303 

304 def __repr__(self) -> str: 

305 """ 

306 Return a representation naming what this entrypoint reads. 

307 

308 :returns: The identifier and its source. 

309 """ 

310 if len(self._files) > 0: 

311 source = ", ".join(str(file) for file in self._files) 

312 else: 

313 source = ", ".join(name if extra is None else f"{name}[{extra}]" for name, extra in self._packages) 

314 

315 return f"<Entrypoint {self._identifier}: {source}>" 

316 

317 

318@export 

319class DependencyCollector(metaclass=ExtendedType, slots=True): 

320 """ 

321 The entrypoints a build declared, the package index it queries, and what querying it cost. 

322 

323 One collector is shared by every table of a build: a package required by two entrypoints is downloaded once, and 

324 the time is accumulated so the build can report a total. It exists because the alternative - a table that queries 

325 the index for itself - multiplies a documentation build's runtime by however many tables it has, and 

326 :file:`requirements.txt`, :file:`tests/requirements.txt` and :file:`doc/requirements.txt` share most of what they 

327 require. 

328 

329 The index is opened the first time a table asks for a package, not when the collector is created: a project may 

330 declare its entrypoints and then build a document that shows none of them, and that build should not open an 

331 HTTP session. 

332 """ 

333 

334 _entrypoints: dict[str, Entrypoint] #: The entrypoints declared in :file:`conf.py`. 

335 _indexURL: str #: URL of the package index's website. 

336 _apiURL: str #: URL of the package index's JSON API. 

337 _overrides: LicenseOverrides #: Licenses stated by hand, where the index can't. 

338 _graph: Nullable[PythonPackageDependencyGraph] #: Graph the downloaded packages are collected in. 

339 _index: Nullable[PythonPackageIndex] #: The package index this build queries, once opened. 

340 _projects: dict[str, Nullable[Project]] #: Projects downloaded so far; ``None`` if unknown. 

341 _detailed: set[str] #: Releases whose details were downloaded. 

342 _undescribed: set[str] #: Releases the index lists but can't describe. 

343 _stopwatch: Stopwatch #: Runs only while a request to the index is in flight. 

344 _unresolved: dict[str, tuple[str, ...]] #: Packages whose license the index couldn't answer for, 

345 #: mapped to what it published instead. 

346 

347 def __init__( 

348 self, 

349 entrypoints: dict[str, Entrypoint], 

350 indexURL: str, 

351 apiURL: str, 

352 overrides: LicenseOverrides 

353 ) -> None: 

354 """ 

355 Collect what a build declared, without opening the package index yet. 

356 

357 :param entrypoints: The entrypoints declared in :file:`conf.py`, by identifier. 

358 :param indexURL: URL of the package index's website. 

359 :param apiURL: URL of the package index's JSON API. 

360 :param overrides: Licenses stated by hand. 

361 """ 

362 self._entrypoints = entrypoints 

363 self._indexURL = indexURL 

364 self._apiURL = apiURL 

365 self._overrides = overrides 

366 self._graph = None 

367 self._index = None 

368 self._projects = {} 

369 self._detailed = set() 

370 self._undescribed = set() 

371 self._unresolved = {} 

372 

373 # 'preferPause', so each 'with' around a request is one active span: 'Activity' is the time spent waiting for 

374 # the index rather than the age of the collector, and 'ActiveCount' is the number of requests 

375 self._stopwatch = Stopwatch(preferPause=True) 

376 

377 @readonly 

378 def Entrypoints(self) -> dict[str, Entrypoint]: 

379 """ 

380 The entrypoints declared in :file:`conf.py`. 

381 

382 :returns: Every entrypoint by its identifier. 

383 """ 

384 return self._entrypoints 

385 

386 @readonly 

387 def Index(self) -> PythonPackageIndex: 

388 """ 

389 The package index this build queries, opened the first time it is asked for. 

390 

391 :returns: The package index. 

392 """ 

393 from pyTooling.Dependency.Python import PythonPackageDependencyGraph, PythonPackageIndex 

394 

395 if self._index is None: 

396 self._graph = PythonPackageDependencyGraph("documentation") 

397 self._index = PythonPackageIndex("index", self._indexURL, self._apiURL, self._graph, self._overrides) 

398 

399 return self._index 

400 

401 @readonly 

402 def RequestCount(self) -> int: 

403 """ 

404 Number of requests sent to the package index. 

405 

406 The stopwatch runs for exactly one span per request, so this is its 

407 :attr:`~pyTooling.Stopwatch.Stopwatch.ActiveCount` - counting them a second time in a field of our own 

408 would be a second answer to one question. 

409 

410 :returns: Number of requests sent. 

411 """ 

412 return self._stopwatch.ActiveCount 

413 

414 @readonly 

415 def Seconds(self) -> float: 

416 """ 

417 Time spent waiting for the package index, in seconds. 

418 

419 This is the stopwatch's :attr:`~pyTooling.Stopwatch.Stopwatch.Activity` - the sum of the intervals it ran - 

420 not its duration, because it is paused between requests and everything the build does in between is not 

421 time this collector spent. 

422 

423 :returns: Seconds spent on the index. 

424 """ 

425 return self._stopwatch.Activity 

426 

427 @readonly 

428 def UnresolvedLicenses(self) -> dict[str, tuple[str, ...]]: 

429 """ 

430 Packages whose license the index couldn't answer for, and what it published instead. 

431 

432 The published fields are what the override file has to answer for, so they are kept rather than only the 

433 package's name: ``License :: OSI Approved :: BSD License`` names three licenses and is never guessed at, and 

434 a ``license`` field holding a license's title instead of its SPDX identifier doesn't parse. 

435 

436 :returns: Names of the packages needing a license override, mapped to what the index published for them. 

437 """ 

438 return self._unresolved 

439 

440 def Project(self, packageName: str) -> Nullable[Project]: 

441 """ 

442 Return a project, downloading it the first time it is asked for. 

443 

444 A package the index doesn't know is remembered as unknown, so a table naming it doesn't ask again for every 

445 row that mentions it. 

446 

447 :param packageName: Name of the package to look up. 

448 :returns: The project, or ``None`` if the index doesn't know it. 

449 :raises MissingDependencyError: If the 'pypi' extra isn't installed. 

450 """ 

451 try: 

452 from requests import RequestException 

453 except ImportError as ex: # pragma: no cover 

454 raise MissingDependencyError(dependency="requests", extra="pypi") from ex 

455 

456 from pyTooling.Dependency import DependencyError 

457 from pyTooling.Dependency.Python import LazyLoaderState 

458 

459 if packageName in self._projects: 

460 return self._projects[packageName] 

461 

462 project: Nullable[Project] 

463 with self._stopwatch: 

464 try: 

465 project = self.Index.DownloadProject(packageName, LazyLoaderState.PartiallyLoaded) 

466 except (DependencyError, RequestException, ValueError, KeyError): 

467 project = None 

468 

469 self._projects[packageName] = project 

470 

471 return project 

472 

473 def Details(self, release: Release) -> Nullable[Release]: 

474 """ 

475 Make sure a release knows its own requirements and its license. 

476 

477 A release the index lists but can't describe - a yanked one, or a version its release endpoint spells 

478 differently - is remembered as unusable and answered with ``None``. Handing back the release itself would 

479 be worse than useless: its lazily loaded properties would each retry the download and raise. 

480 

481 :param release: The release to fill in. 

482 :returns: The release with its details, or ``None`` if the index can't describe it. 

483 :raises MissingDependencyError: If the 'pypi' extra isn't installed. 

484 """ 

485 try: 

486 from requests import RequestException 

487 except ImportError as ex: # pragma: no cover 

488 raise MissingDependencyError(dependency="requests", extra="pypi") from ex 

489 

490 from pyTooling.Dependency import DependencyError 

491 

492 key = f"{release.Package.Name}=={release.Version}" 

493 if key in self._detailed: 

494 return release if key not in self._undescribed else None 

495 

496 warnings: list[BaseException] = [] 

497 with self._stopwatch, WarningCollector(warnings): 

498 try: 

499 release.DownloadDetails() 

500 except (DependencyError, RequestException, ValueError, KeyError): 

501 self._undescribed.add(key) 

502 

503 self._detailed.add(key) 

504 

505 for warning in warnings: 

506 if isinstance(warning, UnknownLicenseWarning): 

507 # the warning's notes are what the index published, which is the reason an override is needed 

508 self._unresolved[release.Package.Name] = warning.Notes 

509 

510 return None if key in self._undescribed else release 

511 

512 

513@export 

514def readEntrypoints(configuration: Any, confDirectory: Path) -> dict[str, Entrypoint]: 

515 """ 

516 Turn ``pyTooling_Dependency_Requirements`` into entrypoints, reading every requirements file it names. 

517 

518 A requirements file is read here rather than when a table is built, so a path that doesn't exist ends the build 

519 with one message naming the identifier instead of an error box in the middle of a page - and so two tables 

520 naming the same file read it once. 

521 

522 :param configuration: Value of ``pyTooling_Dependency_Requirements``. 

523 :param confDirectory: Directory :file:`conf.py` lives in; relative paths are resolved against it. 

524 :returns: Every declared entrypoint, by its identifier. 

525 :raises MissingDependencyError: If the 'pypi' extra isn't installed. 

526 :raises SphinxExtensionError: If the configuration is malformed, or a requirements file can't be read. 

527 """ 

528 if not isinstance(configuration, dict): 

529 raise SphinxExtensionError( 

530 f"conf.py: {CONFIG_PREFIX}_Requirements: Expected a dictionary, " 

531 f"got '{getFullyQualifiedName(configuration)}'." 

532 ) 

533 

534 entrypoints: dict[str, Entrypoint] = {} 

535 for identifier, declaration in configuration.items(): 

536 location = f"conf.py: {CONFIG_PREFIX}_Requirements:[{identifier}]" 

537 

538 if not isinstance(declaration, dict): 

539 raise SphinxExtensionError( 

540 f"{location}: Expected a dictionary, got '{getFullyQualifiedName(declaration)}'." 

541 ) 

542 

543 if (unknown := set(declaration) - set(ENTRYPOINT_FIELDS)) != set(): 

544 raise SphinxExtensionError( 

545 f"{location}: Unknown field(s): {', '.join(sorted(unknown))}. " 

546 f"Known are: {', '.join(ENTRYPOINT_FIELDS)}." 

547 ) 

548 

549 if len(stated := [field for field in ENTRYPOINT_FIELDS if field in declaration]) != 1: 

550 known = ", ".join(ENTRYPOINT_FIELDS) 

551 raise SphinxExtensionError( 

552 f"{location}: Exactly one of {known} has to be configured, " 

553 f"{'none is' if len(stated) == 0 else f'{len(stated)} are'}." 

554 ) 

555 

556 field = stated[0] 

557 fieldLocation = f"{location}.{field}" 

558 value = declaration[field] 

559 values: tuple[str, ...] 

560 

561 if field in ("file", "package"): 

562 if not isinstance(value, str): 

563 raise SphinxExtensionError( 

564 f"{fieldLocation}: Expected a string, got '{getFullyQualifiedName(value)}'." 

565 ) 

566 

567 values = (value,) 

568 else: 

569 # a string is an iterable of strings itself, so the plural form has to reject one explicitly - otherwise 

570 # {"files": "requirements.txt"} would silently become sixteen one-character paths 

571 if isinstance(value, str) or not isinstance(value, Iterable): 

572 raise SphinxExtensionError( 

573 f"{fieldLocation}: Expected an iterable of strings, got '{getFullyQualifiedName(value)}'. " 

574 f"Use '{field[:-1]}' for a single value." 

575 ) 

576 

577 # materialized before the items are checked, because an iterable may be a generator this would consume 

578 values = tuple(value) 

579 for item in values: 

580 if not isinstance(item, str): 580 ↛ 581line 580 didn't jump to line 581 because the condition on line 580 was never true

581 raise SphinxExtensionError( 

582 f"{fieldLocation}: Expected strings, got '{getFullyQualifiedName(item)}'." 

583 ) 

584 

585 if field in ("file", "files"): 

586 entrypoints[identifier] = _FileEntrypoint(identifier, fieldLocation, values, confDirectory) 

587 else: 

588 entrypoints[identifier] = _PackageEntrypoint(identifier, values) 

589 

590 return entrypoints 

591 

592 

593def _FileEntrypoint( 

594 identifier: str, 

595 location: str, 

596 files: tuple[str, ...], 

597 confDirectory: Path 

598) -> Entrypoint: 

599 """ 

600 Read one entrypoint's requirements files. 

601 

602 Several files are read as several trees and flattened in the order they are declared, so a later file's 

603 statement wins - the rule a single file's ``-r`` references already follow. 

604 

605 :param identifier: Identifier of the entrypoint. 

606 :param location: Where in :file:`conf.py` this came from. 

607 :param files: The declared paths. 

608 :param confDirectory: Directory relative paths resolve against. 

609 :returns: The entrypoint, with its requirements read. 

610 :raises MissingDependencyError: If the 'pypi' extra isn't installed. 

611 :raises SphinxExtensionError: If a file can't be read. 

612 """ 

613 try: 

614 from packaging.utils import canonicalize_name 

615 except ImportError as ex: # pragma: no cover 

616 raise MissingDependencyError(dependency="packaging", extra="pypi") from ex 

617 

618 from pyTooling.Dependency import DependencyError 

619 from pyTooling.Dependency.Python import RequirementsFile 

620 

621 readFiles: list[Path] = [] 

622 requirements: dict[str, Requirement] = {} 

623 

624 for file in files: 

625 path = Path(file) 

626 if not path.is_absolute(): 626 ↛ 629line 626 didn't jump to line 629 because the condition on line 626 was always true

627 path = confDirectory / path 

628 

629 try: 

630 requirementsFile = RequirementsFile(path) 

631 except (DependencyError, OSError, UnicodeDecodeError) as cause: 

632 raise SphinxExtensionError(f"{location}: Requirements file '{path}' can't be read: {cause}") from cause 

633 

634 # the tree knows every file it was read from; walking it here would be a second answer to one question 

635 readFiles.extend(requirementsFile.AnalyzedRequirementFiles) 

636 requirements.update({canonicalize_name(req.name): req for req in requirementsFile.AllRequirements}) 

637 

638 return Entrypoint(identifier, files=tuple(readFiles), requirements=requirements) 

639 

640 

641def _PackageEntrypoint(identifier: str, packages: tuple[str, ...]) -> Entrypoint: 

642 """ 

643 Describe one entrypoint's packages, which only the package index can resolve. 

644 

645 :param identifier: Identifier of the entrypoint. 

646 :param packages: The declared packages, each optionally with one extra. 

647 :returns: The entrypoint, with its packages recorded and its requirements still unresolved. 

648 """ 

649 requested: list[tuple[str, Nullable[str]]] = [] 

650 for package in packages: 

651 name, _, bracket = package.partition("[") 

652 requested.append((name.strip(), bracket.rstrip("]").strip() or None)) 

653 

654 return Entrypoint(identifier, packages=tuple(requested)) 

655 

656 

657@export 

658def prepareEntrypoints(sphinx: Sphinx, config: Config) -> None: 

659 """ 

660 Call-back for Sphinx' ``config-inited`` event, reading the entrypoints and the license overrides. 

661 

662 A build declaring no entrypoint does nothing here - not even import :mod:`pyTooling.Dependency.Python`, so a 

663 project using only this extension's roles doesn't need the ``pypi`` extra. 

664 

665 :param sphinx: The Sphinx application. 

666 :param config: The configuration, after :file:`conf.py` was read. 

667 :raises SphinxExtensionError: If the configuration is malformed, or a 

668 requirements or license override file can't be read. 

669 """ 

670 if len(declarations := getattr(config, f"{CONFIG_PREFIX}_Requirements", {})) == 0: 670 ↛ 673line 670 didn't jump to line 673 because the condition on line 670 was always true

671 return 

672 

673 confDirectory = Path(sphinx.confdir) 

674 

675 try: 

676 from pyTooling.Dependency import DependencyError 

677 from pyTooling.Dependency.Python import LicenseOverrides 

678 except MissingDependencyError as cause: # pragma: no cover 

679 raise SphinxExtensionError( 

680 f"conf.py: {CONFIG_PREFIX}_Requirements: Querying a package index needs the 'pypi' extra: " 

681 f"pip install pyTooling[pypi]" 

682 ) from cause 

683 

684 overrides = LicenseOverrides() 

685 if (overrideFile := getattr(config, f"{CONFIG_PREFIX}_PackageOverrides", None)) is not None: 

686 path = Path(overrideFile) 

687 if not path.is_absolute(): 

688 path = confDirectory / path 

689 

690 try: 

691 overrides = LicenseOverrides.FromFile(path) 

692 except (DependencyError, ConfigurationError, OSError) as cause: 

693 raise SphinxExtensionError( 

694 f"conf.py: {CONFIG_PREFIX}_PackageOverrides: Override file '{path}' can't be read: {cause}" 

695 ) from cause 

696 

697 _COLLECTORS[id(sphinx)] = DependencyCollector( 

698 readEntrypoints(declarations, confDirectory), 

699 getattr(config, f"{CONFIG_PREFIX}_IndexURL", DEFAULT_INDEX_URL), 

700 getattr(config, f"{CONFIG_PREFIX}_APIURL", DEFAULT_API_URL), 

701 overrides 

702 ) 

703 

704 

705@export 

706class DependencyTable(BaseDirective): 

707 """ 

708 The ``dependency-table`` directive: an entrypoint's dependencies, rendered from the requirements. 

709 

710 One argument, the identifier of an entrypoint declared in ``pyTooling_Dependency_Requirements``. ``:depth:`` 

711 says how many levels of sub-dependencies to expand, ``:simplified-versions:`` whether a constraint is reduced to 

712 its lower bound, and ``:caption:`` puts a caption under the table; which package index is queried and which 

713 licenses are stated by hand are build-wide and configured in :file:`conf.py`. 

714 """ 

715 

716 directiveName: str = "dependency-table" #: Name the directive is invoked by. 

717 

718 #: The configuration values this directive adds to :file:`conf.py`, as ``name: (default, rebuild, types)``. Each is 

719 #: registered with :data:`CONFIG_PREFIX` as prefix, e.g. ``pyTooling_Dependency_Requirements``. 

720 #: 

721 #: ``Requirements`` maps an identifier to what it names - a file, files, a package or packages. The other three are 

722 #: build-wide, because one package index is queried per build and one override file answers for it. All four are 

723 #: ``"env"``-rebuilt: changing any of them changes every table. 

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

725 "Requirements": ({}, "env", dict), 

726 "PackageOverrides": (None, "env", (str, Path)), 

727 "IndexURL": (DEFAULT_INDEX_URL, "env", str), 

728 "APIURL": (DEFAULT_API_URL, "env", str), 

729 } 

730 

731 _simplify: bool #: Whether this table's version constraints are reduced to their lower bound. 

732 _versionFormat: VersionFormat #: How many parts of a version number this table prints. 

733 _dependencyFormat: DependencyFormat #: What a line of this table's dependency trees states. 

734 

735 has_content = False #: A boolean; ``True`` if content is allowed. 

736 required_arguments = 1 #: Number of required directive arguments: the entrypoint's identifier. 

737 optional_arguments = 0 #: Number of optional arguments after the required ones. 

738 final_argument_whitespace = False #: A boolean; ``True`` if the last argument may contain spaces. 

739 # docutils declares 'option_spec' on 'Directive' and 'BaseDirective' assigns it, so mypy calls every 

740 # spelling of this override a conflict with one of them 

741 #: Mapping of option names to validator functions. 

742 option_spec: dict[str, Any] = { # type: ignore[misc] 

743 "caption": strip, 

744 "depth": directives.nonnegative_int, 

745 "simplified-versions": stripAndNormalize, 

746 "version-format": stripAndNormalize, 

747 "dependency-format": stripAndNormalize, 

748 } 

749 

750 def run(self) -> list[nodes.Node]: 

751 """ 

752 Resolve the named entrypoint against the package index and return its requirements as a table. 

753 

754 :returns: A ``table`` node, or an error node when the entrypoint couldn't be resolved. 

755 """ 

756 identifier = self.arguments[0].strip() 

757 self._simplify = self._ParseBooleanOption("simplified-versions", DEFAULT_SIMPLIFIED_VERSIONS) 

758 self._versionFormat = self._ParseFormatOption("version-format", VersionFormat, DEFAULT_VERSION_FORMAT) 

759 self._dependencyFormat = self._ParseFormatOption( 

760 "dependency-format", DependencyFormat, DEFAULT_DEPENDENCY_FORMAT 

761 ) 

762 

763 with Stopwatch() as stopwatch: 

764 try: 

765 collector = self._Collector() 

766 requestsBefore = collector.RequestCount 

767 requirements = self._Resolve(identifier, collector) 

768 table = self._CreateTable(identifier, requirements, collector) 

769 except SphinxExtensionError as cause: 

770 return [self.state.document.reporter.error( 

771 f"{self.directiveName}: {cause}", line=self.lineno 

772 )] 

773 

774 # what this table cost, not what the build has spent so far - a package another table already downloaded is 

775 # free here, and that is the point of sharing the collector 

776 _logger.info( 

777 f"[{self.directiveName}] {identifier}: {len(requirements)} package(s), " 

778 f"{collector.RequestCount - requestsBefore} request(s), {stopwatch.Duration:.2f} s" 

779 ) 

780 

781 return [table] 

782 

783 def _ParseFormatOption(self, optionName: str, enumType: type[_FormatType], default: _FormatType) -> _FormatType: 

784 """ 

785 Read an option naming a member of an enumeration, or fall back to its default. 

786 

787 :attr:`~pyTooling.Sphinx.BaseDirective._ParseEnumOption` requires the option and 

788 lower-cases what it reads; these two have a default and are written the way the members are spelled, so a 

789 document says ``:version-format: MajorMinor`` rather than ``major_minor``. 

790 

791 :param optionName: Name of the option to read. 

792 :param enumType: The enumeration its value names a member of. 

793 :param default: The member to use when the option isn't given. 

794 :returns: The named member. 

795 :raises SphinxExtensionError: If the value names no member. 

796 """ 

797 if (option := self.options.get(optionName, None)) is None: 

798 return default 

799 

800 for member in enumType: 

801 if option.lower() == member.name.lower(): 

802 return member 

803 

804 known = ", ".join(member.name for member in enumType) 

805 raise SphinxExtensionError( 

806 f"{self.directiveName}::{optionName}: '{option}' is not one of: {known}." 

807 ) 

808 

809 def _Collector(self) -> DependencyCollector: 

810 """ 

811 Return the build's collector, which :func:`prepareEntrypoints` created when :file:`conf.py` was read. 

812 

813 The collector belongs to the running application rather than to the directive, because a document with three 

814 tables would otherwise open three indexes and download the same packages three times. It deliberately does 

815 *not* live on the build environment: Sphinx pickles that between runs, and neither an open HTTP session nor a 

816 cached view of a package index survives being pickled - or should. 

817 

818 :returns: The collector shared by every table of this build. 

819 :raises SphinxExtensionError: If no entrypoint was configured. 

820 """ 

821 if (collector := _COLLECTORS.get(id(self.env.app), None)) is None: 

822 raise SphinxExtensionError( 

823 f"No entrypoint is configured. Declare one in conf.py: {CONFIG_PREFIX}_Requirements." 

824 ) 

825 

826 return collector 

827 

828 def _Resolve(self, identifier: str, collector: DependencyCollector) -> dict[str, Requirement]: 

829 """ 

830 Return what the named entrypoint requires. 

831 

832 A file entrypoint was read when :file:`conf.py` was processed and answers immediately; a package entrypoint 

833 is resolved against the package index the first time a table names it, and remembers the answer. 

834 

835 :param identifier: Identifier the document names. 

836 :param collector: The build's collector. 

837 :returns: Every required package, by its canonical name. 

838 :raises MissingDependencyError: If the 'pypi' extra isn't installed. 

839 :raises SphinxExtensionError: If the identifier is unknown, or the 

840 package index can't answer for the entrypoint's package. 

841 """ 

842 try: 

843 from packaging.utils import canonicalize_name 

844 except ImportError as ex: # pragma: no cover 

845 raise MissingDependencyError(dependency="packaging", extra="pypi") from ex 

846 

847 if (entrypoint := collector.Entrypoints.get(identifier, None)) is None: 

848 known = ", ".join(sorted(collector.Entrypoints)) or "none" 

849 raise SphinxExtensionError( 

850 f"Entrypoint '{identifier}' is not configured in conf.py: {CONFIG_PREFIX}_Requirements. " 

851 f"Known are: {known}." 

852 ) 

853 

854 for file in entrypoint.Files: 

855 self.env.note_dependency(str(file)) 

856 

857 if (requirements := entrypoint.Requirements) is not None: 

858 return requirements 

859 

860 # several packages flatten in the order they are declared, the rule a file's '-r' references already follow 

861 requirements = {} 

862 for packageName, extra in entrypoint.Packages: 

863 for requirement in self._PublishedRequirements(packageName, extra, collector): 

864 requirements[canonicalize_name(requirement.name)] = requirement 

865 

866 entrypoint.CacheRequirements(requirements) 

867 

868 return requirements 

869 

870 def _PublishedRequirements( 

871 self, 

872 packageName: str, 

873 extra: Nullable[str], 

874 collector: DependencyCollector 

875 ) -> list[Requirement]: 

876 """ 

877 Ask the package index what a package's latest release requires. 

878 

879 :param packageName: Name of the package to ask about. 

880 :param extra: Extra whose requirements are wanted, or ``None`` for the package's own. 

881 :param collector: The build's collector. 

882 :returns: What that release requires. 

883 :raises SphinxExtensionError: If the index doesn't know the 

884 package, can't describe its latest release, or the package has no such extra. 

885 """ 

886 if (project := collector.Project(packageName)) is None: 

887 raise SphinxExtensionError(f"Package '{packageName}' is unknown to the package index.") 

888 

889 if (release := collector.Details(project.LatestRelease)) is None: 

890 raise SphinxExtensionError(f"The package index can't describe the latest release of '{packageName}'.") 

891 

892 published: Nullable[list[Requirement]] = release.Requirements.get(extra, None) 

893 if published is None: 

894 known = ", ".join(sorted(str(key) for key in release.Requirements if key is not None)) 

895 raise SphinxExtensionError(f"Package '{packageName}' has no extra '{extra}'. Known are: {known}.") 

896 

897 return published 

898 

899 def _CreateTable( 

900 self, 

901 identifier: str, 

902 requirements: dict[str, Requirement], 

903 collector: DependencyCollector 

904 ) -> nodes.table: 

905 """ 

906 Render the requirements as a four-column table. 

907 

908 :param identifier: Identifier of the entrypoint, used as the table's identifier. 

909 :param requirements: Every required package, by its canonical name. 

910 :param collector: The build's collector. 

911 :returns: The finished table. 

912 """ 

913 tableGroup = self._CreateSingleRowTableHeader( 

914 columns=list(TABLE_COLUMNS), 

915 identifier=identifier, 

916 classes=["dependency-table"] 

917 ) 

918 tableGroup += (tableBody := nodes.tbody()) 

919 

920 # ':depth: 0' - and the default - means expand until the tree ends; 'None' is that, internally, because 

921 # 'depth - 1' would otherwise walk 0 into negative numbers and mean two different things at once 

922 depth = self.options.get("depth", DEFAULT_DEPTH) 

923 levels: Nullable[int] = None if depth == 0 else depth 

924 

925 if len(requirements) == 0: 

926 tableBody += self._CreateEmptyRow(len(TABLE_COLUMNS)) 

927 else: 

928 for name in sorted(requirements, key=str.lower): 

929 tableBody += self._CreateRow(requirements[name], collector, levels) 

930 

931 table = cast(nodes.table, tableGroup.parent) 

932 if (caption := self.options.get("caption", None)) is not None: 

933 # the caption is ReST, not text: it is written with markup - ``packaging`` in pyTooling's own captions - 

934 # and a 'title' built from a string would print the backticks 

935 captionNodes, messages = self.state.inline_text(caption, self.lineno) 

936 table.insert(0, nodes.title(caption, "", *captionNodes, *messages)) 

937 

938 return table 

939 

940 @staticmethod 

941 def _CreateEmptyRow(columnCount: int) -> nodes.row: 

942 """ 

943 Render the one row a table with no requirements gets: a single cell spanning every column. 

944 

945 A table showing nothing but its header reads as a defect. pyTooling's own :file:`requirements.txt` is empty 

946 - the package has no mandatory dependencies - and that is a statement worth printing. 

947 

948 :param columnCount: Number of columns the cell has to span. 

949 :returns: The table row. 

950 """ 

951 tableRow = nodes.row("", classes=["dependency-table-row"]) 

952 

953 entry = nodes.entry("", morecols=columnCount - 1) 

954 entry += nodes.paragraph("", "", nodes.emphasis(text="No dependencies")) 

955 tableRow += entry 

956 

957 return tableRow 

958 

959 def _CreateRow( 

960 self, 

961 requirement: Requirement, 

962 collector: DependencyCollector, 

963 depth: Nullable[int] 

964 ) -> nodes.row: 

965 """ 

966 Render one required package as a table row. 

967 

968 A package the index doesn't know, or one with no release matching the requirement, still gets a row - the 

969 specifier the entrypoint states is worth showing even when nothing else could be resolved. 

970 

971 :param requirement: The requirement to render. 

972 :param collector: The build's collector. 

973 :param depth: Levels of sub-dependencies still to expand. 

974 :returns: The table row. 

975 """ 

976 tableRow = nodes.row("", classes=["dependency-table-row"]) 

977 

978 project = collector.Project(requirement.name) 

979 release = self._SelectRelease(project, requirement, collector) 

980 

981 tableRow += self._PackageEntry(requirement, project) 

982 specifier = self._FormatSpecifier(requirement.specifier, self._simplify, self._versionFormat) 

983 

984 tableRow += nodes.entry("", nodes.paragraph(text=specifier)) 

985 tableRow += self._LicenseEntry(release) 

986 tableRow += self._DependenciesEntry(release, collector, depth, {requirement.name.lower()}) 

987 

988 return tableRow 

989 

990 def _SelectRelease( 

991 self, 

992 project: Nullable[Project], 

993 requirement: Requirement, 

994 collector: DependencyCollector 

995 ) -> Nullable[Release]: 

996 """ 

997 Return the newest release satisfying a requirement. 

998 

999 Pre-releases are skipped unless the specifier asks for them, because that is what an installer would resolve 

1000 to and the table describes what would be installed. 

1001 

1002 :param project: The project to pick a release of, or ``None`` if the index doesn't know it. 

1003 :param requirement: The requirement to satisfy. 

1004 :param collector: The build's collector. 

1005 :returns: The newest matching release, or ``None`` if nothing matches. 

1006 """ 

1007 if project is None: 

1008 return None 

1009 

1010 matching = [ 

1011 release for version, release in project.Releases.items() 

1012 if requirement.specifier.contains(str(version)) 

1013 ] 

1014 if len(matching) == 0: 

1015 return None 

1016 

1017 return collector.Details(max(matching, key=lambda release: release.Version)) 

1018 

1019 @staticmethod 

1020 def _FormatVersion(version: str, versionFormat: VersionFormat) -> str: 

1021 """ 

1022 Shorten a version number to the parts a table prints. 

1023 

1024 ``≥0.4.6`` says more than a reader of a dependency table needs; the parts that matter are the ones a 

1025 constraint is usually written against. A version with fewer parts than asked for is left as it is - ``≥9`` 

1026 does not become ``≥9.0`` - because padding would state a precision the requirement didn't. 

1027 

1028 :param version: The version, as the constraint writes it. 

1029 :param versionFormat: How many parts to keep. 

1030 :returns: The shortened version. 

1031 """ 

1032 if versionFormat is VersionFormat.All: 

1033 return version 

1034 

1035 parts = {VersionFormat.Major: 1, VersionFormat.MajorMinor: 2, VersionFormat.MajorMinorPatch: 3}[versionFormat] 

1036 

1037 return ".".join(version.split(".")[:parts]) 

1038 

1039 @staticmethod 

1040 def _FormatSpecifier(specifier: SpecifierSet, simplify: bool, versionFormat: VersionFormat) -> str: 

1041 """ 

1042 Render a version constraint the way a reader writes one. 

1043 

1044 The comparison operators become their mathematical symbols and the versions are shortened to 

1045 ``versionFormat``. A simplified constraint keeps only what a package *has to be at least*: an upper bound 

1046 and an exclusion say what a release must not be, which is the packaging problem rather than the reader's, 

1047 and ``~=`` is written as the lower bound it implies. Simplifying everything away leaves the constraint as it 

1048 was written - ``<4.0`` alone is still the whole statement. 

1049 

1050 :param specifier: The constraint to render. 

1051 :param simplify: Whether to reduce the constraint to its lower bound. 

1052 :param versionFormat: How many parts of each version to keep. 

1053 :returns: The constraint, or ``any`` when nothing is constrained. 

1054 """ 

1055 def render(operator: str, version: str) -> str: 

1056 shortened = DependencyTable._FormatVersion(version, versionFormat) 

1057 for written, symbol in OPERATOR_SYMBOLS: 

1058 if operator == written: 

1059 return f"{symbol}{shortened}" 

1060 

1061 return f"{operator}{shortened}" 

1062 

1063 if len(specifier) == 0: 

1064 return "any" 

1065 

1066 specifiers = sorted(specifier, key=lambda item: (item.version, item.operator)) 

1067 if simplify: 

1068 kept = [ 

1069 render(">=" if item.operator == "~=" else item.operator, item.version) 

1070 for item in specifiers if item.operator not in _DROPPED_OPERATORS 

1071 ] 

1072 if len(kept) > 0: 

1073 # shortening can make two constraints identical - '>=1.2.3, >1.2.9' is '≥1.2, >1.2' at MajorMinor - 

1074 # and printing one statement twice reads as a defect 

1075 return ", ".join(dict.fromkeys(kept)) 

1076 

1077 rendered = [render(item.operator, item.version) for item in specifiers] 

1078 

1079 return ", ".join(dict.fromkeys(rendered)) 

1080 

1081 @staticmethod 

1082 def _PackageURL(project: Nullable[Project]) -> Nullable[str]: 

1083 """ 

1084 Return the page a package's name should link to, most useful first. 

1085 

1086 A project states none of these reliably, so there are three chances at one: its **documentation** answers 

1087 *what is this*, its **repository** answers *where does it come from*, and its page on the **package index** 

1088 is what the index itself can always answer. Only a package the index doesn't know at all goes unlinked. 

1089 

1090 :param project: The project, or ``None`` if the index doesn't know it. 

1091 :returns: The URL to link the name to, or ``None`` if there is nothing to link to. 

1092 """ 

1093 if project is None: 

1094 return None 

1095 

1096 for url in (project.DocumentationURL, project.RepositoryURL, project.URL): 

1097 if url is not None: 

1098 return str(url) 

1099 

1100 return None 

1101 

1102 @classmethod 

1103 def _PackageEntry(cls, requirement: Requirement, project: Nullable[Project]) -> nodes.entry: 

1104 """ 

1105 Render the package's name, linked to where a reader can find out about it. 

1106 

1107 :param requirement: The requirement naming the package. 

1108 :param project: The project, or ``None`` if the index doesn't know it. 

1109 :returns: The table entry. 

1110 """ 

1111 entry = nodes.entry() 

1112 name = project.Name if project is not None else requirement.name 

1113 

1114 if (url := cls._PackageURL(project)) is not None: 

1115 entry += nodes.paragraph("", "", nodes.reference("", name, refuri=url)) 

1116 else: 

1117 entry += nodes.paragraph(text=name) 

1118 

1119 return entry 

1120 

1121 @staticmethod 

1122 def _LicenseEntry(release: Nullable[Release]) -> nodes.entry: 

1123 """ 

1124 Render a release's license, linked to its text where one is known. 

1125 

1126 The license' **name** is shown rather than its SPDX identifier - ``Apache License 2.0``, not ``Apache-2.0`` - 

1127 because the table is read by a person and the identifier is what an expression writes. 

1128 

1129 A license that didn't resolve is an :class:`~pyTooling.Licensing.UnknownLicense`, never a blank cell, and it 

1130 is shown **as the index published it** - in italics, so it reads as a quotation rather than as an identifier. 

1131 The reader should see that the index said *something*, and what it was. 

1132 

1133 :param release: The release to render the license of, or ``None``. 

1134 :returns: The table entry. 

1135 """ 

1136 entry = nodes.entry() 

1137 

1138 if (text := DependencyTable._LicenseName(release)) is None: 

1139 entry += nodes.paragraph("", "", nodes.emphasis(text=DependencyTable._PublishedLicense(release))) 

1140 

1141 return entry 

1142 

1143 if (url := DependencyTable._LicenseURL(release)) is not None: 

1144 entry += nodes.paragraph("", "", nodes.reference("", text, refuri=url)) 

1145 else: 

1146 entry += nodes.paragraph(text=text) 

1147 

1148 return entry 

1149 

1150 @staticmethod 

1151 def _LicenseName(release: Nullable[Release]) -> Nullable[str]: 

1152 """ 

1153 Return the name(s) of the licenses a release is published under. 

1154 

1155 :param release: The release to name the license of, or ``None``. 

1156 :returns: The license' name, or ``None`` if nothing resolved. 

1157 """ 

1158 from pyTooling.Licensing import UnknownLicense 

1159 

1160 if release is None: 

1161 return None 

1162 

1163 # 'Licenses' never comes back empty: what didn't resolve is an 'UnknownLicense', which is SPDX's own way of 

1164 # saying so and keeps the published text 

1165 licenses = release.Licenses 

1166 if all(isinstance(license, UnknownLicense) for license in licenses): 

1167 return None 

1168 

1169 return ", ".join(license.Name for license in licenses) 

1170 

1171 @staticmethod 

1172 def _PublishedLicense(release: Nullable[Release]) -> str: 

1173 """ 

1174 Return what the package index published, for a license that didn't resolve. 

1175 

1176 :param release: The release, or ``None`` if the index couldn't describe it. 

1177 :returns: What was published, or ``unknown`` when that was nothing either. 

1178 """ 

1179 if release is None: 

1180 return "unknown" 

1181 

1182 published = release.LicenseExpression.OriginalText.strip() 

1183 

1184 return published if published != "" else ", ".join(license.Name for license in release.Licenses) or "unknown" 

1185 

1186 @staticmethod 

1187 def _LicenseURL(release: Nullable[Release]) -> Nullable[str]: 

1188 """ 

1189 Return the page a license should link to, most specific first. 

1190 

1191 **The project's own** :file:`LICENSE` **file wins**: it is the license as this project publishes it, which 

1192 is the document a reader auditing a dependency actually wants. Most projects don't state one, though - it 

1193 comes from ``project_urls`` or from the override file - so a license on the SPDX List falls back to its own 

1194 published pages, in the order of who is speaking: the licensor's own page, then OSI's entry, then SPDX's. 

1195 A ``LicenseRef-`` has none of those and stays unlinked, because nothing published it. 

1196 

1197 :param release: The release to link the license of, or ``None``. 

1198 :returns: The URL to link to, or ``None`` if nothing published this license. 

1199 """ 

1200 from pyTooling.Licensing import SPDXLicense 

1201 

1202 if release is None: 

1203 return None 

1204 

1205 if release.LicenseURL is not None: 

1206 return str(release.LicenseURL) 

1207 

1208 for license in release.Licenses: 

1209 if isinstance(license, SPDXLicense): 

1210 for url in (license.License.URL, license.License.OSIURL, license.License.SPDXURL): 

1211 if url is not None: 

1212 return url 

1213 

1214 return None 

1215 

1216 def _DependenciesEntry( 

1217 self, 

1218 release: Nullable[Release], 

1219 collector: DependencyCollector, 

1220 depth: Nullable[int], 

1221 visited: set[str] 

1222 ) -> nodes.entry: 

1223 """ 

1224 Render a release's own requirements as a nested bullet list. 

1225 

1226 Only the unconditional requirements are listed - what an extra pulls in is that extra's table, not this one. 

1227 A package already on the path is not expanded again, so a dependency cycle terminates. 

1228 

1229 :param release: The release to render the dependencies of, or ``None``. 

1230 :param collector: The build's collector. 

1231 :param depth: Levels still to expand; at zero nothing is expanded. 

1232 :param visited: Packages already on this path, lower-cased. 

1233 :returns: The table entry. 

1234 """ 

1235 entry = nodes.entry() 

1236 

1237 if release is None or (depth is not None and depth <= 0): 

1238 entry += nodes.paragraph("", "", nodes.emphasis(text="not evaluated")) 

1239 return entry 

1240 

1241 requirements = [ 

1242 requirement for requirement in release.Requirements.get(None, []) 

1243 if requirement.name.lower() not in visited 

1244 ] 

1245 if len(requirements) == 0: 

1246 entry += nodes.paragraph("", "", nodes.emphasis(text="none")) 

1247 return entry 

1248 

1249 entry += self._CreateBulletList(requirements, collector, _OneLevelDown(depth), visited) 

1250 

1251 return entry 

1252 

1253 def _CreateBulletList( 

1254 self, 

1255 requirements: list[Requirement], 

1256 collector: DependencyCollector, 

1257 depth: Nullable[int], 

1258 visited: set[str] 

1259 ) -> nodes.bullet_list: 

1260 """ 

1261 Render requirements as a bullet list, each item expanded by one more level. 

1262 

1263 :param requirements: The requirements to list. 

1264 :param collector: The build's collector. 

1265 :param depth: Levels still to expand below this list. 

1266 :param visited: Packages already on this path, lower-cased. 

1267 :returns: The bullet list. 

1268 """ 

1269 bulletList = nodes.bullet_list() 

1270 

1271 for requirement in sorted(requirements, key=lambda item: item.name.lower()): 

1272 item = nodes.list_item() 

1273 

1274 # a leaf is resolved too when the line states a license - that is the whole point of stating it - but a 

1275 # ':dependency-format:' that prints no license has no reason to send the requests 

1276 project = collector.Project(requirement.name) 

1277 release = ( 

1278 self._SelectRelease(project, requirement, collector) 

1279 if depth is None or depth > 0 or self._dependencyFormat.ShowsLicense 

1280 else None 

1281 ) 

1282 

1283 item += self._RequirementParagraph(requirement, project, release) 

1284 

1285 if (depth is None or depth > 0) and release is not None: 

1286 nested = [ 

1287 nestedRequirement for nestedRequirement in release.Requirements.get(None, []) 

1288 if nestedRequirement.name.lower() not in visited 

1289 ] 

1290 if len(nested) > 0: 

1291 item += self._CreateBulletList( 

1292 nested, collector, _OneLevelDown(depth), visited | {requirement.name.lower()} 

1293 ) 

1294 

1295 bulletList += item 

1296 

1297 return bulletList 

1298 

1299 def _RequirementParagraph( 

1300 self, 

1301 requirement: Requirement, 

1302 project: Nullable[Project], 

1303 release: Nullable[Release] 

1304 ) -> nodes.paragraph: 

1305 """ 

1306 Render one line of a dependency tree: the package, what is required of it, and what it is licensed under. 

1307 

1308 The license is the reason a dependency tree is in this table at all - a package pulls in what its own 

1309 dependencies are licensed under, and reading that off the tree is the point. It is linked and parenthesised 

1310 so the line still reads as one requirement. 

1311 

1312 :param requirement: The requirement to render. 

1313 :param project: The project, or ``None`` if the index doesn't know it. 

1314 :param release: The release satisfying the requirement, or ``None`` if none was found. 

1315 :returns: The paragraph. 

1316 """ 

1317 paragraph = nodes.paragraph() 

1318 

1319 name = project.Name if project is not None else requirement.name 

1320 if (url := self._PackageURL(project)) is not None: 

1321 paragraph += nodes.reference("", name, refuri=url) 

1322 else: 

1323 paragraph += nodes.Text(name) 

1324 

1325 if self._dependencyFormat.ShowsVersion: 

1326 specifier = self._FormatSpecifier(requirement.specifier, self._simplify, self._versionFormat) 

1327 if specifier != "any": 

1328 paragraph += nodes.Text(f" {specifier}") 

1329 

1330 if self._dependencyFormat.ShowsLicense: 

1331 paragraph += nodes.Text(" (") 

1332 if (license := self._LicenseName(release)) is None: 

1333 # the same statement the License column makes: what the index published, in italics, so it reads as 

1334 # a quotation rather than as an identifier 

1335 paragraph += nodes.emphasis(text=self._PublishedLicense(release)) 

1336 elif (licenseURL := self._LicenseURL(release)) is not None: 

1337 paragraph += nodes.reference("", license, refuri=licenseURL) 

1338 else: 

1339 paragraph += nodes.Text(license) 

1340 paragraph += nodes.Text(")") 

1341 

1342 return paragraph 

1343 

1344 

1345@export 

1346def formatUnresolvedLicenses(unresolved: Mapping[str, tuple[str, ...]]) -> str: 

1347 """ 

1348 Describe the packages needing a license override, grouped by what the package index published for them. 

1349 

1350 Grouped rather than listed one per line, because one ambiguous statement usually accounts for most of the list: 

1351 ``License :: OSI Approved :: BSD License`` names three licenses, so every package whose only license information 

1352 is that classifier lands here for the same reason and is worth reading as one group. 

1353 

1354 :param unresolved: Names of the packages needing an override, mapped to what the index published for them. 

1355 :returns: The message, as one line naming the count and two lines per reason. 

1356 """ 

1357 byReason: dict[tuple[str, ...], list[str]] = {} 

1358 for packageName, published in sorted(unresolved.items()): 

1359 byReason.setdefault(published, []).append(packageName) 

1360 

1361 lines = [f"[dependency-table] {len(unresolved)} package(s) need a license override:"] 

1362 

1363 # the biggest group first, so the statement to fix first is the one at the top 

1364 for published, packageNames in sorted(byReason.items(), key=lambda item: (-len(item[1]), item[0])): 

1365 reason = "; ".join(published) if len(published) > 0 else "the index published no license information" 

1366 lines.append(f" {reason}") 

1367 lines.append(f" {', '.join(packageNames)}") 

1368 

1369 return "\n".join(lines) 

1370 

1371 

1372def reportBuildTime(app: Sphinx, exception: Nullable[Exception]) -> None: 

1373 """ 

1374 Report what querying the package index cost this build. 

1375 

1376 The tables are fetched live, so this is the number to look at before deciding what a cache would be worth. The 

1377 packages whose license had to be guessed at - or couldn't be - are named too, because that is the list the 

1378 override file has to answer for. 

1379 

1380 :param app: The Sphinx application that finished building. 

1381 :param exception: The exception that ended the build, or ``None`` if it succeeded. 

1382 """ 

1383 if (collector := _COLLECTORS.pop(id(app), None)) is None or collector.RequestCount == 0: 1383 ↛ 1386line 1383 didn't jump to line 1386 because the condition on line 1383 was always true

1384 return 

1385 

1386 _logger.info( 

1387 f"[dependency-table] {collector.RequestCount} request(s) to the package index, " 

1388 f"{collector.Seconds:.2f} s in total." 

1389 ) 

1390 

1391 if len(unresolved := collector.UnresolvedLicenses) > 0: 

1392 _logger.warning(formatUnresolvedLicenses(unresolved))