Skip to content
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
63e108c
Update configuration.rst
FazeelUsmani Nov 7, 2025
caae7eb
Add linkcheck_ignore_case config option
FazeelUsmani Nov 7, 2025
9e6dd40
Update i18n.py
FazeelUsmani Nov 7, 2025
eccd6d7
fixed the failing test test_numfig_disabled_warn
FazeelUsmani Nov 7, 2025
6300483
Enable case-insensitive URL and anchor checking for linkcheck builder
FazeelUsmani Nov 7, 2025
b61366c
strip ANSI color codes from stderr before assertion
FazeelUsmani Nov 7, 2025
7ea45c6
fixed the failing test test_connect_to_selfsigned_fails
FazeelUsmani Nov 7, 2025
99a5dc0
Update test_build_linkcheck.py
FazeelUsmani Nov 7, 2025
f99651f
Merge branch 'master' into linkcheck_case_insensitive
FazeelUsmani Nov 10, 2025
ac12d63
Update linkcheck.py
FazeelUsmani Nov 11, 2025
1a0d9ed
Update test_build_linkcheck.py
FazeelUsmani Nov 11, 2025
d115b1e
Update test_build_linkcheck.py
FazeelUsmani Nov 11, 2025
0075419
fix ruff check linkcheck.py
FazeelUsmani Nov 11, 2025
4eceef2
fix ruff check test_build_linkcheck.py
FazeelUsmani Nov 11, 2025
e772df9
Update configuration.rst
FazeelUsmani Nov 11, 2025
14ded5b
Update configuration.rst
FazeelUsmani Nov 11, 2025
386d4ac
Update configuration.rst
FazeelUsmani Nov 11, 2025
53a47e3
Update doc/usage/configuration.rst
FazeelUsmani Nov 12, 2025
3e545f3
Update i18n.py (reert \)
FazeelUsmani Nov 12, 2025
d9940da
Use .casefold() for case-insensitive URL comparison
FazeelUsmani Nov 12, 2025
322fcf5
Update test_build_linkcheck.py (revert)
FazeelUsmani Nov 12, 2025
cfcbef2
Update test_build_linkcheck.py (revert)
FazeelUsmani Nov 12, 2025
2c4567d
restore original pytest markers
FazeelUsmani Nov 12, 2025
c18d573
Removed the duplicate @pytest.mark.sphinx
FazeelUsmani Nov 12, 2025
07b1795
Removed test_linkcheck_anchors_remain_case_sensitive
FazeelUsmani Nov 12, 2025
bc8fa7c
Rename linkcheck_ignore_case to linkcheck_case_insensitive and update…
FazeelUsmani Nov 13, 2025
029a720
Fix ruff format check
FazeelUsmani Nov 13, 2025
539adaa
remove unused code paths
FazeelUsmani Nov 17, 2025
ae5708f
Merge branch 'master' into linkcheck_case_insensitive
FazeelUsmani Nov 17, 2025
66ae54d
Remove unused test parameter from numfig test
FazeelUsmani Nov 17, 2025
5bc9f2d
Tests: Add complete coverage for linkcheck case sensitivity tests
FazeelUsmani Nov 18, 2025
eaa1caa
Refactor linkcheck case sensitivity: rename config and fix fragment h…
FazeelUsmani Nov 18, 2025
57e8b3c
Improve formatting and update config value handling
FazeelUsmani Nov 18, 2025
5dffff4
Update tests/test_builders/test_build_linkcheck.py
FazeelUsmani Nov 18, 2025
5e08ab3
Remove deprecated linkcheck_case_insensitive config handling
FazeelUsmani Nov 18, 2025
45cf720
Merge branch 'linkcheck_case_insensitive' of github.com:FazeelUsmani/…
FazeelUsmani Nov 18, 2025
06663cf
Refactor linkcheck tests: rename handler for case sensitivity and sim…
FazeelUsmani Nov 18, 2025
5615ffc
Add support for case-insensitive URL checking in linkcheck builder
FazeelUsmani Nov 18, 2025
842b756
restore @pytest.mark.test_params and update documentation
FazeelUsmani Nov 19, 2025
1fe4293
efactor linkcheck case sensitivity tests with dynamic path handler
FazeelUsmani Nov 20, 2025
8c7648b
"Update test document with path1 and path2 for case sensitivity tests
FazeelUsmani Nov 20, 2025
d95224b
Apply ruff formatting
FazeelUsmani Nov 20, 2025
422b2d5
Refactor linkcheck case sensitivity tests per review feedback
FazeelUsmani Nov 24, 2025
a3744b0
ruff format
FazeelUsmani Nov 24, 2025
a53c44a
Update tests/test_builders/test_build_linkcheck.py
FazeelUsmani Nov 24, 2025
4457493
dd test case for non-redirecting URL in linkcheck case sensitivity tests
FazeelUsmani Nov 24, 2025
515d0c5
Merge branch 'linkcheck_case_insensitive' of github.com:FazeelUsmani/…
FazeelUsmani Nov 24, 2025
cf03035
Merge branch 'master' into linkcheck_case_insensitive
AA-Turner Nov 24, 2025
a3d6a37
misc tweaks; rename to linkcheck_case_insensitive_urls
AA-Turner Nov 24, 2025
05d1049
fixup
AA-Turner Nov 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions doc/usage/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3813,6 +3813,27 @@ and the number of workers to use.

