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:
- 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))