From 5fc3d99537de32a5e6063bccb90b63b423226fec Mon Sep 17 00:00:00 2001 From: James Garner Date: Mon, 22 Jun 2026 10:40:31 +1200 Subject: [PATCH 1/9] docs: create underscored versions of every page as a redirect target --- .docs/conf.py | 17 +- .docs/extensions/underscore_redirects.py | 118 +++++++++++++ .docs/tests/test_underscore_redirects.py | 201 +++++++++++++++++++++++ 3 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 .docs/extensions/underscore_redirects.py create mode 100644 .docs/tests/test_underscore_redirects.py diff --git a/.docs/conf.py b/.docs/conf.py index e2cddf821..24e8ab645 100644 --- a/.docs/conf.py +++ b/.docs/conf.py @@ -6,7 +6,13 @@ # local extensions sys.path.insert(0, str(pathlib.Path(__file__).parent / 'extensions')) -local_extensions = ['generate_tables', 'interface_docs', 'package_docs', 'diataxis_docs_fallback'] +local_extensions = [ + 'generate_tables', + 'interface_docs', + 'package_docs', + 'diataxis_docs_fallback', + 'underscore_redirects', +] # So that sphinx.ext.autodoc can find charmlibs code root = pathlib.Path(__file__).parent.parent @@ -102,6 +108,14 @@ redirects = {} +# sphinx-rerediraffe: https://github.com/wpilibsuite/sphinx-rerediraffe +# Used by the local `underscore_redirects` extension, which populates this +# mapping at build time with separator-variant aliases (for example, +# `how_to/manage_libraries` -> `how-to/manage-libraries`). Seed it as an empty +# dict so the extension can extend it; set to None to disable redirect +# generation entirely. +rediraffe_redirects: dict[str, str] = {} + ############################ # sphinx-llm configuration # ############################ @@ -155,6 +169,7 @@ "notfound.extension", "sphinx_design", "sphinx_reredirects", + "sphinx_rerediraffe", "sphinxcontrib.jquery", "sphinxext.opengraph", # Previously bundled as canonical-sphinx-extensions diff --git a/.docs/extensions/underscore_redirects.py b/.docs/extensions/underscore_redirects.py new file mode 100644 index 000000000..73dae2cef --- /dev/null +++ b/.docs/extensions/underscore_redirects.py @@ -0,0 +1,118 @@ +# Copyright 2025 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Populate ``rediraffe_redirects`` with underscore/hyphen separator variants. + +All hand-written docs pages use hyphens in their slugs (for example +``how-to/manage-libraries``). This extension makes the underscore versions +(``how_to/manage_libraries``) resolve too, by generating redirects from the +separator-swapped variant of every page to the page itself. + +The mapping is populated into the ``rediraffe_redirects`` config value, which +is consumed by the ``sphinx-rerediraffe`` extension (enabled in ``conf.py``) +to write the actual redirect HTML during the build. rediraffe also validates +that every redirect target exists, so a misconfigured redirect fails the +build rather than silently 404ing. + +The mapping is symmetric: for each existing page, the variant with ``-`` and +``_`` swapped in every path segment is computed. If that variant is not +already a real page, a redirect is added from the variant to the page. This +means hyphenated pages get underscore aliases *and* underscored pages (for +example, interface reference pages like +``reference/interfaces/fiveg_core_gnb/v1``) get hyphen aliases. When both +separators are already real pages, no redirect is generated for either. + +Only the final combined docs pass populates the mapping. Per-package +reference passes (where ``package`` is set) are skipped, because those passes +only build a subset of pages and most redirect targets would not exist. +""" + +from __future__ import annotations + +import typing + +if typing.TYPE_CHECKING: + import sphinx.application + import sphinx.environment + + +def setup(app: sphinx.application.Sphinx) -> dict[str, str | bool]: + """Sphinx extension entrypoint -- register the ``env-updated`` hook.""" + app.connect('env-updated', _populate) + return { + 'version': '1.0.0', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } + + +def _populate(app: sphinx.application.Sphinx, env: sphinx.environment.BuildEnvironment) -> None: + # Skip the per-package reference passes: they only build a subset of pages, + # so most redirect targets would be missing. The final combined pass + # (where 'package' is unset) is the one that writes the full site. + if getattr(app.config, 'package', None) is not None: + return + # Only extend an existing dict mapping. If a user has configured + # rediraffe_redirects as a filename (str) or left it unset, respect that + # and don't clobber it. + existing = app.config.rediraffe_redirects + if not isinstance(existing, dict): + return + for variant, target in _build_redirects(set(env.found_docs)).items(): + # Never overwrite a user-configured redirect. + if variant not in existing: + existing[variant] = target + + +def _build_redirects(found_docs: set[str]) -> dict[str, str]: + """Return a ``{variant: original}`` redirect mapping for the given docnames. + + For each docname, the separator-swapped variant (``-`` <-> ``_`` in every + path segment) is computed. If the variant differs from the original and is + not itself a real page, a redirect is added from the variant to the + original. Existing user redirects are never overwritten. + """ + redirects: dict[str, str] = {} + for docname in found_docs: + variant = _separator_variant(docname) + if variant is None: + continue + if variant in found_docs: + # Both separator variants are real pages; don't shadow either. + continue + if variant in redirects: + # Two originals map to the same variant (shouldn't happen with a + # pure swap, but guard against it regardless). + continue + redirects[variant] = docname + return redirects + + +def _separator_variant(docname: str) -> str | None: + """Return the docname with ``-`` and ``_`` swapped in each segment. + + Returns ``None`` when the result is identical to the input (that is, when + no path segment contains either separator). Uses a NUL placeholder so the + swap is a true involution even for segments containing both separators. + """ + new_parts: list[str] = [] + changed = False + for part in docname.split('/'): + new_part = part.replace('-', '\0').replace('_', '-').replace('\0', '_') + if new_part != part: + changed = True + new_parts.append(new_part) + if not changed: + return None + return '/'.join(new_parts) diff --git a/.docs/tests/test_underscore_redirects.py b/.docs/tests/test_underscore_redirects.py new file mode 100644 index 000000000..29d48a738 --- /dev/null +++ b/.docs/tests/test_underscore_redirects.py @@ -0,0 +1,201 @@ +# Copyright 2025 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ruff: noqa: D103 (function docstrings) + +"""Unit tests for the `underscore_redirects` local Sphinx extension.""" + +from __future__ import annotations + +import types +import typing + +import underscore_redirects + +if typing.TYPE_CHECKING: + import sphinx.application + + +# --- _separator_variant --- + + +def test_separator_variant_hyphens_to_underscores(): + assert underscore_redirects._separator_variant('how-to/manage-libraries') == ( + 'how_to/manage_libraries' + ) + + +def test_separator_variant_underscores_to_hyphens(): + assert ( + underscore_redirects._separator_variant('reference/interfaces/fiveg_core_gnb/v1') + == 'reference/interfaces/fiveg-core-gnb/v1' + ) + + +def test_separator_variant_mixed_segment(): + # A segment with both separators is a true involution. + variant = underscore_redirects._separator_variant('foo-bar_baz/qux') + assert variant == 'foo_bar-baz/qux' + assert underscore_redirects._separator_variant(variant) == 'foo-bar_baz/qux' + + +def test_separator_variant_no_separators_returns_none(): + assert underscore_redirects._separator_variant('index') is None + assert underscore_redirects._separator_variant('reference/v1') is None + + +def test_separator_variant_is_involution(): + for docname in [ + 'how-to/manage-libraries', + 'reference/interfaces/fiveg_core_gnb/v1', + 'a-b_c/d-e_f', + 'index', + ]: + variant = underscore_redirects._separator_variant(docname) + if variant is None: + assert docname == 'index' + continue + assert underscore_redirects._separator_variant(variant) == docname + + +# --- _build_redirects --- + + +def test_build_redirects_hyphenated_page_gets_underscore_alias(): + found = {'how-to/manage-libraries', 'index'} + redirects = underscore_redirects._build_redirects(found) + assert redirects == {'how_to/manage_libraries': 'how-to/manage-libraries'} + + +def test_build_redirects_underscored_page_gets_hyphen_alias(): + found = {'reference/interfaces/fiveg_core_gnb/v1', 'index'} + redirects = underscore_redirects._build_redirects(found) + assert redirects == { + 'reference/interfaces/fiveg-core-gnb/v1': 'reference/interfaces/fiveg_core_gnb/v1' + } + + +def test_build_redirects_both_variants_present_no_redirect(): + # When both separator variants are real pages, neither shadows the other. + found = {'how-to/foo', 'how_to/foo'} + assert underscore_redirects._build_redirects(found) == {} + + +def test_build_redirects_no_separators_no_redirects(): + assert underscore_redirects._build_redirects({'index', 'reference/v1'}) == {} + + +def test_build_redirects_is_pure_function(): + # _build_redirects is a pure function over found_docs; it doesn't know + # about user-configured redirects. Merge protection lives in _populate + # (see test_populate_does_not_overwrite_user_redirect_with_generated). + found = {'how-to/manage-libraries', 'index'} + assert underscore_redirects._build_redirects(found) == { + 'how_to/manage_libraries': 'how-to/manage-libraries' + } + + +# --- _populate (integration with config) --- + + +def _make_app( + *, + package: str | None = None, + rediraffe_redirects: dict[str, str] | str | None = None, + found_docs: typing.Iterable[str] = (), +) -> sphinx.application.Sphinx: + """Build a minimal fake Sphinx app/config/env for ``_populate()``.""" + config = types.SimpleNamespace( + package=package, + rediraffe_redirects=rediraffe_redirects, + ) + env = types.SimpleNamespace(found_docs=set(found_docs)) + return typing.cast( + 'sphinx.application.Sphinx', + types.SimpleNamespace(config=config, env=env), + ) + + +def _rediraffe_redirects(app: sphinx.application.Sphinx) -> dict[str, str] | str | None: + return typing.cast('types.SimpleNamespace', app.config).rediraffe_redirects + + +def test_populate_extends_dict_in_final_pass(): + app = _make_app( + rediraffe_redirects={}, + found_docs={'how-to/manage-libraries', 'index'}, + ) + underscore_redirects._populate(app, app.env) + assert _rediraffe_redirects(app) == {'how_to/manage_libraries': 'how-to/manage-libraries'} + + +def test_populate_skips_per_package_pass(): + app = _make_app( + package='pathops', + rediraffe_redirects={}, + found_docs={'how-to/manage-libraries', 'index'}, + ) + underscore_redirects._populate(app, app.env) + assert _rediraffe_redirects(app) == {} + + +def test_populate_preserves_existing_user_redirects(): + app = _make_app( + rediraffe_redirects={'old/page': 'new/page'}, + found_docs={'how-to/manage-libraries', 'index'}, + ) + underscore_redirects._populate(app, app.env) + redirects = _rediraffe_redirects(app) + assert isinstance(redirects, dict) + assert redirects['old/page'] == 'new/page' + assert redirects['how_to/manage_libraries'] == 'how-to/manage-libraries' + + +def test_populate_noop_when_rediraffe_redirects_not_a_dict(): + # If rediraffe_redirects is a filename (str) or None, don't clobber it. + for value in (None, 'redirects.txt'): + app = _make_app( + rediraffe_redirects=value, + found_docs={'how-to/manage-libraries', 'index'}, + ) + underscore_redirects._populate(app, app.env) + assert _rediraffe_redirects(app) is value + + +def test_populate_does_not_overwrite_user_redirect_with_generated(): + # A user-configured redirect for a variant that would also be generated + # must be preserved. + app = _make_app( + rediraffe_redirects={'how_to/manage_libraries': 'custom/target'}, + found_docs={'how-to/manage-libraries', 'index'}, + ) + underscore_redirects._populate(app, app.env) + redirects = _rediraffe_redirects(app) + assert isinstance(redirects, dict) + assert redirects['how_to/manage_libraries'] == 'custom/target' + + +# --- setup --- + + +def test_setup_returns_metadata(): + def _connect(*args: object, **kwargs: object) -> None: + pass + + metadata = underscore_redirects.setup( + typing.cast('sphinx.application.Sphinx', types.SimpleNamespace(connect=_connect)) + ) + assert metadata['version'] == '1.0.0' + assert metadata['parallel_read_safe'] is True + assert metadata['parallel_write_safe'] is True From 60cbc6bcefd3f35b895202ae45d098a4063b30ed Mon Sep 17 00:00:00 2001 From: James Garner Date: Mon, 22 Jun 2026 11:29:48 +1200 Subject: [PATCH 2/9] docs: hand rewrite extension logic --- .docs/extensions/underscore_redirects.py | 98 ++++++++++-------------- 1 file changed, 41 insertions(+), 57 deletions(-) diff --git a/.docs/extensions/underscore_redirects.py b/.docs/extensions/underscore_redirects.py index 73dae2cef..0cd1b2f70 100644 --- a/.docs/extensions/underscore_redirects.py +++ b/.docs/extensions/underscore_redirects.py @@ -49,70 +49,54 @@ def setup(app: sphinx.application.Sphinx) -> dict[str, str | bool]: """Sphinx extension entrypoint -- register the ``env-updated`` hook.""" - app.connect('env-updated', _populate) - return { - 'version': '1.0.0', - 'parallel_read_safe': True, - 'parallel_write_safe': True, - } - - -def _populate(app: sphinx.application.Sphinx, env: sphinx.environment.BuildEnvironment) -> None: - # Skip the per-package reference passes: they only build a subset of pages, - # so most redirect targets would be missing. The final combined pass - # (where 'package' is unset) is the one that writes the full site. - if getattr(app.config, 'package', None) is not None: - return - # Only extend an existing dict mapping. If a user has configured - # rediraffe_redirects as a filename (str) or left it unset, respect that - # and don't clobber it. - existing = app.config.rediraffe_redirects - if not isinstance(existing, dict): + app.connect('env-updated', _automatic_redirects) + return {'version': '1.0.0', 'parallel_read_safe': True, 'parallel_write_safe': True} + + +def _automatic_redirects( + app: sphinx.application.Sphinx, env: sphinx.environment.BuildEnvironment +) -> None: + # Don't create redirects during the per-package passes. + if app.config.package is not None: return - for variant, target in _build_redirects(set(env.found_docs)).items(): - # Never overwrite a user-configured redirect. - if variant not in existing: - existing[variant] = target + target = typing.cast('dict[str, str]', app.config.rediraffe_redirects) + assert isinstance(target, dict) + redirects = _build_redirects(set(env.found_docs)) + assert set(target).isdisjoint(redirects) + target.update(redirects) def _build_redirects(found_docs: set[str]) -> dict[str, str]: - """Return a ``{variant: original}`` redirect mapping for the given docnames. + """Redirect underscored names to hyphenated ones and vice versa. - For each docname, the separator-swapped variant (``-`` <-> ``_`` in every - path segment) is computed. If the variant differs from the original and is - not itself a real page, a redirect is added from the variant to the - original. Existing user redirects are never overwritten. + Categories area also separately aliased with both variants and with no separator. """ redirects: dict[str, str] = {} - for docname in found_docs: - variant = _separator_variant(docname) - if variant is None: - continue - if variant in found_docs: - # Both separator variants are real pages; don't shadow either. - continue - if variant in redirects: - # Two originals map to the same variant (shouldn't happen with a - # pure swap, but guard against it regardless). - continue - redirects[variant] = docname + for docname in sorted(found_docs): + category, _, doc = docname.partition('/') + category_variants = { + category, + category.replace('-', '_'), # e.g. how_to + category.replace('_', '-'), # e.g. how-to + category.replace('_', '').replace('-', ''), # e.g. howto + } + for category_variant in sorted(category_variants): + doc_variant = _separator_variant(doc) + if (category_variant, doc_variant) == (category, doc): + continue + alias = f'{category_variant}/{doc_variant}' + assert alias not in found_docs, f'Alias {alias} is a real page!' + assert alias not in redirects, f'Alias {alias} already redirects to {redirects[alias]}' + redirects[alias] = docname.removesuffix('index.html') return redirects -def _separator_variant(docname: str) -> str | None: - """Return the docname with ``-`` and ``_`` swapped in each segment. - - Returns ``None`` when the result is identical to the input (that is, when - no path segment contains either separator). Uses a NUL placeholder so the - swap is a true involution even for segments containing both separators. - """ - new_parts: list[str] = [] - changed = False - for part in docname.split('/'): - new_part = part.replace('-', '\0').replace('_', '-').replace('\0', '_') - if new_part != part: - changed = True - new_parts.append(new_part) - if not changed: - return None - return '/'.join(new_parts) +def _separator_variant(docname: str) -> str: + """Return the docname with ``-`` and ``_`` swapped.""" + if '-' in docname: + assert '_' not in docname, f"Docname {docname} should not contain both '-' and '_'" + return docname.replace('-', '_') + if '_' in docname: + assert '-' not in docname, f"Docname {docname} should not contain both '-' and '_'" + return docname.replace('_', '-') + return docname From 1b04635bf0d1fd8b382a9dbb1c19979fb1c09aed Mon Sep 17 00:00:00 2001 From: James Garner Date: Mon, 22 Jun 2026 11:50:59 +1200 Subject: [PATCH 3/9] docs: update tests --- .docs/tests/test_underscore_redirects.py | 87 +++++++++++++----------- 1 file changed, 48 insertions(+), 39 deletions(-) diff --git a/.docs/tests/test_underscore_redirects.py b/.docs/tests/test_underscore_redirects.py index 29d48a738..f1d1d9281 100644 --- a/.docs/tests/test_underscore_redirects.py +++ b/.docs/tests/test_underscore_redirects.py @@ -21,6 +21,8 @@ import types import typing +import pytest + import underscore_redirects if typing.TYPE_CHECKING: @@ -43,29 +45,24 @@ def test_separator_variant_underscores_to_hyphens(): ) -def test_separator_variant_mixed_segment(): - # A segment with both separators is a true involution. - variant = underscore_redirects._separator_variant('foo-bar_baz/qux') - assert variant == 'foo_bar-baz/qux' - assert underscore_redirects._separator_variant(variant) == 'foo-bar_baz/qux' +def test_separator_variant_mixed_segment_raises(): + # A docname containing both separators is a configuration error. + with pytest.raises(AssertionError, match="should not contain both"): + underscore_redirects._separator_variant('foo-bar_baz/qux') -def test_separator_variant_no_separators_returns_none(): - assert underscore_redirects._separator_variant('index') is None - assert underscore_redirects._separator_variant('reference/v1') is None +def test_separator_variant_no_separators_returns_unchanged(): + assert underscore_redirects._separator_variant('index') == 'index' + assert underscore_redirects._separator_variant('reference/v1') == 'reference/v1' def test_separator_variant_is_involution(): for docname in [ 'how-to/manage-libraries', 'reference/interfaces/fiveg_core_gnb/v1', - 'a-b_c/d-e_f', 'index', ]: variant = underscore_redirects._separator_variant(docname) - if variant is None: - assert docname == 'index' - continue assert underscore_redirects._separator_variant(variant) == docname @@ -75,7 +72,11 @@ def test_separator_variant_is_involution(): def test_build_redirects_hyphenated_page_gets_underscore_alias(): found = {'how-to/manage-libraries', 'index'} redirects = underscore_redirects._build_redirects(found) - assert redirects == {'how_to/manage_libraries': 'how-to/manage-libraries'} + assert redirects == { + 'how-to/manage_libraries': 'how-to/manage-libraries', + 'how_to/manage_libraries': 'how-to/manage-libraries', + 'howto/manage_libraries': 'how-to/manage-libraries', + } def test_build_redirects_underscored_page_gets_hyphen_alias(): @@ -86,10 +87,13 @@ def test_build_redirects_underscored_page_gets_hyphen_alias(): } -def test_build_redirects_both_variants_present_no_redirect(): - # When both separator variants are real pages, neither shadows the other. +def test_build_redirects_both_variants_present_raises(): + # When both separator variants are real pages, the generated alias would + # collide with a real page -- a configuration error that should fail + # loudly rather than silently shadowing a page. found = {'how-to/foo', 'how_to/foo'} - assert underscore_redirects._build_redirects(found) == {} + with pytest.raises(AssertionError, match='is a real page'): + underscore_redirects._build_redirects(found) def test_build_redirects_no_separators_no_redirects(): @@ -98,15 +102,18 @@ def test_build_redirects_no_separators_no_redirects(): def test_build_redirects_is_pure_function(): # _build_redirects is a pure function over found_docs; it doesn't know - # about user-configured redirects. Merge protection lives in _populate - # (see test_populate_does_not_overwrite_user_redirect_with_generated). + # about user-configured redirects. Merge protection lives in + # _automatic_redirects (see + # test_populate_does_not_overwrite_user_redirect_with_generated). found = {'how-to/manage-libraries', 'index'} assert underscore_redirects._build_redirects(found) == { - 'how_to/manage_libraries': 'how-to/manage-libraries' + 'how-to/manage_libraries': 'how-to/manage-libraries', + 'how_to/manage_libraries': 'how-to/manage-libraries', + 'howto/manage_libraries': 'how-to/manage-libraries', } -# --- _populate (integration with config) --- +# --- _automatic_redirects (integration with config) --- def _make_app( @@ -115,7 +122,7 @@ def _make_app( rediraffe_redirects: dict[str, str] | str | None = None, found_docs: typing.Iterable[str] = (), ) -> sphinx.application.Sphinx: - """Build a minimal fake Sphinx app/config/env for ``_populate()``.""" + """Build a minimal fake Sphinx app/config/env for ``_automatic_redirects()``.""" config = types.SimpleNamespace( package=package, rediraffe_redirects=rediraffe_redirects, @@ -131,59 +138,61 @@ def _rediraffe_redirects(app: sphinx.application.Sphinx) -> dict[str, str] | str return typing.cast('types.SimpleNamespace', app.config).rediraffe_redirects -def test_populate_extends_dict_in_final_pass(): +def test_automatic_redirects_extends_dict_in_final_pass(): app = _make_app( rediraffe_redirects={}, found_docs={'how-to/manage-libraries', 'index'}, ) - underscore_redirects._populate(app, app.env) - assert _rediraffe_redirects(app) == {'how_to/manage_libraries': 'how-to/manage-libraries'} + underscore_redirects._automatic_redirects(app, app.env) + redirects = _rediraffe_redirects(app) + assert isinstance(redirects, dict) + assert redirects['how_to/manage_libraries'] == 'how-to/manage-libraries' -def test_populate_skips_per_package_pass(): +def test_automatic_redirects_skips_per_package_pass(): app = _make_app( package='pathops', rediraffe_redirects={}, found_docs={'how-to/manage-libraries', 'index'}, ) - underscore_redirects._populate(app, app.env) + underscore_redirects._automatic_redirects(app, app.env) assert _rediraffe_redirects(app) == {} -def test_populate_preserves_existing_user_redirects(): +def test_automatic_redirects_preserves_existing_user_redirects(): app = _make_app( rediraffe_redirects={'old/page': 'new/page'}, found_docs={'how-to/manage-libraries', 'index'}, ) - underscore_redirects._populate(app, app.env) + underscore_redirects._automatic_redirects(app, app.env) redirects = _rediraffe_redirects(app) assert isinstance(redirects, dict) assert redirects['old/page'] == 'new/page' assert redirects['how_to/manage_libraries'] == 'how-to/manage-libraries' -def test_populate_noop_when_rediraffe_redirects_not_a_dict(): - # If rediraffe_redirects is a filename (str) or None, don't clobber it. +def test_automatic_redirects_raises_when_rediraffe_redirects_not_a_dict(): + # The extension requires rediraffe_redirects to be a dict; a filename + # (str) or None is a misconfiguration and should fail loudly. for value in (None, 'redirects.txt'): app = _make_app( rediraffe_redirects=value, found_docs={'how-to/manage-libraries', 'index'}, ) - underscore_redirects._populate(app, app.env) - assert _rediraffe_redirects(app) is value + with pytest.raises(AssertionError): + underscore_redirects._automatic_redirects(app, app.env) -def test_populate_does_not_overwrite_user_redirect_with_generated(): - # A user-configured redirect for a variant that would also be generated - # must be preserved. +def test_automatic_redirects_raises_when_user_redirect_shadows_generated(): + # A user-configured redirect whose key collides with a generated alias + # is a configuration error -- the build should fail rather than silently + # overwrite the user's redirect. app = _make_app( rediraffe_redirects={'how_to/manage_libraries': 'custom/target'}, found_docs={'how-to/manage-libraries', 'index'}, ) - underscore_redirects._populate(app, app.env) - redirects = _rediraffe_redirects(app) - assert isinstance(redirects, dict) - assert redirects['how_to/manage_libraries'] == 'custom/target' + with pytest.raises(AssertionError): + underscore_redirects._automatic_redirects(app, app.env) # --- setup --- From 12424c5ea032f9c6d7b5fe586384d94ca97833d0 Mon Sep 17 00:00:00 2001 From: James Garner Date: Mon, 22 Jun 2026 12:11:46 +1200 Subject: [PATCH 4/9] docs: rename extension --- .docs/conf.py | 4 +- ...re_redirects.py => automatic_redirects.py} | 0 ...directs.py => test_automatic_redirects.py} | 42 +++++++++---------- 3 files changed, 23 insertions(+), 23 deletions(-) rename .docs/extensions/{underscore_redirects.py => automatic_redirects.py} (100%) rename .docs/tests/{test_underscore_redirects.py => test_automatic_redirects.py} (81%) diff --git a/.docs/conf.py b/.docs/conf.py index 24e8ab645..71405c15c 100644 --- a/.docs/conf.py +++ b/.docs/conf.py @@ -11,7 +11,7 @@ 'interface_docs', 'package_docs', 'diataxis_docs_fallback', - 'underscore_redirects', + 'automatic_redirects', ] # So that sphinx.ext.autodoc can find charmlibs code @@ -109,7 +109,7 @@ redirects = {} # sphinx-rerediraffe: https://github.com/wpilibsuite/sphinx-rerediraffe -# Used by the local `underscore_redirects` extension, which populates this +# Used by the local `automatic_redirects` extension, which populates this # mapping at build time with separator-variant aliases (for example, # `how_to/manage_libraries` -> `how-to/manage-libraries`). Seed it as an empty # dict so the extension can extend it; set to None to disable redirect diff --git a/.docs/extensions/underscore_redirects.py b/.docs/extensions/automatic_redirects.py similarity index 100% rename from .docs/extensions/underscore_redirects.py rename to .docs/extensions/automatic_redirects.py diff --git a/.docs/tests/test_underscore_redirects.py b/.docs/tests/test_automatic_redirects.py similarity index 81% rename from .docs/tests/test_underscore_redirects.py rename to .docs/tests/test_automatic_redirects.py index f1d1d9281..f54b384b3 100644 --- a/.docs/tests/test_underscore_redirects.py +++ b/.docs/tests/test_automatic_redirects.py @@ -14,7 +14,7 @@ # ruff: noqa: D103 (function docstrings) -"""Unit tests for the `underscore_redirects` local Sphinx extension.""" +"""Unit tests for the `automatic_redirects` local Sphinx extension.""" from __future__ import annotations @@ -23,7 +23,7 @@ import pytest -import underscore_redirects +import automatic_redirects if typing.TYPE_CHECKING: import sphinx.application @@ -33,14 +33,14 @@ def test_separator_variant_hyphens_to_underscores(): - assert underscore_redirects._separator_variant('how-to/manage-libraries') == ( + assert automatic_redirects._separator_variant('how-to/manage-libraries') == ( 'how_to/manage_libraries' ) def test_separator_variant_underscores_to_hyphens(): assert ( - underscore_redirects._separator_variant('reference/interfaces/fiveg_core_gnb/v1') + automatic_redirects._separator_variant('reference/interfaces/fiveg_core_gnb/v1') == 'reference/interfaces/fiveg-core-gnb/v1' ) @@ -48,12 +48,12 @@ def test_separator_variant_underscores_to_hyphens(): def test_separator_variant_mixed_segment_raises(): # A docname containing both separators is a configuration error. with pytest.raises(AssertionError, match="should not contain both"): - underscore_redirects._separator_variant('foo-bar_baz/qux') + automatic_redirects._separator_variant('foo-bar_baz/qux') def test_separator_variant_no_separators_returns_unchanged(): - assert underscore_redirects._separator_variant('index') == 'index' - assert underscore_redirects._separator_variant('reference/v1') == 'reference/v1' + assert automatic_redirects._separator_variant('index') == 'index' + assert automatic_redirects._separator_variant('reference/v1') == 'reference/v1' def test_separator_variant_is_involution(): @@ -62,8 +62,8 @@ def test_separator_variant_is_involution(): 'reference/interfaces/fiveg_core_gnb/v1', 'index', ]: - variant = underscore_redirects._separator_variant(docname) - assert underscore_redirects._separator_variant(variant) == docname + variant = automatic_redirects._separator_variant(docname) + assert automatic_redirects._separator_variant(variant) == docname # --- _build_redirects --- @@ -71,7 +71,7 @@ def test_separator_variant_is_involution(): def test_build_redirects_hyphenated_page_gets_underscore_alias(): found = {'how-to/manage-libraries', 'index'} - redirects = underscore_redirects._build_redirects(found) + redirects = automatic_redirects._build_redirects(found) assert redirects == { 'how-to/manage_libraries': 'how-to/manage-libraries', 'how_to/manage_libraries': 'how-to/manage-libraries', @@ -81,7 +81,7 @@ def test_build_redirects_hyphenated_page_gets_underscore_alias(): def test_build_redirects_underscored_page_gets_hyphen_alias(): found = {'reference/interfaces/fiveg_core_gnb/v1', 'index'} - redirects = underscore_redirects._build_redirects(found) + redirects = automatic_redirects._build_redirects(found) assert redirects == { 'reference/interfaces/fiveg-core-gnb/v1': 'reference/interfaces/fiveg_core_gnb/v1' } @@ -93,20 +93,20 @@ def test_build_redirects_both_variants_present_raises(): # loudly rather than silently shadowing a page. found = {'how-to/foo', 'how_to/foo'} with pytest.raises(AssertionError, match='is a real page'): - underscore_redirects._build_redirects(found) + automatic_redirects._build_redirects(found) def test_build_redirects_no_separators_no_redirects(): - assert underscore_redirects._build_redirects({'index', 'reference/v1'}) == {} + assert automatic_redirects._build_redirects({'index', 'reference/v1'}) == {} def test_build_redirects_is_pure_function(): # _build_redirects is a pure function over found_docs; it doesn't know # about user-configured redirects. Merge protection lives in # _automatic_redirects (see - # test_populate_does_not_overwrite_user_redirect_with_generated). + # test_automatic_redirects_raises_when_user_redirect_shadows_generated). found = {'how-to/manage-libraries', 'index'} - assert underscore_redirects._build_redirects(found) == { + assert automatic_redirects._build_redirects(found) == { 'how-to/manage_libraries': 'how-to/manage-libraries', 'how_to/manage_libraries': 'how-to/manage-libraries', 'howto/manage_libraries': 'how-to/manage-libraries', @@ -143,7 +143,7 @@ def test_automatic_redirects_extends_dict_in_final_pass(): rediraffe_redirects={}, found_docs={'how-to/manage-libraries', 'index'}, ) - underscore_redirects._automatic_redirects(app, app.env) + automatic_redirects._automatic_redirects(app, app.env) redirects = _rediraffe_redirects(app) assert isinstance(redirects, dict) assert redirects['how_to/manage_libraries'] == 'how-to/manage-libraries' @@ -155,7 +155,7 @@ def test_automatic_redirects_skips_per_package_pass(): rediraffe_redirects={}, found_docs={'how-to/manage-libraries', 'index'}, ) - underscore_redirects._automatic_redirects(app, app.env) + automatic_redirects._automatic_redirects(app, app.env) assert _rediraffe_redirects(app) == {} @@ -164,7 +164,7 @@ def test_automatic_redirects_preserves_existing_user_redirects(): rediraffe_redirects={'old/page': 'new/page'}, found_docs={'how-to/manage-libraries', 'index'}, ) - underscore_redirects._automatic_redirects(app, app.env) + automatic_redirects._automatic_redirects(app, app.env) redirects = _rediraffe_redirects(app) assert isinstance(redirects, dict) assert redirects['old/page'] == 'new/page' @@ -180,7 +180,7 @@ def test_automatic_redirects_raises_when_rediraffe_redirects_not_a_dict(): found_docs={'how-to/manage-libraries', 'index'}, ) with pytest.raises(AssertionError): - underscore_redirects._automatic_redirects(app, app.env) + automatic_redirects._automatic_redirects(app, app.env) def test_automatic_redirects_raises_when_user_redirect_shadows_generated(): @@ -192,7 +192,7 @@ def test_automatic_redirects_raises_when_user_redirect_shadows_generated(): found_docs={'how-to/manage-libraries', 'index'}, ) with pytest.raises(AssertionError): - underscore_redirects._automatic_redirects(app, app.env) + automatic_redirects._automatic_redirects(app, app.env) # --- setup --- @@ -202,7 +202,7 @@ def test_setup_returns_metadata(): def _connect(*args: object, **kwargs: object) -> None: pass - metadata = underscore_redirects.setup( + metadata = automatic_redirects.setup( typing.cast('sphinx.application.Sphinx', types.SimpleNamespace(connect=_connect)) ) assert metadata['version'] == '1.0.0' From ac587a863016e1d3c52d682a08f610f75489de98 Mon Sep 17 00:00:00 2001 From: James Garner Date: Mon, 22 Jun 2026 12:48:06 +1200 Subject: [PATCH 5/9] chore: just format --- .docs/tests/test_automatic_redirects.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.docs/tests/test_automatic_redirects.py b/.docs/tests/test_automatic_redirects.py index f54b384b3..eec6c4c21 100644 --- a/.docs/tests/test_automatic_redirects.py +++ b/.docs/tests/test_automatic_redirects.py @@ -21,9 +21,8 @@ import types import typing -import pytest - import automatic_redirects +import pytest if typing.TYPE_CHECKING: import sphinx.application @@ -47,7 +46,7 @@ def test_separator_variant_underscores_to_hyphens(): def test_separator_variant_mixed_segment_raises(): # A docname containing both separators is a configuration error. - with pytest.raises(AssertionError, match="should not contain both"): + with pytest.raises(AssertionError, match='should not contain both'): automatic_redirects._separator_variant('foo-bar_baz/qux') From 8d987c303100a9814ae096d78bf28a65742f59c5 Mon Sep 17 00:00:00 2001 From: James Garner Date: Mon, 13 Jul 2026 15:29:07 +1200 Subject: [PATCH 6/9] feat: rewrite tests and extension for desired behaviour --- .docs/extensions/automatic_redirects.py | 48 +++- .docs/tests/test_automatic_redirects.py | 284 ++++++++++-------------- 2 files changed, 161 insertions(+), 171 deletions(-) diff --git a/.docs/extensions/automatic_redirects.py b/.docs/extensions/automatic_redirects.py index 0cd1b2f70..a0f8d21ba 100644 --- a/.docs/extensions/automatic_redirects.py +++ b/.docs/extensions/automatic_redirects.py @@ -59,10 +59,32 @@ def _automatic_redirects( # Don't create redirects during the per-package passes. if app.config.package is not None: return - target = typing.cast('dict[str, str]', app.config.rediraffe_redirects) - assert isinstance(target, dict) redirects = _build_redirects(set(env.found_docs)) - assert set(target).isdisjoint(redirects) + target = typing.cast('dict[str, str]', app.config.rediraffe_redirects) + assert isinstance(target, dict), f'rediraffe_redirects must be a dict, not {target}' + if not set(target).isdisjoint(redirects.keys()): + lines = ['Manually configured redirects would be clobbered by automatic redirects:'] + lines.extend( + f'{k} -> {target[k]} would be overwritten by {k} -> {v}' + for k, v in redirects.items() + if k in target + ) + raise ValueError('\n - '.join(lines)) + if not set(target.values()).isdisjoint(redirects.keys()): + lines = [ + 'The following manually configured redirects would chain with automatic redirects:' + ] + lines.extend( + f'{k} -> {v} should point to {redirects[v]}' + for k, v in target.items() + if v in redirects + ) + raise ValueError('\n - '.join(lines)) + if not set(target).isdisjoint(redirects.values()): + # This is probably safe in practice, but we'd like to notice the first time it happens. + lines = ['The following automatic redirects chain with manually configured redirects:'] + lines.extend(f'{k} -> {v} -> {target[v]}' for k, v in redirects.items() if v in target) + raise ValueError('\n - '.join(lines)) target.update(redirects) @@ -81,13 +103,19 @@ def _build_redirects(found_docs: set[str]) -> dict[str, str]: category.replace('_', '').replace('-', ''), # e.g. howto } for category_variant in sorted(category_variants): - doc_variant = _separator_variant(doc) - if (category_variant, doc_variant) == (category, doc): - continue - alias = f'{category_variant}/{doc_variant}' - assert alias not in found_docs, f'Alias {alias} is a real page!' - assert alias not in redirects, f'Alias {alias} already redirects to {redirects[alias]}' - redirects[alias] = docname.removesuffix('index.html') + for doc_variant in sorted({doc, _separator_variant(doc)}): + if (category_variant, doc_variant) == (category, doc): + continue + alias = f'{category_variant}/{doc_variant}' + assert alias not in found_docs, f'Alias {alias} is a real page!' + assert alias not in redirects, ( + f'Alias {alias} already redirects to {redirects[alias]}' + ) + if docname.endswith('/index.html'): + # Remove 'index.html', preserve trailing '/'. + redirects[alias] = docname.removesuffix('index.html') + else: + redirects[alias] = docname return redirects diff --git a/.docs/tests/test_automatic_redirects.py b/.docs/tests/test_automatic_redirects.py index eec6c4c21..f8980c7f7 100644 --- a/.docs/tests/test_automatic_redirects.py +++ b/.docs/tests/test_automatic_redirects.py @@ -28,182 +28,144 @@ import sphinx.application -# --- _separator_variant --- - - -def test_separator_variant_hyphens_to_underscores(): - assert automatic_redirects._separator_variant('how-to/manage-libraries') == ( - 'how_to/manage_libraries' +class TestSeparatorVariant: + @pytest.mark.parametrize( + ('docname', 'expected'), + [ + # Hyphens are translated to underscores and vice versa. + ('---', '___'), + ('___', '---'), + # / isn't special for this function. + ('how-to/manage-libraries', 'how_to/manage_libraries'), + ('how_to/manage_libraries', 'how-to/manage-libraries'), + # If there are no separators, the value is returned unmodified. + ('noseparators', 'noseparators'), + ('', ''), + ], ) - - -def test_separator_variant_underscores_to_hyphens(): - assert ( - automatic_redirects._separator_variant('reference/interfaces/fiveg_core_gnb/v1') - == 'reference/interfaces/fiveg-core-gnb/v1' - ) - - -def test_separator_variant_mixed_segment_raises(): - # A docname containing both separators is a configuration error. - with pytest.raises(AssertionError, match='should not contain both'): - automatic_redirects._separator_variant('foo-bar_baz/qux') - - -def test_separator_variant_no_separators_returns_unchanged(): - assert automatic_redirects._separator_variant('index') == 'index' - assert automatic_redirects._separator_variant('reference/v1') == 'reference/v1' - - -def test_separator_variant_is_involution(): - for docname in [ - 'how-to/manage-libraries', - 'reference/interfaces/fiveg_core_gnb/v1', - 'index', - ]: + def test_ok(self, docname: str, expected: str): variant = automatic_redirects._separator_variant(docname) - assert automatic_redirects._separator_variant(variant) == docname - - -# --- _build_redirects --- - - -def test_build_redirects_hyphenated_page_gets_underscore_alias(): - found = {'how-to/manage-libraries', 'index'} - redirects = automatic_redirects._build_redirects(found) - assert redirects == { - 'how-to/manage_libraries': 'how-to/manage-libraries', - 'how_to/manage_libraries': 'how-to/manage-libraries', - 'howto/manage_libraries': 'how-to/manage-libraries', - } - - -def test_build_redirects_underscored_page_gets_hyphen_alias(): - found = {'reference/interfaces/fiveg_core_gnb/v1', 'index'} - redirects = automatic_redirects._build_redirects(found) - assert redirects == { - 'reference/interfaces/fiveg-core-gnb/v1': 'reference/interfaces/fiveg_core_gnb/v1' - } - - -def test_build_redirects_both_variants_present_raises(): - # When both separator variants are real pages, the generated alias would - # collide with a real page -- a configuration error that should fail - # loudly rather than silently shadowing a page. - found = {'how-to/foo', 'how_to/foo'} - with pytest.raises(AssertionError, match='is a real page'): - automatic_redirects._build_redirects(found) - - -def test_build_redirects_no_separators_no_redirects(): - assert automatic_redirects._build_redirects({'index', 'reference/v1'}) == {} - + assert variant == expected + + def test_mixed_separators_raises(self): + """A docname containing both separators is an error.""" + with pytest.raises(AssertionError, match='should not contain both'): + automatic_redirects._separator_variant('-_-') + + +class TestBuildRedirects: + @pytest.mark.parametrize( + ('found', 'expected'), + [ + # No separators -> no redirects. + ({'foo', 'bar'}, {}), + # Hyphen -> underscore and vice versa. + ({'foo/a-b'}, {'foo/a_b': 'foo/a-b'}), + ({'foo/a_b'}, {'foo/a-b': 'foo/a_b'}), + # Redirects are created for category variants. + ({'how-to/foo'}, {'how_to/foo': 'how-to/foo', 'howto/foo': 'how-to/foo'}), + # Trailing /index.html is handled. + ({'foo/a-b/index.html'}, {'foo/a_b/index.html': 'foo/a-b/'}), + # Redirects are created from original name with category variant. + ( + {'how-to/a-b/index.html'}, + { + 'how-to/a_b/index.html': 'how-to/a-b/', + 'how_to/a-b/index.html': 'how-to/a-b/', + 'how_to/a_b/index.html': 'how-to/a-b/', + 'howto/a-b/index.html': 'how-to/a-b/', + 'howto/a_b/index.html': 'how-to/a-b/', + }, + ), + ], + ) + def test_ok(self, found: set[str], expected: dict[str, str]): + redirects = automatic_redirects._build_redirects(found) + assert sorted(redirects.items()) == list(expected.items()) -def test_build_redirects_is_pure_function(): - # _build_redirects is a pure function over found_docs; it doesn't know - # about user-configured redirects. Merge protection lives in - # _automatic_redirects (see - # test_automatic_redirects_raises_when_user_redirect_shadows_generated). - found = {'how-to/manage-libraries', 'index'} - assert automatic_redirects._build_redirects(found) == { - 'how-to/manage_libraries': 'how-to/manage-libraries', - 'how_to/manage_libraries': 'how-to/manage-libraries', - 'howto/manage_libraries': 'how-to/manage-libraries', - } + def test_variant_as_real_page_raises(self): + # When both separator variants are real pages, the generated alias would + # collide with a real page -- a configuration error that should fail + # loudly rather than silently shadowing a page. + found = {'how-to/foo', 'how_to/foo'} + with pytest.raises(AssertionError, match='is a real page'): + automatic_redirects._build_redirects(found) # --- _automatic_redirects (integration with config) --- -def _make_app( - *, - package: str | None = None, - rediraffe_redirects: dict[str, str] | str | None = None, - found_docs: typing.Iterable[str] = (), -) -> sphinx.application.Sphinx: - """Build a minimal fake Sphinx app/config/env for ``_automatic_redirects()``.""" - config = types.SimpleNamespace( - package=package, - rediraffe_redirects=rediraffe_redirects, - ) - env = types.SimpleNamespace(found_docs=set(found_docs)) - return typing.cast( - 'sphinx.application.Sphinx', - types.SimpleNamespace(config=config, env=env), - ) - - -def _rediraffe_redirects(app: sphinx.application.Sphinx) -> dict[str, str] | str | None: - return typing.cast('types.SimpleNamespace', app.config).rediraffe_redirects - - -def test_automatic_redirects_extends_dict_in_final_pass(): - app = _make_app( - rediraffe_redirects={}, - found_docs={'how-to/manage-libraries', 'index'}, - ) - automatic_redirects._automatic_redirects(app, app.env) - redirects = _rediraffe_redirects(app) - assert isinstance(redirects, dict) - assert redirects['how_to/manage_libraries'] == 'how-to/manage-libraries' +class TestAutomaticRedirects: + @staticmethod + def fake_app( + *, + package: str | None = None, + rediraffe_redirects: dict[str, str] | str | None = None, + found_docs: typing.Iterable[str] = (), + ) -> sphinx.application.Sphinx: + """Build a minimal fake Sphinx app/config/env for ``_automatic_redirects()``.""" + config = types.SimpleNamespace(package=package, rediraffe_redirects=rediraffe_redirects) + env = types.SimpleNamespace(found_docs=set(found_docs)) + return types.SimpleNamespace(config=config, env=env) # pyright: ignore[reportReturnType] + + def test_automatic_redirects_extends_dict_in_final_pass(self): + app = self.fake_app(rediraffe_redirects={}, found_docs={'foo/a-b', 'index'}) + automatic_redirects._automatic_redirects(app, app.env) + assert app.config.rediraffe_redirects == {'foo/a_b': 'foo/a-b'} + def test_automatic_redirects_skips_per_package_pass(self): + app = self.fake_app( + package='pathops', rediraffe_redirects={}, found_docs={'foo/a-b', 'index'} + ) + automatic_redirects._automatic_redirects(app, app.env) + assert app.config.rediraffe_redirects == {} -def test_automatic_redirects_skips_per_package_pass(): - app = _make_app( - package='pathops', - rediraffe_redirects={}, - found_docs={'how-to/manage-libraries', 'index'}, - ) - automatic_redirects._automatic_redirects(app, app.env) - assert _rediraffe_redirects(app) == {} + def test_automatic_redirects_preserves_existing_user_redirects(self): + app = self.fake_app( + rediraffe_redirects={'old/page': 'new/page'}, found_docs={'foo/a-b', 'index'} + ) + automatic_redirects._automatic_redirects(app, app.env) + assert app.config.rediraffe_redirects == {'old/page': 'new/page', 'foo/a_b': 'foo/a-b'} + + @pytest.mark.parametrize('value', ('redirects.txt', None)) + def test_automatic_redirects_raises_when_rediraffe_redirects_not_a_dict( + self, value: str | None + ): + # The extension requires rediraffe_redirects to be a dict; a filename + # (str) or None is a misconfiguration and should fail loudly. + app = self.fake_app(rediraffe_redirects=value, found_docs={}) + with pytest.raises(AssertionError, match='be a dict'): + automatic_redirects._automatic_redirects(app, app.env) + def test_alias_clobbering_user_redirect_raises(self): + app = self.fake_app( + rediraffe_redirects={'how_to/manage_libraries': 'custom/target'}, + found_docs={'how-to/manage-libraries', 'index'}, + ) + with pytest.raises(ValueError, match='clobbered'): + automatic_redirects._automatic_redirects(app, app.env) -def test_automatic_redirects_preserves_existing_user_redirects(): - app = _make_app( - rediraffe_redirects={'old/page': 'new/page'}, - found_docs={'how-to/manage-libraries', 'index'}, - ) - automatic_redirects._automatic_redirects(app, app.env) - redirects = _rediraffe_redirects(app) - assert isinstance(redirects, dict) - assert redirects['old/page'] == 'new/page' - assert redirects['how_to/manage_libraries'] == 'how-to/manage-libraries' - - -def test_automatic_redirects_raises_when_rediraffe_redirects_not_a_dict(): - # The extension requires rediraffe_redirects to be a dict; a filename - # (str) or None is a misconfiguration and should fail loudly. - for value in (None, 'redirects.txt'): - app = _make_app( - rediraffe_redirects=value, + def test_alias_chaining_with_user_redirect_raises(self): + app = self.fake_app( + rediraffe_redirects={'how-to/manage-libraries': 'custom/target'}, found_docs={'how-to/manage-libraries', 'index'}, ) - with pytest.raises(AssertionError): + with pytest.raises(ValueError, match='chain with manually'): automatic_redirects._automatic_redirects(app, app.env) + def test_user_redirect_chaining_with_alias_raises(self): + app = self.fake_app( + rediraffe_redirects={'custom/alias': 'how_to/manage_libraries'}, + found_docs={'how-to/manage-libraries', 'index'}, + ) + with pytest.raises(ValueError, match='should point to'): + automatic_redirects._automatic_redirects(app, app.env) -def test_automatic_redirects_raises_when_user_redirect_shadows_generated(): - # A user-configured redirect whose key collides with a generated alias - # is a configuration error -- the build should fail rather than silently - # overwrite the user's redirect. - app = _make_app( - rediraffe_redirects={'how_to/manage_libraries': 'custom/target'}, - found_docs={'how-to/manage-libraries', 'index'}, - ) - with pytest.raises(AssertionError): + def test_user_redirect_to_canonical_name_ok(self): + app = self.fake_app( + rediraffe_redirects={'custom/alias': 'how-to/manage-libraries'}, + found_docs={'how-to/manage-libraries', 'index'}, + ) automatic_redirects._automatic_redirects(app, app.env) - - -# --- setup --- - - -def test_setup_returns_metadata(): - def _connect(*args: object, **kwargs: object) -> None: - pass - - metadata = automatic_redirects.setup( - typing.cast('sphinx.application.Sphinx', types.SimpleNamespace(connect=_connect)) - ) - assert metadata['version'] == '1.0.0' - assert metadata['parallel_read_safe'] is True - assert metadata['parallel_write_safe'] is True + assert 'custom/alias' in app.config.rediraffe_redirects + assert len(app.config.rediraffe_redirects) > 1 From d06a6051ce7e1d8ca789b1a4f17a5a5b2ac3ac46 Mon Sep 17 00:00:00 2001 From: James Garner Date: Mon, 13 Jul 2026 15:32:54 +1200 Subject: [PATCH 7/9] docs: shorten docstring --- .docs/extensions/automatic_redirects.py | 26 +------------------------ 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/.docs/extensions/automatic_redirects.py b/.docs/extensions/automatic_redirects.py index a0f8d21ba..69a77b7c5 100644 --- a/.docs/extensions/automatic_redirects.py +++ b/.docs/extensions/automatic_redirects.py @@ -12,31 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Populate ``rediraffe_redirects`` with underscore/hyphen separator variants. - -All hand-written docs pages use hyphens in their slugs (for example -``how-to/manage-libraries``). This extension makes the underscore versions -(``how_to/manage_libraries``) resolve too, by generating redirects from the -separator-swapped variant of every page to the page itself. - -The mapping is populated into the ``rediraffe_redirects`` config value, which -is consumed by the ``sphinx-rerediraffe`` extension (enabled in ``conf.py``) -to write the actual redirect HTML during the build. rediraffe also validates -that every redirect target exists, so a misconfigured redirect fails the -build rather than silently 404ing. - -The mapping is symmetric: for each existing page, the variant with ``-`` and -``_`` swapped in every path segment is computed. If that variant is not -already a real page, a redirect is added from the variant to the page. This -means hyphenated pages get underscore aliases *and* underscored pages (for -example, interface reference pages like -``reference/interfaces/fiveg_core_gnb/v1``) get hyphen aliases. When both -separators are already real pages, no redirect is generated for either. - -Only the final combined docs pass populates the mapping. Per-package -reference passes (where ``package`` is set) are skipped, because those passes -only build a subset of pages and most redirect targets would not exist. -""" +"""Populate ``rediraffe_redirects`` with underscore/hyphen separator variants.""" from __future__ import annotations From 8ddcba3570c6fbc5fe5465dc895640362756f48b Mon Sep 17 00:00:00 2001 From: James Garner Date: Mon, 13 Jul 2026 15:41:35 +1200 Subject: [PATCH 8/9] docs: clean up comments --- .docs/extensions/automatic_redirects.py | 6 +++--- .docs/tests/test_automatic_redirects.py | 3 --- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.docs/extensions/automatic_redirects.py b/.docs/extensions/automatic_redirects.py index 69a77b7c5..668107ac2 100644 --- a/.docs/extensions/automatic_redirects.py +++ b/.docs/extensions/automatic_redirects.py @@ -74,9 +74,9 @@ def _build_redirects(found_docs: set[str]) -> dict[str, str]: category, _, doc = docname.partition('/') category_variants = { category, - category.replace('-', '_'), # e.g. how_to - category.replace('_', '-'), # e.g. how-to - category.replace('_', '').replace('-', ''), # e.g. howto + category.replace('-', '_'), # how_to + category.replace('_', '-'), # how-to + category.replace('_', '').replace('-', ''), # howto } for category_variant in sorted(category_variants): for doc_variant in sorted({doc, _separator_variant(doc)}): diff --git a/.docs/tests/test_automatic_redirects.py b/.docs/tests/test_automatic_redirects.py index f8980c7f7..fd3206735 100644 --- a/.docs/tests/test_automatic_redirects.py +++ b/.docs/tests/test_automatic_redirects.py @@ -92,9 +92,6 @@ def test_variant_as_real_page_raises(self): automatic_redirects._build_redirects(found) -# --- _automatic_redirects (integration with config) --- - - class TestAutomaticRedirects: @staticmethod def fake_app( From de420e981c5cdd0b2571bd03ed5ae3db1e775da6 Mon Sep 17 00:00:00 2001 From: James Garner Date: Mon, 13 Jul 2026 15:43:00 +1200 Subject: [PATCH 9/9] docs: clean up comment in conf.py --- .docs/conf.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.docs/conf.py b/.docs/conf.py index 71405c15c..d88f4e972 100644 --- a/.docs/conf.py +++ b/.docs/conf.py @@ -111,9 +111,7 @@ # sphinx-rerediraffe: https://github.com/wpilibsuite/sphinx-rerediraffe # Used by the local `automatic_redirects` extension, which populates this # mapping at build time with separator-variant aliases (for example, -# `how_to/manage_libraries` -> `how-to/manage-libraries`). Seed it as an empty -# dict so the extension can extend it; set to None to disable redirect -# generation entirely. +# `explanation/foo_bar` -> `explanation/foo-bar`). rediraffe_redirects: dict[str, str] = {} ############################