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