DependencyTable.py

Summary

Lines:

303 of 498 covered (60.8%)

Excluded lines:

16

Branches:

58 of 158 taken (36.7%), 8 lines partially

Coverage:

55.0%

Directory:

Sphinx

Missed lines:

279, 317, 325-330, 399, 411-412, 427, 440, 476, 482-483, 518, 524-525, 607, 707-731, 790-815, 831-839, 855-860, 876-902, 920-931, 947-972, 985-991, 1010-1022, 1041-1051, 1127-1134, 1145-1153, 1170-1182, 1192-1203, 1213-1218, 1234-1248, 1269-1285, 1365-1390, 1405-1417, 1434-1440

Source

pyTooling/Sphinx/DependencyTable.pycoveredpartialuncoveredexcludednot executable
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
215def _ReportWarning(prefix: str, warning: BaseException) -> None:
216	"""
217	Report a warning collected from :mod:`pyTooling.Dependency` as a Sphinx warning.
218
219	Each of the warning's notes follows the message on a line of its own.
220
221	:param prefix:  Text the message starts with, naming what the warning is about.
222	:param warning: The collected warning.
223	"""
224	lines = [f"{prefix} {warning}"]
225	lines.extend(f"  {note}" for note in getattr(warning, "__notes__", ()))
226
227	_logger.warning("\n".join(lines))
228
229
230#: The collector of each running build, by the id of its Sphinx application.
231#:
232#: It can't live on the build environment, which Sphinx pickles between runs - an open HTTP session and the locks
233#: guarding lazy loading are not picklable, and a cached view of a package index would be stale anyway.
234_COLLECTORS: dict[int, "DependencyCollector"] = {}
235
236
237@export
238class Entrypoint(metaclass=ExtendedType, slots=True):
239	"""
240	One entry of ``pyTooling_Dependency_Requirements``: an identifier and the requirements it stands for.
241
242	A file entrypoint is read while :file:`conf.py` is being processed and carries its requirements from then on.
243	A package entrypoint can only be resolved by asking the package index, so it carries the package's name and
244	extra and is resolved the first time a table names it.
245	"""
246
247	_identifier:   str                                    #: Name the documents refer to this entrypoint by.
248	_files:        tuple[Path, ...]                       #: Every requirements file read, references included.
249	_packages:     tuple[tuple[str, Nullable[str]], ...]  #: The packages to read, as name and extra.
250	_requirements: Nullable[dict[str, Requirement]]       #: The resolved requirements, by canonical package name.
251
252	def __init__(
253		self,
254		identifier: str,
255		files: tuple[Path, ...] = (),
256		packages: tuple[tuple[str, Nullable[str]], ...] = (),
257		requirements: Nullable[dict[str, Requirement]] = None
258	) -> None:
259		"""
260		Describe one entrypoint.
261
262		:param identifier:   Name the documents refer to this entrypoint by.
263		:param files:        Optional, every requirements file read, for a file entrypoint. Default: ``()``.
264		:param packages:     Optional, the packages and their extras, for a package entrypoint. Default: ``()``.
265		:param requirements: Optional, the requirements, if they are known already. Default: ``None``.
266		"""
267		self._identifier =   identifier
268		self._files =        files
269		self._packages =     packages
270		self._requirements = requirements
271
272	@readonly
273	def Identifier(self) -> str:
274		"""
275		Name the documents refer to this entrypoint by.
276
277		:returns: The identifier.
278		"""
279		return self._identifier
280
281	@readonly
282	def Files(self) -> tuple[Path, ...]:
283		"""
284		The requirements file and every file it includes.
285
286		:returns: The files this entrypoint was read from; empty for a package entrypoint.
287		"""
288		return self._files
289
290	@readonly
291	def Packages(self) -> tuple[tuple[str, Nullable[str]], ...]:
292		"""
293		The packages this entrypoint reads the requirements of, as ``(name, extra)`` pairs.
294
295		:returns: The packages, or an empty tuple for a file entrypoint.
296		"""
297		return self._packages
298
299	@readonly
300	def Requirements(self) -> Nullable[dict[str, Requirement]]:
301		"""
302		The requirements this entrypoint stands for.
303
304		:returns: Every required package by its canonical name, or ``None`` if they weren't resolved yet.
305		"""
306		return self._requirements
307
308	def CacheRequirements(self, requirements: dict[str, Requirement]) -> None:
309		"""
310		Remember the requirements the package index answered with.
311
312		A package entrypoint can only be resolved by asking the index; remembering the answer is what keeps a second
313		table naming the same entrypoint from asking again.
314
315		:param requirements: Every required package, by its canonical name.
316		"""
317		self._requirements = requirements
318
319	def __repr__(self) -> str:
320		"""
321		Return a representation naming what this entrypoint reads.
322
323		:returns: The identifier and its source.
324		"""
325		if len(self._files) > 0:
326			source = ", ".join(str(file) for file in self._files)
327		else:
328			source = ", ".join(name if extra is None else f"{name}[{extra}]" for name, extra in self._packages)
329
330		return f"<Entrypoint {self._identifier}: {source}>"
331
332
333@export
334class DependencyCollector(metaclass=ExtendedType, slots=True):
335	"""
336	The entrypoints a build declared, the package index it queries, and what querying it cost.
337
338	One collector is shared by every table of a build: a package required by two entrypoints is downloaded once, and
339	the time is accumulated so the build can report a total. It exists because the alternative - a table that queries
340	the index for itself - multiplies a documentation build's runtime by however many tables it has, and
341	:file:`requirements.txt`, :file:`tests/requirements.txt` and :file:`doc/requirements.txt` share most of what they
342	require.
343
344	The index is opened the first time a table asks for a package, not when the collector is created: a project may
345	declare its entrypoints and then build a document that shows none of them, and that build should not open an
346	HTTP session.
347	"""
348
349	_entrypoints: dict[str, Entrypoint]                    #: The entrypoints declared in :file:`conf.py`.
350	_indexURL:    str                                      #: URL of the package index's website.
351	_apiURL:      str                                      #: URL of the package index's JSON API.
352	_overrides:   LicenseOverrides                         #: Licenses stated by hand, where the index can't.
353	_graph:       Nullable[PythonPackageDependencyGraph]   #: Graph the downloaded packages are collected in.
354	_index:       Nullable[PythonPackageIndex]             #: The package index this build queries, once opened.
355	_projects:    dict[str, Nullable[Project]]             #: Projects downloaded so far; ``None`` if unknown.
356	_detailed:    set[str]                                 #: Releases whose details were downloaded.
357	_undescribed: set[str]                                 #: Releases the index lists but can't describe.
358	_stopwatch:   Stopwatch                                #: Runs only while a request to the index is in flight.
359	_unresolved:  dict[str, tuple[str, ...]]               #: Packages whose license the index couldn't answer for,
360	                                                       #: mapped to what it published instead.
361
362	def __init__(
363		self,
364		entrypoints: dict[str, Entrypoint],
365		indexURL: str,
366		apiURL: str,
367		overrides: LicenseOverrides
368	) -> None:
369		"""
370		Collect what a build declared, without opening the package index yet.
371
372		:param entrypoints: The entrypoints declared in :file:`conf.py`, by identifier.
373		:param indexURL:    URL of the package index's website.
374		:param apiURL:      URL of the package index's JSON API.
375		:param overrides:   Licenses stated by hand.
376		"""
377		self._entrypoints = entrypoints
378		self._indexURL =    indexURL
379		self._apiURL =      apiURL
380		self._overrides =   overrides
381		self._graph =       None
382		self._index =       None
383		self._projects =    {}
384		self._detailed =    set()
385		self._undescribed = set()
386		self._unresolved =  {}
387
388		# 'preferPause', so each 'with' around a request is one active span: 'Activity' is the time spent waiting for
389		# the index rather than the age of the collector, and 'ActiveCount' is the number of requests
390		self._stopwatch =   Stopwatch(preferPause=True)
391
392	@readonly
393	def Entrypoints(self) -> dict[str, Entrypoint]:
394		"""
395		The entrypoints declared in :file:`conf.py`.
396
397		:returns: Every entrypoint by its identifier.
398		"""
399		return self._entrypoints
400
401	@readonly
402	def Index(self) -> PythonPackageIndex:
403		"""
404		The package index this build queries, opened the first time it is asked for.
405
406		:returns: The package index.
407		"""
408		from pyTooling.Dependency.Python import PythonPackageDependencyGraph, PythonPackageIndex
409
410		if self._index is None:
411			self._graph = PythonPackageDependencyGraph("documentation")
412			self._index = PythonPackageIndex("index", self._indexURL, self._apiURL, self._graph, self._overrides)
413
414		return self._index
415
416	@readonly
417	def RequestCount(self) -> int:
418		"""
419		Number of requests sent to the package index.
420
421		The stopwatch runs for exactly one span per request, so this is its
422		:attr:`~pyTooling.Stopwatch.Stopwatch.ActiveCount` - counting them a second time in a field of our own
423		would be a second answer to one question.
424
425		:returns: Number of requests sent.
426		"""
427		return self._stopwatch.ActiveCount
428
429	@readonly
430	def Seconds(self) -> float:
431		"""
432		Time spent waiting for the package index, in seconds.
433
434		This is the stopwatch's :attr:`~pyTooling.Stopwatch.Stopwatch.Activity` - the sum of the intervals it ran -
435		not its duration, because it is paused between requests and everything the build does in between is not
436		time this collector spent.
437
438		:returns: Seconds spent on the index.
439		"""
440		return self._stopwatch.Activity
441
442	@readonly
443	def UnresolvedLicenses(self) -> dict[str, tuple[str, ...]]:
444		"""
445		Packages whose license the index couldn't answer for, and what it published instead.
446
447		The published fields are what the override file has to answer for, so they are kept rather than only the
448		package's name: ``License :: OSI Approved :: BSD License`` names three licenses and is never guessed at, and
449		a ``license`` field holding a license's title instead of its SPDX identifier doesn't parse.
450
451		:returns: Canonical names of the packages needing a license override, mapped to what the index published for them.
452		"""
453		return self._unresolved
454
455	def Project(self, packageName: str) -> Nullable[Project]:
456		"""
457		Return a project, downloading it the first time it is asked for.
458
459		A package the index doesn't know is remembered as unknown, so a table naming it doesn't ask again for every
460		row that mentions it. A warning reported while loading the project, e.g. for a release whose version the parser
461		doesn't support, is reported as a Sphinx warning.
462
463		:param packageName:             Name of the package to look up.
464		:returns:                       The project, or ``None`` if the index doesn't know it.
465		:raises MissingDependencyError: If the 'pypi' extra isn't installed.
466		"""
467		try:
468			from requests import RequestException
469		except ImportError as ex:  # pragma: no cover
470			raise MissingDependencyError(dependency="requests", extra="pypi") from ex
471
472		from pyTooling.Dependency        import DependencyError
473		from pyTooling.Dependency.Python import LazyLoaderState
474
475		if packageName in self._projects:
476			return self._projects[packageName]
477
478		warnings: list[BaseException] = []
479		with self._stopwatch, WarningCollector(warnings):
480			try:
481				project: Nullable[Project] = self.Index.DownloadProject(packageName, LazyLoaderState.PartiallyLoaded)
482			except (DependencyError, RequestException, ValueError, KeyError):
483				project = None
484
485		self._projects[packageName] = project
486
487		for warning in warnings:
488			_ReportWarning("[dependency-table]", warning)
489
490		return project
491
492	def Details(self, release: Release) -> Nullable[Release]:
493		"""
494		Make sure a release knows its own requirements and its license.
495
496		A release the index lists but can't describe - a yanked one, or a version its release endpoint spells
497		differently - is remembered as unusable and answered with ``None``. Handing back the release itself would
498		be worse than useless: its lazily loaded properties would each retry the download and raise.
499
500		An :class:`~pyTooling.Dependency.UnknownLicenseWarning` is recorded in :attr:`UnresolvedLicenses`. Every other
501		warning, e.g. a :class:`~pyTooling.Dependency.BrokenRequirementWarning` for a requirement whose marker names no
502		extra the release declares, is reported as a Sphinx warning.
503
504		:param release:                 The release to fill in.
505		:returns:                       The release with its details, or ``None`` if the index can't describe it.
506		:raises MissingDependencyError: If the 'pypi' extra isn't installed.
507		"""
508		try:
509			from packaging.utils import canonicalize_name
510			from requests        import RequestException
511		except ImportError as ex:  # pragma: no cover
512			raise MissingDependencyError(dependency="requests", extra="pypi") from ex
513
514		from pyTooling.Dependency import DependencyError
515
516		key = f"{release.Package.Name}=={release.Version}"
517		if key in self._detailed:
518			return release if key not in self._undescribed else None
519
520		warnings: list[BaseException] = []
521		with self._stopwatch, WarningCollector(warnings):
522			try:
523				release.DownloadDetails()
524			except (DependencyError, RequestException, ValueError, KeyError):
525				self._undescribed.add(key)
526
527		self._detailed.add(key)
528
529		for warning in warnings:
530			if isinstance(warning, UnknownLicenseWarning):
531				# the warning's notes are what the index published, which is the reason an override is needed; keyed by the
532				# canonical name, which is the override file's spelling, so two spellings of one package are one entry
533				self._unresolved[canonicalize_name(release.Package.Name)] = warning.Notes
534			else:
535				_ReportWarning("[dependency-table]", warning)
536
537		return None if key in self._undescribed else release
538
539
540@export
541def readEntrypoints(configuration: Any, confDirectory: Path) -> dict[str, Entrypoint]:
542	"""
543	Turn ``pyTooling_Dependency_Requirements`` into entrypoints, reading every requirements file it names.
544
545	A requirements file is read here rather than when a table is built, so a path that doesn't exist ends the build
546	with one message naming the identifier instead of an error box in the middle of a page - and so two tables
547	naming the same file read it once.
548
549	:param configuration:           Value of ``pyTooling_Dependency_Requirements``.
550	:param confDirectory:           Directory :file:`conf.py` lives in; relative paths are resolved against it.
551	:returns:                       Every declared entrypoint, by its identifier.
552	:raises MissingDependencyError: If the 'pypi' extra isn't installed.
553	:raises SphinxExtensionError:   If the configuration is malformed, or a requirements file can't be read.
554	"""
555	if not isinstance(configuration, dict):
556		raise SphinxExtensionError(
557			f"conf.py: {CONFIG_PREFIX}_Requirements: Expected a dictionary, "
558			f"got '{getFullyQualifiedName(configuration)}'."
559		)
560
561	entrypoints: dict[str, Entrypoint] = {}
562	for identifier, declaration in configuration.items():
563		location = f"conf.py: {CONFIG_PREFIX}_Requirements:[{identifier}]"
564
565		if not isinstance(declaration, dict):
566			raise SphinxExtensionError(
567				f"{location}: Expected a dictionary, got '{getFullyQualifiedName(declaration)}'."
568			)
569
570		if (unknown := set(declaration) - set(ENTRYPOINT_FIELDS)) != set():
571			raise SphinxExtensionError(
572				f"{location}: Unknown field(s): {', '.join(sorted(unknown))}. "
573				f"Known are: {', '.join(ENTRYPOINT_FIELDS)}."
574			)
575
576		if len(stated := [field for field in ENTRYPOINT_FIELDS if field in declaration]) != 1:
577			known = ", ".join(ENTRYPOINT_FIELDS)
578			raise SphinxExtensionError(
579				f"{location}: Exactly one of {known} has to be configured, "
580				f"{'none is' if len(stated) == 0 else f'{len(stated)} are'}."
581			)
582
583		field = stated[0]
584		fieldLocation = f"{location}.{field}"
585		value = declaration[field]
586
587		if field in ("file", "package"):
588			if not isinstance(value, str):
589				raise SphinxExtensionError(
590					f"{fieldLocation}: Expected a string, got '{getFullyQualifiedName(value)}'."
591				)
592
593			values: tuple[str, ...] = (value,)
594		else:
595			# a string is an iterable of strings itself, so the plural form has to reject one explicitly - otherwise
596			# {"files": "requirements.txt"} would silently become sixteen one-character paths
597			if isinstance(value, str) or not isinstance(value, Iterable):
598				raise SphinxExtensionError(
599					f"{fieldLocation}: Expected an iterable of strings, got '{getFullyQualifiedName(value)}'. "
600					f"Use '{field[:-1]}' for a single value."
601				)
602
603			# materialized before the items are checked, because an iterable may be a generator this would consume
604			values = tuple(value)
605			for item in values:
606				if not isinstance(item, str):
607					raise SphinxExtensionError(
608						f"{fieldLocation}: Expected strings, got '{getFullyQualifiedName(item)}'."
609					)
610
611		if field in ("file", "files"):
612			entrypoints[identifier] = _FileEntrypoint(identifier, fieldLocation, values, confDirectory)
613		else:
614			entrypoints[identifier] = _PackageEntrypoint(identifier, values)
615
616	return entrypoints
617
618
619def _FileEntrypoint(
620	identifier: str,
621	location: str,
622	files: tuple[str, ...],
623	confDirectory: Path
624) -> Entrypoint:
625	"""
626	Read one entrypoint's requirements files.
627
628	Several files are read as several trees and flattened in the order they are declared, so a later file's
629	statement wins - the rule a single file's ``-r`` references already follow.
630
631	A line that can't be parsed as a requirement - a :class:`~pyTooling.Dependency.BrokenRequirementWarning` - is
632	reported as a Sphinx warning naming the file and line; the file's other requirements are read.
633
634	:param identifier:              Identifier of the entrypoint.
635	:param location:                Where in :file:`conf.py` this came from.
636	:param files:                   The declared paths.
637	:param confDirectory:           Directory relative paths resolve against.
638	:returns:                       The entrypoint, with its requirements read.
639	:raises MissingDependencyError: If the 'pypi' extra isn't installed.
640	:raises SphinxExtensionError:   If a file can't be read.
641	"""
642	try:
643		from packaging.utils import canonicalize_name
644	except ImportError as ex:  # pragma: no cover
645		raise MissingDependencyError(dependency="packaging", extra="pypi") from ex
646
647	from pyTooling.Dependency        import DependencyError
648	from pyTooling.Dependency.Python import RequirementsFile
649
650	readFiles: list[Path] = []
651	requirements: dict[str, Requirement] = {}
652
653	for file in files:
654		path = Path(file)
655		if not path.is_absolute():
656			path = confDirectory / path
657
658		warnings: list[BaseException] = []
659		try:
660			with WarningCollector(warnings):
661				requirementsFile = RequirementsFile(path)
662		except (DependencyError, OSError, UnicodeDecodeError) as cause:
663			raise SphinxExtensionError(f"{location}: Requirements file '{path}' can't be read: {cause}") from cause
664
665		for warning in warnings:
666			_ReportWarning(f"{location}:", warning)
667
668		# the tree knows every file it was read from; walking it here would be a second answer to one question
669		readFiles.extend(requirementsFile.AnalyzedRequirementFiles)
670		requirements.update({canonicalize_name(req.name): req for req in requirementsFile.AllRequirements})
671
672	return Entrypoint(identifier, files=tuple(readFiles), requirements=requirements)
673
674
675def _PackageEntrypoint(identifier: str, packages: tuple[str, ...]) -> Entrypoint:
676	"""
677	Describe one entrypoint's packages, which only the package index can resolve.
678
679	:param identifier: Identifier of the entrypoint.
680	:param packages:   The declared packages, each optionally with one extra.
681	:returns:          The entrypoint, with its packages recorded and its requirements still unresolved.
682	"""
683	requested: list[tuple[str, Nullable[str]]] = []
684	for package in packages:
685		name, _, bracket = package.partition("[")
686		requested.append((name.strip(), bracket.rstrip("]").strip() or None))
687
688	return Entrypoint(identifier, packages=tuple(requested))
689
690
691@export
692def prepareEntrypoints(sphinx: Sphinx, config: Config) -> None:
693	"""
694	Call-back for Sphinx' ``config-inited`` event, reading the entrypoints and the license overrides.
695
696	A build declaring no entrypoint does nothing here - not even import :mod:`pyTooling.Dependency.Python`, so a
697	project using only this extension's roles doesn't need the ``pypi`` extra.
698
699	:param sphinx:                The Sphinx application.
700	:param config:                The configuration, after :file:`conf.py` was read.
701	:raises SphinxExtensionError: If the configuration is malformed, or a
702	  requirements or license override file can't be read.
703	"""
704	if len(declarations := getattr(config, f"{CONFIG_PREFIX}_Requirements", {})) == 0:
705		return
706
707	confDirectory = Path(sphinx.confdir)
708
709	try:
710		from pyTooling.Dependency        import DependencyError
711		from pyTooling.Dependency.Python import LicenseOverrides
712	except MissingDependencyError as cause:  # pragma: no cover
713		raise SphinxExtensionError(
714			f"conf.py: {CONFIG_PREFIX}_Requirements: Querying a package index needs the 'pypi' extra: "
715			f"pip install pyTooling[pypi]"
716		) from cause
717
718	overrides = LicenseOverrides()
719	if (overrideFile := getattr(config, f"{CONFIG_PREFIX}_PackageOverrides", None)) is not None:
720		path = Path(overrideFile)
721		if not path.is_absolute():
722			path = confDirectory / path
723
724		try:
725			overrides = LicenseOverrides.FromFile(path)
726		except (DependencyError, ConfigurationError, OSError) as cause:
727			raise SphinxExtensionError(
728				f"conf.py: {CONFIG_PREFIX}_PackageOverrides: Override file '{path}' can't be read: {cause}"
729			) from cause
730
731	_COLLECTORS[id(sphinx)] = DependencyCollector(
732		readEntrypoints(declarations, confDirectory),
733		getattr(config, f"{CONFIG_PREFIX}_IndexURL", DEFAULT_INDEX_URL),
734		getattr(config, f"{CONFIG_PREFIX}_APIURL", DEFAULT_API_URL),
735		overrides
736	)
737
738
739@export
740class DependencyTable(BaseDirective):
741	"""
742	The ``dependency-table`` directive: an entrypoint's dependencies, rendered from the requirements.
743
744	One argument, the identifier of an entrypoint declared in ``pyTooling_Dependency_Requirements``. ``:depth:``
745	says how many levels of sub-dependencies to expand, ``:simplified-versions:`` whether a constraint is reduced to
746	its lower bound, and ``:caption:`` puts a caption under the table; which package index is queried and which
747	licenses are stated by hand are build-wide and configured in :file:`conf.py`.
748	"""
749
750	directiveName: str = "dependency-table"  #: Name the directive is invoked by.
751
752	#: The configuration values this directive adds to :file:`conf.py`, as ``name: (default, rebuild, types)``. Each is
753	#: registered with :data:`CONFIG_PREFIX` as prefix, e.g. ``pyTooling_Dependency_Requirements``.
754	#:
755	#: ``Requirements`` maps an identifier to what it names - a file, files, a package or packages. The other three are
756	#: build-wide, because one package index is queried per build and one override file answers for it. All four are
757	#: ``"env"``-rebuilt: changing any of them changes every table.
758	configValues: ClassVar[dict[str, tuple[Any, _ConfigRebuild, Any]]] = {
759		"Requirements":     ({},                "env", dict),
760		"PackageOverrides": (None,              "env", (str, Path)),
761		"IndexURL":         (DEFAULT_INDEX_URL, "env", str),
762		"APIURL":           (DEFAULT_API_URL,   "env", str),
763	}
764
765	_simplify:         bool               #: Whether this table's version constraints are reduced to their lower bound.
766	_versionFormat:    VersionFormat      #: How many parts of a version number this table prints.
767	_dependencyFormat: DependencyFormat   #: What a line of this table's dependency trees states.
768
769	has_content =               False  #: A boolean; ``True`` if content is allowed.
770	required_arguments =        1      #: Number of required directive arguments: the entrypoint's identifier.
771	optional_arguments =        0      #: Number of optional arguments after the required ones.
772	final_argument_whitespace = False  #: A boolean; ``True`` if the last argument may contain spaces.
773	# docutils declares 'option_spec' on 'Directive' and 'BaseDirective' assigns it, so mypy calls every
774	# spelling of this override a conflict with one of them
775	#: Mapping of option names to validator functions.
776	option_spec: dict[str, Any] = {  # type: ignore[misc]
777		"caption":             strip,
778		"depth":               directives.nonnegative_int,
779		"simplified-versions": stripAndNormalize,
780		"version-format":      stripAndNormalize,
781		"dependency-format":   stripAndNormalize,
782	}
783
784	def run(self) -> list[nodes.Node]:
785		"""
786		Resolve the named entrypoint against the package index and return its requirements as a table.
787
788		:returns: A ``table`` node, or an error node when the entrypoint couldn't be resolved.
789		"""
790		identifier =             self.arguments[0].strip()
791		self._simplify =         self._ParseBooleanOption("simplified-versions", DEFAULT_SIMPLIFIED_VERSIONS)
792		self._versionFormat =    self._ParseFormatOption("version-format", VersionFormat, DEFAULT_VERSION_FORMAT)
793		self._dependencyFormat = self._ParseFormatOption(
794			"dependency-format", DependencyFormat, DEFAULT_DEPENDENCY_FORMAT
795		)
796
797		with Stopwatch() as stopwatch:
798			try:
799				collector = self._Collector()
800				requestsBefore = collector.RequestCount
801				requirements = self._Resolve(identifier, collector)
802				table = self._CreateTable(identifier, requirements, collector)
803			except SphinxExtensionError as cause:
804				return [self.state.document.reporter.error(
805					f"{self.directiveName}: {cause}", line=self.lineno
806				)]
807
808		# what this table cost, not what the build has spent so far - a package another table already downloaded is
809		# free here, and that is the point of sharing the collector
810		_logger.info(
811			f"[{self.directiveName}] {identifier}: {len(requirements)} package(s), "
812			f"{collector.RequestCount - requestsBefore} request(s), {stopwatch.Duration:.2f} s"
813		)
814
815		return [table]
816
817	def _ParseFormatOption(self, optionName: str, enumType: type[_FormatType], default: _FormatType) -> _FormatType:
818		"""
819		Read an option naming a member of an enumeration, or fall back to its default.
820
821		:attr:`~pyTooling.Sphinx.BaseDirective._ParseEnumOption` requires the option and
822		lower-cases what it reads; these two have a default and are written the way the members are spelled, so a
823		document says ``:version-format: MajorMinor`` rather than ``major_minor``.
824
825		:param optionName:            Name of the option to read.
826		:param enumType:              The enumeration its value names a member of.
827		:param default:               The member to use when the option isn't given.
828		:returns:                     The named member.
829		:raises SphinxExtensionError: If the value names no member.
830		"""
831		if (option := self.options.get(optionName, None)) is None:
832			return default
833
834		for member in enumType:
835			if option.lower() == member.name.lower():
836				return member
837
838		known = ", ".join(member.name for member in enumType)
839		raise SphinxExtensionError(
840			f"{self.directiveName}::{optionName}: '{option}' is not one of: {known}."
841		)
842
843	def _Collector(self) -> DependencyCollector:
844		"""
845		Return the build's collector, which :func:`prepareEntrypoints` created when :file:`conf.py` was read.
846
847		The collector belongs to the running application rather than to the directive, because a document with three
848		tables would otherwise open three indexes and download the same packages three times. It deliberately does
849		*not* live on the build environment: Sphinx pickles that between runs, and neither an open HTTP session nor a
850		cached view of a package index survives being pickled - or should.
851
852		:returns:                     The collector shared by every table of this build.
853		:raises SphinxExtensionError: If no entrypoint was configured.
854		"""
855		if (collector := _COLLECTORS.get(id(self.env.app), None)) is None:
856			raise SphinxExtensionError(
857				f"No entrypoint is configured. Declare one in conf.py: {CONFIG_PREFIX}_Requirements."
858			)
859
860		return collector
861
862	def _Resolve(self, identifier: str, collector: DependencyCollector) -> dict[str, Requirement]:
863		"""
864		Return what the named entrypoint requires.
865
866		A file entrypoint was read when :file:`conf.py` was processed and answers immediately; a package entrypoint
867		is resolved against the package index the first time a table names it, and remembers the answer.
868
869		:param identifier:              Identifier the document names.
870		:param collector:               The build's collector.
871		:returns:                       Every required package, by its canonical name.
872		:raises MissingDependencyError: If the 'pypi' extra isn't installed.
873		:raises SphinxExtensionError:   If the identifier is unknown, or the
874		  package index can't answer for the entrypoint's package.
875		"""
876		try:
877			from packaging.utils import canonicalize_name
878		except ImportError as ex:  # pragma: no cover
879			raise MissingDependencyError(dependency="packaging", extra="pypi") from ex
880
881		if (entrypoint := collector.Entrypoints.get(identifier, None)) is None:
882			known = ", ".join(sorted(collector.Entrypoints)) or "none"
883			raise SphinxExtensionError(
884				f"Entrypoint '{identifier}' is not configured in conf.py: {CONFIG_PREFIX}_Requirements. "
885				f"Known are: {known}."
886			)
887
888		for file in entrypoint.Files:
889			self.env.note_dependency(str(file))
890
891		if (requirements := entrypoint.Requirements) is not None:
892			return requirements
893
894		# several packages flatten in the order they are declared, the rule a file's '-r' references already follow
895		requirements = {}
896		for packageName, extra in entrypoint.Packages:
897			for requirement in self._PublishedRequirements(packageName, extra, collector):
898				requirements[canonicalize_name(requirement.name)] = requirement
899
900		entrypoint.CacheRequirements(requirements)
901
902		return requirements
903
904	def _PublishedRequirements(
905		self,
906		packageName: str,
907		extra: Nullable[str],
908		collector: DependencyCollector
909	) -> list[Requirement]:
910		"""
911		Ask the package index what a package's latest release requires.
912
913		:param packageName:           Name of the package to ask about.
914		:param extra:                 Extra whose requirements are wanted, or ``None`` for the package's own.
915		:param collector:             The build's collector.
916		:returns:                     What that release requires.
917		:raises SphinxExtensionError: If the index doesn't know the
918		  package, can't describe its latest release, or the package has no such extra.
919		"""
920		if (project := collector.Project(packageName)) is None:
921			raise SphinxExtensionError(f"Package '{packageName}' is unknown to the package index.")
922
923		if (release := collector.Details(project.LatestRelease)) is None:
924			raise SphinxExtensionError(f"The package index can't describe the latest release of '{packageName}'.")
925
926		published: Nullable[list[Requirement]] = release.Requirements.get(extra, None)
927		if published is None:
928			known = ", ".join(sorted(str(key) for key in release.Requirements if key is not None))
929			raise SphinxExtensionError(f"Package '{packageName}' has no extra '{extra}'. Known are: {known}.")
930
931		return published
932
933	def _CreateTable(
934		self,
935		identifier: str,
936		requirements: dict[str, Requirement],
937		collector: DependencyCollector
938	) -> nodes.table:
939		"""
940		Render the requirements as a four-column table.
941
942		:param identifier:   Identifier of the entrypoint, used as the table's identifier.
943		:param requirements: Every required package, by its canonical name.
944		:param collector:    The build's collector.
945		:returns:            The finished table.
946		"""
947		tableGroup = self._CreateSingleRowTableHeader(
948			columns=list(TABLE_COLUMNS),
949			identifier=identifier,
950			classes=["dependency-table"]
951		)
952		tableGroup += (tableBody := nodes.tbody())
953
954		# ':depth: 0' - and the default - means expand until the tree ends; 'None' is that, internally, because
955		# 'depth - 1' would otherwise walk 0 into negative numbers and mean two different things at once
956		depth = self.options.get("depth", DEFAULT_DEPTH)
957		levels: Nullable[int] = None if depth == 0 else depth
958
959		if len(requirements) == 0:
960			tableBody += self._CreateEmptyRow(len(TABLE_COLUMNS))
961		else:
962			for name in sorted(requirements, key=str.lower):
963				tableBody += self._CreateRow(requirements[name], collector, levels)
964
965		table = cast(nodes.table, tableGroup.parent)
966		if (caption := self.options.get("caption", None)) is not None:
967			# the caption is ReST, not text: it is written with markup - ``packaging`` in pyTooling's own captions -
968			# and a 'title' built from a string would print the backticks
969			captionNodes, messages = self.state.inline_text(caption, self.lineno)
970			table.insert(0, nodes.title(caption, "", *captionNodes, *messages))
971
972		return table
973
974	@staticmethod
975	def _CreateEmptyRow(columnCount: int) -> nodes.row:
976		"""
977		Render the one row a table with no requirements gets: a single cell spanning every column.
978
979		A table showing nothing but its header reads as a defect. pyTooling's own :file:`requirements.txt` is empty
980		- the package has no mandatory dependencies - and that is a statement worth printing.
981
982		:param columnCount: Number of columns the cell has to span.
983		:returns:           The table row.
984		"""
985		tableRow = nodes.row("", classes=["dependency-table-row"])
986
987		entry = nodes.entry("", morecols=columnCount - 1)
988		entry += nodes.paragraph("", "", nodes.emphasis(text="No dependencies"))
989		tableRow += entry
990
991		return tableRow
992
993	def _CreateRow(
994		self,
995		requirement: Requirement,
996		collector: DependencyCollector,
997		depth: Nullable[int]
998	) -> nodes.row:
999		"""
1000		Render one required package as a table row.
1001
1002		A package the index doesn't know, or one with no release matching the requirement, still gets a row - the
1003		specifier the entrypoint states is worth showing even when nothing else could be resolved.
1004
1005		:param requirement: The requirement to render.
1006		:param collector:   The build's collector.
1007		:param depth:       Levels of sub-dependencies still to expand.
1008		:returns:           The table row.
1009		"""
1010		tableRow = nodes.row("", classes=["dependency-table-row"])
1011
1012		project = collector.Project(requirement.name)
1013		release = self._SelectRelease(project, requirement, collector)
1014
1015		tableRow += self._PackageEntry(requirement, project)
1016		specifier = self._FormatSpecifier(requirement.specifier, self._simplify, self._versionFormat)
1017
1018		tableRow += nodes.entry("", nodes.paragraph(text=specifier))
1019		tableRow += self._LicenseEntry(release)
1020		tableRow += self._DependenciesEntry(release, collector, depth, {requirement.name.lower()})
1021
1022		return tableRow
1023
1024	def _SelectRelease(
1025		self,
1026		project: Nullable[Project],
1027		requirement: Requirement,
1028		collector: DependencyCollector
1029	) -> Nullable[Release]:
1030		"""
1031		Return the newest release satisfying a requirement.
1032
1033		Pre-releases are skipped unless the specifier asks for them, because that is what an installer would resolve
1034		to and the table describes what would be installed.
1035
1036		:param project:     The project to pick a release of, or ``None`` if the index doesn't know it.
1037		:param requirement: The requirement to satisfy.
1038		:param collector:   The build's collector.
1039		:returns:           The newest matching release, or ``None`` if nothing matches.
1040		"""
1041		if project is None:
1042			return None
1043
1044		matching = [
1045			release for version, release in project.Releases.items()
1046			if requirement.specifier.contains(str(version))
1047		]
1048		if len(matching) == 0:
1049			return None
1050
1051		return collector.Details(max(matching, key=lambda release: release.Version))
1052
1053	@staticmethod
1054	def _FormatVersion(version: str, versionFormat: VersionFormat) -> str:
1055		"""
1056		Shorten a version number to the parts a table prints.
1057
1058		``≥0.4.6`` says more than a reader of a dependency table needs; the parts that matter are the ones a
1059		constraint is usually written against. A version with fewer parts than asked for is left as it is - ``≥9``
1060		does not become ``≥9.0`` - because padding would state a precision the requirement didn't.
1061
1062		:param version:       The version, as the constraint writes it.
1063		:param versionFormat: How many parts to keep.
1064		:returns:             The shortened version.
1065		"""
1066		if versionFormat is VersionFormat.All:
1067			return version
1068
1069		parts = {VersionFormat.Major: 1, VersionFormat.MajorMinor: 2, VersionFormat.MajorMinorPatch: 3}[versionFormat]
1070
1071		return ".".join(version.split(".")[:parts])
1072
1073	@staticmethod
1074	def _FormatSpecifier(specifier: SpecifierSet, simplify: bool, versionFormat: VersionFormat) -> str:
1075		"""
1076		Render a version constraint the way a reader writes one.
1077
1078		The comparison operators become their mathematical symbols and the versions are shortened to
1079		``versionFormat``. A simplified constraint keeps only what a package *has to be at least*: an upper bound
1080		and an exclusion say what a release must not be, which is the packaging problem rather than the reader's,
1081		and ``~=`` is written as the lower bound it implies. Simplifying everything away leaves the constraint as it
1082		was written - ``<4.0`` alone is still the whole statement.
1083
1084		:param specifier:     The constraint to render.
1085		:param simplify:      Whether to reduce the constraint to its lower bound.
1086		:param versionFormat: How many parts of each version to keep.
1087		:returns:             The constraint, or ``any`` when nothing is constrained.
1088		"""
1089		def render(operator: str, version: str) -> str:
1090			shortened = DependencyTable._FormatVersion(version, versionFormat)
1091			for written, symbol in OPERATOR_SYMBOLS:
1092				if operator == written:
1093					return f"{symbol}{shortened}"
1094
1095			return f"{operator}{shortened}"
1096
1097		if len(specifier) == 0:
1098			return "any"
1099
1100		specifiers = sorted(specifier, key=lambda item: (item.version, item.operator))
1101		if simplify:
1102			kept = [
1103				render(">=" if item.operator == "~=" else item.operator, item.version)
1104				for item in specifiers if item.operator not in _DROPPED_OPERATORS
1105			]
1106			if len(kept) > 0:
1107				# shortening can make two constraints identical - '>=1.2.3, >1.2.9' is '≥1.2, >1.2' at MajorMinor -
1108				# and printing one statement twice reads as a defect
1109				return ", ".join(dict.fromkeys(kept))
1110
1111		rendered = [render(item.operator, item.version) for item in specifiers]
1112
1113		return ", ".join(dict.fromkeys(rendered))
1114
1115	@staticmethod
1116	def _PackageURL(project: Nullable[Project]) -> Nullable[str]:
1117		"""
1118		Return the page a package's name should link to, most useful first.
1119
1120		A project states none of these reliably, so there are three chances at one: its **documentation** answers
1121		*what is this*, its **repository** answers *where does it come from*, and its page on the **package index**
1122		is what the index itself can always answer. Only a package the index doesn't know at all goes unlinked.
1123
1124		:param project: The project, or ``None`` if the index doesn't know it.
1125		:returns:       The URL to link the name to, or ``None`` if there is nothing to link to.
1126		"""
1127		if project is None:
1128			return None
1129
1130		for url in (project.DocumentationURL, project.RepositoryURL, project.URL):
1131			if url is not None:
1132				return str(url)
1133
1134		return None
1135
1136	@classmethod
1137	def _PackageEntry(cls, requirement: Requirement, project: Nullable[Project]) -> nodes.entry:
1138		"""
1139		Render the package's name, linked to where a reader can find out about it.
1140
1141		:param requirement: The requirement naming the package.
1142		:param project:     The project, or ``None`` if the index doesn't know it.
1143		:returns:           The table entry.
1144		"""
1145		entry = nodes.entry()
1146		name = project.Name if project is not None else requirement.name
1147
1148		if (url := cls._PackageURL(project)) is not None:
1149			entry += nodes.paragraph("", "", nodes.reference("", name, refuri=url))
1150		else:
1151			entry += nodes.paragraph(text=name)
1152
1153		return entry
1154
1155	@staticmethod
1156	def _LicenseEntry(release: Nullable[Release]) -> nodes.entry:
1157		"""
1158		Render a release's license, linked to its text where one is known.
1159
1160		The license' **name** is shown rather than its SPDX identifier - ``Apache License 2.0``, not ``Apache-2.0`` -
1161		because the table is read by a person and the identifier is what an expression writes.
1162
1163		A license that didn't resolve is an :class:`~pyTooling.Licensing.UnknownLicense`, never a blank cell, and it
1164		is shown **as the index published it** - in italics, so it reads as a quotation rather than as an identifier.
1165		The reader should see that the index said *something*, and what it was.
1166
1167		:param release: The release to render the license of, or ``None``.
1168		:returns:       The table entry.
1169		"""
1170		entry = nodes.entry()
1171
1172		if (text := DependencyTable._LicenseName(release)) is None:
1173			entry += nodes.paragraph("", "", nodes.emphasis(text=DependencyTable._PublishedLicense(release)))
1174
1175			return entry
1176
1177		if (url := DependencyTable._LicenseURL(release)) is not None:
1178			entry += nodes.paragraph("", "", nodes.reference("", text, refuri=url))
1179		else:
1180			entry += nodes.paragraph(text=text)
1181
1182		return entry
1183
1184	@staticmethod
1185	def _LicenseName(release: Nullable[Release]) -> Nullable[str]:
1186		"""
1187		Return the name(s) of the licenses a release is published under.
1188
1189		:param release: The release to name the license of, or ``None``.
1190		:returns:       The license' name, or ``None`` if nothing resolved.
1191		"""
1192		from pyTooling.Licensing import UnknownLicense
1193
1194		if release is None:
1195			return None
1196
1197		# 'Licenses' never comes back empty: what didn't resolve is an 'UnknownLicense', which is SPDX's own way of
1198		# saying so and keeps the published text
1199		licenses = release.Licenses
1200		if all(isinstance(license, UnknownLicense) for license in licenses):
1201			return None
1202
1203		return ", ".join(license.Name for license in licenses)
1204
1205	@staticmethod
1206	def _PublishedLicense(release: Nullable[Release]) -> str:
1207		"""
1208		Return what the package index published, for a license that didn't resolve.
1209
1210		:param release: The release, or ``None`` if the index couldn't describe it.
1211		:returns:       What was published, or ``unknown`` when that was nothing either.
1212		"""
1213		if release is None:
1214			return "unknown"
1215
1216		published = release.LicenseExpression.OriginalText.strip()
1217
1218		return published if published != "" else ", ".join(license.Name for license in release.Licenses) or "unknown"
1219
1220	@staticmethod
1221	def _LicenseURL(release: Nullable[Release]) -> Nullable[str]:
1222		"""
1223		Return the page a license should link to, most specific first.
1224
1225		**The project's own** :file:`LICENSE` **file wins**: it is the license as this project publishes it, which
1226		is the document a reader auditing a dependency actually wants. Most projects don't state one, though - it
1227		comes from ``project_urls`` or from the override file - so a license on the SPDX List falls back to its own
1228		published pages, in the order of who is speaking: the licensor's own page, then OSI's entry, then SPDX's.
1229		A ``LicenseRef-`` has none of those and stays unlinked, because nothing published it.
1230
1231		:param release: The release to link the license of, or ``None``.
1232		:returns:       The URL to link to, or ``None`` if nothing published this license.
1233		"""
1234		from pyTooling.Licensing import SPDXLicense
1235
1236		if release is None:
1237			return None
1238
1239		if release.LicenseURL is not None:
1240			return str(release.LicenseURL)
1241
1242		for license in release.Licenses:
1243			if isinstance(license, SPDXLicense):
1244				for url in (license.License.URL, license.License.OSIURL, license.License.SPDXURL):
1245					if url is not None:
1246						return url
1247
1248		return None
1249
1250	def _DependenciesEntry(
1251		self,
1252		release: Nullable[Release],
1253		collector: DependencyCollector,
1254		depth: Nullable[int],
1255		visited: set[str]
1256	) -> nodes.entry:
1257		"""
1258		Render a release's own requirements as a nested bullet list.
1259
1260		Only the unconditional requirements are listed - what an extra pulls in is that extra's table, not this one.
1261		A package already on the path is not expanded again, so a dependency cycle terminates.
1262
1263		:param release:   The release to render the dependencies of, or ``None``.
1264		:param collector: The build's collector.
1265		:param depth:     Levels still to expand; at zero nothing is expanded.
1266		:param visited:   Packages already on this path, lower-cased.
1267		:returns:         The table entry.
1268		"""
1269		entry = nodes.entry()
1270
1271		if release is None or (depth is not None and depth <= 0):
1272			entry += nodes.paragraph("", "", nodes.emphasis(text="not evaluated"))
1273			return entry
1274
1275		requirements = [
1276			requirement for requirement in release.Requirements.get(None, [])
1277			if requirement.name.lower() not in visited
1278		]
1279		if len(requirements) == 0:
1280			entry += nodes.paragraph("", "", nodes.emphasis(text="none"))
1281			return entry
1282
1283		entry += self._CreateBulletList(requirements, collector, _OneLevelDown(depth), visited)
1284
1285		return entry
1286
1287	def _CreateBulletList(
1288		self,
1289		requirements: list[Requirement],
1290		collector: DependencyCollector,
1291		depth: Nullable[int],
1292		visited: set[str]
1293	) -> nodes.bullet_list:
1294		"""
1295		Render requirements as a bullet list, each item expanded by one more level.
1296
1297		An item whose own dependencies the depth limit cuts off gets a marker saying how many weren't expanded.
1298
1299		:param requirements: The requirements to list.
1300		:param collector:    The build's collector.
1301		:param depth:        Levels still to expand below this list.
1302		:param visited:      Packages already on this path, lower-cased.
1303		:returns:            The bullet list.
1304		"""
1305		bulletList = nodes.bullet_list()
1306
1307		for requirement in sorted(requirements, key=lambda item: item.name.lower()):
1308			item = nodes.list_item()
1309
1310			# a leaf at the depth limit is resolved too: whether its tree goes on is what the limit marker states
1311			project = collector.Project(requirement.name)
1312			release = self._SelectRelease(project, requirement, collector)
1313
1314			item += self._RequirementParagraph(requirement, project, release)
1315
1316			if release is not None:
1317				nested = [
1318					nestedRequirement for nestedRequirement in release.Requirements.get(None, [])
1319					if nestedRequirement.name.lower() not in visited
1320				]
1321				if len(nested) > 0 and (depth is None or depth > 0):
1322					item += self._CreateBulletList(
1323						nested, collector, _OneLevelDown(depth), visited | {requirement.name.lower()}
1324					)
1325				elif len(nested) > 0:
1326					item += self._CreateLimitMarker(len(nested))
1327
1328			bulletList += item
1329
1330		return bulletList
1331
1332	@staticmethod
1333	def _CreateLimitMarker(count: int) -> nodes.bullet_list:
1334		"""
1335		Render the marker of a tree the ``:depth:`` limit cut off: how many dependencies weren't expanded.
1336
1337		:param count: Number of the package's own dependencies, which weren't expanded.
1338		:returns:     A bullet list with one item.
1339		"""
1340		text = f"… {count} more {'dependency' if count == 1 else 'dependencies'} not expanded"
1341
1342		bulletList = nodes.bullet_list()
1343		bulletList += nodes.list_item("", nodes.paragraph("", "", nodes.emphasis(text=text)), classes=["dependency-limit"])
1344
1345		return bulletList
1346
1347	def _RequirementParagraph(
1348		self,
1349		requirement: Requirement,
1350		project: Nullable[Project],
1351		release: Nullable[Release]
1352	) -> nodes.paragraph:
1353		"""
1354		Render one line of a dependency tree: the package, what is required of it, and what it is licensed under.
1355
1356		The license is the reason a dependency tree is in this table at all - a package pulls in what its own
1357		dependencies are licensed under, and reading that off the tree is the point. It is linked and parenthesised
1358		so the line still reads as one requirement.
1359
1360		:param requirement: The requirement to render.
1361		:param project:     The project, or ``None`` if the index doesn't know it.
1362		:param release:     The release satisfying the requirement, or ``None`` if none was found.
1363		:returns:           The paragraph.
1364		"""
1365		paragraph = nodes.paragraph()
1366
1367		name = project.Name if project is not None else requirement.name
1368		if (url := self._PackageURL(project)) is not None:
1369			paragraph += nodes.reference("", name, refuri=url)
1370		else:
1371			paragraph += nodes.Text(name)
1372
1373		if self._dependencyFormat.ShowsVersion:
1374			specifier = self._FormatSpecifier(requirement.specifier, self._simplify, self._versionFormat)
1375			if specifier != "any":
1376				paragraph += nodes.Text(f" {specifier}")
1377
1378		if self._dependencyFormat.ShowsLicense:
1379			paragraph += nodes.Text(" (")
1380			if (license := self._LicenseName(release)) is None:
1381				# the same statement the License column makes: what the index published, in italics, so it reads as
1382				# a quotation rather than as an identifier
1383				paragraph += nodes.emphasis(text=self._PublishedLicense(release))
1384			elif (licenseURL := self._LicenseURL(release)) is not None:
1385				paragraph += nodes.reference("", license, refuri=licenseURL)
1386			else:
1387				paragraph += nodes.Text(license)
1388			paragraph += nodes.Text(")")
1389
1390		return paragraph
1391
1392
1393@export
1394def formatUnresolvedLicenses(unresolved: Mapping[str, tuple[str, ...]]) -> str:
1395	"""
1396	Describe the packages needing a license override, grouped by what the package index published for them.
1397
1398	Grouped rather than listed one per line, because one ambiguous statement usually accounts for most of the list:
1399	``License :: OSI Approved :: BSD License`` names three licenses, so every package whose only license information
1400	is that classifier lands here for the same reason and is worth reading as one group.
1401
1402	:param unresolved: Names of the packages needing an override, mapped to what the index published for them.
1403	:returns:          The message, as one line naming the count and two lines per reason.
1404	"""
1405	byReason: dict[tuple[str, ...], list[str]] = {}
1406	for packageName, published in sorted(unresolved.items()):
1407		byReason.setdefault(published, []).append(packageName)
1408
1409	lines = [f"[dependency-table] {len(unresolved)} package(s) need a license override:"]
1410
1411	# the biggest group first, so the statement to fix first is the one at the top
1412	for published, packageNames in sorted(byReason.items(), key=lambda item: (-len(item[1]), item[0])):
1413		reason = "; ".join(published) if len(published) > 0 else "the index published no license information"
1414		lines.append(f"  {reason}")
1415		lines.append(f"    {', '.join(packageNames)}")
1416
1417	return "\n".join(lines)
1418
1419
1420def reportBuildTime(app: Sphinx, exception: Nullable[Exception]) -> None:
1421	"""
1422	Report what querying the package index cost this build.
1423
1424	The tables are fetched live, so this is the number to look at before deciding what a cache would be worth. The
1425	packages whose license had to be guessed at - or couldn't be - are named too, because that is the list the
1426	override file has to answer for.
1427
1428	:param app:       The Sphinx application that finished building.
1429	:param exception: The exception that ended the build, or ``None`` if it succeeded.
1430	"""
1431	if (collector := _COLLECTORS.pop(id(app), None)) is None or collector.RequestCount == 0:
1432		return
1433
1434	_logger.info(
1435		f"[dependency-table] {collector.RequestCount} request(s) to the package index, "
1436		f"{collector.Seconds:.2f} s in total."
1437	)
1438
1439	if len(unresolved := collector.UnresolvedLicenses) > 0:
1440		_logger.warning(formatUnresolvedLicenses(unresolved))