Sphinx 7.4.7__py3-none-any.whl → 8.0.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of Sphinx might be problematic. Click here for more details.

Files changed (107) hide show
  1. sphinx/__init__.py +2 -2
  2. sphinx/_cli/__init__.py +4 -4
  3. sphinx/application.py +7 -7
  4. sphinx/builders/__init__.py +2 -3
  5. sphinx/builders/_epub_base.py +33 -12
  6. sphinx/builders/changes.py +13 -5
  7. sphinx/builders/epub3.py +6 -2
  8. sphinx/builders/html/__init__.py +88 -58
  9. sphinx/builders/latex/__init__.py +38 -12
  10. sphinx/builders/latex/transforms.py +1 -1
  11. sphinx/builders/linkcheck.py +8 -49
  12. sphinx/builders/texinfo.py +12 -6
  13. sphinx/builders/text.py +7 -3
  14. sphinx/builders/xml.py +7 -3
  15. sphinx/cmd/quickstart.py +10 -20
  16. sphinx/config.py +12 -12
  17. sphinx/deprecation.py +8 -8
  18. sphinx/directives/other.py +2 -3
  19. sphinx/directives/patches.py +2 -2
  20. sphinx/domains/__init__.py +4 -2
  21. sphinx/domains/c/__init__.py +2 -2
  22. sphinx/domains/c/_ast.py +3 -2
  23. sphinx/domains/c/_parser.py +4 -3
  24. sphinx/domains/cpp/__init__.py +2 -2
  25. sphinx/domains/cpp/_ast.py +1 -2
  26. sphinx/domains/cpp/_parser.py +2 -2
  27. sphinx/domains/cpp/_symbol.py +2 -2
  28. sphinx/domains/math.py +1 -1
  29. sphinx/domains/python/_object.py +0 -1
  30. sphinx/domains/std/__init__.py +7 -8
  31. sphinx/environment/__init__.py +14 -32
  32. sphinx/environment/adapters/indexentries.py +4 -6
  33. sphinx/environment/adapters/toctree.py +4 -4
  34. sphinx/environment/collectors/title.py +1 -1
  35. sphinx/environment/collectors/toctree.py +1 -1
  36. sphinx/events.py +3 -1
  37. sphinx/ext/autodoc/__init__.py +17 -63
  38. sphinx/ext/autodoc/directive.py +7 -5
  39. sphinx/ext/autodoc/importer.py +2 -1
  40. sphinx/ext/autodoc/preserve_defaults.py +2 -2
  41. sphinx/ext/autosummary/__init__.py +7 -6
  42. sphinx/ext/autosummary/generate.py +5 -4
  43. sphinx/ext/doctest.py +5 -5
  44. sphinx/ext/graphviz.py +1 -1
  45. sphinx/ext/imgmath.py +1 -1
  46. sphinx/ext/inheritance_diagram.py +1 -1
  47. sphinx/ext/intersphinx/__init__.py +25 -5
  48. sphinx/ext/intersphinx/_cli.py +7 -6
  49. sphinx/ext/intersphinx/_load.py +240 -115
  50. sphinx/ext/intersphinx/_resolve.py +12 -11
  51. sphinx/ext/intersphinx/_shared.py +102 -9
  52. sphinx/ext/mathjax.py +1 -1
  53. sphinx/ext/napoleon/docstring.py +2 -2
  54. sphinx/ext/todo.py +2 -2
  55. sphinx/ext/viewcode.py +2 -1
  56. sphinx/highlighting.py +3 -3
  57. sphinx/io.py +2 -2
  58. sphinx/jinja2glue.py +13 -6
  59. sphinx/locale/__init__.py +4 -3
  60. sphinx/project.py +23 -19
  61. sphinx/pycode/ast.py +2 -2
  62. sphinx/pycode/parser.py +2 -2
  63. sphinx/pygments_styles.py +3 -3
  64. sphinx/registry.py +3 -8
  65. sphinx/search/__init__.py +1 -1
  66. sphinx/testing/path.py +2 -1
  67. sphinx/testing/util.py +1 -1
  68. sphinx/texinputs/Makefile.jinja +2 -1
  69. sphinx/texinputs_win/Makefile.jinja +2 -1
  70. sphinx/theming.py +3 -12
  71. sphinx/transforms/__init__.py +5 -5
  72. sphinx/transforms/references.py +1 -1
  73. sphinx/util/__init__.py +11 -35
  74. sphinx/util/_timestamps.py +12 -0
  75. sphinx/util/cfamily.py +5 -5
  76. sphinx/util/console.py +4 -3
  77. sphinx/util/display.py +3 -3
  78. sphinx/util/docfields.py +1 -1
  79. sphinx/util/docutils.py +44 -10
  80. sphinx/util/fileutil.py +25 -20
  81. sphinx/util/i18n.py +9 -4
  82. sphinx/util/images.py +3 -2
  83. sphinx/util/inspect.py +28 -43
  84. sphinx/util/inventory.py +2 -2
  85. sphinx/util/matching.py +2 -2
  86. sphinx/util/math.py +1 -1
  87. sphinx/util/nodes.py +8 -8
  88. sphinx/util/osutil.py +29 -28
  89. sphinx/util/parallel.py +2 -2
  90. sphinx/util/requests.py +1 -1
  91. sphinx/util/template.py +3 -3
  92. sphinx/util/typing.py +36 -72
  93. sphinx/writers/html.py +1 -1
  94. sphinx/writers/html5.py +1 -1
  95. sphinx/writers/latex.py +4 -4
  96. sphinx/writers/manpage.py +2 -2
  97. sphinx/writers/texinfo.py +5 -5
  98. sphinx/writers/text.py +4 -4
  99. sphinx/writers/xml.py +2 -2
  100. {sphinx-7.4.7.dist-info → sphinx-8.0.0rc1.dist-info}/METADATA +10 -9
  101. {sphinx-7.4.7.dist-info → sphinx-8.0.0rc1.dist-info}/RECORD +104 -106
  102. sphinx/templates/quickstart/Makefile.jinja +0 -98
  103. sphinx/templates/quickstart/make.bat.jinja +0 -110
  104. sphinx/util/_pathlib.py +0 -120
  105. {sphinx-7.4.7.dist-info → sphinx-8.0.0rc1.dist-info}/LICENSE.rst +0 -0
  106. {sphinx-7.4.7.dist-info → sphinx-8.0.0rc1.dist-info}/WHEEL +0 -0
  107. {sphinx-7.4.7.dist-info → sphinx-8.0.0rc1.dist-info}/entry_points.txt +0 -0
