Validate parent_dir and base_url with actionable errors #33

Manually merged
marvin8 merged 7 commits from feat/issue-26-cli-validation into main 2026-08-19 23:07:57 +00:00
Collaborator

Closes #26

Validates the two CLI inputs up front and fails with actionable messages instead of surfacing cryptic errors mid-run:

  • parent_dir missing → FileNotFoundError naming the path
  • parent_dir is a file → NotADirectoryError naming the path
  • base_url without an http:///https:// scheme → ValueError

Validation is extracted into _validate_inputs() to keep main under the complexipy threshold. Covered by Tryke tests (red → green).

Closes #26 Validates the two CLI inputs up front and fails with actionable messages instead of surfacing cryptic errors mid-run: - `parent_dir` missing → `FileNotFoundError` naming the path - `parent_dir` is a file → `NotADirectoryError` naming the path - `base_url` without an `http://`/`https://` scheme → `ValueError` Validation is extracted into `_validate_inputs()` to keep `main` under the complexipy threshold. Covered by Tryke tests (red → green).
validate parent_dir and base_url with actionable errors
All checks were successful
/ gitleaks (pull_request) Successful in 17s
/ checks (pull_request) Successful in 1m40s
/ publish (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ pr-review (pull_request) Successful in 2m2s
f3cf31d64b
forgejo-actions left a comment

WuMing

Found 3 issue(s). See inline comments below.

## WuMing Found **3** issue(s). See inline comments below.
@ -18,0 +30,4 @@
"""
if not parent_dir.exists():
raise FileNotFoundError(f"parent_dir does not exist: {parent_dir}")
if not parent_dir.is_dir():

code [LOW]

Validation checks existence and directory type but not read/traverse permissions. A directory that exists and is a directory but lacks permissions will still raise a raw PermissionError later at parent_dir.iterdir(). Consider adding an os.access(parent_dir, os.R_OK | os.X_OK) check and raising PermissionError with an actionable message in _validate_inputs.

**code** [LOW] Validation checks existence and directory type but not read/traverse permissions. A directory that exists and is a directory but lacks permissions will still raise a raw PermissionError later at `parent_dir.iterdir()`. Consider adding an `os.access(parent_dir, os.R_OK | os.X_OK)` check and raising PermissionError with an actionable message in `_validate_inputs`. <!-- wuming:sha256:e1f8a65e6d347f6c15448cf11e9308feeb69e9fea775fe85b48700b3cac1885d -->
@ -18,0 +32,4 @@
raise FileNotFoundError(f"parent_dir does not exist: {parent_dir}")
if not parent_dir.is_dir():
raise NotADirectoryError(f"parent_dir is not a directory: {parent_dir}")
if not base_url.startswith(("http://", "https://")):

code [MEDIUM]

URL scheme validation is case-sensitive. URL schemes are case-insensitive per RFC 3986, so valid inputs like HTTPS://example.com or Http://example.com will be incorrectly rejected with ValueError. Normalize before checking, e.g. base_url.lower().startswith(('http://', 'https://')), or use urllib.parse.urlparse and compare the lowercased scheme.

**code** [MEDIUM] URL scheme validation is case-sensitive. URL schemes are case-insensitive per RFC 3986, so valid inputs like `HTTPS://example.com` or `Http://example.com` will be incorrectly rejected with ValueError. Normalize before checking, e.g. `base_url.lower().startswith(('http://', 'https://'))`, or use `urllib.parse.urlparse` and compare the lowercased scheme. <!-- wuming:sha256:3250ba06c05a385402eed9d4f3264fd79a11834b26c80ec703cec992f9f00e30 -->
@ -116,0 +158,4 @@
def test_valid_base_url_accepted() -> None:
with tempfile.TemporaryDirectory() as tmp:
_make_channel(Path(tmp))
main(Path(tmp), "https://pod.example.com")

tests [MEDIUM]

Test calls main() without any explicit assertion or expectation. It only relies on an exception failing the test; add an explicit assertion such as expect(...).not_to_raise or verify the expected outcome.

**tests** [MEDIUM] Test calls main() without any explicit assertion or expectation. It only relies on an exception failing the test; add an explicit assertion such as expect(...).not_to_raise or verify the expected outcome. <!-- wuming:sha256:230c8b111b8025be85df731f830a1a9f051b8d8e4438da699a9cd21db1971290 -->
🐛 normalize base_url scheme check to be case-insensitive
All checks were successful
/ gitleaks (pull_request) Successful in 18s
/ checks (pull_request) Successful in 1m36s
/ publish (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ pr-review (pull_request) Successful in 2m57s
dd1408f782
Author
Collaborator

WuMing review — 2 fixed, 1 declined

src/sub2pod/cli.py line 35

URL scheme validation is case-sensitive. URL schemes are case-insensitive per RFC 3986…

Fixed in commit dd1408f — scheme check now uses base_url.lower().startswith(("http://", "https://")).

tests/test_cli.py line 161

Test calls main() without any explicit assertion or expectation…

Fixed in commit dd1408ftest_valid_base_url_accepted now asserts feed.xml exists, and a new test covers the case-insensitive scheme.

src/sub2pod/cli.py line 33

Validation checks existence and directory type but not read/traverse permissions…

🔴 Declined — issue #26 scopes validation to existence + directory-ness. os.access is unreliable here (returns true as root and under ACLs), so it would not reliably produce the actionable error it aims for. Happy to add permission handling as a follow-up if you want it.

### WuMing review — 2 fixed, 1 declined [`src/sub2pod/cli.py` line 35](https://forge.marvin8.zone/marvin8/sub2pod/pulls/33#issuecomment-1678) > URL scheme validation is case-sensitive. URL schemes are case-insensitive per RFC 3986… ✅ Fixed in commit `dd1408f` — scheme check now uses `base_url.lower().startswith(("http://", "https://"))`. [`tests/test_cli.py` line 161](https://forge.marvin8.zone/marvin8/sub2pod/pulls/33#issuecomment-1680) > Test calls main() without any explicit assertion or expectation… ✅ Fixed in commit `dd1408f` — `test_valid_base_url_accepted` now asserts `feed.xml` exists, and a new test covers the case-insensitive scheme. [`src/sub2pod/cli.py` line 33](https://forge.marvin8.zone/marvin8/sub2pod/pulls/33#issuecomment-1679) > Validation checks existence and directory type but not read/traverse permissions… 🔴 Declined — issue #26 scopes validation to existence + directory-ness. `os.access` is unreliable here (returns true as root and under ACLs), so it would not reliably produce the actionable error it aims for. Happy to add permission handling as a follow-up if you want it.
forgejo-actions left a comment

WuMing

Found 2 issue(s). See inline comments below.

## WuMing Found **2** issue(s). See inline comments below.
@ -18,0 +32,4 @@
raise FileNotFoundError(f"parent_dir does not exist: {parent_dir}")
if not parent_dir.is_dir():
raise NotADirectoryError(f"parent_dir is not a directory: {parent_dir}")
if not base_url.lower().startswith(("http://", "https://")):

code [MEDIUM]

The prefix check accepts malformed URLs like "http://" or "https://" (scheme with no host) because it only validates that base_url starts with http:// or https://. These pass _validate_inputs but can still produce invalid feed links later. Use urllib.parse.urlparse/urlsplit to require scheme in {'http','https'} and a non-empty netloc before accepting.

**code** [MEDIUM] The prefix check accepts malformed URLs like "http://" or "https://" (scheme with no host) because it only validates that base_url starts with http:// or https://. These pass _validate_inputs but can still produce invalid feed links later. Use urllib.parse.urlparse/urlsplit to require scheme in {'http','https'} and a non-empty netloc before accepting. <!-- wuming:sha256:0994a033052ddc94081743240bd96df8dc44e325b0529bb71ea2183746ae8baa -->
@ -116,0 +147,4 @@
@test("base_url without a scheme raises ValueError")
def test_schemaless_base_url_raises() -> None:
with tempfile.TemporaryDirectory() as tmp:
expect(lambda: main(Path(tmp), "not-a-url")).to_raise(ValueError)

code [LOW]

There is no test for hostless URLs such as "http://" or "https://". Such values start with a valid scheme prefix but have no host, so the current validation lets them through. Add tests that these raise ValueError (or add support in validation first).

**code** [LOW] There is no test for hostless URLs such as "http://" or "https://". Such values start with a valid scheme prefix but have no host, so the current validation lets them through. Add tests that these raise ValueError (or add support in validation first). <!-- wuming:sha256:6a18b2d019265a2e08f88c596617b2de3a57ea900da1fe4967d414b2e0cc8694 -->
🐛 require a host in base_url, rejecting hostless http(s):// URLs
All checks were successful
/ gitleaks (pull_request) Successful in 15s
/ checks (pull_request) Successful in 1m42s
/ publish (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ pr-review (pull_request) Successful in 3m12s
e9b24a8050
Author
Collaborator

WuMing review — fixed (hostless URL validation)

src/sub2pod/cli.py line 35

The prefix check accepts malformed URLs like "http://" or "https://" (scheme with no host)…

Fixed in commit e9b24a8 — validation now uses urllib.parse.urlparse and requires a non-empty netloc in addition to an http/https scheme.

tests/test_cli.py line 150

There is no test for hostless URLs such as "http://" or "https://"…

Fixed in commit e9b24a8 — added test_hostless_http_base_url_raises and test_hostless_https_base_url_raises.

### WuMing review — fixed (hostless URL validation) [`src/sub2pod/cli.py` line 35](https://forge.marvin8.zone/marvin8/sub2pod/pulls/33#issuecomment-1691) > The prefix check accepts malformed URLs like "http://" or "https://" (scheme with no host)… ✅ Fixed in commit `e9b24a8` — validation now uses `urllib.parse.urlparse` and requires a non-empty `netloc` in addition to an `http`/`https` scheme. [`tests/test_cli.py` line 150](https://forge.marvin8.zone/marvin8/sub2pod/pulls/33#issuecomment-1692) > There is no test for hostless URLs such as "http://" or "https://"… ✅ Fixed in commit `e9b24a8` — added `test_hostless_http_base_url_raises` and `test_hostless_https_base_url_raises`.
forgejo-actions left a comment

WuMing

Found 1 issue(s). See inline comments below.

## WuMing Found **1** issue(s). See inline comments below.
@ -18,0 +34,4 @@
if not parent_dir.is_dir():
raise NotADirectoryError(f"parent_dir is not a directory: {parent_dir}")
parsed = urlparse(base_url)
if parsed.scheme.lower() not in ("http", "https") or not parsed.netloc:

code [MEDIUM]

Using parsed.netloc to check for a host lets URLs with empty hosts but non-empty ports through (e.g. http://:80 has netloc ':80'), and whitespace-only hosts are also truthy. This defeats the stated host validation. Check parsed.hostname instead, e.g. if parsed.scheme.lower() not in ("http", "https") or not (parsed.hostname and parsed.hostname.strip()):, and add a test for http://:80.

**code** [MEDIUM] Using parsed.netloc to check for a host lets URLs with empty hosts but non-empty ports through (e.g. http://:80 has netloc ':80'), and whitespace-only hosts are also truthy. This defeats the stated host validation. Check parsed.hostname instead, e.g. `if parsed.scheme.lower() not in ("http", "https") or not (parsed.hostname and parsed.hostname.strip()):`, and add a test for `http://:80`. <!-- wuming:sha256:2db8e3be294becf0d5e82ea28ec5f4bdda6cd9ded172efb2b738a9a8c28c02ee -->
🐛 validate hostname not netloc, rejecting empty and whitespace hosts
All checks were successful
/ gitleaks (pull_request) Successful in 15s
/ checks (pull_request) Successful in 1m37s
/ publish (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ pr-review (pull_request) Successful in 3m55s
78c84dcbb1
Author
Collaborator

src/sub2pod/cli.py line 37

Using parsed.netloc to check for a host lets URLs with empty hosts but non-empty ports through…

Fixed in commit 78c84dc — validation now checks parsed.hostname and parsed.hostname.strip(). Added tests for http://:80 and a whitespace-only host.

[`src/sub2pod/cli.py` line 37](https://forge.marvin8.zone/marvin8/sub2pod/pulls/33#issuecomment-1698) > Using parsed.netloc to check for a host lets URLs with empty hosts but non-empty ports through… ✅ Fixed in commit `78c84dc` — validation now checks `parsed.hostname and parsed.hostname.strip()`. Added tests for `http://:80` and a whitespace-only host.
forgejo-actions left a comment

WuMing

Found 1 issue(s). See inline comments below.

## WuMing Found **1** issue(s). See inline comments below.
@ -18,0 +34,4 @@
if not parent_dir.is_dir():
raise NotADirectoryError(f"parent_dir is not a directory: {parent_dir}")
parsed = urlparse(base_url)
if parsed.scheme.lower() not in ("http", "https") or not (parsed.hostname and parsed.hostname.strip()):

code [MEDIUM]

The base_url validation only checks that parsed.hostname is non-empty after stripping, so invalid hostnames and ports are accepted. For example, https://example.com:bad and https:// example.com both pass because parsed.hostname returns a truthy value. These will lead to invalid podcast URLs later instead of an actionable ValueError. Validate parsed.hostname against allowed hostname characters, reject surrounding/embedded whitespace, and/or validate parsed.port (catching ValueError) before accepting.

**code** [MEDIUM] The base_url validation only checks that parsed.hostname is non-empty after stripping, so invalid hostnames and ports are accepted. For example, https://example.com:bad and https:// example.com both pass because parsed.hostname returns a truthy value. These will lead to invalid podcast URLs later instead of an actionable ValueError. Validate parsed.hostname against allowed hostname characters, reject surrounding/embedded whitespace, and/or validate parsed.port (catching ValueError) before accepting. <!-- wuming:sha256:f5dac3a8bc2a49a8e9c771bf6161b9d1b0b15999566c1109f668abfdcda92c18 -->
🐛 reject whitespace hosts and invalid ports in base_url
All checks were successful
/ gitleaks (pull_request) Successful in 17s
/ checks (pull_request) Successful in 1m40s
/ publish (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ pr-review (pull_request) Successful in 3m16s
7631cf027c
Author
Collaborator

src/sub2pod/cli.py line 37

The base_url validation only checks that parsed.hostname is non-empty after stripping, so invalid hostnames and ports are accepted…

Fixed in commit 7631cf0 — validation now rejects any whitespace in the hostname and catches ValueError from parsed.port (invalid ports). Added tests for https://example.com:bad and https:// example.com.

[`src/sub2pod/cli.py` line 37](https://forge.marvin8.zone/marvin8/sub2pod/pulls/33#issuecomment-1702) > The base_url validation only checks that parsed.hostname is non-empty after stripping, so invalid hostnames and ports are accepted… ✅ Fixed in commit `7631cf0` — validation now rejects any whitespace in the hostname and catches `ValueError` from `parsed.port` (invalid ports). Added tests for `https://example.com:bad` and `https:// example.com`.
forgejo-actions left a comment

WuMing

Found 2 issue(s). See inline comments below.

## WuMing Found **2** issue(s). See inline comments below.
@ -18,0 +30,4 @@
parsed = urlparse(base_url)
if parsed.scheme.lower() not in ("http", "https"):
raise ValueError(f"base_url must be an http:// or https:// URL, got {base_url!r}")
hostname = parsed.hostname

code [MEDIUM]

parsed.hostname can itself raise ValueError for malformed ports (e.g. https://example.com:bad) before the try around parsed.port is reached. As a result, the intended actionable message base_url has an invalid port is unreachable for non-numeric ports. Move hostname extraction inside the try/except or access parsed.port before parsed.hostname so the custom error is used.

**code** [MEDIUM] `parsed.hostname` can itself raise `ValueError` for malformed ports (e.g. `https://example.com:bad`) before the `try` around `parsed.port` is reached. As a result, the intended actionable message `base_url has an invalid port` is unreachable for non-numeric ports. Move hostname extraction inside the try/except or access `parsed.port` before `parsed.hostname` so the custom error is used. <!-- wuming:sha256:506f49855f93d8ba2b4845f91ff9a4ebfb404fc7eb425924855bcb6f92ef30df -->
@ -116,0 +191,4 @@
@test("invalid port (https://example.com:bad) raises ValueError")
def test_invalid_port_raises() -> None:
with tempfile.TemporaryDirectory() as tmp:
expect(lambda: main(Path(tmp), "https://example.com:bad")).to_raise(ValueError)

code [LOW]

This test only asserts that a ValueError is raised, not the actionable message. A regression that leaks urllib's lower-level Port could not be cast... message (instead of base_url has an invalid port) would still pass.

**code** [LOW] This test only asserts that a ValueError is raised, not the actionable message. A regression that leaks urllib's lower-level `Port could not be cast...` message (instead of `base_url has an invalid port`) would still pass. <!-- wuming:sha256:71c9cf1dfa1d9ac0b5afe44c8a1c617c97b2d0f97dea9abb6c6360d0c8292a9c -->
assert actionable message in invalid-port test
All checks were successful
/ gitleaks (pull_request) Successful in 18s
/ checks (pull_request) Successful in 1m39s
/ publish (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ pr-review (pull_request) Successful in 4m20s
e7cdd73c16
Author
Collaborator

WuMing review — 1 fixed, 1 declined

src/sub2pod/cli.py line 33

parsed.hostname can itself raise ValueError for malformed ports (e.g. https://example.com:bad) before the try around parsed.port is reached…

🔴 Declined — this is not correct. Verified empirically: urlparse("https://example.com:bad").hostname returns "example.com" without raising; only .port raises ValueError. The hostname extraction before the try is therefore safe, and the base_url has an invalid port message is reachable for non-numeric ports.

tests/test_cli.py line 194

This test only asserts that a ValueError is raised, not the actionable message…

Fixed in commit e7cdd73test_invalid_port_raises now asserts the message contains "invalid port".

### WuMing review — 1 fixed, 1 declined [`src/sub2pod/cli.py` line 33](https://forge.marvin8.zone/marvin8/sub2pod/pulls/33#issuecomment-1706) > `parsed.hostname` can itself raise `ValueError` for malformed ports (e.g. `https://example.com:bad`) before the `try` around `parsed.port` is reached… 🔴 Declined — this is not correct. Verified empirically: `urlparse("https://example.com:bad").hostname` returns `"example.com"` without raising; only `.port` raises `ValueError`. The `hostname` extraction before the `try` is therefore safe, and the `base_url has an invalid port` message is reachable for non-numeric ports. [`tests/test_cli.py` line 194](https://forge.marvin8.zone/marvin8/sub2pod/pulls/33#issuecomment-1707) > This test only asserts that a ValueError is raised, not the actionable message… ✅ Fixed in commit `e7cdd73` — `test_invalid_port_raises` now asserts the message contains "invalid port".
agent-pi force-pushed feat/issue-26-cli-validation from e7cdd73c16
All checks were successful
/ gitleaks (pull_request) Successful in 18s
/ checks (pull_request) Successful in 1m39s
/ publish (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ pr-review (pull_request) Successful in 4m20s
to a54ffe344b
All checks were successful
/ gitleaks (pull_request) Successful in 17s
/ checks (pull_request) Successful in 1m46s
/ publish (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ pr-review (pull_request) Successful in 4m12s
2026-08-19 21:50:33 +00:00
Compare
forgejo-actions left a comment

WuMing

Found 1 issue(s). See inline comments below.

## WuMing Found **1** issue(s). See inline comments below.
@ -116,0 +154,4 @@
with tempfile.TemporaryDirectory() as tmp:
expect(lambda: main(Path(tmp), "ftp://example.com")).to_raise(ValueError)
@test("valid https base_url is accepted")

code [LOW]

The validation tests cover https:// and uppercase HTTPS://, but there is no test that a lowercase http:// base_url is accepted. Since the code explicitly allows http, add a test to cover this branch.

**code** [LOW] The validation tests cover `https://` and uppercase `HTTPS://`, but there is no test that a lowercase `http://` base_url is accepted. Since the code explicitly allows `http`, add a test to cover this branch. <!-- wuming:sha256:fc95ce786def72eab44ef09b7c383d5ee9f0f7a8d44e7d1862d6244d81f10b46 -->
add coverage for lowercase http:// base_url
All checks were successful
/ gitleaks (pull_request) Successful in 17s
/ checks (pull_request) Successful in 1m39s
/ publish (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ pr-review (pull_request) Successful in 3m44s
a2445c58e3
Author
Collaborator

tests/test_cli.py line 157

The validation tests cover https:// and uppercase HTTPS://, but there is no test that a lowercase http:// base_url is accepted…

Fixed in commit a2445c5 — added test_valid_http_base_url_accepted covering a lowercase http://pod.example.com.

[`tests/test_cli.py` line 157](https://forge.marvin8.zone/marvin8/sub2pod/pulls/33#issuecomment-1715) > The validation tests cover `https://` and uppercase `HTTPS://`, but there is no test that a lowercase `http://` base_url is accepted… ✅ Fixed in commit `a2445c5` — added `test_valid_http_base_url_accepted` covering a lowercase `http://pod.example.com`.
marvin8 approved these changes 2026-08-19 23:07:27 +00:00
marvin8 manually merged commit e921d501ce into main 2026-08-19 23:07:57 +00:00
marvin8 deleted branch feat/issue-26-cli-validation 2026-08-19 23:11:38 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
marvin8/sub2pod!33
No description provided.