DependencyTable.py

Summary

Lines:

447 of 627 covered (71.3%)

Excluded lines:

12

Branches:

120 of 202 taken (59.4%), 6 lines partially

Coverage:

68.4%

Directory:

Sphinx

Missed lines:

319, 357, 365-370, 443-449, 462, 475, 501-510, 525-541, 642, 849-850, 923-958, 972-977, 993-1010, 1020-1040, 1053-1059, 1071-1085, 1161-1168, 1179-1191, 1235-1247, 1257-1266, 1276-1281, 1297-1309, 1328-1334, 1574-1602, 1646-1658, 1675-1681

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 the domain ``report``
40declares 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     "packaging":     {"pyproject": True}
51   }
52
53.. code-block:: rest
54
55   .. dependency-table:: documentation
56      :caption: Documentation dependencies
57
58A requirements file is read - with its ``-r`` includes followed - while :file:`conf.py` is being processed, so a
59path that doesn't exist ends the build with one clear message instead of an error box in the middle of a page. A
60:file:`pyproject.toml` file is read at the same time; its entrypoint stands for the build requirements of the
61``[build-system]`` table. ``True`` names the file in the project's root directory - ``pyTooling_ProjectRoot``,
62relative to :file:`conf.py`, by default the parent of the directory :file:`conf.py` is in.
63
64**The data is fetched live** from the package index, once per build and shared between every table of that build:
65:file:`requirements.txt`, :file:`tests/requirements.txt` and :file:`doc/requirements.txt` overlap heavily, and a
66package they share is downloaded once. The requirements are resolved by pyTooling's
67:class:`~pyTooling.Dependency.Resolver.Resolver`, which downloads the packages of one level of the tree in parallel.
68That still costs real time, so every table reports what it spent, measured with a
69:class:`~pyTooling.Stopwatch.Stopwatch`, and the build ends with the total.
70"""
71from __future__                        import annotations
72
73from pathlib                           import Path
74from typing                            import TYPE_CHECKING, Any, ClassVar, Iterable, Literal, Mapping, \
75                                              Optional as Nullable, cast
76
77from pyTooling.Common                  import StringEnum, getFullyQualifiedName
78from pyTooling.Decorators              import export, readonly
79from pyTooling.Dependency              import DependencyError, UnknownLicenseWarning
80from pyTooling.Exceptions              import ConfigurationError, MissingDependencyError
81from pyTooling.Licensing               import SPDXLicense, UnknownLicense
82from pyTooling.MetaClasses             import ExtendedType
83from pyTooling.Stopwatch               import Stopwatch
84from pyTooling.Warning                 import WarningCollector
85
86from docutils                          import nodes
87from docutils.parsers.rst              import directives
88from sphinx.application                import Sphinx
89from sphinx.transforms.post_transforms import SphinxPostTransform
90from sphinx.util                       import logging
91
92if TYPE_CHECKING:  # pragma: no cover
93	# Only this directive needs a package index, so the model is imported when the configuration declares an
94	# entrypoint rather than when the extension is loaded - otherwise every documentation build using any of these
95	# roles would need the 'dependencies' extra.
96	from sphinx.config                   import Config
97	from packaging.requirements          import Requirement
98	from packaging.specifiers            import SpecifierSet
99	from pyTooling.Dependency.Python     import LicenseOverrides, Project, PythonPackageDependencyGraph, \
100	                                            PythonPackageIndex, Release, RequirementsFile
101	from pyTooling.Dependency.Resolver   import Resolution, ResolvedRequirement
102
103from pyTooling.Sphinx                  import BaseDirective, SphinxExtensionError, strip, stripAndNormalize
104from pyTooling.Sphinx.Node             import FoldedDependencies, FoldedSummary
105
106
107DEFAULT_INDEX_URL = "https://pypi.org"
108"""URL of the package index the tables are built from, unless :file:`conf.py` names another."""
109
110DEFAULT_API_URL = "https://pypi.org/pypi/"
111"""URL of that index's JSON API."""
112
113DEFAULT_DEPTH = 0
114"""Levels of sub-dependencies rendered when nothing says otherwise; ``0`` expands until the tree ends."""
115
116DEFAULT_SIMPLIFIED_VERSIONS = True
117"""Whether a version constraint is reduced to its lower bound when the document doesn't say."""
118
119
120@export
121class VersionFormat(StringEnum):
122	"""
123	How many parts of a version number a dependency table prints, as a ``dependency-table``'s option
124	``:version-format:`` states it.
125	"""
126
127	Major =           "major"              #: The major part alone, ``≥9``.
128	MajorMinor =      "major-minor"        #: Major and minor, ``≥9.1``.
129	MajorMinorPatch = "major-minor-patch"  #: Major, minor and patch, ``≥9.1.2``.
130	All =             "all"                #: Every part the constraint states, ``≥9.1.2.dev3``.
131
132	DEFAULT =         MajorMinor           #: Without the option, a version is printed with major and minor part.
133
134
135@export
136class DependencyFormat(StringEnum):
137	"""
138	What a line of a dependency tree states about a package, as a ``dependency-table``'s option ``:dependency-format:``
139	states it.
140	"""
141
142	Package =               "package"                  #: The name alone.
143	PackageVersion =        "package-version"          #: Name and version constraint.
144	PackageLicense =        "package-license"          #: Name and license.
145	PackageVersionLicense = "package-version-license"  #: Name, version constraint and license.
146
147	DEFAULT =               PackageVersionLicense      #: Without the option, a line states name, version and license.
148
149	@readonly
150	def ShowsVersion(self) -> bool:
151		"""
152		Whether this format states a version constraint.
153
154		:returns: ``True`` if the version is printed.
155		"""
156		return self in (DependencyFormat.PackageVersion, DependencyFormat.PackageVersionLicense)
157
158	@readonly
159	def ShowsLicense(self) -> bool:
160		"""
161		Whether this format states a license.
162
163		:returns: ``True`` if the license is printed.
164		"""
165		return self in (DependencyFormat.PackageLicense, DependencyFormat.PackageVersionLicense)
166
167
168@export
169class DuplicateFormat(StringEnum):
170	"""
171	How a dependency tree shows the dependencies of a package, which an earlier line of the table already shows, as a
172	``dependency-table``'s option ``:duplicates:`` states it.
173	"""
174
175	Expanded =  "expanded"   #: Shown again, as at the earlier line.
176	Collapsed = "collapsed"  #: Folded below a marker linking to the earlier line; HTML unfolds them on a click.
177	Reference = "reference"  #: Replaced by a marker linking to the earlier line.
178
179	DEFAULT =   Collapsed    #: Without the option, a duplicate's dependencies are folded.
180
181
182@export
183class DuplicateMatch(StringEnum):
184	"""
185	When a package's line in a dependency tree is a duplicate of an earlier line of the same package, as a
186	``dependency-table``'s option ``:duplicate-match:`` states it.
187	"""
188
189	Exact =  "exact"   #: The same release with the same extras.
190	Subset = "subset"  #: The same release with the earlier line's extras or fewer.
191	Name =   "name"    #: Any release with any extras.
192
193	DEFAULT = Subset   #: Without the option, a line requiring no more extras than the earlier line is a duplicate.
194
195
196OPERATOR_SYMBOLS = (("<=", "≤"), (">=", "≥"), ("!=", "≠"), ("==", "="))
197"""Comparison operators as a reader writes them. Order matters - the two-character forms have to be tried first."""
198
199_DROPPED_OPERATORS = ("<", "<=", "!=")
200"""Operators a simplified constraint drops: an upper bound and an exclusion say what is *not* required."""
201
202TABLE_COLUMNS = (("Package", 3), ("Version", 1), ("License", 2), ("Dependencies", 4))
203"""The table's columns, as ``(title, relative width)``."""
204
205ENTRYPOINT_FIELDS = ("file", "files", "package", "packages", "pyproject")
206"""
207The fields one entrypoint may state in :file:`conf.py`, exactly one of them.
208
209The singular forms take a string and the plural forms an iterable of strings; they are otherwise the same
210statement, and a project writes whichever reads better where it stands. ``pyproject`` names a project's
211:file:`pyproject.toml` file - ``True`` for the one in the project's root directory, a path relative to that directory,
212or ``None`` for a project without one; a project has one such file, so the field has no plural form.
213"""
214
215PROJECT_ROOT_CONFIG = "pyTooling_ProjectRoot"
216"""
217Configuration value naming the project's root directory, relative to :file:`conf.py`.
218
219Unset, the root is the parent of the directory :file:`conf.py` is in - :file:`doc/conf.py` in a project's root
220directory. A ``pyproject`` entrypoint's path is relative to it.
221"""
222
223CONFIG_PREFIX = "pyTooling_Dependency"
224"""Prefix every configuration value of this extension carries in :file:`conf.py`."""
225
226_ConfigRebuild = Literal["env"]
227"""What Sphinx accepts as the rebuild condition of a configuration value - the one this extension uses."""
228
229__all__ = [
230	"DEFAULT_INDEX_URL", "DEFAULT_API_URL", "DEFAULT_DEPTH", "DEFAULT_SIMPLIFIED_VERSIONS", "OPERATOR_SYMBOLS",
231	"TABLE_COLUMNS", "ENTRYPOINT_FIELDS", "CONFIG_PREFIX", "PROJECT_ROOT_CONFIG"
232]
233
234_logger = logging.getLogger(__name__)
235
236
237
238def _SelectedRelease(resolved: ResolvedRequirement) -> Nullable[Release]:
239	"""
240	Return the release the resolver selected for a requirement, if its details are known.
241
242	A requirement the index couldn't describe still names the release it selected; its lazily loaded properties would
243	each retry the download, so only a resolved requirement's release is handed out.
244
245	:param resolved: The resolved requirement.
246	:returns:        The selected release, or ``None`` if the requirement wasn't resolved.
247	"""
248	from pyTooling.Dependency.Resolver import ResolutionStatus
249
250	return resolved.Release if resolved.Status is ResolutionStatus.Resolved else None
251
252
253def _ReportWarning(prefix: str, warning: BaseException) -> None:
254	"""
255	Report a warning collected from :mod:`pyTooling.Dependency` as a Sphinx warning.
256
257	Each of the warning's notes follows the message on a line of its own.
258
259	:param prefix:  Text the message starts with, naming what the warning is about.
260	:param warning: The collected warning.
261	"""
262	lines = [f"{prefix} {warning}"]
263	lines.extend(f"  {note}" for note in getattr(warning, "__notes__", ()))
264
265	_logger.warning("\n".join(lines))
266
267
268_COLLECTORS: dict[int, "DependencyCollector"] = {}
269"""
270The collector of each running build, by the id of its Sphinx application.
271
272It can't live on the build environment, which Sphinx pickles between runs - an open HTTP session and the locks
273guarding lazy loading are not picklable, and a cached view of a package index would be stale anyway.
274"""
275
276
277@export
278class Entrypoint(metaclass=ExtendedType, slots=True):
279	"""
280	One entry of ``pyTooling_Dependency_Requirements``: an identifier and the requirements it stands for.
281
282	A file entrypoint - a requirements file or a :file:`pyproject.toml` file - is read while :file:`conf.py` is being
283	processed and carries its requirements from then on. A package entrypoint can only be resolved by asking the
284	package index, so it carries the package's name and extra and is resolved the first time a table names it.
285	"""
286
287	_identifier:   str                                    #: Name the documents refer to this entrypoint by.
288	_files:        tuple[Path, ...]                       #: Every file read, a requirements file's references included.
289	_packages:     tuple[tuple[str, Nullable[str]], ...]  #: The packages to read, as name and extra.
290	_requirements: Nullable[dict[str, Requirement]]       #: The resolved requirements, by canonical package name.
291
292	def __init__(
293		self,
294		identifier: str,
295		files: tuple[Path, ...] = (),
296		packages: tuple[tuple[str, Nullable[str]], ...] = (),
297		requirements: Nullable[dict[str, Requirement]] = None
298	) -> None:
299		"""
300		Describe one entrypoint.
301
302		:param identifier:   Name the documents refer to this entrypoint by.
303		:param files:        Optional, every file read, for a file entrypoint. Default: ``()``.
304		:param packages:     Optional, the packages and their extras, for a package entrypoint. Default: ``()``.
305		:param requirements: Optional, the requirements, if they are known already. Default: ``None``.
306		"""
307		self._identifier =   identifier
308		self._files =        files
309		self._packages =     packages
310		self._requirements = requirements
311
312	@readonly
313	def Identifier(self) -> str:
314		"""
315		Name the documents refer to this entrypoint by.
316
317		:returns: The identifier.
318		"""
319		return self._identifier
320
321	@readonly
322	def Files(self) -> tuple[Path, ...]:
323		"""
324		The requirements file and every file it includes, or the :file:`pyproject.toml` file.
325
326		:returns: The files this entrypoint was read from; empty for a package entrypoint.
327		"""
328		return self._files
329
330	@readonly
331	def Packages(self) -> tuple[tuple[str, Nullable[str]], ...]:
332		"""
333		The packages this entrypoint reads the requirements of, as ``(name, extra)`` pairs.
334
335		:returns: The packages, or an empty tuple for a file entrypoint.
336		"""
337		return self._packages
338
339	@readonly
340	def Requirements(self) -> Nullable[dict[str, Requirement]]:
341		"""
342		The requirements this entrypoint stands for.
343
344		:returns: Every required package by its canonical name, or ``None`` if they weren't resolved yet.
345		"""
346		return self._requirements
347
348	def CacheRequirements(self, requirements: dict[str, Requirement]) -> None:
349		"""
350		Remember the requirements the package index answered with.
351
352		A package entrypoint can only be resolved by asking the index; remembering the answer is what keeps a second
353		table naming the same entrypoint from asking again.
354
355		:param requirements: Every required package, by its canonical name.
356		"""
357		self._requirements = requirements
358
359	def __repr__(self) -> str:
360		"""
361		Return a representation naming what this entrypoint reads.
362
363		:returns: The identifier and its source.
364		"""
365		if len(self._files) > 0:
366			source = ", ".join(str(file) for file in self._files)
367		else:
368			source = ", ".join(name if extra is None else f"{name}[{extra}]" for name, extra in self._packages)
369
370		return f"<Entrypoint {self._identifier}: {source}>"
371
372
373@export
374class DependencyCollector(metaclass=ExtendedType, slots=True):
375	"""
376	The entrypoints a build declared, the package index it queries, and what querying it cost.
377
378	One collector is shared by every table of a build: a package required by two entrypoints is downloaded once, and
379	the time is accumulated so the build can report a total. It exists because the alternative - a table that queries
380	the index for itself - multiplies a documentation build's runtime by however many tables it has, and
381	:file:`requirements.txt`, :file:`tests/requirements.txt` and :file:`doc/requirements.txt` share most of what they
382	require.
383
384	The index is opened the first time a table asks for a package, not when the collector is created: a project may
385	declare its entrypoints and then build a document that shows none of them, and that build should not open an
386	HTTP session. The tables resolve their requirements with resolvers of their own, as each table has its own depth
387	limit; the index they share keeps every project and release it downloaded.
388	"""
389
390	_entrypoints: dict[str, Entrypoint]                    #: The entrypoints declared in :file:`conf.py`.
391	_indexURL:    str                                      #: URL of the package index's website.
392	_apiURL:      str                                      #: URL of the package index's JSON API.
393	_overrides:   LicenseOverrides                         #: Licenses stated by hand, where the index can't.
394	_graph:       Nullable[PythonPackageDependencyGraph]   #: Graph the downloaded packages are collected in.
395	_index:       Nullable[PythonPackageIndex]             #: The package index this build queries, once opened.
396	_stopwatch:   Stopwatch                                #: Runs only while a resolution is in progress.
397	_unresolved:  dict[str, tuple[str, ...]]               #: Packages whose license the index couldn't answer for,
398	                                                       #: mapped to what it published instead.
399
400	def __init__(
401		self,
402		entrypoints: dict[str, Entrypoint],
403		indexURL: str,
404		apiURL: str,
405		overrides: LicenseOverrides
406	) -> None:
407		"""
408		Collect what a build declared, without opening the package index yet.
409
410		:param entrypoints: The entrypoints declared in :file:`conf.py`, by identifier.
411		:param indexURL:    URL of the package index's website.
412		:param apiURL:      URL of the package index's JSON API.
413		:param overrides:   Licenses stated by hand.
414		"""
415		self._entrypoints = entrypoints
416		self._indexURL =    indexURL
417		self._apiURL =      apiURL
418		self._overrides =   overrides
419		self._graph =       None
420		self._index =       None
421		self._unresolved =  {}
422
423		# 'preferPause', so each 'with' around a resolution is one active span: 'Activity' is the time spent resolving
424		# rather than the age of the collector, and 'ActiveCount' is the number of resolutions
425		self._stopwatch =   Stopwatch(preferPause=True)
426
427	@readonly
428	def Entrypoints(self) -> dict[str, Entrypoint]:
429		"""
430		The entrypoints declared in :file:`conf.py`.
431
432		:returns: Every entrypoint by its identifier.
433		"""
434		return self._entrypoints
435
436	@readonly
437	def Index(self) -> PythonPackageIndex:
438		"""
439		The package index this build queries, opened the first time it is asked for.
440
441		:returns: The package index.
442		"""
443		from pyTooling.Dependency.Python import PythonPackageDependencyGraph, PythonPackageIndex
444
445		if self._index is None:
446			self._graph = PythonPackageDependencyGraph("documentation")
447			self._index = PythonPackageIndex("index", self._indexURL, self._apiURL, self._graph, self._overrides)
448
449		return self._index
450
451	@readonly
452	def ResolutionCount(self) -> int:
453		"""
454		Number of resolutions against the package index.
455
456		The stopwatch runs for exactly one span per resolution, so this is its
457		:attr:`~pyTooling.Stopwatch.Stopwatch.ActiveCount` - counting them a second time in a field of our own
458		would be a second answer to one question.
459
460		:returns: Number of resolutions.
461		"""
462		return self._stopwatch.ActiveCount
463
464	@readonly
465	def Seconds(self) -> float:
466		"""
467		Time spent resolving requirements against the package index, in seconds.
468
469		This is the stopwatch's :attr:`~pyTooling.Stopwatch.Stopwatch.Activity` - the sum of the intervals it ran -
470		not its duration, because it is paused between resolutions and everything the build does in between is not
471		time this collector spent.
472
473		:returns: Seconds spent on the index.
474		"""
475		return self._stopwatch.ActivityInSeconds
476
477	@readonly
478	def UnresolvedLicenses(self) -> dict[str, tuple[str, ...]]:
479		"""
480		Packages whose license the index couldn't answer for, and what it published instead.
481
482		The published fields are what the override file has to answer for, so they are kept rather than only the
483		package's name: ``License :: OSI Approved :: BSD License`` names three licenses and is never guessed at, and
484		a ``license`` field holding a license's title instead of its SPDX identifier doesn't parse.
485
486		:returns: Canonical names of the packages needing a license override, mapped to what the index published for them.
487		"""
488		return self._unresolved
489
490	def Resolve(self, requirements: Iterable[Requirement], limit: Nullable[int]) -> Resolution:
491		"""
492		Resolve requirements against the package index, every level of the tree up front.
493
494		Warnings reported while resolving are handled by :meth:`_ReportWarnings`.
495
496		:param requirements:            The requirements to resolve.
497		:param limit:                   Number of levels resolved below the requirements, or ``None`` for unlimited.
498		:returns:                       The resolution.
499		:raises MissingDependencyError: If the 'dependencies' extra isn't installed.
500		"""
501		from pyTooling.Dependency.Resolver import ResolutionStrategy, Resolver
502
503		warnings: list[BaseException] = []
504		resolver = Resolver(self.Index, strategy=ResolutionStrategy.Eager, limit=limit)
505		with self._stopwatch, WarningCollector(warnings):
506			resolution = resolver.ResolveRequirements(requirements)
507
508		self._ReportWarnings(warnings)
509
510		return resolution
511
512	def PublishedRequirements(self, packages: Iterable[tuple[str, Nullable[str]]]) -> list[Requirement]:
513		"""
514		Ask the package index what the latest releases of packages require, or one of their extras.
515
516		Warnings reported while asking are handled by :meth:`_ReportWarnings`.
517
518		:param packages:                Pairs of a package name and an extra's name, or ``None`` for the package's own
519		                                requirements.
520		:returns:                       The requirements, flattened in the order the packages are given.
521		:raises MissingDependencyError: If the 'dependencies' extra isn't installed.
522		:raises SphinxExtensionError:   If the index doesn't know a package, can't describe its latest release, or the
523		                                package has no such extra.
524		"""
525		try:
526			from requests import RequestException
527		except ImportError as ex:  # pragma: no cover
528			raise MissingDependencyError(dependency="requests", extra="dependencies") from ex
529
530		from pyTooling.Dependency.Resolver import Resolver
531
532		warnings: list[BaseException] = []
533		try:
534			with self._stopwatch, WarningCollector(warnings):
535				resolution = Resolver(self.Index, limit=0).ResolvePackages(packages)
536		except (DependencyError, RequestException, ValueError, KeyError) as cause:
537			raise SphinxExtensionError(" ".join((str(cause), *getattr(cause, "__notes__", ())))) from cause
538		finally:
539			self._ReportWarnings(warnings)
540
541		return [resolved.Requirement for resolved in resolution.Requirements]
542
543	def _ReportWarnings(self, warnings: Iterable[BaseException]) -> None:
544		"""
545		Record or report the warnings collected while resolving.
546
547		An :class:`~pyTooling.Dependency.UnknownLicenseWarning` is recorded in :attr:`UnresolvedLicenses`. Every other
548		warning, e.g. a :class:`~pyTooling.Dependency.BrokenRequirementWarning` for a requirement naming an extra its
549		release doesn't declare, is reported as a Sphinx warning.
550
551		:param warnings: The collected warnings.
552		"""
553		from packaging.utils import canonicalize_name
554
555		for warning in warnings:
556			if isinstance(warning, UnknownLicenseWarning):
557				# the warning's notes are what the index published, which is the reason an override is needed; keyed by the
558				# canonical name, which is the override file's spelling, so two spellings of one package are one entry
559				self._unresolved[canonicalize_name(warning.ProjectName)] = warning.Notes
560			else:
561				_ReportWarning("[dependency-table]", warning)
562
563
564@export
565def readEntrypoints(configuration: Any, confDirectory: Path, projectRoot: Path) -> dict[str, Entrypoint]:
566	"""
567	Turn ``pyTooling_Dependency_Requirements`` into entrypoints, reading every file it names.
568
569	A requirements file or :file:`pyproject.toml` file is read here rather than when a table is built, so a path that
570	doesn't exist ends the build with one message naming the identifier instead of an error box in the middle of a
571	page - and so two tables naming the same file read it once.
572
573	:param configuration:           Value of ``pyTooling_Dependency_Requirements``.
574	:param confDirectory:           Directory :file:`conf.py` lives in; a requirements file's relative path is resolved
575	                                against it.
576	:param projectRoot:             The project's root directory; a ``pyproject`` entrypoint's relative path is resolved
577	                                against it.
578	:returns:                       Every declared entrypoint, by its identifier.
579	:raises MissingDependencyError: If the 'dependencies' extra isn't installed.
580	:raises SphinxExtensionError:   If the configuration is malformed, or a requirements file or
581	                                :file:`pyproject.toml` file can't be read.
582	"""
583	if not isinstance(configuration, dict):
584		raise SphinxExtensionError(
585			f"conf.py: {CONFIG_PREFIX}_Requirements: Expected a dictionary, "
586			f"got '{getFullyQualifiedName(configuration)}'."
587		)
588
589	entrypoints: dict[str, Entrypoint] = {}
590	for identifier, declaration in configuration.items():
591		location = f"conf.py: {CONFIG_PREFIX}_Requirements:[{identifier}]"
592
593		if not isinstance(declaration, dict):
594			raise SphinxExtensionError(
595				f"{location}: Expected a dictionary, got '{getFullyQualifiedName(declaration)}'."
596			)
597
598		if (unknown := set(declaration) - set(ENTRYPOINT_FIELDS)) != set():
599			raise SphinxExtensionError(
600				f"{location}: Unknown field(s): {', '.join(sorted(unknown))}. "
601				f"Known are: {', '.join(ENTRYPOINT_FIELDS)}."
602			)
603
604		if len(stated := [field for field in ENTRYPOINT_FIELDS if field in declaration]) != 1:
605			known = ", ".join(ENTRYPOINT_FIELDS)
606			raise SphinxExtensionError(
607				f"{location}: Exactly one of {known} has to be configured, "
608				f"{'none is' if len(stated) == 0 else f'{len(stated)} are'}."
609			)
610
611		field = stated[0]
612		fieldLocation = f"{location}.{field}"
613		value = declaration[field]
614
615		if field == "pyproject":
616			if value is not None and value is not True and not isinstance(value, str):
617				raise SphinxExtensionError(
618					f"{fieldLocation}: Expected None, True or a string, got '{getFullyQualifiedName(value)}'."
619				)
620			elif value == "":
621				raise SphinxExtensionError(f"{fieldLocation}: Path is empty. Use True for the project root's file.")
622		elif field in ("file", "package"):
623			if not isinstance(value, str):
624				raise SphinxExtensionError(
625					f"{fieldLocation}: Expected a string, got '{getFullyQualifiedName(value)}'."
626				)
627
628			values: tuple[str, ...] = (value,)
629		else:
630			# a string is an iterable of strings itself, so the plural form has to reject one explicitly - otherwise
631			# {"files": "requirements.txt"} would silently become sixteen one-character paths
632			if isinstance(value, str) or not isinstance(value, Iterable):
633				raise SphinxExtensionError(
634					f"{fieldLocation}: Expected an iterable of strings, got '{getFullyQualifiedName(value)}'. "
635					f"Use '{field[:-1]}' for a single value."
636				)
637
638			# materialized before the items are checked, because an iterable may be a generator this would consume
639			values = tuple(value)
640			for item in values:
641				if not isinstance(item, str):
642					raise SphinxExtensionError(
643						f"{fieldLocation}: Expected strings, got '{getFullyQualifiedName(item)}'."
644					)
645
646		if field in ("file", "files"):
647			entrypoints[identifier] = _FileEntrypoint(identifier, fieldLocation, values, confDirectory)
648		elif field == "pyproject":
649			entrypoints[identifier] = _PyProjectEntrypoint(identifier, fieldLocation, value, projectRoot)
650		else:
651			entrypoints[identifier] = _PackageEntrypoint(identifier, values)
652
653	return entrypoints
654
655
656def _FileEntrypoint(
657	identifier: str,
658	location: str,
659	files: tuple[str, ...],
660	confDirectory: Path
661) -> Entrypoint:
662	"""
663	Read one entrypoint's requirements files.
664
665	Several files are read as several trees and flattened in the order they are declared, so a later file's
666	statement wins - the rule a single file's ``-r`` references already follow.
667
668	A line that can't be parsed as a requirement - a :class:`~pyTooling.Dependency.BrokenRequirementWarning` - is
669	reported as a Sphinx warning naming the file and line; the file's other requirements are read.
670
671	:param identifier:              Identifier of the entrypoint.
672	:param location:                Where in :file:`conf.py` this came from.
673	:param files:                   The declared paths.
674	:param confDirectory:           Directory relative paths resolve against.
675	:returns:                       The entrypoint, with its requirements read.
676	:raises MissingDependencyError: If the 'dependencies' extra isn't installed.
677	:raises SphinxExtensionError:   If a file can't be read.
678	"""
679	from pyTooling.Dependency.Python import RequirementsFile
680
681	readFiles: list[Path] = []
682	stated: list[Requirement] = []
683
684	for file in files:
685		path = Path(file)
686		if not path.is_absolute():
687			path = confDirectory / path
688
689		warnings: list[BaseException] = []
690		try:
691			with WarningCollector(warnings):
692				requirementsFile = RequirementsFile.Parse(path)
693		except (DependencyError, OSError, UnicodeDecodeError) as cause:
694			raise SphinxExtensionError(f"{location}: Requirements file '{path}' can't be read: {cause}") from cause
695
696		for warning in warnings:
697			_ReportWarning(f"{location}:", warning)
698
699		# the tree knows every file it was read from; walking it here would be a second answer to one question
700		readFiles.extend(requirementsFile.AnalyzedRequirementFiles)
701		stated.extend(requirementsFile.AllRequirements)
702
703	return Entrypoint(identifier, files=tuple(readFiles), requirements=_MergeRequirements(stated))
704
705
706def _PyProjectEntrypoint(identifier: str, location: str, file: Nullable[bool | str], projectRoot: Path) -> Entrypoint:
707	"""
708	Read the build requirements of one entrypoint's :file:`pyproject.toml` file.
709
710	The requirements are the ``requires`` key of the ``[build-system]`` table (:pep:`518`), read by pyTooling's
711	:meth:`PyProjectFile.Parse <pyTooling.Dependency.Python.PyProjectFile.Parse>`. A file without a ``[build-system]``
712	table has no build requirements, and neither has a project declaring ``None``, which has no such file. A requirement
713	that can't be parsed - an :class:`~pyTooling.Dependency.UnparsableRequirementWarning` - is reported as a Sphinx
714	warning naming the file; the file's other requirements are read.
715
716	:param identifier:              Identifier of the entrypoint.
717	:param location:                Where in :file:`conf.py` this came from.
718	:param file:                    ``True`` for :file:`pyproject.toml` in the project's root directory, a path relative
719	                                to that directory, or ``None`` if the project has no such file.
720	:param projectRoot:             The project's root directory.
721	:returns:                       The entrypoint, with its requirements read.
722	:raises MissingDependencyError: If the 'dependencies' extra isn't installed.
723	:raises SphinxExtensionError:   If the file can't be read, isn't valid TOML, or its ``[build-system]`` table
724	                                states no list of requirements.
725	"""
726	from pyTooling.Dependency.Python import PyProjectFile
727
728	if file is None:
729		return Entrypoint(identifier, requirements={})
730	elif file is True:
731		path = projectRoot / "pyproject.toml"
732	else:
733		path = Path(file)
734		if not path.is_absolute():
735			path = projectRoot / path
736
737	warnings: list[BaseException] = []
738	try:
739		with WarningCollector(warnings):
740			pyProjectFile = PyProjectFile.Parse(path)
741	except (DependencyError, OSError, UnicodeDecodeError) as cause:
742		raise SphinxExtensionError(f"{location}: Project file '{path}' can't be read: {cause}") from cause
743
744	for warning in warnings:
745		_ReportWarning(f"{location}:", warning)
746
747	requirements = _MergeRequirements(pyProjectFile.BuildRequirements)
748
749	return Entrypoint(identifier, files=(pyProjectFile.Path,), requirements=requirements)
750
751
752def _PackageEntrypoint(identifier: str, packages: tuple[str, ...]) -> Entrypoint:
753	"""
754	Describe one entrypoint's packages, which only the package index can resolve.
755
756	:param identifier: Identifier of the entrypoint.
757	:param packages:   The declared packages, each optionally with one extra.
758	:returns:          The entrypoint, with its packages recorded and its requirements still unresolved.
759	"""
760	requested: list[tuple[str, Nullable[str]]] = []
761	for package in packages:
762		name, _, bracket = package.partition("[")
763		requested.append((name.strip(), bracket.rstrip("]").strip() or None))
764
765	return Entrypoint(identifier, packages=tuple(requested))
766
767
768def _MergeRequirements(requirements: Iterable[Requirement]) -> dict[str, Requirement]:
769	"""
770	Index requirements by their canonical package name; a later statement of a package wins, but keeps earlier extras.
771
772	pip installs every extra any statement of a package names, so a later ``pyTooling`` doesn't drop the extra of an
773	earlier ``pyTooling[yaml]``: the later statement's version constraint and marker are kept, and its extras are
774	extended by the earlier ones. The requirements given are not changed; an extended one is a copy.
775
776	:param requirements:            The requirements, in the order they are stated.
777	:returns:                       Every required package, by its canonical name.
778	:raises MissingDependencyError: If the 'dependencies' extra isn't installed.
779	"""
780	try:
781		from packaging.requirements import Requirement
782		from packaging.utils        import canonicalize_name
783	except ImportError as ex:  # pragma: no cover
784		raise MissingDependencyError(dependency="packaging", extra="dependencies") from ex
785
786	merged: dict[str, Requirement] = {}
787	for requirement in requirements:
788		name = canonicalize_name(requirement.name)
789		if (earlier := merged.get(name, None)) is not None and not earlier.extras <= requirement.extras:
790			extended = Requirement(str(requirement))
791			extended.extras = requirement.extras | earlier.extras
792			requirement = extended
793
794		merged[name] = requirement
795
796	return merged
797
798
799@export
800def prepareEntrypoints(sphinx: Sphinx, config: Config) -> None:
801	"""
802	Call-back for Sphinx' ``config-inited`` event, reading the entrypoints and the license overrides.
803
804	A ``pyproject`` entrypoint is resolved against the project's root directory, :data:`PROJECT_ROOT_CONFIG` relative to
805	:file:`conf.py`, or the parent of :file:`conf.py`'s directory if it isn't set.
806
807	A build declaring no entrypoint does nothing here - not even import :mod:`pyTooling.Dependency.Python`, so a
808	project using only this extension's roles doesn't need the ``dependencies`` extra.
809
810	:param sphinx:                The Sphinx application.
811	:param config:                The configuration, after :file:`conf.py` was read.
812	:raises SphinxExtensionError: If the configuration is malformed, or a requirements file, :file:`pyproject.toml`
813	                              file or license override file can't be read.
814	:raises SphinxExtensionError: If a license override file is configured, but pyTooling's ``yaml`` extra isn't
815	                              installed.
816	"""
817	if len(declarations := getattr(config, f"{CONFIG_PREFIX}_Requirements", {})) == 0:
818		return
819
820	confDirectory = Path(sphinx.confdir)
821	if (projectRootSetting := getattr(config, PROJECT_ROOT_CONFIG, None)) is None:
822		projectRoot = confDirectory.parent
823	else:
824		projectRoot = Path(projectRootSetting)
825		if not projectRoot.is_absolute():
826			projectRoot = confDirectory / projectRoot
827
828	try:
829		from pyTooling.Dependency.Python import LicenseOverrides
830	except MissingDependencyError as cause:  # pragma: no cover
831		raise SphinxExtensionError(
832			f"conf.py: {CONFIG_PREFIX}_Requirements: Querying a package index needs the 'dependencies' extra: "
833			"pip install pyTooling[dependencies]"
834		) from cause
835
836	overrides = LicenseOverrides()
837	if (overrideFile := getattr(config, f"{CONFIG_PREFIX}_PackageOverrides", None)) is not None:
838		path = Path(overrideFile)
839		if not path.is_absolute():
840			path = confDirectory / path
841
842		try:
843			overrides = LicenseOverrides.FromFile(path)
844		except MissingDependencyError as cause:
845			raise SphinxExtensionError(
846				f"conf.py: {CONFIG_PREFIX}_PackageOverrides: Reading the override file needs the 'yaml' extra: "
847				f"pip install pyTooling[yaml]"
848			) from cause
849		except (DependencyError, ConfigurationError, OSError) as cause:
850			raise SphinxExtensionError(
851				f"conf.py: {CONFIG_PREFIX}_PackageOverrides: Override file '{path}' can't be read: {cause}"
852			) from cause
853
854	_COLLECTORS[id(sphinx)] = DependencyCollector(
855		readEntrypoints(declarations, confDirectory, projectRoot),
856		getattr(config, f"{CONFIG_PREFIX}_IndexURL", DEFAULT_INDEX_URL),
857		getattr(config, f"{CONFIG_PREFIX}_APIURL", DEFAULT_API_URL),
858		overrides
859	)
860
861
862@export
863class DependencyTable(BaseDirective):
864	"""
865	The ``dependency-table`` directive: an entrypoint's dependencies, rendered from the requirements.
866
867	One argument, the identifier of an entrypoint declared in ``pyTooling_Dependency_Requirements``. ``:depth:``
868	says how many levels of sub-dependencies to expand, ``:simplified-versions:`` whether a constraint is reduced to
869	its lower bound, and ``:caption:`` puts a caption under the table; which package index is queried and which
870	licenses are stated by hand are build-wide and configured in :file:`conf.py`.
871	"""
872
873	directiveName: str = "dependency-table"  #: Name the directive is invoked by.
874
875	#: The configuration values this directive adds to :file:`conf.py`, as ``name: (default, rebuild, types)``. Each is
876	#: registered with :data:`CONFIG_PREFIX` as prefix, e.g. ``pyTooling_Dependency_Requirements``.
877	#:
878	#: ``Requirements`` maps an identifier to what it names - a file, files, a package, packages or a
879	#: :file:`pyproject.toml` file. The other three are build-wide, because one package index is queried per build and
880	#: one override file answers for it. All four are ``"env"``-rebuilt: changing any of them changes every table.
881	configValues: ClassVar[dict[str, tuple[Any, _ConfigRebuild, Any]]] = {
882		"Requirements":     ({},                "env", dict),
883		"PackageOverrides": (None,              "env", (str, Path)),
884		"IndexURL":         (DEFAULT_INDEX_URL, "env", str),
885		"APIURL":           (DEFAULT_API_URL,   "env", str),
886	}
887
888	_simplify:         bool               #: Whether this table's version constraints are reduced to their lower bound.
889	_versionFormat:    VersionFormat      #: How many parts of a version number this table prints.
890	_dependencyFormat: DependencyFormat   #: What a line of this table's dependency trees states.
891	_duplicateFormat:  DuplicateFormat    #: How this table's dependency trees show a duplicate's dependencies.
892	_duplicateMatch:   DuplicateMatch     #: When a line of this table's dependency trees is a duplicate.
893
894	_identifier:        str                                                #: The entrypoint this table renders.
895	_duplicateOf:       dict[ResolvedRequirement, ResolvedRequirement]     #: Each duplicate, mapped to its original.
896	_originals:         set[ResolvedRequirement]                           #: The originals duplicates link to.
897	_anchors:           dict[ResolvedRequirement, str]                     #: The ID of each original's line.
898	_pendingReferences: list[tuple[nodes.reference, ResolvedRequirement]]  #: Links waiting for their original's ID.
899
900	has_content =               False  #: A boolean; ``True`` if content is allowed.
901	required_arguments =        1      #: Number of required directive arguments: the entrypoint's identifier.
902	optional_arguments =        0      #: Number of optional arguments after the required ones.
903	final_argument_whitespace = False  #: A boolean; ``True`` if the last argument may contain spaces.
904	# docutils declares 'option_spec' on 'Directive' and 'BaseDirective' assigns it, so mypy calls every
905	# spelling of this override a conflict with one of them
906	#: Mapping of option names to validator functions.
907	option_spec: dict[str, Any] = {  # type: ignore[misc]
908		"caption":             strip,
909		"depth":               directives.nonnegative_int,
910		"simplified-versions": stripAndNormalize,
911		"version-format":      VersionFormat.Parse,
912		"dependency-format":   DependencyFormat.Parse,
913		"duplicates":          DuplicateFormat.Parse,
914		"duplicate-match":     DuplicateMatch.Parse,
915	}
916
917	def run(self) -> list[nodes.Node]:
918		"""
919		Resolve the named entrypoint against the package index and return its requirements as a table.
920
921		:returns: A ``table`` node, or an error node when the entrypoint couldn't be resolved.
922		"""
923		identifier =             self.arguments[0].strip()
924		self._simplify =         self._ParseBooleanOption("simplified-versions", DEFAULT_SIMPLIFIED_VERSIONS)
925		self._versionFormat =    self.options.get("version-format", VersionFormat.DEFAULT)
926		self._dependencyFormat = self.options.get("dependency-format", DependencyFormat.DEFAULT)
927		self._duplicateFormat =  self.options.get("duplicates", DuplicateFormat.DEFAULT)
928		self._duplicateMatch =   self.options.get("duplicate-match", DuplicateMatch.DEFAULT)
929
930		# ':depth: 0' - and the default - means expand until the tree ends, which is the resolver's 'None'
931		depth = self.options.get("depth", DEFAULT_DEPTH)
932		limit: Nullable[int] = None if depth == 0 else depth
933
934		with Stopwatch() as stopwatch:
935			try:
936				collector = self._Collector()
937				requirements = self._Requirements(identifier, collector)
938				resolution = collector.Resolve(requirements.values(), limit)
939				self._identifier =        identifier
940				self._duplicateOf =       self._FindDuplicates(resolution)
941				self._originals =         set(self._duplicateOf.values())
942				self._anchors =           {}
943				self._pendingReferences = []
944				table = self._CreateTable(identifier, resolution)
945				for reference, original in self._pendingReferences:
946					reference["refid"] = self._anchors[original]
947			except SphinxExtensionError as cause:
948				return [self.state.document.reporter.error(
949					f"{self.directiveName}: {cause}", line=self.lineno
950				)]
951
952		seconds = stopwatch.DurationInSeconds
953
954		# what this table cost, not what the build has spent so far - a package another table already downloaded is
955		# free here, and that is the point of sharing the collector
956		_logger.info(f"[{self.directiveName}] {identifier}: {len(requirements)} package(s), {seconds:.2f} s")
957
958		return [table]
959
960	def _Collector(self) -> DependencyCollector:
961		"""
962		Return the build's collector, which :func:`prepareEntrypoints` created when :file:`conf.py` was read.
963
964		The collector belongs to the running application rather than to the directive, because a document with three
965		tables would otherwise open three indexes and download the same packages three times. It deliberately does
966		*not* live on the build environment: Sphinx pickles that between runs, and neither an open HTTP session nor a
967		cached view of a package index survives being pickled - or should.
968
969		:returns:                     The collector shared by every table of this build.
970		:raises SphinxExtensionError: If no entrypoint was configured.
971		"""
972		if (collector := _COLLECTORS.get(id(self.env.app), None)) is None:
973			raise SphinxExtensionError(
974				f"No entrypoint is configured. Declare one in conf.py: {CONFIG_PREFIX}_Requirements."
975			)
976
977		return collector
978
979	def _Requirements(self, identifier: str, collector: DependencyCollector) -> dict[str, Requirement]:
980		"""
981		Return what the named entrypoint requires.
982
983		A file entrypoint was read when :file:`conf.py` was processed and answers immediately; a package entrypoint
984		is resolved against the package index the first time a table names it, and remembers the answer.
985
986		:param identifier:              Identifier the document names.
987		:param collector:               The build's collector.
988		:returns:                       Every required package, by its canonical name.
989		:raises MissingDependencyError: If the 'dependencies' extra isn't installed.
990		:raises SphinxExtensionError:   If the identifier is unknown, or the package index can't answer for the
991		                                entrypoint's packages.
992		"""
993		if (entrypoint := collector.Entrypoints.get(identifier, None)) is None:
994			known = ", ".join(sorted(collector.Entrypoints)) or "none"
995			raise SphinxExtensionError(
996				f"Entrypoint '{identifier}' is not configured in conf.py: {CONFIG_PREFIX}_Requirements. "
997				f"Known are: {known}."
998			)
999
1000		for file in entrypoint.Files:
1001			self.env.note_dependency(str(file))
1002
1003		if (requirements := entrypoint.Requirements) is not None:
1004			return requirements
1005
1006		# several packages flatten in the order they are declared, the rule a file's '-r' references already follow
1007		requirements = _MergeRequirements(collector.PublishedRequirements(entrypoint.Packages))
1008		entrypoint.CacheRequirements(requirements)
1009
1010		return requirements
1011
1012	def _CreateTable(self, identifier: str, resolution: Resolution) -> nodes.table:
1013		"""
1014		Render the resolved requirements as a four-column table, one row per package, sorted by name.
1015
1016		:param identifier: Identifier of the entrypoint, used as the table's identifier.
1017		:param resolution: The entrypoint's requirements, resolved.
1018		:returns:          The finished table.
1019		"""
1020		tableGroup = self._CreateSingleRowTableHeader(
1021			columns=list(TABLE_COLUMNS),
1022			identifier=identifier,
1023			classes=["dependency-table"]
1024		)
1025		tableGroup += (tableBody := nodes.tbody())
1026
1027		if resolution.RequirementCount == 0:
1028			tableBody += self._CreateEmptyRow(len(TABLE_COLUMNS))
1029		else:
1030			for resolved in self._SortedRequirements(resolution):
1031				tableBody += self._CreateRow(resolved)
1032
1033		table = cast(nodes.table, tableGroup.parent)
1034		if (caption := self.options.get("caption", None)) is not None:
1035			# the caption is ReST, not text: it is written with markup - ``packaging`` in pyTooling's own captions -
1036			# and a 'title' built from a string would print the backticks
1037			captionNodes, messages = self.state.inline_text(caption, self.lineno)
1038			table.insert(0, nodes.title(caption, "", *captionNodes, *messages))
1039
1040		return table
1041
1042	@staticmethod
1043	def _CreateEmptyRow(columnCount: int) -> nodes.row:
1044		"""
1045		Render the one row a table with no requirements gets: a single cell spanning every column.
1046
1047		A table showing nothing but its header reads as a defect. pyTooling's own :file:`requirements.txt` is empty
1048		- the package has no mandatory dependencies - and that is a statement worth printing.
1049
1050		:param columnCount: Number of columns the cell has to span.
1051		:returns:           The table row.
1052		"""
1053		tableRow = nodes.row("", classes=["dependency-table-row"])
1054
1055		entry = nodes.entry("", morecols=columnCount - 1)
1056		entry += nodes.paragraph("", "", nodes.emphasis(text="No dependencies"))
1057		tableRow += entry
1058
1059		return tableRow
1060
1061	def _CreateRow(self, resolved: ResolvedRequirement) -> nodes.row:
1062		"""
1063		Render one required package as a table row.
1064
1065		A package the index doesn't know, or one with no release matching the requirement, still gets a row - the
1066		specifier the entrypoint states is worth showing even when nothing else could be resolved.
1067
1068		:param resolved: The resolved requirement to render.
1069		:returns:        The table row.
1070		"""
1071		tableRow = nodes.row("", classes=["dependency-table-row"])
1072		if resolved in self._originals:
1073			self._SetAnchor(tableRow, resolved)
1074
1075		requirement = resolved.Requirement
1076		release =     _SelectedRelease(resolved)
1077
1078		tableRow += self._PackageEntry(requirement, resolved.Project)
1079		specifier = self._FormatSpecifier(requirement.specifier, self._simplify, self._versionFormat)
1080
1081		tableRow += nodes.entry("", nodes.paragraph(text=specifier))
1082		tableRow += self._LicenseEntry(release)
1083		tableRow += self._DependenciesEntry(resolved)
1084
1085		return tableRow
1086
1087	@staticmethod
1088	def _FormatVersion(version: str, versionFormat: VersionFormat) -> str:
1089		"""
1090		Shorten a version number to the parts a table prints.
1091
1092		``≥0.4.6`` says more than a reader of a dependency table needs; the parts that matter are the ones a
1093		constraint is usually written against. A version with fewer parts than asked for is left as it is - ``≥9``
1094		does not become ``≥9.0`` - because padding would state a precision the requirement didn't.
1095
1096		:param version:       The version, as the constraint writes it.
1097		:param versionFormat: How many parts to keep.
1098		:returns:             The shortened version.
1099		"""
1100		if versionFormat is VersionFormat.All:
1101			return version
1102
1103		parts = {VersionFormat.Major: 1, VersionFormat.MajorMinor: 2, VersionFormat.MajorMinorPatch: 3}[versionFormat]
1104
1105		return ".".join(version.split(".")[:parts])
1106
1107	@staticmethod
1108	def _FormatSpecifier(specifier: SpecifierSet, simplify: bool, versionFormat: VersionFormat) -> str:
1109		"""
1110		Render a version constraint the way a reader writes one.
1111
1112		The comparison operators become their mathematical symbols and the versions are shortened to
1113		``versionFormat``. A simplified constraint keeps only what a package *has to be at least*: an upper bound
1114		and an exclusion say what a release must not be, which is the packaging problem rather than the reader's,
1115		and ``~=`` is written as the lower bound it implies. Simplifying everything away leaves the constraint as it
1116		was written - ``<4.0`` alone is still the whole statement.
1117
1118		:param specifier:     The constraint to render.
1119		:param simplify:      Whether to reduce the constraint to its lower bound.
1120		:param versionFormat: How many parts of each version to keep.
1121		:returns:             The constraint, or ``any`` when nothing is constrained.
1122		"""
1123		def render(operator: str, version: str) -> str:
1124			shortened = DependencyTable._FormatVersion(version, versionFormat)
1125			for written, symbol in OPERATOR_SYMBOLS:
1126				if operator == written:
1127					return f"{symbol}{shortened}"
1128
1129			return f"{operator}{shortened}"
1130
1131		if len(specifier) == 0:
1132			return "any"
1133
1134		specifiers = sorted(specifier, key=lambda item: (item.version, item.operator))
1135		if simplify:
1136			kept = [
1137				render(">=" if item.operator == "~=" else item.operator, item.version)
1138				for item in specifiers if item.operator not in _DROPPED_OPERATORS
1139			]
1140			if len(kept) > 0:
1141				# shortening can make two constraints identical - '>=1.2.3, >1.2.9' is '≥1.2, >1.2' at MajorMinor -
1142				# and printing one statement twice reads as a defect
1143				return ", ".join(dict.fromkeys(kept))
1144
1145		rendered = [render(item.operator, item.version) for item in specifiers]
1146
1147		return ", ".join(dict.fromkeys(rendered))
1148
1149	@staticmethod
1150	def _PackageURL(project: Nullable[Project]) -> Nullable[str]:
1151		"""
1152		Return the page a package's name should link to, most useful first.
1153
1154		A project states none of these reliably, so there are three chances at one: its **documentation** answers
1155		*what is this*, its **repository** answers *where does it come from*, and its page on the **package index**
1156		is what the index itself can always answer. Only a package the index doesn't know at all goes unlinked.
1157
1158		:param project: The project, or ``None`` if the index doesn't know it.
1159		:returns:       The URL to link the name to, or ``None`` if there is nothing to link to.
1160		"""
1161		if project is None:
1162			return None
1163
1164		for url in (project.DocumentationURL, project.RepositoryURL, project.URL):
1165			if url is not None:
1166				return str(url)
1167
1168		return None
1169
1170	@classmethod
1171	def _PackageEntry(cls, requirement: Requirement, project: Nullable[Project]) -> nodes.entry:
1172		"""
1173		Render the package's name, linked to where a reader can find out about it.
1174
1175		:param requirement: The requirement naming the package.
1176		:param project:     The project, or ``None`` if the index doesn't know it.
1177		:returns:           The table entry.
1178		"""
1179		entry = nodes.entry()
1180		entry += (paragraph := nodes.paragraph())
1181
1182		name = cls._PackageName(requirement, project)
1183		if (url := cls._PackageURL(project)) is not None:
1184			paragraph += nodes.reference("", name, refuri=url)
1185		else:
1186			paragraph += nodes.Text(name)
1187
1188		if (extras := cls._ExtrasText(requirement)) != "":
1189			paragraph += nodes.Text(extras)
1190
1191		return entry
1192
1193	@staticmethod
1194	def _PackageName(requirement: Requirement, project: Nullable[Project]) -> str:
1195		"""
1196		Return a package's name as its project spells it, or as the requirement does if the index doesn't know it.
1197
1198		:param requirement: The requirement naming the package.
1199		:param project:     The project, or ``None`` if the index doesn't know it.
1200		:returns:           The package's name.
1201		"""
1202		return project.Name if project is not None else requirement.name
1203
1204	@staticmethod
1205	def _ExtrasText(requirement: Requirement) -> str:
1206		"""
1207		Return the extras a requirement names, as they follow the package's name: ``[console,packaging,yaml]``.
1208
1209		The extras are sorted, so the same requirement reads the same in every line. Two lines of one package with
1210		different extras require different dependencies, and only the extras tell them apart.
1211
1212		:param requirement: The requirement.
1213		:returns:           The extras in brackets, or an empty string if the requirement names none.
1214		"""
1215		if len(requirement.extras) == 0:
1216			return ""
1217
1218		return f"[{','.join(sorted(requirement.extras))}]"
1219
1220	@staticmethod
1221	def _LicenseEntry(release: Nullable[Release]) -> nodes.entry:
1222		"""
1223		Render a release's license, linked to its text where one is known.
1224
1225		The license' **name** is shown rather than its SPDX identifier - ``Apache License 2.0``, not ``Apache-2.0`` -
1226		because the table is read by a person and the identifier is what an expression writes.
1227
1228		A license that didn't resolve is an :class:`~pyTooling.Licensing.UnknownLicense`, never a blank cell, and it
1229		is shown **as the index published it** - in italics, so it reads as a quotation rather than as an identifier.
1230		The reader should see that the index said *something*, and what it was.
1231
1232		:param release: The release to render the license of, or ``None``.
1233		:returns:       The table entry.
1234		"""
1235		entry = nodes.entry()
1236
1237		if (text := DependencyTable._LicenseName(release)) is None:
1238			entry += nodes.paragraph("", "", nodes.emphasis(text=DependencyTable._PublishedLicense(release)))
1239
1240			return entry
1241
1242		if (url := DependencyTable._LicenseURL(release)) is not None:
1243			entry += nodes.paragraph("", "", nodes.reference("", text, refuri=url))
1244		else:
1245			entry += nodes.paragraph(text=text)
1246
1247		return entry
1248
1249	@staticmethod
1250	def _LicenseName(release: Nullable[Release]) -> Nullable[str]:
1251		"""
1252		Return the name(s) of the licenses a release is published under.
1253
1254		:param release: The release to name the license of, or ``None``.
1255		:returns:       The license' name, or ``None`` if nothing resolved.
1256		"""
1257		if release is None:
1258			return None
1259
1260		# 'Licenses' never comes back empty: what didn't resolve is an 'UnknownLicense', which is SPDX's own way of
1261		# saying so and keeps the published text
1262		licenses = release.Licenses
1263		if all(isinstance(license, UnknownLicense) for license in licenses):
1264			return None
1265
1266		return ", ".join(license.Name for license in licenses)
1267
1268	@staticmethod
1269	def _PublishedLicense(release: Nullable[Release]) -> str:
1270		"""
1271		Return what the package index published, for a license that didn't resolve.
1272
1273		:param release: The release, or ``None`` if the index couldn't describe it.
1274		:returns:       What was published, or ``unknown`` when that was nothing either.
1275		"""
1276		if release is None:
1277			return "unknown"
1278
1279		published = release.LicenseExpression.OriginalText.strip()
1280
1281		return published if published != "" else ", ".join(license.Name for license in release.Licenses) or "unknown"
1282
1283	@staticmethod
1284	def _LicenseURL(release: Nullable[Release]) -> Nullable[str]:
1285		"""
1286		Return the page a license should link to, most specific first.
1287
1288		**The project's own** :file:`LICENSE` **file wins**: it is the license as this project publishes it, which
1289		is the document a reader auditing a dependency actually wants. Most projects don't state one, though - it
1290		comes from ``project_urls`` or from the override file - so a license on the SPDX List falls back to its own
1291		published pages, in the order of who is speaking: the licensor's own page, then OSI's entry, then SPDX's.
1292		A ``LicenseRef-`` has none of those and stays unlinked, because nothing published it.
1293
1294		:param release: The release to link the license of, or ``None``.
1295		:returns:       The URL to link to, or ``None`` if nothing published this license.
1296		"""
1297		if release is None:
1298			return None
1299
1300		if release.LicenseURL is not None:
1301			return str(release.LicenseURL)
1302
1303		for license in release.Licenses:
1304			if isinstance(license, SPDXLicense):
1305				for url in (license.License.URL, license.License.OSIURL, license.License.SPDXURL):
1306					if url is not None:
1307						return url
1308
1309		return None
1310
1311	def _DependenciesEntry(self, resolved: ResolvedRequirement) -> nodes.entry:
1312		"""
1313		Render the dependencies of a resolved requirement as a nested bullet list.
1314
1315		The dependencies are the selected release's unconditional requirements and the requirements of the extras the
1316		requirement names (``pyTooling[yaml]``). A package already on the path isn't listed again, so a dependency
1317		cycle ends.
1318
1319		:param resolved: The resolved requirement to render the dependencies of.
1320		:returns:        The table entry.
1321		"""
1322		entry = nodes.entry()
1323
1324		if _SelectedRelease(resolved) is None:
1325			entry += nodes.paragraph("", "", nodes.emphasis(text="not evaluated"))
1326			return entry
1327
1328		if len(dependencies := self._Dependencies(resolved)) == 0:
1329			entry += nodes.paragraph("", "", nodes.emphasis(text="none"))
1330			return entry
1331
1332		entry += self._CreateBulletList(dependencies)
1333
1334		return entry
1335
1336	@staticmethod
1337	def _Dependencies(resolved: ResolvedRequirement) -> list[ResolvedRequirement]:
1338		"""
1339		Return the dependencies of a resolved requirement, without those closing a cycle.
1340
1341		:param resolved: The resolved requirement.
1342		:returns:        Its dependencies, except those whose package is on the path already.
1343		"""
1344		from pyTooling.Dependency.Resolver import ResolutionStatus
1345
1346		return [dependency for dependency in resolved.Dependencies if dependency.Status is not ResolutionStatus.Cycle]
1347
1348	def _CreateBulletList(self, dependencies: list[ResolvedRequirement]) -> nodes.bullet_list:
1349		"""
1350		Render resolved requirements as a bullet list, each item followed by its own dependencies.
1351
1352		An item whose own dependencies the depth limit cuts off gets a marker saying how many weren't expanded. An item
1353		:meth:`_FindDuplicates` found to be a duplicate gets a marker linking to its original instead, and - if duplicates
1354		are collapsed - its dependencies folded below that marker. The list is ``compact``, so its lines are spaced like
1355		those of a simple list, also next to folded dependencies.
1356
1357		:param dependencies: The resolved requirements to list.
1358		:returns:            The bullet list.
1359		"""
1360		from pyTooling.Dependency.Resolver import ResolutionStatus
1361
1362		bulletList = nodes.bullet_list("", classes=["compact"])
1363
1364		for dependency in self._SortedByName(dependencies):
1365			item = nodes.list_item()
1366			if dependency in self._originals:
1367				self._SetAnchor(item, dependency)
1368
1369			item += self._RequirementParagraph(dependency.Requirement, dependency.Project, _SelectedRelease(dependency))
1370
1371			if len(nested := self._Dependencies(dependency)) > 0:
1372				if (original := self._duplicateOf.get(dependency, None)) is not None:
1373					item += self._CreateDuplicateMarker(nested, original)
1374				elif any(nestedDependency.Status is ResolutionStatus.LimitReached for nestedDependency in nested):
1375					item += self._CreateLimitMarker(len(nested))
1376				else:
1377					item += self._CreateBulletList(nested)
1378
1379			bulletList += item
1380
1381		return bulletList
1382
1383	@staticmethod
1384	def _CreateLimitMarker(count: int) -> nodes.bullet_list:
1385		"""
1386		Render the marker of a tree the ``:depth:`` limit cut off: how many dependencies weren't expanded.
1387
1388		:param count: Number of the package's own dependencies, which weren't expanded.
1389		:returns:     A bullet list with one item.
1390		"""
1391		text = f"… {count} more {'dependency' if count == 1 else 'dependencies'} not expanded"
1392
1393		bulletList = nodes.bullet_list("", classes=["compact"])
1394		bulletList += nodes.list_item("", nodes.paragraph("", "", nodes.emphasis(text=text)), classes=["dependency-limit"])
1395
1396		return bulletList
1397
1398	def _CreateDuplicateMarker(
1399		self,
1400		dependencies: list[ResolvedRequirement],
1401		original: ResolvedRequirement
1402	) -> nodes.Element:
1403		"""
1404		Render the marker of a duplicate: how many dependencies it has, and a link to the line of its original.
1405
1406		Collapsed duplicates fold their dependencies below the marker, which HTML unfolds on a click; referenced
1407		duplicates get the marker alone. The link's target is set once the table is complete, because the original may
1408		be a row further down: :meth:`run` resolves every link of :attr:`_pendingReferences`.
1409
1410		:param dependencies: The duplicate's dependencies.
1411		:param original:     The line the duplicate's dependencies are shown at.
1412		:returns:            A :class:`~pyTooling.Sphinx.Node.FoldedDependencies` node, or a bullet list with one item.
1413		"""
1414		count = len(dependencies)
1415		name = f"{self._PackageName(original.Requirement, original.Project)}{self._ExtrasText(original.Requirement)}"
1416
1417		reference = nodes.reference("", name, internal=True)
1418		self._pendingReferences.append((reference, original))
1419		text =   f"… same {count} {'dependency' if count == 1 else 'dependencies'} as listed at "
1420		marker = nodes.emphasis("", text, reference)
1421
1422		if self._duplicateFormat is DuplicateFormat.Reference:
1423			bulletList = nodes.bullet_list("", classes=["compact"])
1424			bulletList += nodes.list_item("", nodes.paragraph("", "", marker), classes=["dependency-duplicate"])
1425
1426			return bulletList
1427
1428		duplicate = FoldedDependencies("", classes=["dependency-duplicate"])
1429		duplicate += FoldedSummary("", "", marker)
1430		duplicate += self._CreateBulletList(dependencies)
1431
1432		return duplicate
1433
1434	def _SetAnchor(self, node: nodes.Element, original: ResolvedRequirement) -> None:
1435		"""
1436		Give the row or list item of an original a readable ID, which the markers of its duplicates link to.
1437
1438		The ID names the entrypoint and the package with its extras - ``dependency-documentation-requests`` -, so a link
1439		to it reads well and stays the same from build to build, unlike a counted ``id7``. A second original of the
1440		same name on the page gets a number appended. The ID is registered with the document, which keeps it unique on
1441		the page. The whole row or item is the target, so the stylesheet highlights the package with its dependencies.
1442
1443		:param node:     The table row or list item showing the original.
1444		:param original: The original.
1445		"""
1446		document = self.state.document
1447		baseID =   nodes.make_id(
1448			f"dependency-{self._identifier}-{original.Requirement.name}{self._ExtrasText(original.Requirement)}"
1449		)
1450
1451		anchorID = baseID
1452		number =   1
1453		while anchorID in document.ids:
1454			number += 1
1455			anchorID = f"{baseID}-{number}"
1456
1457		node["ids"].append(anchorID)
1458		document.set_id(node)
1459		self._anchors[original] = anchorID
1460
1461	@staticmethod
1462	def _SortedRequirements(resolution: Resolution) -> list[ResolvedRequirement]:
1463		"""
1464		Return the first-level requirements in the order the table lists them: by canonical package name.
1465
1466		:param resolution: The entrypoint's requirements, resolved.
1467		:returns:          The rows' requirements, sorted.
1468		"""
1469		from packaging.utils import canonicalize_name
1470
1471		return sorted(resolution.Requirements, key=lambda item: canonicalize_name(item.Requirement.name))
1472
1473	@staticmethod
1474	def _SortedByName(dependencies: Iterable[ResolvedRequirement]) -> list[ResolvedRequirement]:
1475		"""
1476		Return dependencies in the order a dependency tree lists them: by package name, ignoring case.
1477
1478		:param dependencies: The dependencies of one line.
1479		:returns:            The dependencies, sorted.
1480		"""
1481		return sorted(dependencies, key=lambda item: item.Requirement.name.lower())
1482
1483	def _FindDuplicates(self, resolution: Resolution) -> dict[ResolvedRequirement, ResolvedRequirement]:
1484		"""
1485		Find the lines of the dependency trees whose dependencies an earlier line of the table already shows.
1486
1487		The lines are visited in the order the table shows them, after every first-level requirement became an
1488		original: a dependency the table lists in a row of its own links to that row, even if the row is further down.
1489		A line with dependencies is a duplicate if :meth:`_IsDuplicate` matches it to an original of the same package.
1490		Otherwise it is an original itself - unless the depth limit cut its dependencies off, as a duplicate linking to
1491		it would show less than its own line - and its dependencies are visited. A duplicate's dependencies aren't
1492		visited: they are folded or not shown at all.
1493
1494		:param resolution: The entrypoint's requirements, resolved.
1495		:returns:          Every duplicate, mapped to its original; empty if duplicates are expanded.
1496		"""
1497		from packaging.utils import canonicalize_name
1498
1499		from pyTooling.Dependency.Resolver import ResolutionStatus
1500
1501		duplicateOf: dict[ResolvedRequirement, ResolvedRequirement] = {}
1502		if self._duplicateFormat is DuplicateFormat.Expanded:
1503			return duplicateOf
1504
1505		rows = [row for row in self._SortedRequirements(resolution) if _SelectedRelease(row) is not None]
1506		originals: dict[str, list[ResolvedRequirement]] = {}
1507		for row in rows:
1508			originals.setdefault(canonicalize_name(row.Requirement.name), []).append(row)
1509
1510		def visit(dependencies: list[ResolvedRequirement]) -> None:
1511			"""
1512			Nested function for recursion.
1513
1514			:param dependencies: The dependencies of one line.
1515			"""
1516			for dependency in self._SortedByName(dependencies):
1517				if len(nested := self._Dependencies(dependency)) == 0:
1518					continue
1519
1520				candidates = originals.setdefault(canonicalize_name(dependency.Requirement.name), [])
1521				for original in candidates:
1522					if self._IsDuplicate(dependency, original):
1523						duplicateOf[dependency] = original
1524						break
1525				else:
1526					if not any(nestedDependency.Status is ResolutionStatus.LimitReached for nestedDependency in nested):
1527						candidates.append(dependency)
1528						visit(nested)
1529
1530		for row in rows:
1531			visit(self._Dependencies(row))
1532
1533		return duplicateOf
1534
1535	def _IsDuplicate(self, line: ResolvedRequirement, original: ResolvedRequirement) -> bool:
1536		"""
1537		Whether a line of a dependency tree is a duplicate of an original line of the same package.
1538
1539		:attr:`_duplicateMatch` decides: ``Exact`` requires the same release and the same extras, ``Subset`` the same
1540		release and the original's extras or fewer - so everything the line requires is shown at the original -, and
1541		``Name`` nothing more than the package.
1542
1543		:param line:     The line to check.
1544		:param original: An original line of the same package.
1545		:returns:        ``True`` if the line is a duplicate of the original.
1546		"""
1547		if self._duplicateMatch is DuplicateMatch.Name:
1548			return True
1549		elif _SelectedRelease(line) is not _SelectedRelease(original):
1550			return False
1551		elif self._duplicateMatch is DuplicateMatch.Exact:
1552			return line.Requirement.extras == original.Requirement.extras
1553		else:
1554			return line.Requirement.extras <= original.Requirement.extras
1555
1556	def _RequirementParagraph(
1557		self,
1558		requirement: Requirement,
1559		project: Nullable[Project],
1560		release: Nullable[Release]
1561	) -> nodes.paragraph:
1562		"""
1563		Render one line of a dependency tree: the package, what is required of it, and what it is licensed under.
1564
1565		The license is the reason a dependency tree is in this table at all - a package pulls in what its own
1566		dependencies are licensed under, and reading that off the tree is the point. It is linked and parenthesised
1567		so the line still reads as one requirement.
1568
1569		:param requirement: The requirement to render.
1570		:param project:     The project, or ``None`` if the index doesn't know it.
1571		:param release:     The release satisfying the requirement, or ``None`` if none was found.
1572		:returns:           The paragraph.
1573		"""
1574		paragraph = nodes.paragraph()
1575
1576		name = self._PackageName(requirement, project)
1577		if (url := self._PackageURL(project)) is not None:
1578			paragraph += nodes.reference("", name, refuri=url)
1579		else:
1580			paragraph += nodes.Text(name)
1581
1582		if (extras := self._ExtrasText(requirement)) != "":
1583			paragraph += nodes.Text(extras)
1584
1585		if self._dependencyFormat.ShowsVersion:
1586			specifier = self._FormatSpecifier(requirement.specifier, self._simplify, self._versionFormat)
1587			if specifier != "any":
1588				paragraph += nodes.Text(f" {specifier}")
1589
1590		if self._dependencyFormat.ShowsLicense:
1591			paragraph += nodes.Text(" (")
1592			if (license := self._LicenseName(release)) is None:
1593				# the same statement the License column makes: what the index published, in italics, so it reads as
1594				# a quotation rather than as an identifier
1595				paragraph += nodes.emphasis(text=self._PublishedLicense(release))
1596			elif (licenseURL := self._LicenseURL(release)) is not None:
1597				paragraph += nodes.reference("", license, refuri=licenseURL)
1598			else:
1599				paragraph += nodes.Text(license)
1600			paragraph += nodes.Text(")")
1601
1602		return paragraph
1603
1604
1605@export
1606class ReplaceFoldedDependencies(SphinxPostTransform):
1607	"""
1608	A post-transform replacing the folded dependencies of a duplicate by the marker alone, in the output formats that
1609	can't unfold anything: LaTeX, text, manual pages and Texinfo.
1610
1611	A marker's link to the original's line becomes its text there, as these formats write no target for the line.
1612	"""
1613
1614	default_priority = 200                                  #: Priority among the post-transforms.
1615	formats =          ("latex", "text", "man", "texinfo")  #: Output formats the transform runs for.
1616
1617	def run(self, **kwargs: Any) -> None:
1618		"""
1619		Replace every folded duplicate by a bullet list of its summary, and every marker's link by its text.
1620
1621		:param kwargs: Keyword arguments Sphinx passes to a post-transform; none is used.
1622		"""
1623		for duplicate in list(self.document.findall(FoldedDependencies)):
1624			summary = duplicate.children[0]
1625			item = nodes.list_item("", nodes.paragraph("", "", *summary.children), classes=["dependency-duplicate"])
1626			duplicate.replace_self(nodes.bullet_list("", item))
1627
1628		for item in list(self.document.findall(nodes.list_item)):
1629			if "dependency-duplicate" in item["classes"]:
1630				for reference in list(item.findall(nodes.reference)):
1631					reference.replace_self(nodes.Text(reference.astext()))
1632
1633
1634@export
1635def formatUnresolvedLicenses(unresolved: Mapping[str, tuple[str, ...]]) -> str:
1636	"""
1637	Describe the packages needing a license override, grouped by what the package index published for them.
1638
1639	Grouped rather than listed one per line, because one ambiguous statement usually accounts for most of the list:
1640	``License :: OSI Approved :: BSD License`` names three licenses, so every package whose only license information
1641	is that classifier lands here for the same reason and is worth reading as one group.
1642
1643	:param unresolved: Names of the packages needing an override, mapped to what the index published for them.
1644	:returns:          The message, as one line naming the count and two lines per reason.
1645	"""
1646	byReason: dict[tuple[str, ...], list[str]] = {}
1647	for packageName, published in sorted(unresolved.items()):
1648		byReason.setdefault(published, []).append(packageName)
1649
1650	lines = [f"[dependency-table] {len(unresolved)} package(s) need a license override:"]
1651
1652	# the biggest group first, so the statement to fix first is the one at the top
1653	for published, packageNames in sorted(byReason.items(), key=lambda item: (-len(item[1]), item[0])):
1654		reason = "; ".join(published) if len(published) > 0 else "the index published no license information"
1655		lines.append(f"  {reason}")
1656		lines.append(f"    {', '.join(packageNames)}")
1657
1658	return "\n".join(lines)
1659
1660
1661def reportBuildTime(app: Sphinx, exception: Nullable[Exception]) -> None:
1662	"""
1663	Report what querying the package index cost this build.
1664
1665	The tables are fetched live, so this is the number to look at before deciding what a cache would be worth. The
1666	packages whose license had to be guessed at - or couldn't be - are named too, because that is the list the
1667	override file has to answer for.
1668
1669	:param app:       The Sphinx application that finished building.
1670	:param exception: The exception that ended the build, or ``None`` if it succeeded.
1671	"""
1672	if (collector := _COLLECTORS.pop(id(app), None)) is None or collector.ResolutionCount == 0:
1673		return
1674
1675	_logger.info(
1676		f"[dependency-table] {collector.ResolutionCount} resolution(s) against the package index, "
1677		f"{collector.Seconds:.2f} s in total."
1678	)
1679
1680	if len(unresolved := collector.UnresolvedLicenses) > 0:
1681		_logger.warning(formatUnresolvedLicenses(unresolved))