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
« 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.
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.
39**The entrypoints are declared in** :file:`conf.py` **and named by the documents**, the way
40:mod:`sphinx_reports` declares its reports:
42.. code-block:: Python
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 }
52.. code-block:: rest
54 .. dependency-table:: documentation
55 :caption: Documentation dependencies
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.
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
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
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
80from docutils import nodes
81from docutils.parsers.rst import directives
82from sphinx.application import Sphinx
83from sphinx.util import logging
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
95from pyTooling.Sphinx import BaseDirective, SphinxExtensionError, strip
96from pyTooling.Sphinx import stripAndNormalize
99#: URL of the package index the tables are built from, unless :file:`conf.py` names another.
100DEFAULT_INDEX_URL = "https://pypi.org"
102#: URL of that index's JSON API.
103DEFAULT_API_URL = "https://pypi.org/pypi/"
105#: Levels of sub-dependencies rendered when nothing says otherwise; ``0`` expands until the tree ends.
106DEFAULT_DEPTH = 0
108#: Whether a version constraint is reduced to its lower bound when the document doesn't say.
109DEFAULT_SIMPLIFIED_VERSIONS = True
112@export
113class VersionFormat(Enum):
114 """How many parts of a version number a dependency table prints."""
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``.
121 def __str__(self) -> str:
122 """
123 Return this format's name, as a document writes it.
125 :returns: The enum member's name.
126 """
127 return self.name
130@export
131class DependencyFormat(Enum):
132 """What a line of a dependency tree states about a package."""
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.
139 @readonly
140 def ShowsVersion(self) -> bool:
141 """
142 Whether this format states a version constraint.
144 :returns: ``True`` if the version is printed.
145 """
146 return self in (DependencyFormat.PackageVersion, DependencyFormat.PackageVersionLicense)
148 @readonly
149 def ShowsLicense(self) -> bool:
150 """
151 Whether this format states a license.
153 :returns: ``True`` if the license is printed.
154 """
155 return self in (DependencyFormat.PackageLicense, DependencyFormat.PackageVersionLicense)
157 def __str__(self) -> str:
158 """
159 Return this format's name, as a document writes it.
161 :returns: The enum member's name.
162 """
163 return self.name
166#: How many parts of a version number a table prints when the document doesn't say.
167DEFAULT_VERSION_FORMAT = VersionFormat.MajorMinor
169#: What a line of a dependency tree states when the document doesn't say.
170DEFAULT_DEPENDENCY_FORMAT = DependencyFormat.PackageVersionLicense
172#: Comparison operators as a reader writes them. Order matters - the two-character forms have to be tried first.
173OPERATOR_SYMBOLS = (("<=", "≤"), (">=", "≥"), ("!=", "≠"), ("==", "="))
175#: Operators a simplified constraint drops: an upper bound and an exclusion say what is *not* required.
176_DROPPED_OPERATORS = ("<", "<=", "!=")
178#: The table's columns, as ``(title, relative width)``.
179TABLE_COLUMNS = (("Package", 3), ("Version", 1), ("License", 2), ("Dependencies", 4))
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")
187#: Prefix every configuration value of this extension carries in :file:`conf.py`.
188CONFIG_PREFIX = "pyTooling_Dependency"
190#: What Sphinx accepts as the rebuild condition of a configuration value - the one this extension uses.
191_ConfigRebuild = Literal["env"]
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]
199_logger = logging.getLogger(__name__)
201#: The two option enumerations, so one parser serves both.
202_FormatType = TypeVar("_FormatType", VersionFormat, DependencyFormat)
205def _OneLevelDown(depth: Nullable[int]) -> Nullable[int]:
206 """
207 Return the depth one level deeper, leaving an unlimited depth unlimited.
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
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"] = {}
222@export
223class Entrypoint(metaclass=ExtendedType, slots=True):
224 """
225 One entry of ``pyTooling_Dependency_Requirements``: an identifier and the requirements it stands for.
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 """
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.
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.
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
257 @readonly
258 def Identifier(self) -> str:
259 """
260 Name the documents refer to this entrypoint by.
262 :returns: The identifier.
263 """
264 return self._identifier
266 @readonly
267 def Files(self) -> tuple[Path, ...]:
268 """
269 The requirements file and every file it includes.
271 :returns: The files this entrypoint was read from; empty for a package entrypoint.
272 """
273 return self._files
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.
280 :returns: The packages, or an empty tuple for a file entrypoint.
281 """
282 return self._packages
284 @readonly
285 def Requirements(self) -> Nullable[dict[str, Requirement]]:
286 """
287 The requirements this entrypoint stands for.
289 :returns: Every required package by its canonical name, or ``None`` if they weren't resolved yet.
290 """
291 return self._requirements
293 def CacheRequirements(self, requirements: dict[str, Requirement]) -> None:
294 """
295 Remember the requirements the package index answered with.
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.
300 :param requirements: Every required package, by its canonical name.
301 """
302 self._requirements = requirements
304 def __repr__(self) -> str:
305 """
306 Return a representation naming what this entrypoint reads.
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)
315 return f"<Entrypoint {self._identifier}: {source}>"
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.
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.
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 """
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.
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.
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 = {}
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)
377 @readonly
378 def Entrypoints(self) -> dict[str, Entrypoint]:
379 """
380 The entrypoints declared in :file:`conf.py`.
382 :returns: Every entrypoint by its identifier.
383 """
384 return self._entrypoints
386 @readonly
387 def Index(self) -> PythonPackageIndex:
388 """
389 The package index this build queries, opened the first time it is asked for.
391 :returns: The package index.
392 """
393 from pyTooling.Dependency.Python import PythonPackageDependencyGraph, PythonPackageIndex
395 if self._index is None:
396 self._graph = PythonPackageDependencyGraph("documentation")
397 self._index = PythonPackageIndex("index", self._indexURL, self._apiURL, self._graph, self._overrides)
399 return self._index
401 @readonly
402 def RequestCount(self) -> int:
403 """
404 Number of requests sent to the package index.
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.
410 :returns: Number of requests sent.
411 """
412 return self._stopwatch.ActiveCount
414 @readonly
415 def Seconds(self) -> float:
416 """
417 Time spent waiting for the package index, in seconds.
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.
423 :returns: Seconds spent on the index.
424 """
425 return self._stopwatch.Activity
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.
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.
436 :returns: Names of the packages needing a license override, mapped to what the index published for them.
437 """
438 return self._unresolved
440 def Project(self, packageName: str) -> Nullable[Project]:
441 """
442 Return a project, downloading it the first time it is asked for.
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.
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
456 from pyTooling.Dependency import DependencyError
457 from pyTooling.Dependency.Python import LazyLoaderState
459 if packageName in self._projects:
460 return self._projects[packageName]
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
469 self._projects[packageName] = project
471 return project
473 def Details(self, release: Release) -> Nullable[Release]:
474 """
475 Make sure a release knows its own requirements and its license.
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.
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
490 from pyTooling.Dependency import DependencyError
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
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)
503 self._detailed.add(key)
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
510 return None if key in self._undescribed else release
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.
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.
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 )
534 entrypoints: dict[str, Entrypoint] = {}
535 for identifier, declaration in configuration.items():
536 location = f"conf.py: {CONFIG_PREFIX}_Requirements:[{identifier}]"
538 if not isinstance(declaration, dict):
539 raise SphinxExtensionError(
540 f"{location}: Expected a dictionary, got '{getFullyQualifiedName(declaration)}'."
541 )
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 )
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 )
556 field = stated[0]
557 fieldLocation = f"{location}.{field}"
558 value = declaration[field]
559 values: tuple[str, ...]
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 )
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 )
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 )
585 if field in ("file", "files"):
586 entrypoints[identifier] = _FileEntrypoint(identifier, fieldLocation, values, confDirectory)
587 else:
588 entrypoints[identifier] = _PackageEntrypoint(identifier, values)
590 return entrypoints
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.
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.
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
618 from pyTooling.Dependency import DependencyError
619 from pyTooling.Dependency.Python import RequirementsFile
621 readFiles: list[Path] = []
622 requirements: dict[str, Requirement] = {}
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
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
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})
638 return Entrypoint(identifier, files=tuple(readFiles), requirements=requirements)
641def _PackageEntrypoint(identifier: str, packages: tuple[str, ...]) -> Entrypoint:
642 """
643 Describe one entrypoint's packages, which only the package index can resolve.
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))
654 return Entrypoint(identifier, packages=tuple(requested))
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.
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.
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
673 confDirectory = Path(sphinx.confdir)
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
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
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
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 )
705@export
706class DependencyTable(BaseDirective):
707 """
708 The ``dependency-table`` directive: an entrypoint's dependencies, rendered from the requirements.
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 """
716 directiveName: str = "dependency-table" #: Name the directive is invoked by.
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 }
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.
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 }
750 def run(self) -> list[nodes.Node]:
751 """
752 Resolve the named entrypoint against the package index and return its requirements as a table.
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 )
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 )]
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 )
781 return [table]
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.
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``.
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
800 for member in enumType:
801 if option.lower() == member.name.lower():
802 return member
804 known = ", ".join(member.name for member in enumType)
805 raise SphinxExtensionError(
806 f"{self.directiveName}::{optionName}: '{option}' is not one of: {known}."
807 )
809 def _Collector(self) -> DependencyCollector:
810 """
811 Return the build's collector, which :func:`prepareEntrypoints` created when :file:`conf.py` was read.
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.
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 )
826 return collector
828 def _Resolve(self, identifier: str, collector: DependencyCollector) -> dict[str, Requirement]:
829 """
830 Return what the named entrypoint requires.
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.
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
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 )
854 for file in entrypoint.Files:
855 self.env.note_dependency(str(file))
857 if (requirements := entrypoint.Requirements) is not None:
858 return requirements
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
866 entrypoint.CacheRequirements(requirements)
868 return requirements
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.
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.")
889 if (release := collector.Details(project.LatestRelease)) is None:
890 raise SphinxExtensionError(f"The package index can't describe the latest release of '{packageName}'.")
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}.")
897 return published
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.
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())
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
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)
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))
938 return table
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.
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.
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"])
953 entry = nodes.entry("", morecols=columnCount - 1)
954 entry += nodes.paragraph("", "", nodes.emphasis(text="No dependencies"))
955 tableRow += entry
957 return tableRow
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.
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.
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"])
978 project = collector.Project(requirement.name)
979 release = self._SelectRelease(project, requirement, collector)
981 tableRow += self._PackageEntry(requirement, project)
982 specifier = self._FormatSpecifier(requirement.specifier, self._simplify, self._versionFormat)
984 tableRow += nodes.entry("", nodes.paragraph(text=specifier))
985 tableRow += self._LicenseEntry(release)
986 tableRow += self._DependenciesEntry(release, collector, depth, {requirement.name.lower()})
988 return tableRow
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.
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.
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
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
1017 return collector.Details(max(matching, key=lambda release: release.Version))
1019 @staticmethod
1020 def _FormatVersion(version: str, versionFormat: VersionFormat) -> str:
1021 """
1022 Shorten a version number to the parts a table prints.
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.
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
1035 parts = {VersionFormat.Major: 1, VersionFormat.MajorMinor: 2, VersionFormat.MajorMinorPatch: 3}[versionFormat]
1037 return ".".join(version.split(".")[:parts])
1039 @staticmethod
1040 def _FormatSpecifier(specifier: SpecifierSet, simplify: bool, versionFormat: VersionFormat) -> str:
1041 """
1042 Render a version constraint the way a reader writes one.
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.
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}"
1061 return f"{operator}{shortened}"
1063 if len(specifier) == 0:
1064 return "any"
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))
1077 rendered = [render(item.operator, item.version) for item in specifiers]
1079 return ", ".join(dict.fromkeys(rendered))
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.
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.
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
1096 for url in (project.DocumentationURL, project.RepositoryURL, project.URL):
1097 if url is not None:
1098 return str(url)
1100 return None
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.
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
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)
1119 return entry
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.
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.
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.
1133 :param release: The release to render the license of, or ``None``.
1134 :returns: The table entry.
1135 """
1136 entry = nodes.entry()
1138 if (text := DependencyTable._LicenseName(release)) is None:
1139 entry += nodes.paragraph("", "", nodes.emphasis(text=DependencyTable._PublishedLicense(release)))
1141 return entry
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)
1148 return entry
1150 @staticmethod
1151 def _LicenseName(release: Nullable[Release]) -> Nullable[str]:
1152 """
1153 Return the name(s) of the licenses a release is published under.
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
1160 if release is None:
1161 return None
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
1169 return ", ".join(license.Name for license in licenses)
1171 @staticmethod
1172 def _PublishedLicense(release: Nullable[Release]) -> str:
1173 """
1174 Return what the package index published, for a license that didn't resolve.
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"
1182 published = release.LicenseExpression.OriginalText.strip()
1184 return published if published != "" else ", ".join(license.Name for license in release.Licenses) or "unknown"
1186 @staticmethod
1187 def _LicenseURL(release: Nullable[Release]) -> Nullable[str]:
1188 """
1189 Return the page a license should link to, most specific first.
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.
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
1202 if release is None:
1203 return None
1205 if release.LicenseURL is not None:
1206 return str(release.LicenseURL)
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
1214 return None
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.
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.
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()
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
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
1249 entry += self._CreateBulletList(requirements, collector, _OneLevelDown(depth), visited)
1251 return entry
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.
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()
1271 for requirement in sorted(requirements, key=lambda item: item.name.lower()):
1272 item = nodes.list_item()
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 )
1283 item += self._RequirementParagraph(requirement, project, release)
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 )
1295 bulletList += item
1297 return bulletList
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.
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.
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()
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)
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}")
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(")")
1342 return paragraph
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.
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.
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)
1361 lines = [f"[dependency-table] {len(unresolved)} package(s) need a license override:"]
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)}")
1369 return "\n".join(lines)
1372def reportBuildTime(app: Sphinx, exception: Nullable[Exception]) -> None:
1373 """
1374 Report what querying the package index cost this build.
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.
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
1386 _logger.info(
1387 f"[dependency-table] {collector.RequestCount} request(s) to the package index, "
1388 f"{collector.Seconds:.2f} s in total."
1389 )
1391 if len(unresolved := collector.UnresolvedLicenses) > 0:
1392 _logger.warning(formatUnresolvedLicenses(unresolved))