ExternalLink.py
Summary
- Lines:
263 of 270 covered (97.4%)
- Excluded lines:
1
- Branches:
96 of 98 taken (98.0%), 2 lines partially
- Coverage:
97.6%
- Directory:
- Missed lines:
475, 543-545, 622-623, 629
Source
pyTooling/Sphinx/ExternalLink.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"""
32Icons behind the external links of a document in HTML, each chosen by the link's target.
33
34A link to GitHub gets GitHub's mark, an e-mail address an envelope, and a link to a host without an icon of its own a
35square with an arrow pointing out of it.
36
37Two post-transforms mark the links, for HTML only: :class:`MarkExternalLinks` before Sphinx resolves the references,
38so it sees the links written with their URI - also those of a :mod:`sphinx.ext.extlinks` role -, and
39:class:`MarkIntersphinxLinks` after, so it sees the links Sphinx resolved to another project's documentation - e.g. by
40:mod:`sphinx.ext.intersphinx`. A marked link gets a CSS class naming its icon, which the stylesheet draws, and opens
41in a new browser tab - unless configuration value ``pyTooling_ExternalLink_NewTab`` is ``False``, or the link is an
42e-mail address.
43
44A third post-transform, :class:`MarkDownloadLinks`, gives the links of Sphinx' ``:download:`` role a download icon. A
45link to an archive - a file name ending in an extension of :data:`ARCHIVE_FORMATS`, e.g. ``.zip`` or ``.tar.gz`` - gets
46the archive icon instead, also a link written with its URI.
47
48A fourth post-transform, :class:`AddDownloadSizes`, writes the size of a download's file behind its link, and an
49archive's format, e.g. ``(ZIP, 1.2 MiB)``. The size of a file of the documentation is read from the file system; the
50size of another site's file is asked with an HTTP ``HEAD`` request, only if its URI matches a pattern of configuration
51value ``pyTooling_ExternalLink_RemoteSizes``. An archive, whose size isn't known, gets its format only, e.g. ``(ZIP)``.
52
53.. code-block:: Python
54
55 # doc/conf.py
56 pyTooling_ExternalLink_Icons = "all"
57 pyTooling_ExternalLink_NewTab = True
58 pyTooling_ExternalLink_Hosts = {
59 "gitlab.example.org": "gitlab",
60 }
61 pyTooling_ExternalLink_DownloadSizes = True
62 pyTooling_ExternalLink_RemoteSizes = [
63 "https://github.com/pyTooling/*/releases/download/*",
64 ]
65 pyTooling_ExternalLink_RemoteSizeTimeout = 5.0
66
67.. seealso::
68
69 :ref:`LINK/External`
70 |rarr| The icons, the configuration values and the stylesheet's custom properties, with rendered examples.
71"""
72from fnmatch import fnmatchcase
73from math import isfinite
74from pathlib import Path
75from typing import Any, ClassVar, Literal, Mapping, Self, Sequence
76from urllib.parse import urlsplit
77from weakref import WeakKeyDictionary
78
79from docutils import nodes
80from requests.exceptions import HTTPError, RequestException, Timeout
81from sphinx.addnodes import download_reference
82from sphinx.application import Sphinx
83from sphinx.config import Config
84from sphinx.environment import BuildEnvironment
85from sphinx.transforms.post_transforms import SphinxPostTransform
86from sphinx.util.logging import getLogger
87from sphinx.util.requests import head
88
89from pyTooling.Common import StringEnum, getFullyQualifiedName
90from pyTooling.Decorators import export
91from pyTooling.Sphinx import SphinxExtensionError
92
93
94__all__ = ["CONFIG_PREFIX", "HOST_ICONS", "ARCHIVE_FORMATS"]
95
96#: Prefix of the configuration values, e.g. ``pyTooling_ExternalLink_Icons``.
97CONFIG_PREFIX = "pyTooling_ExternalLink"
98
99
100@export
101class ExternalLinkIcons(StringEnum):
102 """
103 Which external links get an icon, as configuration value ``pyTooling_ExternalLink_Icons`` states it.
104 """
105
106 Disabled = "none" #: No link.
107 Explicit = "explicit" #: Links written with their URI, also those of a :mod:`sphinx.ext.extlinks` role.
108 All = "all" #: These, and links resolved to another project's documentation.
109
110 DEFAULT = Explicit #: Without a configuration value, the links written with their URI get an icon.
111
112
113@export
114class LinkIcon(StringEnum):
115 """
116 The icon drawn behind an external link; the link gets the CSS class ``pytooling-link-icon-<value>``.
117 """
118
119 External = "external" #: A square with an arrow pointing out of it.
120 Mail = "mail" #: An envelope.
121 GitHub = "github" #: GitHub's mark.
122 GitLab = "gitlab" #: GitLab's tanuki.
123 Python = "python" #: Python's logo.
124 NumPy = "numpy" #: NumPy's logo.
125 PyPy = "pypy" #: PyPy's logo.
126 Pytest = "pytest" #: pytest's logo.
127 Pydantic = "pydantic" #: Pydantic's logo.
128 ReadTheDocs = "readthedocs" #: Read the Docs' logo.
129 Sphinx = "sphinx" #: Sphinx' logo.
130 Download = "download" #: An arrow pointing down into a tray; the icon of a download link.
131 Archive = "archive" #: A file with a zipper; the icon of a link to an archive.
132
133 DEFAULT = External #: A host without an icon of its own gets the square with the arrow.
134
135 @classmethod
136 def FromURI(cls, uri: str, hosts: Mapping[str, Self]) -> Self:
137 """
138 Choose the icon of a link by its target.
139
140 A ``mailto:`` URI gets :attr:`Mail`, a URI whose path ends in an archive's extension :attr:`Archive` - see
141 :func:`getArchiveExtension`. Any other URI gets the icon of its host, which is looked up in ``hosts`` with its full
142 domain name first, then without its first label, and so on: ``docs.python.org`` is found as ``python.org``. A URI
143 without a host, or with a host not found, gets :attr:`DEFAULT`.
144
145 :param uri: The link's target.
146 :param hosts: The icons, keyed by domain name in lower case.
147 :returns: The link's icon.
148 """
149 target = urlsplit(uri)
150 if target.scheme == "mailto":
151 return cls.Mail
152 elif getArchiveExtension(uri) != "":
153 return cls.Archive
154 elif (host := target.hostname) is None:
155 return cls.DEFAULT
156
157 labels = host.split(".")
158 for index in range(len(labels)):
159 if (domainName := ".".join(labels[index:])) in hosts:
160 return hosts[domainName]
161
162 return cls.DEFAULT
163
164
165#: The icon of a link to a host or to one of its subdomains, keyed by the host's domain name in lower case.
166#:
167#: A project adds hosts, or replaces an icon, with configuration value ``pyTooling_ExternalLink_Hosts``.
168HOST_ICONS: dict[str, LinkIcon] = {
169 "github.com": LinkIcon.GitHub,
170 "gitlab.com": LinkIcon.GitLab,
171 "python.org": LinkIcon.Python,
172 "pypi.org": LinkIcon.Python,
173 "numpy.org": LinkIcon.NumPy,
174 "pypy.org": LinkIcon.PyPy,
175 "pytest.org": LinkIcon.Pytest,
176 "pydantic.dev": LinkIcon.Pydantic,
177 "readthedocs.io": LinkIcon.ReadTheDocs,
178 "readthedocs.org": LinkIcon.ReadTheDocs,
179 "rtfd.io": LinkIcon.ReadTheDocs,
180 "sphinx-doc.org": LinkIcon.Sphinx,
181}
182
183#: The formats of archives, keyed by their file name extension in lower case, as the label a download's size names.
184#:
185#: A link to a file with one of these extensions gets the icon :attr:`LinkIcon.Archive`.
186ARCHIVE_FORMATS: dict[str, str] = {
187 "zip": "ZIP",
188 "tar.gz": "tar.gz",
189 "tgz": "tar.gz",
190 "tar.bz2": "tar.bz2",
191 "tar.xz": "tar.xz",
192 "tar.zst": "tar.zst",
193 "7z": "7z",
194}
195
196
197@export
198def getArchiveExtension(uri: str) -> str:
199 """
200 Return the extension of the archive a link targets, as key of :data:`ARCHIVE_FORMATS`.
201
202 The extensions are compared with the end of the URI's path, ignoring case; a query and a fragment are ignored. A
203 local file name is a URI without a scheme: ``files/Archive.ZIP`` ends in ``zip``.
204
205 :param uri: The link's target.
206 :returns: The extension, or an empty string if the path doesn't end in an archive's extension.
207 """
208 path = urlsplit(uri).path.lower()
209 for extension in ARCHIVE_FORMATS:
210 if path.endswith(f".{extension}"):
211 return extension
212
213 return ""
214
215
216@export
217def checkExternalLinkConfiguration(sphinx: Sphinx, config: Config) -> None:
218 """
219 Call-back for Sphinx' ``config-inited`` event, reading the configuration values of the external links' icons.
220
221 ``pyTooling_ExternalLink_Icons`` is replaced by the member of :class:`ExternalLinkIcons` it names,
222 ``pyTooling_ExternalLink_Hosts`` by a dictionary of the members of :class:`LinkIcon` its values name, keyed by its
223 hosts in lower case, ``pyTooling_ExternalLink_RemoteSizes`` by a tuple of its patterns, and
224 ``pyTooling_ExternalLink_RemoteSizeTimeout`` by a :class:`float`.
225
226 :param sphinx: The Sphinx application.
227 :param config: The configuration, after :file:`conf.py` was read.
228 :raises SphinxExtensionError: If ``pyTooling_ExternalLink_Icons`` is not a string.
229 :raises SphinxExtensionError: If ``pyTooling_ExternalLink_Icons`` names no member of :class:`ExternalLinkIcons`.
230 :raises SphinxExtensionError: If ``pyTooling_ExternalLink_NewTab`` is not a boolean.
231 :raises SphinxExtensionError: If ``pyTooling_ExternalLink_Hosts`` is not a dictionary.
232 :raises SphinxExtensionError: If a host of ``pyTooling_ExternalLink_Hosts`` is not a string.
233 :raises SphinxExtensionError: If a host of ``pyTooling_ExternalLink_Hosts`` is empty.
234 :raises SphinxExtensionError: If an icon of ``pyTooling_ExternalLink_Hosts`` is not a string.
235 :raises SphinxExtensionError: If an icon of ``pyTooling_ExternalLink_Hosts`` names no member of :class:`LinkIcon`.
236 :raises SphinxExtensionError: If ``pyTooling_ExternalLink_DownloadSizes`` is not a boolean.
237 :raises SphinxExtensionError: If ``pyTooling_ExternalLink_RemoteSizes`` is not a list.
238 :raises SphinxExtensionError: If a pattern of ``pyTooling_ExternalLink_RemoteSizes`` is not a string.
239 :raises SphinxExtensionError: If a pattern of ``pyTooling_ExternalLink_RemoteSizes`` is empty.
240 :raises SphinxExtensionError: If ``pyTooling_ExternalLink_RemoteSizeTimeout`` is not a number.
241 :raises SphinxExtensionError: If ``pyTooling_ExternalLink_RemoteSizeTimeout`` is not a positive, finite number.
242 """
243 icons = config[f"{CONFIG_PREFIX}_Icons"]
244 if icons is not None and not isinstance(icons, str):
245 ex = SphinxExtensionError(f"conf.py: {CONFIG_PREFIX}_Icons: Value is not of type 'str'.")
246 ex.add_note(f"Got type '{getFullyQualifiedName(icons)}'.")
247 raise ex
248
249 try:
250 config[f"{CONFIG_PREFIX}_Icons"] = ExternalLinkIcons.Parse(icons)
251 except ValueError as cause:
252 ex = SphinxExtensionError(f"conf.py: {CONFIG_PREFIX}_Icons: '{icons}' is not a selection of links.")
253 ex.add_note(f"Allowed values: {', '.join(ExternalLinkIcons)}.")
254 raise ex from cause
255
256 if not isinstance(newTab := config[f"{CONFIG_PREFIX}_NewTab"], bool):
257 ex = SphinxExtensionError(f"conf.py: {CONFIG_PREFIX}_NewTab: Value is not of type 'bool'.")
258 ex.add_note(f"Got type '{getFullyQualifiedName(newTab)}'.")
259 raise ex
260
261 hosts = config[f"{CONFIG_PREFIX}_Hosts"]
262 if not isinstance(hosts, Mapping):
263 ex = SphinxExtensionError(f"conf.py: {CONFIG_PREFIX}_Hosts: Value is not a dictionary.")
264 ex.add_note(f"Got type '{getFullyQualifiedName(hosts)}'.")
265 raise ex
266
267 hostIcons: dict[str, LinkIcon] = {}
268 for host, icon in hosts.items():
269 if not isinstance(host, str):
270 ex = SphinxExtensionError(f"conf.py: {CONFIG_PREFIX}_Hosts: Host '{host}' is not of type 'str'.")
271 ex.add_note(f"Got type '{getFullyQualifiedName(host)}'.")
272 raise ex
273 elif host == "":
274 raise SphinxExtensionError(f"conf.py: {CONFIG_PREFIX}_Hosts: Host is empty.")
275
276 if icon is not None and not isinstance(icon, str):
277 ex = SphinxExtensionError(f"conf.py: {CONFIG_PREFIX}_Hosts: Icon of host '{host}' is not of type 'str'.")
278 ex.add_note(f"Got type '{getFullyQualifiedName(icon)}'.")
279 raise ex
280
281 try:
282 hostIcons[host.lower()] = LinkIcon.Parse(icon)
283 except ValueError as cause:
284 ex = SphinxExtensionError(f"conf.py: {CONFIG_PREFIX}_Hosts: Icon '{icon}' of host '{host}' is not an icon.")
285 ex.add_note(f"Allowed values: {', '.join(LinkIcon)}.")
286 raise ex from cause
287
288 config[f"{CONFIG_PREFIX}_Hosts"] = hostIcons
289
290 if not isinstance(downloadSizes := config[f"{CONFIG_PREFIX}_DownloadSizes"], bool):
291 ex = SphinxExtensionError(f"conf.py: {CONFIG_PREFIX}_DownloadSizes: Value is not of type 'bool'.")
292 ex.add_note(f"Got type '{getFullyQualifiedName(downloadSizes)}'.")
293 raise ex
294
295 remoteSizes = config[f"{CONFIG_PREFIX}_RemoteSizes"]
296 if isinstance(remoteSizes, str) or not isinstance(remoteSizes, Sequence):
297 ex = SphinxExtensionError(f"conf.py: {CONFIG_PREFIX}_RemoteSizes: Value is not a list.")
298 ex.add_note(f"Got type '{getFullyQualifiedName(remoteSizes)}'.")
299 raise ex
300
301 for pattern in remoteSizes:
302 if not isinstance(pattern, str):
303 ex = SphinxExtensionError(f"conf.py: {CONFIG_PREFIX}_RemoteSizes: Pattern '{pattern}' is not of type 'str'.")
304 ex.add_note(f"Got type '{getFullyQualifiedName(pattern)}'.")
305 raise ex
306 elif pattern == "":
307 raise SphinxExtensionError(f"conf.py: {CONFIG_PREFIX}_RemoteSizes: Pattern is empty.")
308
309 config[f"{CONFIG_PREFIX}_RemoteSizes"] = tuple(remoteSizes)
310
311 timeout = config[f"{CONFIG_PREFIX}_RemoteSizeTimeout"]
312 if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
313 ex = SphinxExtensionError(f"conf.py: {CONFIG_PREFIX}_RemoteSizeTimeout: Value is not of type 'float'.")
314 ex.add_note(f"Got type '{getFullyQualifiedName(timeout)}'.")
315 raise ex
316 elif not (timeout > 0 and isfinite(timeout)):
317 ex = SphinxExtensionError(f"conf.py: {CONFIG_PREFIX}_RemoteSizeTimeout: Value is not a positive, finite number.")
318 ex.add_note(f"Got value '{timeout}'.")
319 raise ex
320
321 config[f"{CONFIG_PREFIX}_RemoteSizeTimeout"] = float(timeout)
322
323
324@export
325class BaseExternalLinkTransform(SphinxPostTransform):
326 """
327 Base-class of the post-transforms marking external links in HTML, so the stylesheet draws an icon behind each.
328
329 A marked link gets the CSS class :attr:`linkClass`, and the CSS class ``pytooling-link-icon-<icon>`` naming the icon
330 :meth:`LinkIcon.FromURI` chooses for it; a link to an archive also gets the CSS class
331 ``pytooling-archive-<extension>``, e.g. ``pytooling-archive-tar-gz``, naming its format. A link holding an image -
332 e.g. a badge - isn't marked, nor is a download link: :class:`MarkDownloadLinks` marks those.
333
334 If ``pyTooling_ExternalLink_NewTab`` is ``True``, a marked link opens in a new browser tab: it gets the attributes
335 ``target="_blank"`` and ``rel="noopener noreferrer"``, which Sphinx' HTML writer copies into the ``<a>`` element. An
336 e-mail address doesn't, as a browser would open an empty tab beside the mail program.
337 """
338
339 formats = ("html", ) #: Output formats the transform runs for.
340 linkClass: ClassVar[str] #: CSS class of a link the transform marks.
341 selections: ClassVar[tuple[ExternalLinkIcons, ...]] #: Values of ``pyTooling_ExternalLink_Icons`` it runs for.
342
343 #: The configuration values of the external links' icons, as ``name: (default, rebuild, types)``. Each is
344 #: registered with :data:`CONFIG_PREFIX` as prefix, e.g. ``pyTooling_ExternalLink_Icons``.
345 #:
346 #: ``Icons`` names a member of :class:`ExternalLinkIcons`; ``None`` is its ``DEFAULT``. ``NewTab`` states whether a
347 #: marked link opens in a new browser tab. ``Hosts`` maps a host's domain name to the name of a member of
348 #: :class:`LinkIcon`, adding to :data:`HOST_ICONS` or replacing its icon. ``DownloadSizes`` states whether
349 #: :class:`AddDownloadSizes` writes the downloads' sizes, ``RemoteSizes`` lists the patterns of the URIs whose size it
350 #: asks for, and ``RemoteSizeTimeout`` is the seconds it waits for a response. All are ``"html"``-rebuilt: the marks
351 #: and sizes are written into the pages, never into the environment.
352 configValues: ClassVar[dict[str, tuple[Any, Literal["html"], Any]]] = {
353 "Icons": (None, "html", (str, ExternalLinkIcons)),
354 "NewTab": (True, "html", bool),
355 "Hosts": ({}, "html", dict),
356 "DownloadSizes": (True, "html", bool),
357 "RemoteSizes": ([], "html", (list, tuple)),
358 "RemoteSizeTimeout": (5.0, "html", (int, float)),
359 }
360
361 def run(self, **kwargs: Any) -> None:
362 """
363 Mark every link :meth:`_Selects` selects, if ``pyTooling_ExternalLink_Icons`` is one of :attr:`selections`.
364
365 :param kwargs: Keyword arguments Sphinx passes to a post-transform; none is used.
366 """
367 if self.config[f"{CONFIG_PREFIX}_Icons"] not in self.selections:
368 return
369
370 hosts = HOST_ICONS.copy()
371 hosts.update(self.config[f"{CONFIG_PREFIX}_Hosts"])
372 newTab = self.config[f"{CONFIG_PREFIX}_NewTab"]
373 for reference in self.document.findall(nodes.reference):
374 if isinstance(reference, download_reference) or "refuri" not in reference or not self._Selects(reference):
375 continue
376 elif next(reference.findall(nodes.image), None) is not None:
377 continue
378
379 icon = LinkIcon.FromURI(reference["refuri"], hosts)
380 reference["classes"] += [self.linkClass, f"pytooling-link-icon-{icon}"]
381 if icon is LinkIcon.Archive:
382 extension = getArchiveExtension(reference["refuri"])
383 reference["classes"].append(f"pytooling-archive-{extension.replace('.', '-')}")
384
385 if newTab and icon is not LinkIcon.Mail:
386 reference["target"] = "_blank"
387 reference["rel"] = "noopener noreferrer"
388
389 def _Selects(self, reference: nodes.reference) -> bool:
390 """
391 Decide whether a link with a URI is marked.
392
393 :param reference: The link.
394 :returns: ``True`` if the link is marked.
395 """
396 raise NotImplementedError(f"Method '{self.__class__.__name__}._Selects' is abstract.")
397
398
399@export
400class MarkExternalLinks(BaseExternalLinkTransform):
401 """
402 A post-transform marking the links written with an absolute URI, before Sphinx resolves the references.
403
404 A link of a :mod:`sphinx.ext.extlinks` role, e.g. ``:gh:``, is one of them.
405 """
406
407 default_priority = 5 #: Before Sphinx' ``ReferencesResolver`` (10).
408 linkClass = "pytooling-external-link" #: CSS class of a marked link.
409 selections = (ExternalLinkIcons.Explicit, ExternalLinkIcons.All) #: Values it runs for.
410
411 def _Selects(self, reference: nodes.reference) -> bool:
412 """
413 Select a link to an absolute URI of scheme ``http``, ``https``, ``ftp`` or ``mailto``.
414
415 :param reference: The link.
416 :returns: ``True`` if the link isn't internal and its URI has one of these schemes.
417 """
418 return not reference.get("internal", False) and urlsplit(reference["refuri"]).scheme in (
419 "http", "https", "ftp", "mailto"
420 )
421
422
423@export
424class MarkIntersphinxLinks(BaseExternalLinkTransform):
425 """
426 A post-transform marking the links Sphinx resolved to another project's documentation.
427
428 Such a link - e.g. one of :mod:`sphinx.ext.intersphinx` - is one :class:`MarkExternalLinks` didn't mark, which its
429 resolver declared not ``internal``. Its URI may be relative, for a project whose inventory is a local file; then it
430 gets the icon :attr:`LinkIcon.External`.
431 """
432
433 default_priority = 20 #: After Sphinx' ``ReferencesResolver`` (10).
434 linkClass = "pytooling-intersphinx-link" #: CSS class of a marked link.
435 selections = (ExternalLinkIcons.All, ) #: Values it runs for.
436
437 def _Selects(self, reference: nodes.reference) -> bool:
438 """
439 Select a link declared not ``internal`` by its resolver, which :class:`MarkExternalLinks` didn't mark.
440
441 :param reference: The link.
442 :returns: ``True`` if the link's attribute ``internal`` is ``False`` and the link isn't marked yet.
443 """
444 return reference.get("internal", None) is False and MarkExternalLinks.linkClass not in reference["classes"]
445
446
447@export
448class MarkDownloadLinks(SphinxPostTransform):
449 """
450 A post-transform marking the links of Sphinx' ``:download:`` role in HTML, so the stylesheet draws a download icon
451 behind each.
452
453 A marked link gets the CSS classes ``pytooling-download-link`` and ``pytooling-link-icon-download``, whether the file
454 is part of the documentation or another site's. A link to an archive gets ``pytooling-link-icon-archive`` instead,
455 and ``pytooling-archive-<extension>`` naming its format - see :func:`getArchiveExtension`. A marked link stays in the
456 browser's tab, as the browser saves the file. A link holding an image isn't marked.
457 """
458
459 default_priority = 5 #: Before Sphinx' ``ReferencesResolver`` (10).
460 formats = ("html", ) #: Output formats the transform runs for.
461 linkClass = "pytooling-download-link" #: CSS class of a marked link.
462 selections = (ExternalLinkIcons.Explicit, ExternalLinkIcons.All) #: Values it runs for.
463
464 def run(self, **kwargs: Any) -> None:
465 """
466 Mark every download link, if ``pyTooling_ExternalLink_Icons`` is one of :attr:`selections`.
467
468 :param kwargs: Keyword arguments Sphinx passes to a post-transform; none is used.
469 """
470 if self.config[f"{CONFIG_PREFIX}_Icons"] not in self.selections:
471 return
472
473 for reference in self.document.findall(download_reference):
474 if next(reference.findall(nodes.image), None) is not None:
475 continue
476 elif (extension := getArchiveExtension(reference["reftarget"])) == "":
477 reference["classes"] += [self.linkClass, f"pytooling-link-icon-{LinkIcon.Download}"]
478 else:
479 reference["classes"] += [
480 self.linkClass, f"pytooling-link-icon-{LinkIcon.Archive}", f"pytooling-archive-{extension.replace('.', '-')}"
481 ]
482
483
484@export
485class DownloadSizeError(SphinxExtensionError):
486 """
487 The size of a download's file is unknown: the file can't be read, or a server didn't tell it.
488 """
489
490
491@export
492class AddDownloadSizes(SphinxPostTransform):
493 """
494 A post-transform writing the size of a download's file behind its link in HTML, and the format of an archive.
495
496 A download is a link of Sphinx' ``:download:`` role, or a link written with an ``http`` or ``https`` URI whose path
497 ends in an archive's extension - see :func:`getArchiveExtension`. Its size follows the link as an inline node of CSS
498 class :attr:`sizeClass`, e.g. ``(340.0 kiB)``, for an archive with its format's label of :data:`ARCHIVE_FORMATS`,
499 e.g. ``(ZIP, 1.2 MiB)``. A link holding an image gets none.
500
501 The size of a file of the documentation is read from the file system. The size of another site's file is asked with
502 an HTTP ``HEAD`` request, following redirections, only if its URI is of scheme ``http`` or ``https`` and matches a
503 pattern of ``pyTooling_ExternalLink_RemoteSizes``; each URI is asked once per build. If the size is unknown - e.g. a
504 file Sphinx didn't find, no response within ``pyTooling_ExternalLink_RemoteSizeTimeout`` seconds, an HTTP error, or a
505 response without a ``Content-Length`` -, Sphinx warns at the link with the warning type ``pyTooling.download_size``.
506 A download, whose size isn't known, gets no size; an archive gets its format only, e.g. ``(ZIP)``. No download gets
507 a size or a format, if ``pyTooling_ExternalLink_DownloadSizes`` is ``False``.
508 """
509
510 default_priority = 25 #: After the post-transforms marking the links.
511 formats = ("html", ) #: Output formats the transform runs for.
512 sizeClass = "pytooling-download-size" #: CSS class of the size behind a link.
513
514 #: The sizes of the remote files asked for, per build environment - i.e. per build -, in bytes, keyed by URI.
515 _remoteSizes: ClassVar[WeakKeyDictionary[BuildEnvironment, dict[str, int]]] = WeakKeyDictionary()
516 #: Why the sizes of remote files asked for are unknown, per build environment, keyed by URI.
517 _remoteFailures: ClassVar[WeakKeyDictionary[BuildEnvironment, dict[str, DownloadSizeError]]] = WeakKeyDictionary()
518
519 def run(self, **kwargs: Any) -> None:
520 """
521 Write the size behind every download, if ``pyTooling_ExternalLink_DownloadSizes`` is ``True``.
522
523 :param kwargs: Keyword arguments Sphinx passes to a post-transform; none is used.
524 """
525 if not self.config[f"{CONFIG_PREFIX}_DownloadSizes"]:
526 return
527
528 patterns = self.config[f"{CONFIG_PREFIX}_RemoteSizes"]
529 timeout = self.config[f"{CONFIG_PREFIX}_RemoteSizeTimeout"]
530 sizes = self._remoteSizes.setdefault(self.env, {})
531 failures = self._remoteFailures.setdefault(self.env, {})
532 for reference in list(self.document.findall(nodes.reference)):
533 isDownload = isinstance(reference, download_reference)
534 if next(reference.findall(nodes.image), None) is not None:
535 continue
536 elif isDownload and "refuri" not in reference:
537 if "filename" not in reference:
538 continue
539
540 uri = reference["reftarget"]
541 try:
542 sizeText = self._FormatSize(Path(self.env.relfn2path(uri, self.env.docname)[1]).stat().st_size)
543 except OSError as ex:
544 self._WarnUnknownSize(reference, uri, f"{ex.strerror}.")
545 sizeText = ""
546 elif urlsplit(uri := reference.get("refuri", "")).scheme not in ("http", "https"):
547 continue
548 elif not isDownload and getArchiveExtension(uri) == "":
549 continue
550 elif not any(fnmatchcase(uri, pattern) for pattern in patterns):
551 sizeText = ""
552 elif uri in sizes:
553 sizeText = self._FormatSize(sizes[uri])
554 elif uri in failures:
555 self._WarnUnknownSize(reference, uri, failures[uri])
556 sizeText = ""
557 else:
558 try:
559 sizes[uri] = self._RequestSize(uri, timeout, self.config)
560 except DownloadSizeError as ex:
561 failures[uri] = ex
562 self._WarnUnknownSize(reference, uri, ex)
563 sizeText = ""
564 else:
565 sizeText = self._FormatSize(sizes[uri])
566
567 extension = getArchiveExtension(uri)
568 if extension == "" and sizeText == "":
569 continue
570 elif extension == "":
571 text = f"({sizeText})"
572 elif sizeText == "":
573 text = f"({ARCHIVE_FORMATS[extension]})"
574 else:
575 text = f"({ARCHIVE_FORMATS[extension]}, {sizeText})"
576
577 reference.parent.insert(
578 reference.parent.index(reference) + 1, [nodes.Text(" "), nodes.inline("", text, classes=[self.sizeClass])]
579 )
580
581 @staticmethod
582 def _WarnUnknownSize(reference: nodes.reference, uri: str, reason: object) -> None:
583 """
584 Warn at a download's link, that its file's size is unknown.
585
586 The warning's type is ``pyTooling.download_size``, so a project can suppress it.
587
588 :param reference: The download's link.
589 :param uri: The file's URI, or its path as written in the document.
590 :param reason: Why the size is unknown.
591 """
592 getLogger(__name__).warning(
593 f"Size of download '{uri}' is unknown. {reason}", location=reference, type="pyTooling", subtype="download_size"
594 )
595
596 @staticmethod
597 def _RequestSize(uri: str, timeout: float, config: Config) -> int:
598 """
599 Ask a server for the size of a file, with an HTTP ``HEAD`` request.
600
601 The request is sent by :mod:`sphinx.util.requests`, so it follows Sphinx' configuration values ``user_agent``,
602 ``tls_verify`` and ``tls_cacerts``. It follows redirections, e.g. of a GitHub release's asset to its storage.
603
604 :param uri: The file's URI.
605 :param timeout: Seconds to wait for a response.
606 :param config: Sphinx' configuration.
607 :returns: The file's size in bytes, as the response's ``Content-Length`` states it.
608 :raises DownloadSizeError: If the server doesn't respond within ``timeout`` seconds, responds with an HTTP error, or
609 the request fails otherwise.
610 :raises DownloadSizeError: If the response has no ``Content-Length``, or one that is no number.
611 """
612 try:
613 response = head(
614 uri, allow_redirects=True, timeout=timeout, _user_agent=config.user_agent,
615 _tls_info=(config.tls_verify, config.tls_cacerts)
616 )
617 response.raise_for_status()
618 except Timeout as ex:
619 raise DownloadSizeError(f"No response within {timeout} seconds.") from ex
620 except HTTPError as ex:
621 raise DownloadSizeError(f"Server responded {ex.response.status_code} {ex.response.reason}.") from ex
622 except RequestException as ex:
623 raise DownloadSizeError(f"Request failed: {ex}") from ex
624
625 length = response.headers.get("Content-Length", "")
626 if length == "":
627 raise DownloadSizeError("Response has no 'Content-Length'.")
628 elif not length.isdecimal():
629 raise DownloadSizeError(f"Response's 'Content-Length' '{length}' is no number.")
630
631 return int(length)
632
633 @staticmethod
634 def _FormatSize(size: int) -> str:
635 """
636 Format a file's size in binary units.
637
638 A size below 1024 bytes is written in bytes, e.g. ``1023 B``; a larger one in the largest unit of kiB, MiB and GiB
639 it reaches, with one decimal, e.g. ``1.0 kiB``.
640
641 :param size: The size in bytes.
642 :returns: The size and its unit.
643 """
644 if size < 1024:
645 return f"{size} B"
646
647 for exponent, unit in (
648 (1, "kiB"),
649 (2, "MiB")
650 ):
651 if (value := round(size / 1024 ** exponent, 1)) < 1024:
652 return f"{value:.1f} {unit}"
653
654 return f"{size / 1024 ** 3:.1f} GiB"