one-hop 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
one_hop-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Synapse Research Ltd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
one_hop-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: one-hop
3
+ Version: 0.1.0
4
+ Summary: Check a redirect map: every old URL must reach its new URL in exactly one hop, and the new URL must answer 200.
5
+ Author: Synapse Research Ltd
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://synapsereality.io/open-source/one-hop/
8
+ Project-URL: Documentation, https://synapsereality.io/open-source/one-hop/
9
+ Project-URL: Repository, https://github.com/synapsereality/one-hop
10
+ Project-URL: Issues, https://github.com/synapsereality/one-hop/issues
11
+ Keywords: redirects,301,seo,site-migration,ci
12
+ Classifier: Environment :: Console
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Internet :: WWW/HTTP :: Site Management :: Link Checking
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest>=8; extra == "test"
20
+ Dynamic: license-file
21
+
22
+ # one-hop
23
+
24
+ Checks a redirect map. Every old URL has to answer with one permanent redirect
25
+ (301 or 308) straight to its new URL. The new URL has to answer 200. Anything else
26
+ fails: a chain, a loop, a 404, a redirect to the wrong page. The exit code is then
27
+ 1, so CI can stop the release.
28
+
29
+ Docs: https://synapsereality.io/open-source/one-hop/
30
+
31
+ ```bash
32
+ pip install one-hop
33
+ one-hop redirects.csv --base https://example.com
34
+ ```
35
+
36
+ ## Why one hop
37
+
38
+ A chain such as `/about-us` to `/about` to `/about/` still loads in a browser, so
39
+ nobody spots it. Crawlers do. Google follows up to 10 hops and then gives up, and
40
+ every extra hop is one more request per URL. After a migration the map is often
41
+ right on paper and wrong on the server. One trailing-slash rule or a leftover
42
+ redirect plugin adds a second hop to hundreds of rules at once.
43
+
44
+ We wrote the first version for our own site move in September 2026, 2,030 rules
45
+ from a WordPress site to a static build. This is that script, made general.
46
+
47
+ ## The CSV
48
+
49
+ Two columns, old URL then new URL. A header row is optional. With one, the columns
50
+ can be named `old`/`new`, `source`/`destination` or `from`/`to`, and extra columns
51
+ are ignored.
52
+
53
+ ```csv
54
+ old,new
55
+ /blog/old-post,/blog/new-post/
56
+ /about-us,/about/
57
+ /services,https://example.com/services/seo/
58
+ /pricing,
59
+ ```
60
+
61
+ Leave the new URL empty (as for `/pricing` above) when the page should answer 200
62
+ with no redirect at all. Relative URLs are resolved against `--base`. Lines that
63
+ start with `#` are skipped.
64
+
65
+ ## Output
66
+
67
+ Against a local test server with five rules:
68
+
69
+ ```text
70
+ $ one-hop redirects.csv --base https://example.com
71
+ checking 5 rules against https://example.com
72
+ 1/5 OK
73
+ loop: 1
74
+ not_found: 2
75
+ chain: 1
76
+ FAIL [loop] line 5: /team redirect loop: 301 https://example.com/team -> 301 https://example.com/people/ -> /team
77
+ FAIL [not_found] line 4: /services new URL returned 404
78
+ FAIL [not_found] line 6: /pricing old URL returned 404, no redirect
79
+ 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/
80
+ ```
81
+
82
+ | kind | what happened |
83
+ |---|---|
84
+ | `chain` | more than one redirect before a final answer |
85
+ | `loop` | a redirect came back to a URL already visited |
86
+ | `not_found` | the old or the new URL answered 404 or 410 |
87
+ | `wrong_target` | the redirect points somewhere other than the new URL in the map |
88
+ | `no_redirect` | the old URL answered 200 when a redirect was expected |
89
+ | `temporary` | a 302, 303 or 307 where a permanent redirect was expected |
90
+ | `redirected` | a row with no new URL redirected instead of answering 200 |
91
+ | `bad_status` | anything else: a 5xx, a redirect with no `Location` header |
92
+ | `error` | the request failed (DNS, TLS, timeout, refused) |
93
+
94
+ `--json report.json` writes every result with each hop's URL, status and
95
+ `Location`. `--json -` prints it to stdout instead.
96
+
97
+ ## Options
98
+
99
+ | flag | default | |
100
+ |---|---|---|
101
+ | `--base URL` | required | the site to test |
102
+ | `--workers N` | 8 | parallel requests |
103
+ | `--timeout S` | 20 | seconds per request |
104
+ | `--max-hops N` | 10 | stop following a chain after N hops |
105
+ | `--limit N` | all | check only the first N rules |
106
+ | `--auth user:pass` | none | basic auth for a staging host. `ONE_HOP_AUTH` works too |
107
+ | `--header 'Name: value'` | none | extra header, repeatable |
108
+ | `--method GET` | HEAD | HEAD falls back to GET on a 405 or 501 anyway |
109
+ | `--allow-temporary` | off | accept 302, 303 and 307 as the one hop |
110
+ | `--path-only` | off | compare path and query only, ignore scheme and host |
111
+ | `--insecure` | off | skip TLS certificate checks (self-signed staging) |
112
+
113
+ Credentials from `--auth` and `--header` go to the `--base` host only. If a
114
+ redirect points at another host, that request is sent without them.
115
+
116
+ Trailing slashes count. `/about` and `/about/` are two URLs, and a map that says
117
+ `/about/` when the server redirects to `/about` fails as `wrong_target`.
118
+
119
+ ## GitHub Actions
120
+
121
+ ```yaml
122
+ - uses: actions/checkout@v4
123
+ - uses: synapsereality/one-hop@v0.1.0
124
+ with:
125
+ csv: redirects.csv
126
+ base: https://staging.example.com
127
+ auth: ${{ secrets.STAGING_AUTH }}
128
+ args: --workers 4
129
+ ```
130
+
131
+ The action needs `python3` on the runner, which GitHub's hosted runners have. It
132
+ installs nothing.
133
+
134
+ ## Install from source
135
+
136
+ ```bash
137
+ git clone https://github.com/synapsereality/one-hop
138
+ cd one-hop
139
+ pip install .
140
+ ```
141
+
142
+ Python 3.10 or later, no dependencies. `python -m one_hop` works without
143
+ installing if `src` is on `PYTHONPATH`.
144
+
145
+ ## Tests
146
+
147
+ ```bash
148
+ pip install -e ".[test]"
149
+ pytest
150
+ ```
151
+
152
+ The tests start a local HTTP server and cover every result kind, the HEAD to GET
153
+ fallback, and credentials staying on the base host.
154
+
155
+ ## Licence
156
+
157
+ MIT. Made by [Synapse](https://synapsereality.io/open-source/one-hop/).
@@ -0,0 +1,136 @@
1
+ # one-hop
2
+
3
+ Checks a redirect map. Every old URL has to answer with one permanent redirect
4
+ (301 or 308) straight to its new URL. The new URL has to answer 200. Anything else
5
+ fails: a chain, a loop, a 404, a redirect to the wrong page. The exit code is then
6
+ 1, so CI can stop the release.
7
+
8
+ Docs: https://synapsereality.io/open-source/one-hop/
9
+
10
+ ```bash
11
+ pip install one-hop
12
+ one-hop redirects.csv --base https://example.com
13
+ ```
14
+
15
+ ## Why one hop
16
+
17
+ A chain such as `/about-us` to `/about` to `/about/` still loads in a browser, so
18
+ nobody spots it. Crawlers do. Google follows up to 10 hops and then gives up, and
19
+ every extra hop is one more request per URL. After a migration the map is often
20
+ right on paper and wrong on the server. One trailing-slash rule or a leftover
21
+ redirect plugin adds a second hop to hundreds of rules at once.
22
+
23
+ We wrote the first version for our own site move in September 2026, 2,030 rules
24
+ from a WordPress site to a static build. This is that script, made general.
25
+
26
+ ## The CSV
27
+
28
+ Two columns, old URL then new URL. A header row is optional. With one, the columns
29
+ can be named `old`/`new`, `source`/`destination` or `from`/`to`, and extra columns
30
+ are ignored.
31
+
32
+ ```csv
33
+ old,new
34
+ /blog/old-post,/blog/new-post/
35
+ /about-us,/about/
36
+ /services,https://example.com/services/seo/
37
+ /pricing,
38
+ ```
39
+
40
+ Leave the new URL empty (as for `/pricing` above) when the page should answer 200
41
+ with no redirect at all. Relative URLs are resolved against `--base`. Lines that
42
+ start with `#` are skipped.
43
+
44
+ ## Output
45
+
46
+ Against a local test server with five rules:
47
+
48
+ ```text
49
+ $ one-hop redirects.csv --base https://example.com
50
+ checking 5 rules against https://example.com
51
+ 1/5 OK
52
+ loop: 1
53
+ not_found: 2
54
+ chain: 1
55
+ FAIL [loop] line 5: /team redirect loop: 301 https://example.com/team -> 301 https://example.com/people/ -> /team
56
+ FAIL [not_found] line 4: /services new URL returned 404
57
+ FAIL [not_found] line 6: /pricing old URL returned 404, no redirect
58
+ 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/
59
+ ```
60
+
61
+ | kind | what happened |
62
+ |---|---|
63
+ | `chain` | more than one redirect before a final answer |
64
+ | `loop` | a redirect came back to a URL already visited |
65
+ | `not_found` | the old or the new URL answered 404 or 410 |
66
+ | `wrong_target` | the redirect points somewhere other than the new URL in the map |
67
+ | `no_redirect` | the old URL answered 200 when a redirect was expected |
68
+ | `temporary` | a 302, 303 or 307 where a permanent redirect was expected |
69
+ | `redirected` | a row with no new URL redirected instead of answering 200 |
70
+ | `bad_status` | anything else: a 5xx, a redirect with no `Location` header |
71
+ | `error` | the request failed (DNS, TLS, timeout, refused) |
72
+
73
+ `--json report.json` writes every result with each hop's URL, status and
74
+ `Location`. `--json -` prints it to stdout instead.
75
+
76
+ ## Options
77
+
78
+ | flag | default | |
79
+ |---|---|---|
80
+ | `--base URL` | required | the site to test |
81
+ | `--workers N` | 8 | parallel requests |
82
+ | `--timeout S` | 20 | seconds per request |
83
+ | `--max-hops N` | 10 | stop following a chain after N hops |
84
+ | `--limit N` | all | check only the first N rules |
85
+ | `--auth user:pass` | none | basic auth for a staging host. `ONE_HOP_AUTH` works too |
86
+ | `--header 'Name: value'` | none | extra header, repeatable |
87
+ | `--method GET` | HEAD | HEAD falls back to GET on a 405 or 501 anyway |
88
+ | `--allow-temporary` | off | accept 302, 303 and 307 as the one hop |
89
+ | `--path-only` | off | compare path and query only, ignore scheme and host |
90
+ | `--insecure` | off | skip TLS certificate checks (self-signed staging) |
91
+
92
+ Credentials from `--auth` and `--header` go to the `--base` host only. If a
93
+ redirect points at another host, that request is sent without them.
94
+
95
+ Trailing slashes count. `/about` and `/about/` are two URLs, and a map that says
96
+ `/about/` when the server redirects to `/about` fails as `wrong_target`.
97
+
98
+ ## GitHub Actions
99
+
100
+ ```yaml
101
+ - uses: actions/checkout@v4
102
+ - uses: synapsereality/one-hop@v0.1.0
103
+ with:
104
+ csv: redirects.csv
105
+ base: https://staging.example.com
106
+ auth: ${{ secrets.STAGING_AUTH }}
107
+ args: --workers 4
108
+ ```
109
+
110
+ The action needs `python3` on the runner, which GitHub's hosted runners have. It
111
+ installs nothing.
112
+
113
+ ## Install from source
114
+
115
+ ```bash
116
+ git clone https://github.com/synapsereality/one-hop
117
+ cd one-hop
118
+ pip install .
119
+ ```
120
+
121
+ Python 3.10 or later, no dependencies. `python -m one_hop` works without
122
+ installing if `src` is on `PYTHONPATH`.
123
+
124
+ ## Tests
125
+
126
+ ```bash
127
+ pip install -e ".[test]"
128
+ pytest
129
+ ```
130
+
131
+ The tests start a local HTTP server and cover every result kind, the HEAD to GET
132
+ fallback, and credentials staying on the base host.
133
+
134
+ ## Licence
135
+
136
+ MIT. Made by [Synapse](https://synapsereality.io/open-source/one-hop/).
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "one-hop"
7
+ version = "0.1.0"
8
+ description = "Check a redirect map: every old URL must reach its new URL in exactly one hop, and the new URL must answer 200."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "Synapse Research Ltd" }]
14
+ keywords = ["redirects", "301", "seo", "site-migration", "ci"]
15
+ classifiers = [
16
+ "Environment :: Console",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Internet :: WWW/HTTP :: Site Management :: Link Checking",
19
+ ]
20
+ dependencies = []
21
+
22
+ [project.optional-dependencies]
23
+ test = ["pytest>=8"]
24
+
25
+ [project.urls]
26
+ Homepage = "https://synapsereality.io/open-source/one-hop/"
27
+ Documentation = "https://synapsereality.io/open-source/one-hop/"
28
+ Repository = "https://github.com/synapsereality/one-hop"
29
+ Issues = "https://github.com/synapsereality/one-hop/issues"
30
+
31
+ [project.scripts]
32
+ one-hop = "one_hop.cli:main"
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["src"]
36
+
37
+ [tool.pytest.ini_options]
38
+ pythonpath = ["src"]
39
+ testpaths = ["tests"]
40
+
41
+ [tool.ruff]
42
+ line-length = 110
43
+ target-version = "py310"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """one-hop: check that every old URL redirects to its new URL in exactly one hop."""
2
+
3
+ from .core import Checker, Hop, Result, Rule, normalise, read_rules
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["Checker", "Hop", "Result", "Rule", "__version__", "normalise", "read_rules"]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,117 @@
1
+ """Command line entry point. Exit 0 when every rule passes, 1 when any fails, 2 on bad input."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import base64
7
+ import json
8
+ import os
9
+ import sys
10
+ from collections import Counter
11
+ from pathlib import Path
12
+
13
+ from . import __version__
14
+ from .core import KINDS, Checker, read_rules
15
+
16
+ EPILOG = """\
17
+ examples:
18
+ one-hop redirects.csv --base https://example.com
19
+ one-hop redirects.csv --base https://staging.example.com --auth user:pass
20
+ one-hop redirects.csv --base https://example.com --json report.json
21
+
22
+ The CSV has two columns, old URL then new URL. Leave the new URL empty for a
23
+ page that must answer 200 with no redirect. Docs:
24
+ https://synapsereality.io/open-source/one-hop/
25
+ """
26
+
27
+
28
+ def _parse_header(raw: str) -> tuple[str, str]:
29
+ name, sep, value = raw.partition(":")
30
+ if not sep or not name.strip():
31
+ raise argparse.ArgumentTypeError(f"expected 'Name: value', got {raw!r}")
32
+ return name.strip(), value.strip()
33
+
34
+
35
+ def build_parser() -> argparse.ArgumentParser:
36
+ ap = argparse.ArgumentParser(
37
+ prog="one-hop",
38
+ description="Check that every old URL redirects to its new URL in one hop, and the new URL answers 200.",
39
+ epilog=EPILOG,
40
+ formatter_class=argparse.RawDescriptionHelpFormatter,
41
+ )
42
+ ap.add_argument("csv", nargs="+", help="redirect map(s): old,new per line; '-' reads stdin")
43
+ ap.add_argument("--base", required=True, help="site to test, e.g. https://example.com")
44
+ ap.add_argument("--workers", type=int, default=8, help="parallel requests (default 8)")
45
+ ap.add_argument("--timeout", type=float, default=20.0, help="seconds per request (default 20)")
46
+ ap.add_argument("--max-hops", type=int, default=10, help="stop following a chain after this many hops")
47
+ ap.add_argument("--limit", type=int, default=0, help="check only the first N rules")
48
+ ap.add_argument("--auth", help="user:password for HTTP basic auth, sent to the base host only "
49
+ "(or set ONE_HOP_AUTH, which keeps it out of the process list)")
50
+ ap.add_argument("--header", action="append", type=_parse_header, default=[],
51
+ help="extra request header 'Name: value' (base host only; repeatable)")
52
+ ap.add_argument("--method", choices=("HEAD", "GET"), default="HEAD",
53
+ help="HEAD by default; falls back to GET on a 405 or 501")
54
+ ap.add_argument("--allow-temporary", action="store_true", help="accept 302, 303 and 307 as the one hop")
55
+ ap.add_argument("--path-only", action="store_true",
56
+ help="compare only path and query of the target, not scheme and host")
57
+ ap.add_argument("--insecure", action="store_true", help="do not verify TLS certificates")
58
+ ap.add_argument("--json", metavar="FILE", help="write every result as JSON ('-' for stdout)")
59
+ ap.add_argument("--show", type=int, default=50, help="print at most N failures (default 50)")
60
+ ap.add_argument("--version", action="version", version=f"one-hop {__version__}")
61
+ return ap
62
+
63
+
64
+ def main(argv: list[str] | None = None) -> int:
65
+ args = build_parser().parse_args(argv)
66
+ rules = []
67
+ try:
68
+ for src in args.csv:
69
+ text = sys.stdin.read() if src == "-" else Path(src).read_text(encoding="utf-8-sig")
70
+ rules += read_rules(text)
71
+ except (OSError, ValueError) as e:
72
+ print(f"one-hop: {e}", file=sys.stderr)
73
+ return 2
74
+ if args.limit:
75
+ rules = rules[: args.limit]
76
+ if not rules:
77
+ print("one-hop: no rules found in the input", file=sys.stderr)
78
+ return 2
79
+
80
+ headers = dict(args.header)
81
+ auth = args.auth or os.environ.get("ONE_HOP_AUTH")
82
+ if auth:
83
+ headers["Authorization"] = "Basic " + base64.b64encode(auth.encode()).decode()
84
+ try:
85
+ checker = Checker(args.base, headers=headers, timeout=args.timeout, max_hops=args.max_hops,
86
+ allow_temporary=args.allow_temporary, path_only=args.path_only,
87
+ method=args.method, insecure=args.insecure)
88
+ except ValueError as e:
89
+ print(f"one-hop: {e}", file=sys.stderr)
90
+ return 2
91
+
92
+ quiet = args.json == "-"
93
+ log = sys.stderr if quiet else sys.stdout
94
+ print(f"checking {len(rules)} rules against {args.base}", file=log)
95
+ results = checker.check_all(rules, workers=args.workers)
96
+ bad = [r for r in results if not r.ok]
97
+ bad.sort(key=lambda r: (KINDS.index(r.kind), r.rule.line))
98
+
99
+ counts = Counter(r.kind for r in results)
100
+ print(f"{len(results) - len(bad)}/{len(results)} OK", file=log)
101
+ for kind in KINDS:
102
+ if kind != "ok" and counts[kind]:
103
+ print(f" {kind}: {counts[kind]}", file=log)
104
+ for r in bad[: args.show]:
105
+ where = f"line {r.rule.line}: " if r.rule.line else ""
106
+ print(f"FAIL [{r.kind}] {where}{r.rule.old} {r.detail}", file=log)
107
+ if len(bad) > args.show:
108
+ print(f"... and {len(bad) - args.show} more (use --json for all)", file=log)
109
+
110
+ if args.json:
111
+ payload = json.dumps({"base": args.base, "total": len(results), "failed": len(bad),
112
+ "results": [r.as_dict() for r in results]}, indent=2)
113
+ if quiet:
114
+ print(payload)
115
+ else:
116
+ Path(args.json).write_text(payload + "\n", encoding="utf-8")
117
+ return 1 if bad else 0
@@ -0,0 +1,261 @@
1
+ """Check that every old URL reaches its new URL in exactly one redirect.
2
+
3
+ A migration map is only right when each old URL answers with one permanent
4
+ redirect straight to its new URL, and that new URL answers 200 itself. A chain
5
+ (old -> /tmp -> new) still "works" in a browser, so nobody notices it, but each
6
+ extra hop costs crawl budget and can drop signals. This module follows every
7
+ hop by hand and says exactly which rule broke and how.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import csv
13
+ import http.client
14
+ import io
15
+ import ssl
16
+ from concurrent.futures import ThreadPoolExecutor
17
+ from dataclasses import dataclass, field
18
+ from urllib.parse import urljoin, urlsplit, urlunsplit
19
+
20
+ __all__ = ["Checker", "Hop", "Result", "Rule", "normalise", "read_rules"]
21
+
22
+ USER_AGENT = "one-hop/0.1 (+https://synapsereality.io/open-source/one-hop/)"
23
+ PERMANENT = (301, 308)
24
+ TEMPORARY = (302, 303, 307)
25
+ GONE = (404, 410)
26
+
27
+ # Result kinds, in the order a report lists them.
28
+ OK = "ok"
29
+ KINDS = ("error", "loop", "not_found", "no_redirect", "temporary", "wrong_target",
30
+ "chain", "bad_status", "redirected", OK)
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Rule:
35
+ old: str
36
+ new: str # "" means "this URL must answer 200 itself, with no redirect"
37
+ line: int = 0
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Hop:
42
+ url: str
43
+ status: int
44
+ location: str | None = None
45
+
46
+
47
+ @dataclass
48
+ class Result:
49
+ rule: Rule
50
+ kind: str
51
+ detail: str
52
+ hops: list[Hop] = field(default_factory=list)
53
+
54
+ @property
55
+ def ok(self) -> bool:
56
+ return self.kind == OK
57
+
58
+ def as_dict(self) -> dict:
59
+ return {
60
+ "old": self.rule.old,
61
+ "new": self.rule.new,
62
+ "line": self.rule.line,
63
+ "ok": self.ok,
64
+ "kind": self.kind,
65
+ "detail": self.detail,
66
+ "hops": [{"url": h.url, "status": h.status, "location": h.location} for h in self.hops],
67
+ }
68
+
69
+
70
+ _OLD_NAMES = ("old", "source", "from", "old_url", "source_url")
71
+ _NEW_NAMES = ("new", "destination", "to", "target", "new_url", "destination_url")
72
+
73
+
74
+ def read_rules(text: str) -> list[Rule]:
75
+ """Parse a redirect map. Two columns, old then new.
76
+
77
+ A header row is optional. When there is one, the columns can be called
78
+ old/new, source/destination or from/to, in any order, and other columns are
79
+ ignored. Blank lines and lines starting with `#` are skipped.
80
+ """
81
+ rows = [
82
+ (i, r) for i, r in enumerate(csv.reader(io.StringIO(text)), start=1)
83
+ if r and any(c.strip() for c in r) and not r[0].lstrip().startswith("#")
84
+ ]
85
+ if not rows:
86
+ return []
87
+ old_i, new_i = 0, 1
88
+ head = [c.strip().lower() for c in rows[0][1]]
89
+ if any(h in _OLD_NAMES for h in head):
90
+ old_i = next(i for i, h in enumerate(head) if h in _OLD_NAMES)
91
+ new_i = next((i for i, h in enumerate(head) if h in _NEW_NAMES), None)
92
+ if new_i is None:
93
+ raise ValueError("the header names an old-URL column but no new-URL column")
94
+ rows = rows[1:]
95
+ rules = []
96
+ for line, r in rows:
97
+ old = r[old_i].strip() if len(r) > old_i else ""
98
+ new = r[new_i].strip() if len(r) > new_i else ""
99
+ if not old:
100
+ raise ValueError(f"line {line}: no old URL")
101
+ rules.append(Rule(old=old, new=new, line=line))
102
+ return rules
103
+
104
+
105
+ def normalise(url: str) -> str:
106
+ """One spelling per URL: lower-case scheme and host, no default port, `/` for an empty path.
107
+
108
+ The path, query and trailing slash are kept as they are, because the server
109
+ treats `/a` and `/a/` as two URLs and a redirect between them is a real hop.
110
+ """
111
+ p = urlsplit(url)
112
+ scheme = p.scheme.lower()
113
+ host = (p.hostname or "").lower()
114
+ port = p.port
115
+ if port and not ((scheme == "http" and port == 80) or (scheme == "https" and port == 443)):
116
+ host = f"{host}:{port}"
117
+ return urlunsplit((scheme, host, p.path or "/", p.query, ""))
118
+
119
+
120
+ def _origin(url: str) -> str:
121
+ p = urlsplit(normalise(url))
122
+ return f"{p.scheme}://{p.netloc}"
123
+
124
+
125
+ class Checker:
126
+ """Checks rules against one site. Thread-safe: every request opens its own connection."""
127
+
128
+ def __init__(
129
+ self,
130
+ base: str,
131
+ *,
132
+ headers: dict[str, str] | None = None,
133
+ timeout: float = 20.0,
134
+ max_hops: int = 10,
135
+ allow_temporary: bool = False,
136
+ path_only: bool = False,
137
+ method: str = "HEAD",
138
+ insecure: bool = False,
139
+ ) -> None:
140
+ if not urlsplit(base).scheme or not urlsplit(base).netloc:
141
+ raise ValueError(f"base must be an absolute URL such as https://example.com, got {base!r}")
142
+ self.base = base
143
+ self.origin = _origin(base)
144
+ self.headers = dict(headers or {})
145
+ self.timeout = timeout
146
+ self.max_hops = max_hops
147
+ self.allow_temporary = allow_temporary
148
+ self.path_only = path_only
149
+ self.method = method.upper()
150
+ self._ssl = ssl._create_unverified_context() if insecure else ssl.create_default_context()
151
+
152
+ # ------------------------------------------------------------------ http ---
153
+ def request(self, url: str, method: str | None = None) -> Hop:
154
+ """One request. Redirects are never followed here: every hop has to be seen."""
155
+ method = (method or self.method).upper()
156
+ p = urlsplit(url)
157
+ if p.scheme == "https":
158
+ conn = http.client.HTTPSConnection(p.hostname, p.port, timeout=self.timeout, context=self._ssl)
159
+ elif p.scheme == "http":
160
+ conn = http.client.HTTPConnection(p.hostname, p.port, timeout=self.timeout)
161
+ else:
162
+ raise ValueError(f"unsupported URL scheme in {url!r}")
163
+ path = (p.path or "/") + (f"?{p.query}" if p.query else "")
164
+ headers = {"User-Agent": USER_AGENT, "Accept": "*/*"}
165
+ # Extra headers usually carry credentials for a staging host. They go to
166
+ # that host only, never to a host a redirect points at.
167
+ if _origin(url) == self.origin:
168
+ headers.update(self.headers)
169
+ try:
170
+ conn.request(method, path, headers=headers)
171
+ r = conn.getresponse()
172
+ status, location = r.status, r.getheader("Location")
173
+ finally:
174
+ conn.close()
175
+ # Some servers refuse HEAD. Ask again with GET rather than report their 405.
176
+ if method == "HEAD" and status in (405, 501):
177
+ return self.request(url, "GET")
178
+ return Hop(url=url, status=status, location=location)
179
+
180
+ def follow(self, start: str) -> tuple[list[Hop], str]:
181
+ """Follow redirects by hand from `start`. Returns the hops and an end state:
182
+ "final" (a non-redirect answer), "loop", "too_many" or "no_location"."""
183
+ hops: list[Hop] = []
184
+ seen: set[str] = set()
185
+ url = start
186
+ while True:
187
+ key = normalise(url)
188
+ if key in seen:
189
+ return hops, "loop"
190
+ seen.add(key)
191
+ if len(hops) > self.max_hops:
192
+ return hops, "too_many"
193
+ hop = self.request(url)
194
+ hops.append(hop)
195
+ if not (300 <= hop.status < 400):
196
+ return hops, "final"
197
+ if not hop.location:
198
+ return hops, "no_location"
199
+ url = urljoin(url, hop.location)
200
+
201
+ # ----------------------------------------------------------------- check ---
202
+ def _same_target(self, got: str, want: str) -> bool:
203
+ if self.path_only:
204
+ g, w = urlsplit(normalise(got)), urlsplit(normalise(want))
205
+ return (g.path, g.query) == (w.path, w.query)
206
+ return normalise(got) == normalise(want)
207
+
208
+ def check(self, rule: Rule) -> Result:
209
+ src = urljoin(self.base, rule.old)
210
+ want = urljoin(self.base, rule.new) if rule.new else ""
211
+ if want and normalise(want) == normalise(src):
212
+ want = ""
213
+ try:
214
+ hops, end = self.follow(src)
215
+ except Exception as e: # noqa: BLE001 - any network failure is a result, not a crash
216
+ return Result(rule, "error", f"request failed: {type(e).__name__}: {e}")
217
+ first, last = hops[0], hops[-1]
218
+ chain = " -> ".join(f"{h.status} {h.url}" for h in hops)
219
+
220
+ if end == "loop":
221
+ return Result(rule, "loop", f"redirect loop: {chain} -> {hops[-1].location}", hops)
222
+ if end == "too_many":
223
+ return Result(rule, "chain", f"more than {self.max_hops} redirects: {chain}", hops)
224
+ if end == "no_location":
225
+ return Result(rule, "bad_status", f"{last.status} with no Location header at {last.url}", hops)
226
+
227
+ if not want:
228
+ if first.status == 200:
229
+ return Result(rule, OK, "200", hops)
230
+ if first.status in GONE:
231
+ return Result(rule, "not_found", f"expected 200, got {first.status}", hops)
232
+ if 300 <= first.status < 400:
233
+ return Result(rule, "redirected", f"expected 200 with no redirect: {chain}", hops)
234
+ return Result(rule, "bad_status", f"expected 200, got {first.status}", hops)
235
+
236
+ if first.status in GONE:
237
+ return Result(rule, "not_found", f"old URL returned {first.status}, no redirect", hops)
238
+ if first.status == 200:
239
+ return Result(rule, "no_redirect", "old URL returned 200, no redirect", hops)
240
+ if first.status in TEMPORARY and not self.allow_temporary:
241
+ return Result(rule, "temporary", f"{first.status} is a temporary redirect, want 301 or 308", hops)
242
+ if first.status not in PERMANENT + TEMPORARY:
243
+ return Result(rule, "bad_status", f"expected 301 or 308, got {first.status}", hops)
244
+
245
+ target = urljoin(first.url, first.location or "")
246
+ if not self._same_target(target, want):
247
+ if len(hops) > 2 and self._same_target(last.url, want):
248
+ return Result(rule, "chain", f"reaches the new URL after {len(hops) - 1} redirects: {chain}",
249
+ hops)
250
+ return Result(rule, "wrong_target", f"redirects to {target}, want {want} ({chain})", hops)
251
+ if len(hops) > 2:
252
+ return Result(rule, "chain", f"{len(hops) - 1} redirects before a final answer: {chain}", hops)
253
+ if last.status in GONE:
254
+ return Result(rule, "not_found", f"new URL returned {last.status}", hops)
255
+ if last.status != 200:
256
+ return Result(rule, "bad_status", f"new URL returned {last.status}", hops)
257
+ return Result(rule, OK, f"{first.status} -> 200", hops)
258
+
259
+ def check_all(self, rules: list[Rule], workers: int = 8) -> list[Result]:
260
+ with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
261
+ return list(pool.map(self.check, rules))
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: one-hop
3
+ Version: 0.1.0
4
+ Summary: Check a redirect map: every old URL must reach its new URL in exactly one hop, and the new URL must answer 200.
5
+ Author: Synapse Research Ltd
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://synapsereality.io/open-source/one-hop/
8
+ Project-URL: Documentation, https://synapsereality.io/open-source/one-hop/
9
+ Project-URL: Repository, https://github.com/synapsereality/one-hop
10
+ Project-URL: Issues, https://github.com/synapsereality/one-hop/issues
11
+ Keywords: redirects,301,seo,site-migration,ci
12
+ Classifier: Environment :: Console
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Internet :: WWW/HTTP :: Site Management :: Link Checking
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest>=8; extra == "test"
20
+ Dynamic: license-file
21
+
22
+ # one-hop
23
+
24
+ Checks a redirect map. Every old URL has to answer with one permanent redirect
25
+ (301 or 308) straight to its new URL. The new URL has to answer 200. Anything else
26
+ fails: a chain, a loop, a 404, a redirect to the wrong page. The exit code is then
27
+ 1, so CI can stop the release.
28
+
29
+ Docs: https://synapsereality.io/open-source/one-hop/
30
+
31
+ ```bash
32
+ pip install one-hop
33
+ one-hop redirects.csv --base https://example.com
34
+ ```
35
+
36
+ ## Why one hop
37
+
38
+ A chain such as `/about-us` to `/about` to `/about/` still loads in a browser, so
39
+ nobody spots it. Crawlers do. Google follows up to 10 hops and then gives up, and
40
+ every extra hop is one more request per URL. After a migration the map is often
41
+ right on paper and wrong on the server. One trailing-slash rule or a leftover
42
+ redirect plugin adds a second hop to hundreds of rules at once.
43
+
44
+ We wrote the first version for our own site move in September 2026, 2,030 rules
45
+ from a WordPress site to a static build. This is that script, made general.
46
+
47
+ ## The CSV
48
+
49
+ Two columns, old URL then new URL. A header row is optional. With one, the columns
50
+ can be named `old`/`new`, `source`/`destination` or `from`/`to`, and extra columns
51
+ are ignored.
52
+
53
+ ```csv
54
+ old,new
55
+ /blog/old-post,/blog/new-post/
56
+ /about-us,/about/
57
+ /services,https://example.com/services/seo/
58
+ /pricing,
59
+ ```
60
+
61
+ Leave the new URL empty (as for `/pricing` above) when the page should answer 200
62
+ with no redirect at all. Relative URLs are resolved against `--base`. Lines that
63
+ start with `#` are skipped.
64
+
65
+ ## Output
66
+
67
+ Against a local test server with five rules:
68
+
69
+ ```text
70
+ $ one-hop redirects.csv --base https://example.com
71
+ checking 5 rules against https://example.com
72
+ 1/5 OK
73
+ loop: 1
74
+ not_found: 2
75
+ chain: 1
76
+ FAIL [loop] line 5: /team redirect loop: 301 https://example.com/team -> 301 https://example.com/people/ -> /team
77
+ FAIL [not_found] line 4: /services new URL returned 404
78
+ FAIL [not_found] line 6: /pricing old URL returned 404, no redirect
79
+ 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/
80
+ ```
81
+
82
+ | kind | what happened |
83
+ |---|---|
84
+ | `chain` | more than one redirect before a final answer |
85
+ | `loop` | a redirect came back to a URL already visited |
86
+ | `not_found` | the old or the new URL answered 404 or 410 |
87
+ | `wrong_target` | the redirect points somewhere other than the new URL in the map |
88
+ | `no_redirect` | the old URL answered 200 when a redirect was expected |
89
+ | `temporary` | a 302, 303 or 307 where a permanent redirect was expected |
90
+ | `redirected` | a row with no new URL redirected instead of answering 200 |
91
+ | `bad_status` | anything else: a 5xx, a redirect with no `Location` header |
92
+ | `error` | the request failed (DNS, TLS, timeout, refused) |
93
+
94
+ `--json report.json` writes every result with each hop's URL, status and
95
+ `Location`. `--json -` prints it to stdout instead.
96
+
97
+ ## Options
98
+
99
+ | flag | default | |
100
+ |---|---|---|
101
+ | `--base URL` | required | the site to test |
102
+ | `--workers N` | 8 | parallel requests |
103
+ | `--timeout S` | 20 | seconds per request |
104
+ | `--max-hops N` | 10 | stop following a chain after N hops |
105
+ | `--limit N` | all | check only the first N rules |
106
+ | `--auth user:pass` | none | basic auth for a staging host. `ONE_HOP_AUTH` works too |
107
+ | `--header 'Name: value'` | none | extra header, repeatable |
108
+ | `--method GET` | HEAD | HEAD falls back to GET on a 405 or 501 anyway |
109
+ | `--allow-temporary` | off | accept 302, 303 and 307 as the one hop |
110
+ | `--path-only` | off | compare path and query only, ignore scheme and host |
111
+ | `--insecure` | off | skip TLS certificate checks (self-signed staging) |
112
+
113
+ Credentials from `--auth` and `--header` go to the `--base` host only. If a
114
+ redirect points at another host, that request is sent without them.
115
+
116
+ Trailing slashes count. `/about` and `/about/` are two URLs, and a map that says
117
+ `/about/` when the server redirects to `/about` fails as `wrong_target`.
118
+
119
+ ## GitHub Actions
120
+
121
+ ```yaml
122
+ - uses: actions/checkout@v4
123
+ - uses: synapsereality/one-hop@v0.1.0
124
+ with:
125
+ csv: redirects.csv
126
+ base: https://staging.example.com
127
+ auth: ${{ secrets.STAGING_AUTH }}
128
+ args: --workers 4
129
+ ```
130
+
131
+ The action needs `python3` on the runner, which GitHub's hosted runners have. It
132
+ installs nothing.
133
+
134
+ ## Install from source
135
+
136
+ ```bash
137
+ git clone https://github.com/synapsereality/one-hop
138
+ cd one-hop
139
+ pip install .
140
+ ```
141
+
142
+ Python 3.10 or later, no dependencies. `python -m one_hop` works without
143
+ installing if `src` is on `PYTHONPATH`.
144
+
145
+ ## Tests
146
+
147
+ ```bash
148
+ pip install -e ".[test]"
149
+ pytest
150
+ ```
151
+
152
+ The tests start a local HTTP server and cover every result kind, the HEAD to GET
153
+ fallback, and credentials staying on the base host.
154
+
155
+ ## Licence
156
+
157
+ MIT. Made by [Synapse](https://synapsereality.io/open-source/one-hop/).
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/one_hop/__init__.py
5
+ src/one_hop/__main__.py
6
+ src/one_hop/cli.py
7
+ src/one_hop/core.py
8
+ src/one_hop.egg-info/PKG-INFO
9
+ src/one_hop.egg-info/SOURCES.txt
10
+ src/one_hop.egg-info/dependency_links.txt
11
+ src/one_hop.egg-info/entry_points.txt
12
+ src/one_hop.egg-info/requires.txt
13
+ src/one_hop.egg-info/top_level.txt
14
+ tests/test_core.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ one-hop = one_hop.cli:main
@@ -0,0 +1,3 @@
1
+
2
+ [test]
3
+ pytest>=8
@@ -0,0 +1 @@
1
+ one_hop
@@ -0,0 +1,172 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from one_hop import Checker, Rule, normalise, read_rules
6
+ from one_hop.cli import main
7
+
8
+
9
+ def kind(site, old, new, **kw):
10
+ return Checker(site.url, **kw).check(Rule(old, new))
11
+
12
+
13
+ def test_one_hop_to_a_200_passes(site):
14
+ site.routes = {"/old": (301, "/new"), "/new": (200, None)}
15
+ r = kind(site, "/old", "/new")
16
+ assert r.ok and r.detail == "301 -> 200"
17
+
18
+
19
+ def test_308_and_absolute_location_pass(site):
20
+ site.routes = {"/old": (308, f"{site.url}/new"), "/new": (200, None)}
21
+ assert kind(site, "/old", "/new").ok
22
+
23
+
24
+ def test_chain_is_reported_with_every_hop(site):
25
+ site.routes = {"/old": (301, "/new"), "/new": (301, "/new/"), "/new/": (200, None)}
26
+ r = kind(site, "/old", "/new")
27
+ assert r.kind == "chain"
28
+ assert [h.status for h in r.hops] == [301, 301, 200]
29
+
30
+
31
+ def test_loop_is_detected(site):
32
+ site.routes = {"/a": (301, "/b"), "/b": (301, "/a")}
33
+ r = kind(site, "/a", "/b")
34
+ assert r.kind == "loop"
35
+
36
+
37
+ def test_self_redirect_is_a_loop(site):
38
+ site.routes = {"/a": (301, "/a")}
39
+ assert kind(site, "/a", "/b").kind == "loop"
40
+
41
+
42
+ def test_missing_old_url_is_not_found(site):
43
+ assert kind(site, "/gone", "/new").kind == "not_found"
44
+
45
+
46
+ def test_destination_404_is_not_found(site):
47
+ site.routes = {"/old": (301, "/new")}
48
+ r = kind(site, "/old", "/new")
49
+ assert r.kind == "not_found" and "new URL" in r.detail
50
+
51
+
52
+ def test_wrong_target(site):
53
+ site.routes = {"/old": (301, "/elsewhere"), "/elsewhere": (200, None)}
54
+ assert kind(site, "/old", "/new").kind == "wrong_target"
55
+
56
+
57
+ def test_trailing_slash_matters(site):
58
+ site.routes = {"/old": (301, "/new/"), "/new/": (200, None)}
59
+ assert kind(site, "/old", "/new").kind == "wrong_target"
60
+
61
+
62
+ def test_temporary_redirect_fails_unless_allowed(site):
63
+ site.routes = {"/old": (302, "/new"), "/new": (200, None)}
64
+ assert kind(site, "/old", "/new").kind == "temporary"
65
+ assert kind(site, "/old", "/new", allow_temporary=True).ok
66
+
67
+
68
+ def test_old_url_answering_200_is_no_redirect(site):
69
+ site.routes = {"/old": (200, None)}
70
+ assert kind(site, "/old", "/new").kind == "no_redirect"
71
+
72
+
73
+ def test_keep_rule_wants_a_plain_200(site):
74
+ site.routes = {"/keep": (200, None), "/moved": (301, "/x"), "/x": (200, None)}
75
+ assert kind(site, "/keep", "").ok
76
+ assert kind(site, "/moved", "").kind == "redirected"
77
+ assert kind(site, "/keep", "/keep").ok # new == old means keep
78
+
79
+
80
+ def test_redirect_without_location(site):
81
+ site.routes = {"/old": (301, None)}
82
+ assert kind(site, "/old", "/new").kind == "bad_status"
83
+
84
+
85
+ def test_server_error(site):
86
+ site.routes = {"/old": (500, None)}
87
+ assert kind(site, "/old", "/new").kind == "bad_status"
88
+
89
+
90
+ def test_too_many_hops_is_a_chain(site):
91
+ site.routes = {f"/{i}": (301, f"/{i + 1}") for i in range(20)}
92
+ r = kind(site, "/0", "/1", max_hops=5)
93
+ assert r.kind == "chain" and "more than 5" in r.detail
94
+
95
+
96
+ def test_connection_refused_is_an_error():
97
+ r = Checker("http://127.0.0.1:9").check(Rule("/old", "/new"))
98
+ assert r.kind == "error"
99
+
100
+
101
+ def test_head_refused_falls_back_to_get(site):
102
+ site.refuse_head = True
103
+ site.routes = {"/old": (301, "/new"), "/new": (200, None)}
104
+ assert kind(site, "/old", "/new").ok
105
+ assert {m for m, _, _ in site.seen} == {"HEAD", "GET"}
106
+
107
+
108
+ def test_query_is_part_of_the_url(site):
109
+ site.routes = {"/old?id=1": (301, "/new?id=1"), "/new?id=1": (200, None)}
110
+ assert kind(site, "/old?id=1", "/new?id=1").ok
111
+ assert kind(site, "/old?id=1", "/new?id=2").kind == "wrong_target"
112
+
113
+
114
+ def test_cross_host_redirect_and_credentials_stay_home(site, other_site):
115
+ site.routes = {"/old": (301, f"{other_site.url}/new")}
116
+ other_site.routes = {"/new": (200, None)}
117
+ r = Checker(site.url, headers={"Authorization": "Basic c2VjcmV0"}).check(
118
+ Rule("/old", f"{other_site.url}/new"))
119
+ assert r.ok
120
+ assert site.seen[0][2].get("Authorization") == "Basic c2VjcmV0"
121
+ assert "Authorization" not in other_site.seen[0][2]
122
+
123
+
124
+ def test_path_only_ignores_host(site, other_site):
125
+ site.routes = {"/old": (301, f"{other_site.url}/new")}
126
+ other_site.routes = {"/new": (200, None)}
127
+ assert kind(site, "/old", "/new").kind == "wrong_target"
128
+ assert kind(site, "/old", "/new", path_only=True).ok
129
+
130
+
131
+ def test_normalise():
132
+ assert normalise("HTTPS://Example.com:443") == "https://example.com/"
133
+ assert normalise("http://a.b:8080/x?y=1#frag") == "http://a.b:8080/x?y=1"
134
+
135
+
136
+ def test_read_rules_headers_and_comments():
137
+ rules = read_rules("# map\nsource,destination,note\n/a,/b,x\n\n/c,,keep\n")
138
+ assert [(r.old, r.new, r.line) for r in rules] == [("/a", "/b", 3), ("/c", "", 5)]
139
+ rules = read_rules("to,from\n/new,/old\n")
140
+ assert (rules[0].old, rules[0].new) == ("/old", "/new")
141
+ assert [r.old for r in read_rules("/a,/b\n/c,/d\n")] == ["/a", "/c"]
142
+
143
+
144
+ def test_read_rules_rejects_missing_column():
145
+ with pytest.raises(ValueError):
146
+ read_rules("old,notes\n/a,x\n")
147
+
148
+
149
+ def test_base_must_be_absolute():
150
+ with pytest.raises(ValueError):
151
+ Checker("example.com")
152
+
153
+
154
+ def test_cli_exit_codes_and_json(site, tmp_path, capsys):
155
+ site.routes = {"/a": (301, "/b"), "/b": (200, None), "/c": (301, "/d"), "/d": (301, "/e"), "/e": (200, None)}
156
+ good = tmp_path / "good.csv"
157
+ good.write_text("old,new\n/a,/b\n")
158
+ assert main([str(good), "--base", site.url]) == 0
159
+ mixed = tmp_path / "mixed.csv"
160
+ mixed.write_text("old,new\n/a,/b\n/c,/e\n")
161
+ out = tmp_path / "r.json"
162
+ assert main([str(mixed), "--base", site.url, "--json", str(out)]) == 1
163
+ data = json.loads(out.read_text())
164
+ assert data["failed"] == 1 and data["results"][1]["kind"] == "chain"
165
+ assert "FAIL [chain] line 3: /c" in capsys.readouterr().out
166
+
167
+
168
+ def test_cli_bad_input(tmp_path):
169
+ empty = tmp_path / "e.csv"
170
+ empty.write_text("")
171
+ assert main([str(empty), "--base", "https://example.com"]) == 2
172
+ assert main([str(tmp_path / "missing.csv"), "--base", "https://example.com"]) == 2