Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ Features added
* #14023: Add the new :confval:`mathjax_config_path` option
to load MathJax configuration from a file.
Patch by Randolf Scholz and Adam Turner.
* #14046: linkcheck: Add the :confval:`linkcheck_case_insensitive_urls` option
to allow case-insensitive URL comparison for specific URL patterns.
This is useful for links to websites that normalise URL casing (e.g. GitHub)
or case-insensitive servers.
Patch by Fazeel Usmani and James Addison.

Bugs fixed
----------
Expand Down
36 changes: 36 additions & 0 deletions doc/usage/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3813,6 +3813,42 @@ and the number of workers to use.

.. versionadded:: 7.3

.. confval:: linkcheck_case_insensitive_urls
:type: :code-py:`Set[str] | Sequence[str]`
:default: :code-py:`()`

A collection of regular expressions that match URLs for which the *linkcheck*
builder should perform case-insensitive comparisons. This is useful for
links to websites that are case-insensitive or normalise URL casing.

By default, *linkcheck* requires the destination URL to match the
documented URL case-sensitively.
For example, a link to ``http://example.org/PATH`` that redirects to
``http://example.org/path`` will be reported as ``redirected``.

If the URL matches a pattern contained in
:confval:`!linkcheck_case_insensitive_urls`,
it would instead be reported as ``working``.

For example, to treat all GitHub URLs as case-insensitive:

.. code-block:: python

linkcheck_case_insensitive_urls = [
r'https://github\.com/.*',
]

Or, to treat all URLs as case-insensitive:

.. code-block:: python

linkcheck_case_insensitive_urls = ['.*']

.. note:: URI fragments (HTML anchors) are not affected by this option.
They are always checked with case-sensitive comparisons.

.. versionadded:: 8.3

.. confval:: linkcheck_rate_limit_timeout
:type: :code-py:`int`
:default: :code-py:`300`
Expand Down
31 changes: 29 additions & 2 deletions sphinx/builders/linkcheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
from sphinx.util.nodes import get_node_line

if TYPE_CHECKING:
from collections.abc import Callable, Iterator
from collections.abc import Callable, Iterator, Sequence
from typing import Any, Literal, TypeAlias

from requests import Response
Expand Down Expand Up @@ -385,6 +385,9 @@ def __init__(
self.documents_exclude: list[re.Pattern[str]] = list(
map(re.compile, config.linkcheck_exclude_documents)
)
self.ignore_case: Sequence[re.Pattern[str]] = tuple(
map(re.compile, config.linkcheck_case_insensitive_urls)
)
self.auth = [
(re.compile(pattern), auth_info)
for pattern, auth_info in config.linkcheck_auth
Expand Down Expand Up @@ -629,8 +632,15 @@ def _check_uri(self, uri: str, hyperlink: Hyperlink) -> _URIProperties:
netloc = urlsplit(req_url).netloc
self.rate_limits.pop(netloc, None)

# Check if URL should be normalised case-insensitively
ignore_case = any(pat.match(req_url) for pat in self.ignore_case)
normalised_req_url = self._normalise_url(req_url, ignore_case=ignore_case)
normalised_response_url = self._normalise_url(
response_url, ignore_case=ignore_case
)

if (
(response_url.rstrip('/') == req_url.rstrip('/'))
normalised_response_url == normalised_req_url
or _allowed_redirect(req_url, response_url, self.allowed_redirects)
): # fmt: skip
return _Status.WORKING, '', 0
Expand Down Expand Up @@ -676,6 +686,17 @@ def limit_rate(self, response_url: str, retry_after: str | None) -> float | None
self.rate_limits[netloc] = RateLimit(delay, next_check)
return next_check

@staticmethod
def _normalise_url(url: str, *, ignore_case: bool) -> str:
normalised_url = url.rstrip('/')
if not ignore_case:
return normalised_url
# URI fragments are case-sensitive
url_part, sep, fragment = normalised_url.partition('#')
if sep:
return f'{url_part.casefold()}#{fragment}'
return url_part.casefold()


def _get_request_headers(
uri: str,
Expand Down Expand Up @@ -816,6 +837,12 @@ def setup(app: Sphinx) -> ExtensionMetadata:
app.add_config_value(
'linkcheck_report_timeouts_as_broken', False, '', types=frozenset({bool})
)
app.add_config_value(
'linkcheck_case_insensitive_urls',
(),
'',
types=frozenset({frozenset, list, set, tuple}),
)

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

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
5 changes: 5 additions & 0 deletions tests/roots/test-linkcheck-case-check/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
`path1 <http://localhost:7777/path1>`_

`path2 <http://localhost:7777/path2>`_

This comment was marked as resolved.


`PATH3 <http://localhost:7777/PATH3>`_
67 changes: 67 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,70 @@ 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 CapitalisePathHandler(BaseHTTPRequestHandler):
"""Test server that uppercases URL paths via redirects."""

protocol_version = 'HTTP/1.1'

def do_GET(self):
if self.path.islower():
# Redirect lowercase paths to uppercase versions
self.send_response(301, 'Moved Permanently')
self.send_header('Location', self.path.upper())
self.send_header('Content-Length', '0')
self.end_headers()
else:
# Serve uppercase paths
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)


@pytest.mark.sphinx(
'linkcheck',
testroot='linkcheck-case-check',
freshenv=True,
)
@pytest.mark.parametrize(
('case_insensitive_pattern', 'expected_path1', 'expected_path2', 'expected_path3'),
[
([], 'redirected', 'redirected', 'working'), # default: case-sensitive
(
[r'http://localhost:\d+/.*'],
'working',
'working',
'working',
), # all URLs case-insensitive
(
[r'http://localhost:\d+/path1'],
'working',
'redirected',
'working',
), # only path1 case-insensitive
],
)
def test_linkcheck_case_sensitivity(
app: SphinxTestApp,
case_insensitive_pattern: list[str],
expected_path1: str,
expected_path2: str,
expected_path3: str,
) -> None:
"""Test case-sensitive and case-insensitive URL checking."""
app.config.linkcheck_case_insensitive_urls = case_insensitive_pattern

with serve_application(app, CapitalisePathHandler) 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}

# Verify expected status for each path
assert rowsby[f'http://{address}/path1']['status'] == expected_path1
assert rowsby[f'http://{address}/path2']['status'] == expected_path2
assert rowsby[f'http://{address}/PATH3']['status'] == expected_path3
Loading