Manage Read the Docs redirects as code via a YAML source of truth reconciled against the RtD v3 API.
Project description
rtd-redirects
Manage Read the Docs redirects as code. A YAML file in your docs repo is the source of truth; this CLI reconciles it against the RtD v3 API.
Status
v0.1.0 in development. Built for docs.ray.io IA-cleanup campaigns; the patterns generalize to any RtD project. Design rationale and full architecture are in anyscale/docs:strategy/ray-docs/redirect-mgmt/.
Why
Read the Docs has no bulk redirect import. Its dashboard UI requires clicking through each entry by hand, which makes any meaningful slug-rename or IA-cleanup campaign untenable. rtd-redirects reads a YAML file from your repo, diffs it against the live RtD state, and applies the diff. PR-time mode produces a git-only diff with no API calls. Merge-time mode applies via API.
Install
python -m pip install anyscale-rtd-redirects
The PyPI distribution is anyscale-rtd-redirects. The installed CLI command is
still rtd-redirects.
For local development, install the package in editable mode:
git clone git@github.com:anyscale/rtd-redirects.git
cd rtd-redirects
python -m pip install -e .[dev]
Release
The package publishes to PyPI as anyscale-rtd-redirects. The command-line
entry point remains rtd-redirects.
Before the first release, configure a PyPI Trusted Publisher with the following values:
| Field | Value |
|---|---|
| PyPI project | anyscale-rtd-redirects |
| Owner | anyscale |
| Repository | rtd-redirects |
| Workflow | publish.yml |
| Environment | pypi |
To publish a release, complete the following steps:
-
Verify the test and package checks locally:
python -m pip install --upgrade -e .[dev] build twine ruff check . pytest python -m build python -m twine check dist/*
-
Create and push a version tag:
git tag v0.1.0 git push origin v0.1.0
-
Confirm the
publishGitHub Actions workflow succeeds. -
Verify the published package from a clean environment:
python -m pip install anyscale-rtd-redirects==0.1.0 rtd-redirects --help
Quick start
# Auth: token never goes to disk; read from 1Password (or your secret store) into env.
export RTD_API_TOKEN=$(op read "op://Personal/RtD/api-token")
# Optional: avoid passing --project on every call.
export RTD_PROJECT_SLUG=anyscale-ray
# What's in RtD right now?
rtd-redirects list
# Dump the live state to YAML.
rtd-redirects dump --output doc/redirects/current.yaml
# Edit the YAML, then dry-check the diff.
rtd-redirects plan --file doc/redirects/current.yaml
# Apply (interactive — confirms before mutating).
rtd-redirects apply --file doc/redirects/current.yaml
Subcommands
list
Print every redirect currently configured on the RtD project.
rtd-redirects list --project anyscale-ray
dump
Export the RtD project's redirects to a YAML file (or stdout if --output is omitted).
rtd-redirects dump --project anyscale-ray --output doc/redirects/current.yaml
Output is collapsed: records sharing every field except from_url are written as a single multi-source entry with from: as a list.
plan
Compute the diff between your YAML and the RtD project. No mutation.
rtd-redirects plan --project anyscale-ray --file doc/redirects/current.yaml
The output uses + for adds, - for deletes, ~ for updates, @ for position-only reorders, plus a footer counting each phase.
diff-file
Compute the redirect-level diff between two git refs of a YAML file. No RtD API calls — runs entirely from git show. This is the PR-time check engine.
rtd-redirects diff-file --file doc/redirects/current.yaml \
--base origin/master --head HEAD
apply
Apply the YAML to RtD. Confirms interactively unless --yes is set.
# Interactive
rtd-redirects apply --project anyscale-ray --file doc/redirects/current.yaml
# Non-interactive (CI)
rtd-redirects apply --project anyscale-ray --file doc/redirects/current.yaml --yes
Operations run in order: deletes → adds → updates → reorders. Each emits a single audit line to stderr.
audit
Report drift between your YAML and the RtD project, plus ordering / chain validation findings. Exits non-zero on either drift or validation errors so CI can surface them.
rtd-redirects audit --project anyscale-ray --file doc/redirects/current.yaml
validate
Validate ordering and chain risks in one or more YAML files. No RtD credentials required — intended for local use by agents authoring redirects and for pre-commit hooks.
# Single file
rtd-redirects validate doc/redirects/current.yaml
# Multiple files (pre-commit passes them this way)
rtd-redirects validate doc/redirects/*.yaml
# Auto-fix ordering errors in place (chains are left as warnings)
rtd-redirects validate doc/redirects/current.yaml --fix
Two finding kinds:
ERROR ordering— rule A's match set is a strict subset of rule B's, but A's position is higher. B fires first; A is unreachable. Lower A's position so it comes before B.--fixreorders deterministically.WARNING chain— rule A'stocould match rule B'sfrom. A request would 3xx to A.to and the browser would follow to B for another 3xx. Rewrite A'stoto point directly at the final destination. Not auto-fixed (requires choosing the right destination).
Validation is rules-based and decidable in closed form because RtD's pattern surface is intentionally narrow (suffix * only, four redirect types, no embedded wildcards). URL-style types (clean_url_to_html / html_to_clean_url) are excluded since they have no from URL to compare.
Pre-commit integration
This repo ships a .pre-commit-hooks.yaml. Add to your project's .pre-commit-config.yaml:
repos:
- repo: https://github.com/anyscale/rtd-redirects
rev: v0.1.0 # pin to a tag once one is published
hooks:
- id: rtd-redirects-validate
files: ^doc/redirects/.*\.ya?ml$
The hook fails the commit on any ERROR finding. Run pre-commit run rtd-redirects-validate --all-files locally to surface issues before pushing.
--strict on plan / apply
validate is also wired into the project-bound commands for CI use:
# Dry-check ordering during plan
rtd-redirects plan --project anyscale-ray --file doc/redirects/current.yaml --strict
# Refuse to apply if ordering errors exist
rtd-redirects apply --project anyscale-ray --file doc/redirects/current.yaml --strict --yes
audit runs the validator unconditionally and exits non-zero if either drift or validation errors exist (drift is exit 1, validation error is exit 6; validation takes precedence).
Auto-fix caveats
--fix rewrites the YAML using the parsed RedirectSet, which loses comments and authoring formatting (schema_version, language_prefix, and defaults are preserved). Run --fix, review the diff, and commit. The reordering is deterministic — sorted by (specificity, original position, from_url, type) — so re-running on a clean file is a no-op.
YAML schema
Minimal
schema_version: 1
redirects:
- from: /en/latest/old.html
to: /en/latest/new.html
type: exact
Multi-source
One destination, several sources. Each source becomes its own RtD redirect record.
schema_version: 1
redirects:
- from:
- /en/latest/old1.html
- /en/latest/old2.html
to: /en/latest/new.html
type: exact
Multi-version with defaults
Fan a single rename across the active version set. Path-only URLs get qualified with /<language_prefix>/<version> per version.
schema_version: 1
defaults:
versions: [latest, master]
redirects:
- from: /rllib/rllib-algorithms.html
to: /rllib/algorithms.html
type: exact
Expands to four RtD records: /en/latest/rllib/rllib-algorithms.html, /en/master/rllib/rllib-algorithms.html, both pointing at their version-matched /en/<v>/rllib/algorithms.html.
Per-entry versions: override
schema_version: 1
defaults:
versions: [latest, master]
redirects:
- from: /data/old.html
to: /data/new.html
type: exact
versions: [latest] # override: only redirect on latest, not master
Cross-product (sources × versions)
schema_version: 1
redirects:
- from:
- /old1.html
- /old2.html
to: /new.html
type: exact
versions: [latest, master]
Expands to four records: latest×{old1, old2} and master×{old1, old2}.
Cross-host destination
to: can be any absolute URL — useful for redirecting legacy docs to docs.anyscale.com or blog posts.
schema_version: 1
redirects:
- from: /en/latest/old.html
to: https://docs.anyscale.com/new-thing
type: exact
from: must always be a project path. RtD only intercepts requests for paths it serves; external from URLs are rejected at parse time.
Wildcards (* and :splat)
RtD supports a single suffix wildcard * in from_url, with :splat in to_url substituting the matched portion. Prefix and infix wildcards are not supported by RtD.
schema_version: 1
redirects:
# Bulk redirect every page under one prefix to the same path under another.
- from: /en/releases-2.40.0/*
to: /en/latest/:splat
type: exact
# Combine with version expansion: one rule × N versions.
- from: /rllib/rllib/*
to: /rllib/:splat
type: exact
versions: [latest, master]
The tool is a string passthrough for URL fields — * and :splat are stored as-is and interpreted by RtD at request time. Useful for the cohort cutover (legacy version slug → current) and prefix-collapse renames.
page redirects apply across all versions automatically
A page redirect with from: /old.html, to: /new.html triggers on /en/latest/old.html, /en/master/old.html, every legacy version — RtD handles the fan-out itself. Don't pair page with versions: or defaults.versions; the tool ignores defaults.versions for page entries, and an explicit versions: raises a parse error.
schema_version: 1
defaults:
versions: [latest, master] # applies only to `exact` entries below
redirects:
- from: /old.html # page: ignores defaults.versions
to: /new.html
type: page
- from: /api.html # exact: fans out to latest and master
to: /api-v2.html
type: exact
Same applies to clean_url_to_html and html_to_clean_url — these describe project-wide URL transitions and don't need from: or to: at all.
schema_version: 1
redirects:
- type: html_to_clean_url # turn /page.html into /page/
Rule ordering: specific before general
RtD picks the first redirect whose from matches the request URL — position-based first-match, not specificity-based. To make a specific rule override a catch-all wildcard, give the specific rule a lower position (or just write it earlier in the YAML; position defaults to entry index).
schema_version: 1
redirects:
# Specific override fires first (position 0).
- from: /en/releases-2.40.0/api/special_case.html
to: /en/latest/api/its_new_home.html
type: exact
# Catch-all wildcard fires for everything else under that version (position 1).
- from: /en/releases-2.40.0/*
to: /en/latest/:splat
type: exact
The tool preserves ordering across dump / parse / apply. diff flags position-only changes as reorder and runs them in a separate pass at apply time so positions settle without churning the data phase.
Inactive versions and slug renames
When you mark a version inactive on RtD, its artifacts are deleted and its URLs start returning 404. Combined with force: false (redirects fire on 404), this means deactivating a version automatically routes its URLs through any matching redirect rule. Your wildcard catch-all picks up all the old paths without any extra work.
Renaming a version slug has the same effect — old-slug URLs return 404, and matching wildcard rules fire. RtD's own docs suggest pairing slug renames with an exact wildcard:
# After renaming releases-2.40.0 -> v2.40.0:
- from: /en/releases-2.40.0/*
to: /en/v2.40.0/:splat
type: exact
(There's a known corner case where an inactive version's HTML can linger in storage and produce an infinite-redirect loop. RtD's infinite-redirect detector returns 404 as a failsafe; worth knowing about if you see one in practice.)
Avoid chained redirects
RtD doesn't promise to resolve chains server-side. If /a → /b and /b → /c are both configured, RtD serves two 3xx responses (the browser follows each hop). Write each from pointing directly at the final destination rather than relying on the chain to collapse. If you renamed /old → /intermediate → /current over time, the final rule should be /old → /current (rewrite the existing redirect, don't stack).
If RtD detects an infinite loop, it returns 404 and stops trying — useful failsafe, but not a substitute for clean authoring.
Robust fan-out: page + force: false + */:splat
RtD's redirect rules default to force: false, which means a redirect only fires when the source URL would otherwise 404. Combined with page (applies across all versions) and a suffix wildcard, you get a single rule that does the right thing on every version without having to enumerate which versions it applies to.
Concrete example — auto-generated API module renamed from old_module to new_module in current docs, but the old name still exists in legacy version archives that you don't want to rebuild:
schema_version: 1
redirects:
- from: /api/old_module/*
to: /api/new_module/:splat
type: page
# force defaults to false: redirect fires only where /api/old_module/... 404s.
What happens at request time:
| Version | /api/old_module/foo.html exists? |
Behavior |
|---|---|---|
latest (after rename) |
no | redirect fires → /api/new_module/foo.html |
v2.55 (rename hasn't happened) |
yes | no redirect, original page renders |
releases-2.40.0 (legacy) |
yes | no redirect, frozen archive intact |
One rule, applied semantically — newer versions get the redirect, older versions keep working. Authoring this with force: true or per-version exact rules would break legacy renders or require N rules across versions.
Use force: true only when you specifically want to override an existing page — e.g., taking over a path that still exists in current docs but should now point elsewhere. Default force: false is almost always what you want for IA cleanup.
Custom language prefix
The URL language segment is configurable per file. Default is /en.
schema_version: 1
language_prefix: /de
defaults:
versions: [latest]
redirects:
- from: /alt.html
to: /neu.html
type: exact
Languageless RtD setups (no language segment) are not yet supported — see AGENTS.md for the deferred-work catalog.
Field reference
| YAML field | RtD field | Default | Notes |
|---|---|---|---|
schema_version |
n/a | required | Top-level. Currently 1. |
language_prefix |
n/a | /en |
Top-level. URL segment between host and version. |
defaults.versions |
n/a | unset | Active version list for entries that inherit. |
from |
from_url |
required for page and exact |
String or list. Must be a project path, not external. Optional for clean_url_to_html / html_to_clean_url. |
to |
to_url |
required for page and exact |
String. Path-only, fully-qualified, or external (https://, mailto:, etc.). Optional for clean_url_to_html / html_to_clean_url. |
type |
type |
required | One of page, exact, clean_url_to_html, html_to_clean_url. Only exact uses versions: / defaults.versions; the others apply project-wide on RtD's side. |
versions |
n/a (expansion input) | falls back to defaults.versions |
List of plain version names. Pattern identifiers (globs, ranges, exclusions, macros) are not yet supported. Only valid on type: exact. |
status |
http_status |
301 |
3xx code. |
force |
force |
false |
|
enabled |
enabled |
true |
|
description |
description |
"" |
Operator notes. Surface in PR diff output. |
position |
position |
entry index | Set explicitly only when ordering matters. |
Environment variables
| Variable | Required? | Purpose |
|---|---|---|
RTD_API_TOKEN |
required | Your RtD v3 API token. Never written to disk by the tool; never logged. Read from a secret store at the start of the shell session. |
RTD_PROJECT_SLUG |
optional | Alternative to --project flag. |
RTD_BASE_URL |
optional | API base. Defaults to https://readthedocs.com/api/v3 (Business). Set to https://readthedocs.org/api/v3 for Community. |
Development
git clone git@github.com:anyscale/rtd-redirects.git
cd rtd-redirects
python -m venv .venv && source .venv/bin/activate
pip install -e .[dev]
pytest # 253 tests
ruff check . # lint
Branch naming
doc-XXX-short-description per the Anyscale docs team convention (where DOC-XXX is the Jira ticket key).
PR conventions
- Reference
[DOC-XXX]in the title or summary. - Include a test plan in the body.
- Add or update tests for any module change.
- Run
ruff check .andpytestlocally before pushing.
For broader project intent, architecture, and the deferred-work roadmap, see AGENTS.md.
License
MIT. See LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file anyscale_rtd_redirects-0.1.0.tar.gz.
File metadata
- Download URL: anyscale_rtd_redirects-0.1.0.tar.gz
- Upload date:
- Size: 66.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
29c1b6b839a32bbb2230399f0cc8b4ef117a231b55a9681603159f7354bd2af8
|
|
| MD5 |
5c1c1b93d2d0bc959899e4a9a9402139
|
|
| BLAKE2b-256 |
4f73541152628dfb04f4b3b170a79dd93c4312589a78fc19880e87f6d58e0442
|
Provenance
The following attestation bundles were made for anyscale_rtd_redirects-0.1.0.tar.gz:
Publisher:
publish.yml on anyscale/rtd-redirects
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
anyscale_rtd_redirects-0.1.0.tar.gz -
Subject digest:
29c1b6b839a32bbb2230399f0cc8b4ef117a231b55a9681603159f7354bd2af8 - Sigstore transparency entry: 1971854075
- Sigstore integration time:
-
Permalink:
anyscale/rtd-redirects@076377df1366c84033b6066aeb81a4d50db634b0 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/anyscale
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@076377df1366c84033b6066aeb81a4d50db634b0 -
Trigger Event:
push
-
Statement type:
File details
Details for the file anyscale_rtd_redirects-0.1.0-py3-none-any.whl.
File metadata
- Download URL: anyscale_rtd_redirects-0.1.0-py3-none-any.whl
- Upload date:
- Size: 39.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b7f4a6b4f713b42be8e2f333dd1ffab6b614f124946c09e2392c450aa66fa3c4
|
|
| MD5 |
78d9bf9b1a032ee4e35df65a79f33704
|
|
| BLAKE2b-256 |
07e37ae06d90e761986ad2f55ba030f2dc20edb3af6bcc919666c812df8d4fd4
|
Provenance
The following attestation bundles were made for anyscale_rtd_redirects-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on anyscale/rtd-redirects
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
anyscale_rtd_redirects-0.1.0-py3-none-any.whl -
Subject digest:
b7f4a6b4f713b42be8e2f333dd1ffab6b614f124946c09e2392c450aa66fa3c4 - Sigstore transparency entry: 1971854172
- Sigstore integration time:
-
Permalink:
anyscale/rtd-redirects@076377df1366c84033b6066aeb81a4d50db634b0 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/anyscale
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@076377df1366c84033b6066aeb81a4d50db634b0 -
Trigger Event:
push
-
Statement type: