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:

Sphinx

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"