.. versionadded:: 7.3

.. confval:: linkcheck_case_sensitive
:type: :code-py:`bool`
:default: :code-py:`True`

This setting controls how the *linkcheck* builder decides
whether a hyperlink's destination is the same as the URL
written in the documentation.

By default, *linkcheck* requires the destination URL to match the written URL case-sensitively. This means that a link to ``http://webserver.test/USERNAME`` in
the documentation that the server redirects to ``http://webserver.test/username`` will be reported as ``redirected``.

To allow a more lenient URL comparison, that will report the previous case as
``working`` instead, configure this setting to ``False``.

.. note::

HTML anchor checking is always case-sensitive, and is
not affected by this setting.

.. versionadded:: 8.2

.. confval:: linkcheck_rate_limit_timeout
:type: :code-py:`int`
:default: :code-py:`300`
Expand Down
38 changes: 37 additions & 1 deletion sphinx/builders/linkcheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@
self.user_agent = config.user_agent
self.tls_verify = config.tls_verify
self.tls_cacerts = config.tls_cacerts
self.case_insensitive = not config.linkcheck_case_sensitive

self._session = requests._Session(
_ignored_redirects=tuple(map(re.compile, config.linkcheck_ignore))
Expand Down Expand Up @@ -629,8 +630,24 @@
netloc = urlsplit(req_url).netloc
self.rate_limits.pop(netloc, None)

# Compare URLs, optionally case-insensitively
def _normalise_url(url: str) -> str:
"""Reduces a URL to a normal/equality-comparable form."""
normalised_url = url.rstrip('/')
if self.case_insensitive:
# Only casefold the URL before the fragment; fragments are case-sensitive
if '#' in normalised_url:
url_part, fragment = normalised_url.split('#', 1)
normalised_url = url_part.casefold() + '#' + fragment
else:
normalised_url = normalised_url.casefold()
return normalised_url

normalised_request_url = _normalise_url(req_url)
normalised_response_url = _normalise_url(response_url)

if (
(response_url.rstrip('/') == req_url.rstrip('/'))
normalised_request_url == normalised_response_url
or _allowed_redirect(req_url, response_url, self.allowed_redirects)
): # fmt: skip
return _Status.WORKING, '', 0
Expand Down Expand Up @@ -759,6 +776,21 @@
return None


def handle_deprecated_linkcheck_case_config(app: Sphinx, config: Config) -> None:
"""Handle backward compatibility for renamed linkcheck_case_insensitive config."""
# Check if the old config name is used (i.e., user set it to a non-None value)
if config.linkcheck_case_insensitive is not None:
logger.warning(
__(
'The configuration value "linkcheck_case_insensitive" is deprecated. '
'Use "linkcheck_case_sensitive" instead (with inverted logic: '
'linkcheck_case_sensitive = not linkcheck_case_insensitive).'
)
)
# Apply the old config value with inverted logic
config.linkcheck_case_sensitive = not config.linkcheck_case_insensitive


def compile_linkcheck_allowed_redirects(app: Sphinx, config: Config) -> None:
"""Compile patterns to the regexp objects."""
if config.linkcheck_allowed_redirects is _SENTINEL_LAR:
Expand Down Expand Up @@ -816,10 +848,14 @@
app.add_config_value(
'linkcheck_report_timeouts_as_broken', False, '', types=frozenset({bool})
)
app.add_config_value('linkcheck_case_sensitive', True, '', types=frozenset({bool}))
# Deprecated config value for backward compatibility
app.add_config_value('linkcheck_case_insensitive', None, '', types=frozenset({bool, type(None)}))

Check failure on line 853 in sphinx/builders/linkcheck.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (E501)

sphinx/builders/linkcheck.py:853:96: E501 Line too long (101 > 95)

app.add_event('linkcheck-process-uri')

# priority 900 to happen after ``check_confval_types()``
app.connect('config-inited', handle_deprecated_linkcheck_case_config, priority=899)
app.connect('config-inited', compile_linkcheck_allowed_redirects, priority=900)

# FIXME: Disable URL rewrite handler for github.com temporarily.
Expand Down
1 change: 1 addition & 0 deletions tests/roots/test-linkcheck-case-check/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Empty config for linkcheck case sensitivity tests
1 change: 1 addition & 0 deletions tests/roots/test-linkcheck-case-check/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`local server path <http://localhost:7777/path>`_
1 change: 0 additions & 1 deletion tests/test_builders/test_build_html_numfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@


@pytest.mark.sphinx('html', testroot='numfig')
@pytest.mark.test_params(shared_result='test_build_html_numfig')

This comment was marked as resolved.

def test_numfig_disabled_warn(app: SphinxTestApp) -> None:
app.build()
warnings = app.warning.getvalue()
Expand Down
84 changes: 84 additions & 0 deletions tests/test_builders/test_build_linkcheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -1439,3 +1439,87 @@ def test_linkcheck_exclude_documents(app: SphinxTestApp) -> None:
'uri': 'https://www.sphinx-doc.org/this-is-another-broken-link',
'info': 'br0ken_link matched br[0-9]ken_link from linkcheck_exclude_documents',
} in content


class CaseSensitiveHandler(BaseHTTPRequestHandler):

This comment was marked as resolved.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed it from CaseSensitiveHandler to CapitalisePathHandler and updated the docstring to reflect what it actually does.

"""Simple test server for case sensitivity tests."""

protocol_version = 'HTTP/1.1'

def do_HEAD(self):
if self.path == '/path':
# Redirect lowercase /path to uppercase /Path
self.send_response(301, 'Moved Permanently')
self.send_header('Location', f'http://{self.headers["Host"]}/Path')
self.send_header('Content-Length', '0')
self.end_headers()
elif self.path == '/Path':
self.send_response(200, 'OK')
self.send_header('Content-Length', '0')
self.end_headers()
else:
self.send_response(404, 'Not Found')
self.send_header('Content-Length', '0')
self.end_headers()

def do_GET(self):
if self.path == '/path':
# Redirect lowercase /path to uppercase /Path
self.send_response(301, 'Moved Permanently')
self.send_header('Location', f'http://{self.headers["Host"]}/Path')
self.send_header('Content-Length', '0')
self.end_headers()
elif self.path == '/Path':
content = b'ok\n\n'
self.send_response(200, 'OK')
self.send_header('Content-Length', str(len(content)))
self.end_headers()
self.wfile.write(content)
else:
self.send_response(404, 'Not Found')
self.send_header('Content-Length', '0')
self.end_headers()


@pytest.mark.sphinx(
'linkcheck',
testroot='linkcheck-case-check',
freshenv=True,
confoverrides={'linkcheck_case_sensitive': True},
)
def test_linkcheck_case_sensitive(app: SphinxTestApp) -> None:
"""Test that case-sensitive checking is the default behavior."""
with serve_application(app, CaseSensitiveHandler) as address:
app.build()

content = (app.outdir / 'output.json').read_text(encoding='utf8')
rows = [json.loads(x) for x in content.splitlines()]
rowsby = {row['uri']: row for row in rows}

# With case-sensitive checking, a URL that redirects to different case
# should be marked as redirected
lowercase_uri = f'http://{address}/path'
assert lowercase_uri in rowsby, f'Expected {lowercase_uri} to be checked'
assert rowsby[lowercase_uri]['status'] == 'redirected'


@pytest.mark.sphinx(
'linkcheck',
testroot='linkcheck-case-check',
freshenv=True,
confoverrides={'linkcheck_case_sensitive': False},
)
def test_linkcheck_case_insensitive(app: SphinxTestApp) -> None:
"""Test that linkcheck_case_sensitive=False ignores case differences in URLs."""
with serve_application(app, CaseSensitiveHandler) as address:
app.build()

content = (app.outdir / 'output.json').read_text(encoding='utf8')
rows = [json.loads(x) for x in content.splitlines()]
rowsby = {row['uri']: row for row in rows}

# With case-insensitive checking, a URL that differs only in case
# should be marked as working
lowercase_uri = f'http://{address}/path'
assert lowercase_uri in rowsby, f'Expected {lowercase_uri} to be checked'
assert rowsby[lowercase_uri]['status'] == 'working'
Loading