Skip to content

Commit 27f2de7

Browse files
committed
linkcheck: retain default do-not-follow for redirects
1 parent 87f28dc commit 27f2de7

File tree

4 files changed

+58
-56
lines changed

4 files changed

+58
-56
lines changed

CHANGES.rst

+2-2
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ Features added
1616
* #13332: Add :confval:`doctest_fail_fast` option to exit after the first failed
1717
test.
1818
Patch by Till Hoffmann.
19-
* #13439: linkcheck: Permit warning on every redirect with
19+
* #13439, #13462: linkcheck: Permit warning on every redirect with
2020
``linkcheck_allowed_redirects = {}``.
21-
Patch by Adam Turner.
21+
Patch by Adam Turner and James Addison.
2222

2323
Bugs fixed
2424
----------

doc/usage/configuration.rst

+5
Original file line numberDiff line numberDiff line change
@@ -3642,6 +3642,7 @@ and which failures and redirects it ignores.
36423642

36433643
.. confval:: linkcheck_allowed_redirects
36443644
:type: :code-py:`dict[str, str]`
3645+
:default: :code-py:`{}` (do not follow)
36453646

36463647
A dictionary that maps a pattern of the source URI
36473648
to a pattern of the canonical URI.
@@ -3655,6 +3656,10 @@ and which failures and redirects it ignores.
36553656
It can be useful to detect unexpected redirects when using
36563657
:option:`the fail-on-warnings mode <sphinx-build --fail-on-warning>`.
36573658

3659+
To deny all redirects, configure an empty dictionary (the default).
3660+
3661+
To follow all redirections, configure a value of :code-py:`None`.
3662+
36583663
Example:
36593664

36603665
.. code-block:: python

sphinx/builders/linkcheck.py

+21-30
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525

2626
from sphinx._cli.util.colour import darkgray, darkgreen, purple, red, turquoise
2727
from sphinx.builders.dummy import DummyBuilder
28-
from sphinx.errors import ConfigError
2928
from sphinx.locale import __
3029
from sphinx.transforms.post_transforms import SphinxPostTransform
3130
from sphinx.util import logging, requests
@@ -387,7 +386,7 @@ def __init__(
387386
)
388387
self.check_anchors: bool = config.linkcheck_anchors
389388
self.allowed_redirects: dict[re.Pattern[str], re.Pattern[str]]
390-
self.allowed_redirects = config.linkcheck_allowed_redirects or {}
389+
self.allowed_redirects = config.linkcheck_allowed_redirects
391390
self.retries: int = config.linkcheck_retries
392391
self.rate_limit_timeout = config.linkcheck_rate_limit_timeout
393392
self._allow_unauthorized = config.linkcheck_allow_unauthorized
@@ -722,10 +721,13 @@ def handle_starttag(self, tag: Any, attrs: Any) -> None:
722721
def _allowed_redirect(
723722
url: str, new_url: str, allowed_redirects: dict[re.Pattern[str], re.Pattern[str]]
724723
) -> bool:
725-
return any(
726-
from_url.match(url) and to_url.match(new_url)
727-
for from_url, to_url in allowed_redirects.items()
728-
)
724+
if allowed_redirects is None: # no restrictions configured
725+
return True
726+
else:
727+
return any(
728+
from_url.match(url) and to_url.match(new_url)
729+
for from_url, to_url in allowed_redirects.items()
730+
)
729731

730732

731733
class RateLimit(NamedTuple):
@@ -750,29 +752,18 @@ def rewrite_github_anchor(app: Sphinx, uri: str) -> str | None:
750752

751753
def compile_linkcheck_allowed_redirects(app: Sphinx, config: Config) -> None:
752754
"""Compile patterns to the regexp objects."""
753-
if config.linkcheck_allowed_redirects is _sentinel_lar:
754-
config.linkcheck_allowed_redirects = None
755-
return
756-
if not isinstance(config.linkcheck_allowed_redirects, dict):
757-
msg = __(
758-
f'Invalid value `{config.linkcheck_allowed_redirects!r}` in '
759-
'linkcheck_allowed_redirects. Expected a dictionary.'
760-
)
761-
raise ConfigError(msg)
762-
allowed_redirects = {}
763-
for url, pattern in config.linkcheck_allowed_redirects.items():
764-
try:
765-
allowed_redirects[re.compile(url)] = re.compile(pattern)
766-
except re.error as exc:
767-
logger.warning(
768-
__('Failed to compile regex in linkcheck_allowed_redirects: %r %s'),
769-
exc.pattern,
770-
exc.msg,
771-
)
772-
config.linkcheck_allowed_redirects = allowed_redirects
773-
774-
775-
_sentinel_lar = object()
755+
if config.linkcheck_allowed_redirects is not None:
756+
allowed_redirects = {}
757+
for url, pattern in config.linkcheck_allowed_redirects.items():
758+
try:
759+
allowed_redirects[re.compile(url)] = re.compile(pattern)
760+
except re.error as exc:
761+
logger.warning(
762+
__('Failed to compile regex in linkcheck_allowed_redirects: %r %s'),
763+
exc.pattern,
764+
exc.msg,
765+
)
766+
config.linkcheck_allowed_redirects = allowed_redirects
776767

777768

778769
def setup(app: Sphinx) -> ExtensionMetadata:
@@ -784,7 +775,7 @@ def setup(app: Sphinx) -> ExtensionMetadata:
784775
'linkcheck_exclude_documents', [], '', types=frozenset({list, tuple})
785776
)
786777
app.add_config_value(
787-
'linkcheck_allowed_redirects', _sentinel_lar, '', types=frozenset({dict})
778+
'linkcheck_allowed_redirects', None, '', types=frozenset({dict, type(None)})
788779
)
789780
app.add_config_value('linkcheck_auth', [], '', types=frozenset({list, tuple}))
790781
app.add_config_value('linkcheck_request_headers', {}, '', types=frozenset({dict}))

