Skip to main content

one-hop

Checks a redirect map. Every old URL has to answer with one permanent redirect (301 or 308) straight to its new URL. The new URL has to answer 200. Anything else fails: a chain, a loop, a 404, a redirect to the wrong page. The exit code is then 1, so CI can stop the release.

Docs: https://synapsereality.io/open-source/one-hop/

pip install one-hop
one-hop redirects.csv --base https://example.com

Why one hop

A chain such as /about-us to /about to /about/ still loads in a browser, so nobody spots it. Crawlers do. Google follows up to 10 hops and then gives up, and every extra hop is one more request per URL. After a migration the map is often right on paper and wrong on the server. One trailing-slash rule or a leftover redirect plugin adds a second hop to hundreds of rules at once.

We wrote the first version for our own site move in September 2026, 2,030 rules from a WordPress site to a static build. This is that script, made general.

The CSV

Two columns, old URL then new URL. A header row is optional. With one, the columns can be named old/new, source/destination or from/to, and extra columns are ignored.

old,new
/blog/old-post,/blog/new-post/
/about-us,/about/
/services,https://example.com/services/seo/
/pricing,

Leave the new URL empty (as for /pricing above) when the page should answer 200 with no redirect at all. Relative URLs are resolved against --base. Lines that start with # are skipped.

Output

Against a local test server with five rules:

$ one-hop redirects.csv --base https://example.com
checking 5 rules against https://example.com
1/5 OK
  loop: 1
  not_found: 2
  chain: 1
FAIL [loop] line 5: /team  redirect loop: 301 https://example.com/team -> 301 https://example.com/people/ -> /team
FAIL [not_found] line 4: /services  new URL returned 404
FAIL [not_found] line 6: /pricing  old URL returned 404, no redirect
FAIL [chain] line 3: /about-us  reaches the new URL after 2 redirects: 301 https://example.com/about-us -> 301 https://example.com/about -> 200 https://example.com/about/
kind what happened
chain more than one redirect before a final answer
loop a redirect came back to a URL already visited
not_found the old or the new URL answered 404 or 410
wrong_target the redirect points somewhere other than the new URL in the map
no_redirect the old URL answered 200 when a redirect was expected
temporary a 302, 303 or 307 where a permanent redirect was expected
redirected a row with no new URL redirected instead of answering 200
bad_status anything else: a 5xx, a redirect with no Location header
error the request failed (DNS, TLS, timeout, refused)

--json report.json writes every result with each hop's URL, status and Location. --json - prints it to stdout instead.

Options

flag default
--base URL required the site to test
--workers N 8 parallel requests
--timeout S 20 seconds per request
--max-hops N 10 stop following a chain after N hops
--limit N all check only the first N rules
--auth user:pass none basic auth for a staging host. ONE_HOP_AUTH works too
--header 'Name: value' none extra header, repeatable
--method GET HEAD HEAD falls back to GET on a 405 or 501 anyway
--allow-temporary off accept 302, 303 and 307 as the one hop
--path-only off compare path and query only, ignore scheme and host
--insecure off skip TLS certificate checks (self-signed staging)

Credentials from --auth and --header go to the --base host only. If a redirect points at another host, that request is sent without them.

Trailing slashes count. /about and /about/ are two URLs, and a map that says /about/ when the server redirects to /about fails as wrong_target.

GitHub Actions

- uses: actions/checkout@v4
- uses: synapsereality/one-hop@v0.1.0
  with:
    csv: redirects.csv
    base: https://staging.example.com
    auth: ${{ secrets.STAGING_AUTH }}
    args: --workers 4

The action needs python3 on the runner, which GitHub's hosted runners have. It installs nothing.

Install from source

git clone https://github.com/synapsereality/one-hop
cd one-hop
pip install .

Python 3.10 or later, no dependencies. python -m one_hop works without installing if src is on PYTHONPATH.

Tests

pip install -e ".[test]"
pytest

The tests start a local HTTP server and cover every result kind, the HEAD to GET fallback, and credentials staying on the base host.

Licence

MIT. Made by Synapse.

Metadata

Release files for one-hop 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for one-hop 0.1.0
File Size Uploaded
one_hop-0.1.0.tar.gz 13.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for one-hop 0.1.0
File Interpreter ABI Platform
one_hop-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 24.8 kB

Release files / one_hop-0.1.0.tar.gz

Download URL one_hop-0.1.0.tar.gz
Size 13.6 kB
Tags Source
SHA-256 checksum
How to use checksums
34e1f045e5452a7348f89ed34269300e8000e750a722964b452d02a73abf27b6
BLAKE2b-256 checksum
How to use checksums
d107b494182b36d4c5f0cc9be8c9c273c597bc1c0051bc0ae71a0b3a55c6e7b7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.

Transparency log

Release files / one_hop-0.1.0-py3-none-any.whl

Download URL one_hop-0.1.0-py3-none-any.whl
Size 11.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
288bee567201a8369ea92c1024f4a56ce576f12acdd10d2e4ed94cb9fefc7dfa
BLAKE2b-256 checksum
How to use checksums
7be37aeca5f3772aed9089a6bcd29fdad956930c1040538dfaf4494a4eda9ca7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page