From afbe66e4d9e3026e87d8b733ab903cfea1674746 Mon Sep 17 00:00:00 2001 From: Samuel Dowling Date: Fri, 23 Jan 2026 00:06:00 +1030 Subject: [PATCH] Add `intersphinx_request_headers` confval Add `intersphinx_request_headers` confval to allow custom HTTP headers to be set when resolving `objects.inv` on remote hosts. In particular, this enabled Bearer authorization to allow access to private GitLab pages. Closes #14269 --- AUTHORS.rst | 1 + CHANGES.rst | 3 +++ doc/usage/extensions/intersphinx.rst | 18 +++++++++++++ sphinx/ext/intersphinx/__init__.py | 3 +++ sphinx/ext/intersphinx/_cli.py | 1 + sphinx/ext/intersphinx/_load.py | 27 ++++++++++++++++--- sphinx/ext/intersphinx/_shared.py | 5 ++++ .../test_ext_intersphinx.py | 23 +++++++++++++--- 8 files changed, 74 insertions(+), 7 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index fd129bb8f72..5754e2aadfd 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -105,6 +105,7 @@ Contributors * \A. Rafey Khan -- improved intersphinx typing * Rui Pinheiro -- Python 3.14 forward references support * Roland Meister -- epub builder +* Samuel Dowling -- Intersphinx request header support * Sebastian Wiesner -- image handling, distutils support * Slawek Figiel -- additional warning suppression * Stefan Seefeld -- toctree improvements diff --git a/CHANGES.rst b/CHANGES.rst index 6f7279e8011..50aeb5a40a8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -11,6 +11,9 @@ Dependencies Features added -------------- +* Add :confval:`intersphinx_request_headers` to allow custom HTTP headers to + be set when resolving objects.inv on remote hosts. In particular, this + enables Bearer authorization to allow access to private GitLab pages. * Add :meth:`~sphinx.application.Sphinx.add_static_dir` for copying static assets from extensions to the build output. Patch by Jared Dillard diff --git a/doc/usage/extensions/intersphinx.rst b/doc/usage/extensions/intersphinx.rst index 91de6274f36..f0d5f8b6f65 100644 --- a/doc/usage/extensions/intersphinx.rst +++ b/doc/usage/extensions/intersphinx.rst @@ -212,6 +212,24 @@ linking: If ``*`` is in the list of domains, then no non-:rst:role:`external` references will be resolved by intersphinx. +.. confval:: intersphinx_request_headers + :type: :code-py:`dict[str, dict[str, str]]` + :default: :code-py:`{}` + + Headers to pass to the HTTP request for a project's :file:`objects.inv` file. + For example: + + .. code-block:: python + + token = "abcde" + intersphinx_request_headers = { + 'python': { + "Authorization": f"Bearer {token}", + } + } + + .. versionadded:: 9.2.0 + Explicitly Reference External Objects ------------------------------------- diff --git a/sphinx/ext/intersphinx/__init__.py b/sphinx/ext/intersphinx/__init__.py index 585316e9ce7..0021a0e03e6 100644 --- a/sphinx/ext/intersphinx/__init__.py +++ b/sphinx/ext/intersphinx/__init__.py @@ -65,6 +65,9 @@ def setup(app: Sphinx) -> ExtensionMetadata: app.add_config_value('intersphinx_mapping', {}, 'env', types=frozenset({dict})) + app.add_config_value( + 'intersphinx_request_headers', {}, 'env', types=frozenset({dict}) + ) app.add_config_value('intersphinx_resolve_self', '', 'env', types=frozenset({str})) app.add_config_value('intersphinx_cache_limit', 5, '', types=frozenset({int})) app.add_config_value( diff --git a/sphinx/ext/intersphinx/_cli.py b/sphinx/ext/intersphinx/_cli.py index bf3a333eb95..8555e828a7d 100644 --- a/sphinx/ext/intersphinx/_cli.py +++ b/sphinx/ext/intersphinx/_cli.py @@ -34,6 +34,7 @@ def inspect_main(argv: list[str], /) -> int: try: raw_data, _ = _fetch_inventory_data( target_uri='', + headers={}, inv_location=filename, config=config, srcdir=Path(), diff --git a/sphinx/ext/intersphinx/_load.py b/sphinx/ext/intersphinx/_load.py index ab6a373fea0..d75ef2d6f56 100644 --- a/sphinx/ext/intersphinx/_load.py +++ b/sphinx/ext/intersphinx/_load.py @@ -146,12 +146,18 @@ def load_mappings(app: Sphinx) -> None: inventories = InventoryAdapter(env) intersphinx_cache: dict[InventoryURI, InventoryCacheEntry] = inventories.cache intersphinx_mapping: IntersphinxMapping = app.config.intersphinx_mapping + intersphinx_request_headers: dict[str, dict[str, str]] = ( + app.config.intersphinx_request_headers + ) projects = [] for name, (uri, locations) in intersphinx_mapping.values(): try: project = _IntersphinxProject( - name=name, target_uri=uri, locations=locations + name=name, + target_uri=uri, + headers=intersphinx_request_headers.get(name, {}), + locations=locations, ) except ValueError as err: msg = __( @@ -302,6 +308,7 @@ def _fetch_inventory_group( try: raw_data, target_uri = _fetch_inventory_data( target_uri=project.target_uri, + headers=project.headers, inv_location=inv_location, config=config, srcdir=srcdir, @@ -335,10 +342,13 @@ def _fetch_inventory_group( return updated -def fetch_inventory(app: Sphinx, uri: InventoryURI, inv: str) -> Inventory: +def fetch_inventory( + app: Sphinx, uri: InventoryURI, headers: dict[str, str], inv: str +) -> Inventory: """Fetch, parse and return an intersphinx inventory file.""" raw_data, uri = _fetch_inventory_data( target_uri=uri, + headers=headers, inv_location=inv, config=_InvConfig.from_config(app.config), srcdir=app.srcdir, @@ -350,6 +360,7 @@ def fetch_inventory(app: Sphinx, uri: InventoryURI, inv: str) -> Inventory: def _fetch_inventory_data( *, target_uri: InventoryURI, + headers: dict[str, str], inv_location: str, config: _InvConfig, srcdir: Path, @@ -364,7 +375,10 @@ def _fetch_inventory_data( target_uri = _strip_basic_auth(target_uri) if '://' in inv_location: raw_data, target_uri = _fetch_inventory_url( - target_uri=target_uri, inv_location=inv_location, config=config + target_uri=target_uri, + headers=headers, + inv_location=inv_location, + config=config, ) if cache_path is not None: cache_path.parent.mkdir(parents=True, exist_ok=True) @@ -386,7 +400,11 @@ def _load_inventory(raw_data: bytes, /, *, target_uri: InventoryURI) -> _Invento def _fetch_inventory_url( - *, target_uri: InventoryURI, inv_location: str, config: _InvConfig + *, + target_uri: InventoryURI, + headers: dict[str, str], + inv_location: str, + config: _InvConfig, ) -> tuple[bytes, str]: try: with requests.get( @@ -394,6 +412,7 @@ def _fetch_inventory_url( timeout=config.intersphinx_timeout, _user_agent=config.user_agent, _tls_info=(config.tls_verify, config.tls_cacerts), + headers=headers, ) as r: r.raise_for_status() raw_data = r.content diff --git a/sphinx/ext/intersphinx/_shared.py b/sphinx/ext/intersphinx/_shared.py index cbedbf1b380..c7baa5ecfa1 100644 --- a/sphinx/ext/intersphinx/_shared.py +++ b/sphinx/ext/intersphinx/_shared.py @@ -44,6 +44,7 @@ class _IntersphinxProject: name: InventoryName target_uri: InventoryURI + headers: dict[str, str] locations: tuple[InventoryLocation, ...] __slots__ = { @@ -51,6 +52,7 @@ class _IntersphinxProject: 'It is unique and in bijection with an remote inventory URL.', 'target_uri': 'The inventory project URL to which links are resolved. ' 'It is unique and in bijection with an inventory name.', + 'headers': 'The headers to use in the HTTP request', 'locations': 'A tuple of local or remote targets containing ' 'the inventory data to fetch. ' 'None indicates the default inventory file name.', @@ -61,6 +63,7 @@ def __init__( *, name: InventoryName, target_uri: InventoryURI, + headers: dict[str, str], locations: Sequence[InventoryLocation], ) -> None: if not name or not isinstance(name, str): @@ -80,6 +83,7 @@ def __init__( raise ValueError(msg) object.__setattr__(self, 'name', name) object.__setattr__(self, 'target_uri', target_uri) + object.__setattr__(self, 'headers', headers) object.__setattr__(self, 'locations', tuple(locations)) def __repr__(self) -> str: @@ -87,6 +91,7 @@ def __repr__(self) -> str: f'{self.__class__.__name__}(' f'name={self.name!r}, ' f'target_uri={self.target_uri!r}, ' + f'headers={self.headers!r}, ' f'locations={self.locations!r})' ) diff --git a/tests/test_ext_intersphinx/test_ext_intersphinx.py b/tests/test_ext_intersphinx/test_ext_intersphinx.py index 5fcfe4d9260..17444144da6 100644 --- a/tests/test_ext_intersphinx/test_ext_intersphinx.py +++ b/tests/test_ext_intersphinx/test_ext_intersphinx.py @@ -72,6 +72,11 @@ def reference_check(app, *args, **kwds): def set_config(app, mapping): # copy *mapping* so that normalization does not alter it app.config.intersphinx_mapping = mapping.copy() + app.config.intersphinx_request_headers = { + 'python': { + 'Authorization': 'Bearer abcde', + } + } app.config.intersphinx_cache_limit = 0 app.config.intersphinx_disabled_reftypes = [] app.config.intersphinx_resolve_self = '' @@ -89,8 +94,10 @@ def test_fetch_inventory_redirection(get_request, InventoryFile, app): # same uri and inv, not redirected mocked_get.url = 'https://hostname/' + INVENTORY_FILENAME target_uri = 'https://hostname/' + headers = {} raw_data, target_uri = _fetch_inventory_data( target_uri=target_uri, + headers=headers, inv_location='https://hostname/' + INVENTORY_FILENAME, config=_InvConfig.from_config(app.config), srcdir=app.srcdir, @@ -108,6 +115,7 @@ def test_fetch_inventory_redirection(get_request, InventoryFile, app): target_uri = 'https://hostname/' raw_data, target_uri = _fetch_inventory_data( target_uri=target_uri, + headers=headers, inv_location='https://hostname/' + INVENTORY_FILENAME, config=_InvConfig.from_config(app.config), srcdir=app.srcdir, @@ -129,6 +137,7 @@ def test_fetch_inventory_redirection(get_request, InventoryFile, app): target_uri = 'https://hostname/' raw_data, target_uri = _fetch_inventory_data( target_uri=target_uri, + headers=headers, inv_location='https://hostname/new/' + INVENTORY_FILENAME, config=_InvConfig.from_config(app.config), srcdir=app.srcdir, @@ -146,6 +155,7 @@ def test_fetch_inventory_redirection(get_request, InventoryFile, app): target_uri = 'https://hostname/' raw_data, target_uri = _fetch_inventory_data( target_uri=target_uri, + headers=headers, inv_location='https://hostname/new/' + INVENTORY_FILENAME, config=_InvConfig.from_config(app.config), srcdir=app.srcdir, @@ -802,7 +812,9 @@ def test_intersphinx_cache_limit(app, monkeypatch, cache_limit, expected_expired ) for name, (uri, locations) in app.config.intersphinx_mapping.values(): - project = _IntersphinxProject(name=name, target_uri=uri, locations=locations) + project = _IntersphinxProject( + name=name, target_uri=uri, headers={}, locations=locations + ) updated = _fetch_inventory_group( project=project, cache=intersphinx_cache, @@ -836,6 +848,7 @@ def log_message(*args, **kwargs): with http_server(InventoryHandler) as server: url1 = f'http://localhost:{server.server_port}' url2 = f'http://localhost:{server.server_port}/' + headers = {'Authorization': 'Bearer abcde'} config = Config() config.intersphinx_cache_limit = -1 @@ -858,7 +871,7 @@ def log_message(*args, **kwargs): side_effect = ValueError('') project1 = _IntersphinxProject( - name='1', target_uri=url1, locations=(url1, None) + name='1', target_uri=url1, headers=headers, locations=(url1, None) ) with mock.patch( 'sphinx.ext.intersphinx._load._fetch_inventory_data', @@ -867,6 +880,7 @@ def log_message(*args, **kwargs): assert not _fetch_inventory_group(project=project1, **kwds) mockfn.assert_any_call( target_uri=url1, + headers=headers, inv_location=url1, config=config, srcdir=None, @@ -874,6 +888,7 @@ def log_message(*args, **kwargs): ) mockfn.assert_any_call( target_uri=url1, + headers=headers, inv_location=url1 + '/' + INVENTORY_FILENAME, config=config, srcdir=None, @@ -881,7 +896,7 @@ def log_message(*args, **kwargs): ) project2 = _IntersphinxProject( - name='2', target_uri=url2, locations=(url2, None) + name='2', target_uri=url2, headers=headers, locations=(url2, None) ) with mock.patch( 'sphinx.ext.intersphinx._load._fetch_inventory_data', @@ -890,6 +905,7 @@ def log_message(*args, **kwargs): assert not _fetch_inventory_group(project=project2, **kwds) mockfn.assert_any_call( target_uri=url2, + headers=headers, inv_location=url2, config=config, srcdir=None, @@ -897,6 +913,7 @@ def log_message(*args, **kwargs): ) mockfn.assert_any_call( target_uri=url2, + headers=headers, inv_location=url2 + INVENTORY_FILENAME, config=config, srcdir=None,