tests/test_builders/test_build_linkcheck.py

+30-24
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import wsgiref.handlers
1111
from base64 import b64encode
1212
from http.server import BaseHTTPRequestHandler
13-
from io import StringIO
1413
from queue import Queue
1514
from typing import TYPE_CHECKING
1615
from unittest import mock
@@ -28,7 +27,6 @@
2827
RateLimit,
2928
compile_linkcheck_allowed_redirects,
3029
)
31-
from sphinx.errors import ConfigError
3230
from sphinx.testing.util import SphinxTestApp
3331
from sphinx.util import requests
3432
from sphinx.util._pathlib import _StrPath
@@ -680,7 +678,7 @@ def check_headers(self):
680678
assert content['status'] == 'working'
681679

682680

683-
def make_redirect_handler(*, support_head: bool) -> type[BaseHTTPRequestHandler]:
681+
def make_redirect_handler(*, support_head: bool = True) -> type[BaseHTTPRequestHandler]:
684682
class RedirectOnceHandler(BaseHTTPRequestHandler):
685683
protocol_version = 'HTTP/1.1'
686684

@@ -712,17 +710,15 @@ def log_date_time_string(self):
712710
'linkcheck',
713711
testroot='linkcheck-localserver',
714712
freshenv=True,
713+
confoverrides={'linkcheck_allowed_redirects': None},
715714
)
716715
def test_follows_redirects_on_HEAD(app, capsys):
717716
with serve_application(app, make_redirect_handler(support_head=True)) as address:
718717
compile_linkcheck_allowed_redirects(app, app.config)
719718
app.build()
720719
_stdout, stderr = capsys.readouterr()
721720
content = (app.outdir / 'output.txt').read_text(encoding='utf8')
722-
assert content == (
723-
'index.rst:1: [redirected with Found] '
724-
f'http://{address}/ to http://{address}/?redirected=1\n'
725-
)
721+
assert content == ''
726722
assert stderr == textwrap.dedent(
727723
"""\
728724
127.0.0.1 - - [] "HEAD / HTTP/1.1" 302 -
@@ -736,17 +732,15 @@ def test_follows_redirects_on_HEAD(app, capsys):
736732
'linkcheck',
737733
testroot='linkcheck-localserver',
738734
freshenv=True,
735+
confoverrides={'linkcheck_allowed_redirects': None},
739736
)
740737
def test_follows_redirects_on_GET(app, capsys):
741738
with serve_application(app, make_redirect_handler(support_head=False)) as address:
742739
compile_linkcheck_allowed_redirects(app, app.config)
743740
app.build()
744741
_stdout, stderr = capsys.readouterr()
745742
content = (app.outdir / 'output.txt').read_text(encoding='utf8')
746-
assert content == (
747-
'index.rst:1: [redirected with Found] '
748-
f'http://{address}/ to http://{address}/?redirected=1\n'
749-
)
743+
assert content == ''
750744
assert stderr == textwrap.dedent(
751745
"""\
752746
127.0.0.1 - - [] "HEAD / HTTP/1.1" 405 -
@@ -757,25 +751,37 @@ def test_follows_redirects_on_GET(app, capsys):
757751
assert app.warning.getvalue() == ''
758752

759753

754+
@pytest.mark.sphinx(
755+
'linkcheck',
756+
testroot='linkcheck-localserver',
757+
freshenv=True,
758+
confoverrides={'linkcheck_allowed_redirects': {}}, # do not follow any redirects
759+
)
760+
def test_warns_redirects_on_GET(app, capsys):
761+
with serve_application(app, make_redirect_handler()) as address:
762+
compile_linkcheck_allowed_redirects(app, app.config)
763+
app.build()
764+
_stdout, stderr = capsys.readouterr()
765+
content = (app.outdir / 'output.txt').read_text(encoding='utf8')
766+
assert content == (
767+
'index.rst:1: [redirected with Found] '
768+
f'http://{address}/ to http://{address}/?redirected=1\n'
769+
)
770+
assert stderr == textwrap.dedent(
771+
"""\
772+
127.0.0.1 - - [] "HEAD / HTTP/1.1" 302 -
773+
127.0.0.1 - - [] "HEAD /?redirected=1 HTTP/1.1" 204 -
774+
""",
775+
)
776+
assert len(app.warning.getvalue().splitlines()) == 1
777+
778+
760779
def test_linkcheck_allowed_redirects_config(
761780
make_app: Callable[..., SphinxTestApp], tmp_path: Path
762781
) -> None:
763782
tmp_path.joinpath('conf.py').touch()
764783
tmp_path.joinpath('index.rst').touch()
765784

766-
# ``linkcheck_allowed_redirects = None`` is rejected
767-
warning_stream = StringIO()
768-
with pytest.raises(ConfigError):
769-
make_app(
770-
'linkcheck',
771-
srcdir=tmp_path,
772-
confoverrides={'linkcheck_allowed_redirects': None},
773-
warning=warning_stream,
774-
)
775-
assert strip_escape_sequences(warning_stream.getvalue()).splitlines() == [
776-
"WARNING: The config value `linkcheck_allowed_redirects' has type `NoneType'; expected `dict'."
777-
]
778-
779785
# ``linkcheck_allowed_redirects = {}`` is permitted
780786
app = make_app(
781787
'linkcheck',

0 commit comments

Comments
 (0)