@@ -69,13 +69,14 @@ import sphinx
69
69
  from sphinx import addnodes
70
70
  from sphinx.config import Config
71
71
  from sphinx.environment import BuildEnvironment
72
- from sphinx.ext.autodoc import INSTANCEATTR, Documenter
73
- from sphinx.ext.autodoc.directive import DocumenterBridge, Options
72
+ from sphinx.errors import PycodeError
73
+ from sphinx.ext.autodoc import INSTANCEATTR, Documenter, Options
74
+ from sphinx.ext.autodoc.directive import DocumenterBridge
74
75
  from sphinx.ext.autodoc.importer import import_module
75
76
  from sphinx.ext.autodoc.mock import mock
76
77
  from sphinx.locale import __
77
78
  from sphinx.project import Project
78
- from sphinx.pycode import ModuleAnalyzer, PycodeError
79
+ from sphinx.pycode import ModuleAnalyzer
79
80
  from sphinx.registry import SphinxComponentRegistry
80
81
  from sphinx.util import logging, rst
81
82
  from sphinx.util.docutils import (
@@ -97,7 +98,7 @@ if TYPE_CHECKING:
97
98
  from sphinx.application import Sphinx
98
99
  from sphinx.extension import Extension
99
100
  from sphinx.util.typing import ExtensionMetadata, OptionSpec
100
- from sphinx.writers.html import HTML5Translator
101
+ from sphinx.writers.html5 import HTML5Translator
101
102
 
102
103
  logger = logging.getLogger(__name__)
103
104
 
@@ -248,7 +249,7 @@ class Autosummary(SphinxDirective):
248
249
  docname = posixpath.join(tree_prefix, real_name)
249
250
  docname = posixpath.normpath(posixpath.join(dirname, docname))
250
251
  if docname not in self.env.found_docs:
251
- if excluded(self.env.doc2path(docname, False)):
252
+ if excluded(str(self.env.doc2path(docname, False))):
252
253
  msg = __('autosummary references excluded document %r. Ignored.')
253
254
  else:
254
255
  msg = __('autosummary: stub file not found %r. '
@@ -801,7 +802,7 @@ def process_generate_options(app: Sphinx) -> None:
801
802
 
802
803
  if genfiles is True:
803
804
  env = app.builder.env
804
- genfiles = [env.doc2path(x, base=False) for x in env.found_docs
805
+ genfiles = [str(env.doc2path(x, base=False)) for x in env.found_docs
805
806
  if os.path.isfile(env.doc2path(x))]
806
807
  elif genfiles is False:
807
808
  pass
@@ -34,6 +34,7 @@ import sphinx.locale
34
34
  from sphinx import __display_version__, package_dir
35
35
  from sphinx.builders import Builder
36
36
  from sphinx.config import Config
37
+ from sphinx.errors import PycodeError
37
38
  from sphinx.ext.autodoc.importer import import_module
38
39
  from sphinx.ext.autosummary import (
39
40
  ImportExceptionGroup,
@@ -42,7 +43,7 @@ from sphinx.ext.autosummary import (
42
43
  import_ivar_by_name,
43
44
  )
44
45
  from sphinx.locale import __
45
- from sphinx.pycode import ModuleAnalyzer, PycodeError
46
+ from sphinx.pycode import ModuleAnalyzer
46
47
  from sphinx.registry import SphinxComponentRegistry
47
48
  from sphinx.util import logging, rst
48
49
  from sphinx.util.inspect import getall, safe_getattr
@@ -145,7 +146,7 @@ class AutosummaryRenderer:
145
146
  # ``install_gettext_translations`` is injected by the ``jinja2.ext.i18n`` extension
146
147
  self.env.install_gettext_translations(app.translator) # type: ignore[attr-defined]
147
148
 
148
- def render(self, template_name: str, context: dict) -> str:
149
+ def render(self, template_name: str, context: dict[str, Any]) -> str:
149
150
  """Render a template file."""
150
151
  try:
151
152
  template = self.env.get_template(template_name)
@@ -282,7 +283,7 @@ def generate_autosummary_content(
282
283
  imported_members: bool,
283
284
  app: Any,
284
285
  recursive: bool,
285
- context: dict,
286
+ context: dict[str, Any],
286
287
  modname: str | None = None,
287
288
  qualname: str | None = None,
288
289
  ) -> str:
@@ -392,7 +393,7 @@ def _skip_member(app: Sphinx, obj: Any, name: str, objtype: str) -> bool:
392
393
 
393
394
 
394
395
  def _get_class_members(obj: Any) -> dict[str, Any]:
395
- members = sphinx.ext.autodoc.get_class_members(obj, None, safe_getattr)
396
+ members = sphinx.ext.autodoc.importer.get_class_members(obj, None, safe_getattr)
396
397
  return {name: member.object for name, member in members.items()}
397
398
 
398
399
 
sphinx/ext/doctest.py CHANGED
@@ -11,7 +11,7 @@ import sys
11
11
  import time
12
12
  from io import StringIO
13
13
  from os import path
14
- from typing import TYPE_CHECKING, Any, Callable, ClassVar
14
+ from typing import TYPE_CHECKING, Any, ClassVar
15
15
 
16
16
  from docutils import nodes
17
17
  from docutils.parsers.rst import directives
@@ -27,7 +27,7 @@ from sphinx.util.docutils import SphinxDirective
27
27
  from sphinx.util.osutil import relpath
28
28
 
29
29
  if TYPE_CHECKING:
30
- from collections.abc import Iterable, Sequence
30
+ from collections.abc import Callable, Iterable, Sequence
31
31
 
32
32
  from docutils.nodes import Element, Node, TextElement
33
33
 
@@ -373,7 +373,7 @@ Doctest summary
373
373
  try:
374
374
  filename = relpath(node.source, self.env.srcdir).rsplit(':docstring of ', maxsplit=1)[0] # type: ignore[arg-type] # noqa: E501
375
375
  except Exception:
376
- filename = self.env.doc2path(docname, False)
376
+ filename = str(self.env.doc2path(docname, False))
377
377
  return filename
378
378
 
379
379
  @staticmethod
@@ -420,12 +420,12 @@ Doctest summary
420
420
 
421
421
  if self.config.doctest_test_doctest_blocks:
422
422
  def condition(node: Node) -> bool:
423
- return (isinstance(node, (nodes.literal_block, nodes.comment)) and
423
+ return (isinstance(node, nodes.literal_block | nodes.comment) and
424
424
  'testnodetype' in node) or \
425
425
  isinstance(node, nodes.doctest_block)
426
426
  else:
427
427
  def condition(node: Node) -> bool:
428
- return isinstance(node, (nodes.literal_block, nodes.comment)) \
428
+ return isinstance(node, nodes.literal_block | nodes.comment) \
429
429
  and 'testnodetype' in node
430
430
  for node in doctree.findall(condition):
431
431
  if self.skipped(node): # type: ignore[arg-type]
sphinx/ext/graphviz.py CHANGED
@@ -32,7 +32,7 @@ if TYPE_CHECKING:
32
32
  from sphinx.application import Sphinx
33
33
  from sphinx.config import Config
34
34
  from sphinx.util.typing import ExtensionMetadata, OptionSpec
35
- from sphinx.writers.html import HTML5Translator
35
+ from sphinx.writers.html5 import HTML5Translator
36
36
  from sphinx.writers.latex import LaTeXTranslator
37
37
  from sphinx.writers.manpage import ManualPageTranslator
38
38
  from sphinx.writers.texinfo import TexinfoTranslator
sphinx/ext/imgmath.py CHANGED
@@ -36,7 +36,7 @@ if TYPE_CHECKING:
36
36
  from sphinx.builders import Builder
37
37
  from sphinx.config import Config
38
38
  from sphinx.util.typing import ExtensionMetadata
39
- from sphinx.writers.html import HTML5Translator
39
+ from sphinx.writers.html5 import HTML5Translator
40
40
 
41
41
  logger = logging.getLogger(__name__)
42
42
 
@@ -59,7 +59,7 @@ if TYPE_CHECKING:
59
59
  from sphinx.application import Sphinx
60
60
  from sphinx.environment import BuildEnvironment
61
61
  from sphinx.util.typing import ExtensionMetadata, OptionSpec
62
- from sphinx.writers.html import HTML5Translator
62
+ from sphinx.writers.html5 import HTML5Translator
63
63
  from sphinx.writers.latex import LaTeXTranslator
64
64
  from sphinx.writers.texinfo import TexinfoTranslator
65
65
 
@@ -21,9 +21,8 @@ from __future__ import annotations
21
21
  __all__ = (
22
22
  'InventoryAdapter',
23
23
  'fetch_inventory',
24
- 'fetch_inventory_group',
25
24
  'load_mappings',
26
- 'normalize_intersphinx_mapping',
25
+ 'validate_intersphinx_mapping',
27
26
  'IntersphinxRoleResolver',
28
27
  'inventory_exists',
29
28
  'install_dispatcher',
@@ -42,9 +41,8 @@ import sphinx
42
41
  from sphinx.ext.intersphinx._cli import inspect_main
43
42
  from sphinx.ext.intersphinx._load import (
44
43
  fetch_inventory,
45
- fetch_inventory_group,
46
44
  load_mappings,
47
- normalize_intersphinx_mapping,
45
+ validate_intersphinx_mapping,
48
46
  )
49
47
  from sphinx.ext.intersphinx._resolve import (
50
48
  IntersphinxDispatcher,
@@ -69,7 +67,7 @@ def setup(app: Sphinx) -> ExtensionMetadata:
69
67
  app.add_config_value('intersphinx_cache_limit', 5, '')
70
68
  app.add_config_value('intersphinx_timeout', None, '')
71
69
  app.add_config_value('intersphinx_disabled_reftypes', ['std:doc'], 'env')
72
- app.connect('config-inited', normalize_intersphinx_mapping, priority=800)
70
+ app.connect('config-inited', validate_intersphinx_mapping, priority=800)
73
71
  app.connect('builder-inited', load_mappings)
74
72
  app.connect('source-read', install_dispatcher)
75
73
  app.connect('missing-reference', missing_reference)
@@ -79,3 +77,25 @@ def setup(app: Sphinx) -> ExtensionMetadata:
79
77
  'env_version': 1,
80
78
  'parallel_read_safe': True,
81
79
  }
80
+
81
+
82
+ # deprecated name -> (object to return, canonical path or empty string, removal version)
83
+ _DEPRECATED_OBJECTS: dict[str, tuple[object, str, tuple[int, int]]] = {
84
+ 'normalize_intersphinx_mapping': (
85
+ validate_intersphinx_mapping,
86
+ 'sphinx.ext.intersphinx.validate_intersphinx_mapping',
87
+ (10, 0),
88
+ ),
89
+ }
90
+
91
+
92
+ def __getattr__(name: str) -> object:
93
+ if name not in _DEPRECATED_OBJECTS:
94
+ msg = f'module {__name__!r} has no attribute {name!r}'
95
+ raise AttributeError(msg)
96
+
97
+ from sphinx.deprecation import _deprecation_warning
98
+
99
+ deprecated_object, canonical_name, remove = _DEPRECATED_OBJECTS[name]
100
+ _deprecation_warning(__name__, name, canonical_name, remove=remove)
101
+ return deprecated_object
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import sys
6
6
 
7
- from sphinx.ext.intersphinx._load import fetch_inventory
7
+ from sphinx.ext.intersphinx._load import _fetch_inventory
8
8
 
9
9
 
10
10
  def inspect_main(argv: list[str], /) -> int:
@@ -21,13 +21,14 @@ def inspect_main(argv: list[str], /) -> int:
21
21
  tls_cacerts: str | dict[str, str] | None = None
22
22
  user_agent: str = ''
23
23
 
24
- class MockApp:
25
- srcdir = ''
26
- config = MockConfig()
27
-
28
24
  try:
29
25
  filename = argv[0]
30
- inv_data = fetch_inventory(MockApp(), '', filename) # type: ignore[arg-type]
26
+ inv_data = _fetch_inventory(
27
+ target_uri='',
28
+ inv_location=filename,
29
+ config=MockConfig(), # type: ignore[arg-type]
30
+ srcdir='' # type: ignore[arg-type]
31
+ )
31
32
  for key in sorted(inv_data or {}):
32
33
  print(key)
33
34
  inv_entries = sorted(inv_data[key].items())
@@ -6,175 +6,300 @@ import concurrent.futures
6
6
  import functools
7
7
  import posixpath
8
8
  import time
9
+ from operator import itemgetter
9
10
  from os import path
10
11
  from typing import TYPE_CHECKING
11
12
  from urllib.parse import urlsplit, urlunsplit
12
13
 
13
14
  from sphinx.builders.html import INVENTORY_FILENAME
14
- from sphinx.ext.intersphinx._shared import LOGGER, InventoryAdapter
15
+ from sphinx.errors import ConfigError
16
+ from sphinx.ext.intersphinx._shared import LOGGER, InventoryAdapter, _IntersphinxProject
15
17
  from sphinx.locale import __
16
18
  from sphinx.util import requests
17
19
  from sphinx.util.inventory import InventoryFile
18
20
 
19
21
  if TYPE_CHECKING:
22
+ from pathlib import Path
20
23
  from typing import IO
21
24
 
22
25
  from sphinx.application import Sphinx
23
26
  from sphinx.config import Config
24
- from sphinx.ext.intersphinx._shared import InventoryCacheEntry
27
+ from sphinx.ext.intersphinx._shared import (
28
+ IntersphinxMapping,
29
+ InventoryCacheEntry,
30
+ InventoryLocation,
31
+ InventoryName,
32
+ InventoryURI,
33
+ )
25
34
  from sphinx.util.typing import Inventory
26
35
 
27
36
 
28
- def normalize_intersphinx_mapping(app: Sphinx, config: Config) -> None:
29
- for key, value in config.intersphinx_mapping.copy().items():
37
+ def validate_intersphinx_mapping(app: Sphinx, config: Config) -> None:
38
+ """Validate and normalise :confval:`intersphinx_mapping`.
39
+
40
+ Ensure that:
41
+
42
+ * Keys are non-empty strings.
43
+ * Values are two-element tuples or lists.
44
+ * The first element of each value pair (the target URI)
45
+ is a non-empty string.
46
+ * The second element of each value pair (inventory locations)
47
+ is a tuple of non-empty strings or None.
48
+ """
49
+ # URIs should NOT be duplicated, otherwise different builds may use
50
+ # different project names (and thus, the build are no more reproducible)
51
+ # depending on which one is inserted last in the cache.
52
+ seen: dict[InventoryURI, InventoryName] = {}
53
+
54
+ errors = 0
55
+ for name, value in config.intersphinx_mapping.copy().items():
56
+ # ensure that intersphinx projects are always named
57
+ if not isinstance(name, str) or not name:
58
+ errors += 1
59
+ msg = __(
60
+ 'Invalid intersphinx project identifier `%r` in intersphinx_mapping. '
61
+ 'Project identifiers must be non-empty strings.'
62
+ )
63
+ LOGGER.error(msg, name)
64
+ del config.intersphinx_mapping[name]
65
+ continue
66
+
67
+ # ensure values are properly formatted
68
+ if not isinstance(value, (tuple | list)):
69
+ errors += 1
70
+ msg = __(
71
+ 'Invalid value `%r` in intersphinx_mapping[%r]. '
72
+ 'Expected a two-element tuple or list.'
73
+ )
74
+ LOGGER.error(msg, value, name)
75
+ del config.intersphinx_mapping[name]
76
+ continue
30
77
  try:
31
- if isinstance(value, (list, tuple)):
32
- # new format
33
- name, (uri, inv) = key, value
34
- if not isinstance(name, str):
35
- LOGGER.warning(__('intersphinx identifier %r is not string. Ignored'),
36
- name)
37
- config.intersphinx_mapping.pop(key)
38
- continue
78
+ uri, inv = value
79
+ except (TypeError, ValueError, Exception):
80
+ errors += 1
81
+ msg = __(
82
+ 'Invalid value `%r` in intersphinx_mapping[%r]. '
83
+ 'Values must be a (target URI, inventory locations) pair.'
84
+ )
85
+ LOGGER.error(msg, value, name)
86
+ del config.intersphinx_mapping[name]
87
+ continue
88
+
89
+ # ensure target URIs are non-empty and unique
90
+ if not uri or not isinstance(uri, str):
91
+ errors += 1
92
+ msg = __('Invalid target URI value `%r` in intersphinx_mapping[%r][0]. '
93
+ 'Target URIs must be unique non-empty strings.')
94
+ LOGGER.error(msg, uri, name)
95
+ del config.intersphinx_mapping[name]
96
+ continue
97
+ if uri in seen:
98
+ errors += 1
99
+ msg = __(
100
+ 'Invalid target URI value `%r` in intersphinx_mapping[%r][0]. '
101
+ 'Target URIs must be unique (other instance in intersphinx_mapping[%r]).'
102
+ )
103
+ LOGGER.error(msg, uri, name, seen[uri])
104
+ del config.intersphinx_mapping[name]
105
+ continue
106
+ seen[uri] = name
107
+
108
+ # ensure inventory locations are None or non-empty
109
+ targets: list[InventoryLocation] = []
110
+ for target in (inv if isinstance(inv, (tuple | list)) else (inv,)):
111
+ if target is None or target and isinstance(target, str):
112
+ targets.append(target)
39
113
  else:
40
- # old format, no name
41
- # xref RemovedInSphinx80Warning
42
- name, uri, inv = None, key, value
43
- msg = (
44
- "The pre-Sphinx 1.0 'intersphinx_mapping' format is "
45
- 'deprecated and will be removed in Sphinx 8. Update to the '
46
- 'current format as described in the documentation. '
47
- f"Hint: `intersphinx_mapping = {{'<name>': {(uri, inv)!r}}}`."
48
- 'https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#confval-intersphinx_mapping' # NoQA: E501
114
+ errors += 1
115
+ msg = __(
116
+ 'Invalid inventory location value `%r` in intersphinx_mapping[%r][1]. '
117
+ 'Inventory locations must be non-empty strings or None.'
49
118
  )
50
- LOGGER.warning(msg)
119
+ LOGGER.error(msg, target, name)
120
+ del config.intersphinx_mapping[name]
121
+ continue
51
122
 
52
- if not isinstance(inv, tuple):
53
- config.intersphinx_mapping[key] = (name, (uri, (inv,)))
54
- else:
55
- config.intersphinx_mapping[key] = (name, (uri, inv))
56
- except Exception as exc:
57
- LOGGER.warning(__('Failed to read intersphinx_mapping[%s], ignored: %r'), key, exc)
58
- config.intersphinx_mapping.pop(key)
123
+ config.intersphinx_mapping[name] = (name, (uri, tuple(targets)))
124
+
125
+ if errors == 1:
126
+ msg = __('Invalid `intersphinx_mapping` configuration (1 error).')
127
+ raise ConfigError(msg)
128
+ if errors > 1:
129
+ msg = __('Invalid `intersphinx_mapping` configuration (%s errors).')
130
+ raise ConfigError(msg % errors)
59
131
 
60
132
 
61
133
  def load_mappings(app: Sphinx) -> None:
62
- """Load all intersphinx mappings into the environment."""
134
+ """Load all intersphinx mappings into the environment.
135
+
136
+ The intersphinx mappings are expected to be normalized.
137
+ """
63
138
  now = int(time.time())
64
139
  inventories = InventoryAdapter(app.builder.env)
65
- intersphinx_cache: dict[str, InventoryCacheEntry] = inventories.cache
140
+ intersphinx_cache: dict[InventoryURI, InventoryCacheEntry] = inventories.cache
141
+ intersphinx_mapping: IntersphinxMapping = app.config.intersphinx_mapping
142
+
143
+ projects = []
144
+ for name, (uri, locations) in intersphinx_mapping.values():
145
+ try:
146
+ project = _IntersphinxProject(name=name, target_uri=uri, locations=locations)
147
+ except ValueError as err:
148
+ msg = __('An invalid intersphinx_mapping entry was added after normalisation.')
149
+ raise ConfigError(msg) from err
150
+ else:
151
+ projects.append(project)
152
+
153
+ expected_uris = {project.target_uri for project in projects}
154
+ for uri in frozenset(intersphinx_cache):
155
+ if intersphinx_cache[uri][0] not in intersphinx_mapping:
156
+ # Remove all cached entries that are no longer in `intersphinx_mapping`.
157
+ del intersphinx_cache[uri]
158
+ elif uri not in expected_uris:
159
+ # Remove cached entries with a different target URI
160
+ # than the one in `intersphinx_mapping`.
161
+ # This happens when the URI in `intersphinx_mapping` is changed.
162
+ del intersphinx_cache[uri]
66
163
 
67
164
  with concurrent.futures.ThreadPoolExecutor() as pool:
68
- futures = []
69
- name: str | None
70
- uri: str
71
- invs: tuple[str | None, ...]
72
- for name, (uri, invs) in app.config.intersphinx_mapping.values():
73
- futures.append(pool.submit(
74
- fetch_inventory_group, name, uri, invs, intersphinx_cache, app, now,
75
- ))
165
+ futures = [
166
+ pool.submit(
167
+ _fetch_inventory_group,
168
+ project=project,
169
+ cache=intersphinx_cache,
170
+ now=now,
171
+ config=app.config,
172
+ srcdir=app.srcdir,
173
+ )
174
+ for project in projects
175
+ ]
76
176
  updated = [f.result() for f in concurrent.futures.as_completed(futures)]
77
177
 
78
178
  if any(updated):
179
+ # clear the local inventories
79
180
  inventories.clear()
80
181
 
81
182
  # Duplicate values in different inventories will shadow each
82
- # other; which one will override which can vary between builds
83
- # since they are specified using an unordered dict. To make
84
- # it more consistent, we sort the named inventories and then
85
- # add the unnamed inventories last. This means that the
86
- # unnamed inventories will shadow the named ones but the named
87
- # ones can still be accessed when the name is specified.
88
- named_vals = []
89
- unnamed_vals = []
90
- for name, _expiry, invdata in intersphinx_cache.values():
91
- if name:
92
- named_vals.append((name, invdata))
93
- else:
94
- unnamed_vals.append((name, invdata))
95
- for name, invdata in sorted(named_vals) + unnamed_vals:
96
- if name:
97
- inventories.named_inventory[name] = invdata
98
- for type, objects in invdata.items():
99
- inventories.main_inventory.setdefault(type, {}).update(objects)
100
-
101
-
102
- def fetch_inventory_group(
103
- name: str | None,
104
- uri: str,
105
- invs: tuple[str | None, ...],
106
- cache: dict[str, InventoryCacheEntry],
107
- app: Sphinx,
183
+ # other; which one will override which can vary between builds.
184
+ #
185
+ # In an attempt to make this more consistent,
186
+ # we sort the named inventories in the cache
187
+ # by their name and expiry time ``(NAME, EXPIRY)``.
188
+ by_name_and_time = itemgetter(0, 1) # 0: name, 1: expiry
189
+ cache_values = sorted(intersphinx_cache.values(), key=by_name_and_time)
190
+ for name, _expiry, invdata in cache_values:
191
+ inventories.named_inventory[name] = invdata
192
+ for objtype, objects in invdata.items():
193
+ inventories.main_inventory.setdefault(objtype, {}).update(objects)
194
+
195
+
196
+ def _fetch_inventory_group(
197
+ *,
198
+ project: _IntersphinxProject,
199
+ cache: dict[InventoryURI, InventoryCacheEntry],
108
200
  now: int,
201
+ config: Config,
202
+ srcdir: Path,
109
203
  ) -> bool:
110
- cache_time = now - app.config.intersphinx_cache_limit * 86400
204
+ cache_time = now - config.intersphinx_cache_limit * 86400
205
+
206
+ updated = False
111
207
  failures = []
112
- try:
113
- for inv in invs:
114
- if not inv:
115
- inv = posixpath.join(uri, INVENTORY_FILENAME)
116
- # decide whether the inventory must be read: always read local
117
- # files; remote ones only if the cache time is expired
118
- if '://' not in inv or uri not in cache or cache[uri][1] < cache_time:
119
- safe_inv_url = _get_safe_url(inv)
120
- inv_descriptor = name or 'main_inventory'
121
- LOGGER.info(__("loading intersphinx inventory '%s' from %s..."),
122
- inv_descriptor, safe_inv_url)
123
- try:
124
- invdata = fetch_inventory(app, uri, inv)
125
- except Exception as err:
126
- failures.append(err.args)
127
- continue
128
- if invdata:
129
- cache[uri] = name, now, invdata
130
- return True
131
- return False
132
- finally:
133
- if failures == []:
134
- pass
135
- elif len(failures) < len(invs):
136
- LOGGER.info(__('encountered some issues with some of the inventories,'
137
- ' but they had working alternatives:'))
138
- for fail in failures:
139
- LOGGER.info(*fail)
140
- else:
141
- issues = '\n'.join(f[0] % f[1:] for f in failures)
142
- LOGGER.warning(__('failed to reach any of the inventories '
143
- 'with the following issues:') + '\n' + issues)
208
+
209
+ for location in project.locations:
210
+ # location is either None or a non-empty string
211
+ inv = f'{project.target_uri}/{INVENTORY_FILENAME}' if location is None else location
212
+
213
+ # decide whether the inventory must be read: always read local
214
+ # files; remote ones only if the cache time is expired
215
+ if (
216
+ '://' not in inv
217
+ or project.target_uri not in cache
218
+ or cache[project.target_uri][1] < cache_time
219
+ ):
220
+ LOGGER.info(__("loading intersphinx inventory '%s' from %s ..."),
221
+ project.name, _get_safe_url(inv))
222
+
223
+ try:
224
+ invdata = _fetch_inventory(
225
+ target_uri=project.target_uri,
226
+ inv_location=inv,
227
+ config=config,
228
+ srcdir=srcdir,
229
+ )
230
+ except Exception as err:
231
+ failures.append(err.args)
232
+ continue
233
+
234
+ if invdata:
235
+ cache[project.target_uri] = project.name, now, invdata
236
+ updated = True
237
+ break
238
+
239
+ if not failures:
240
+ pass
241
+ elif len(failures) < len(project.locations):
242
+ LOGGER.info(__('encountered some issues with some of the inventories,'
243
+ ' but they had working alternatives:'))
244
+ for fail in failures:
245
+ LOGGER.info(*fail)
246
+ else:
247
+ issues = '\n'.join(f[0] % f[1:] for f in failures)
248
+ LOGGER.warning(__('failed to reach any of the inventories '
249
+ 'with the following issues:') + '\n' + issues)
250
+ return updated
251
+
252
+
253
+ def fetch_inventory(app: Sphinx, uri: InventoryURI, inv: str) -> Inventory:
254
+ """Fetch, parse and return an intersphinx inventory file."""
255
+ return _fetch_inventory(
256
+ target_uri=uri,
257
+ inv_location=inv,
258
+ config=app.config,
259
+ srcdir=app.srcdir,
260
+ )
144
261
 
145
262
 
146
- def fetch_inventory(app: Sphinx, uri: str, inv: str) -> Inventory:
263
+ def _fetch_inventory(
264
+ *, target_uri: InventoryURI, inv_location: str, config: Config, srcdir: Path,
265
+ ) -> Inventory:
147
266
  """Fetch, parse and return an intersphinx inventory file."""
148
- # both *uri* (base URI of the links to generate) and *inv* (actual
149
- # location of the inventory file) can be local or remote URIs
150
- if '://' in uri:
267
+ # both *target_uri* (base URI of the links to generate)
268
+ # and *inv_location* (actual location of the inventory file)
269
+ # can be local or remote URIs
270
+ if '://' in target_uri:
151
271
  # case: inv URI points to remote resource; strip any existing auth
152
- uri = _strip_basic_auth(uri)
272
+ target_uri = _strip_basic_auth(target_uri)
153
273
  try:
154
- if '://' in inv:
155
- f = _read_from_url(inv, config=app.config)
274
+ if '://' in inv_location:
275
+ f = _read_from_url(inv_location, config=config)
156
276
  else:
157
- f = open(path.join(app.srcdir, inv), 'rb') # NoQA: SIM115
277
+ f = open(path.join(srcdir, inv_location), 'rb') # NoQA: SIM115
158
278
  except Exception as err:
159
279
  err.args = ('intersphinx inventory %r not fetchable due to %s: %s',
160
- inv, err.__class__, str(err))
280
+ inv_location, err.__class__, str(err))
161
281
  raise
162
282
  try:
163
283
  if hasattr(f, 'url'):
164
- newinv = f.url
165
- if inv != newinv:
166
- LOGGER.info(__('intersphinx inventory has moved: %s -> %s'), inv, newinv)
167
-
168
- if uri in (inv, path.dirname(inv), path.dirname(inv) + '/'):
169
- uri = path.dirname(newinv)
284
+ new_inv_location = f.url
285
+ if inv_location != new_inv_location:
286
+ msg = __('intersphinx inventory has moved: %s -> %s')
287
+ LOGGER.info(msg, inv_location, new_inv_location)
288
+
289
+ if target_uri in {
290
+ inv_location,
291
+ path.dirname(inv_location),
292
+ path.dirname(inv_location) + '/'
293
+ }:
294
+ target_uri = path.dirname(new_inv_location)
170
295
  with f:
171
296
  try:
172
- invdata = InventoryFile.load(f, uri, posixpath.join)
297
+ invdata = InventoryFile.load(f, target_uri, posixpath.join)
173
298
  except ValueError as exc:
174
299
  raise ValueError('unknown or unsupported inventory version: %r' % exc) from exc
175
300
  except Exception as err:
176
301
  err.args = ('intersphinx inventory %r not readable due to %s: %s',
177
- inv, err.__class__.__name__, str(err))
302
+ inv_location, err.__class__.__name__, str(err))
178
303
  raise
179
304
  else:
180
305
  return invdata