From c5aefbc349b2c80519fccdba8a0363e879783fde Mon Sep 17 00:00:00 2001 From: Jelmer de Wit <1598297+jdwit@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:32:43 +0200 Subject: [PATCH 1/2] fix: clean error messages for expected API and OAuth failures handle_api_error keeps re-raising unknown errors so command-specific handlers can add context. A new CLI boundary (main.cli) turns any HttpError/OAuth2Error that reaches the top into a single clean line, and _authenticate_local_server catches OAuth denial during login. Closes #38 --- pyproject.toml | 4 ++-- src/ytstudio/api.py | 29 +++++++++++++++--------- src/ytstudio/main.py | 19 +++++++++++++++- tests/test_api.py | 53 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 91 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 96ac01d..c9add0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,8 +61,8 @@ exclude_lines = [ ] [project.scripts] -ytstudio = "ytstudio.main:app" -yts = "ytstudio.main:app" +ytstudio = "ytstudio.main:cli" +yts = "ytstudio.main:cli" [project.urls] Homepage = "https://github.com/jdwit/ytstudio-cli" diff --git a/src/ytstudio/api.py b/src/ytstudio/api.py index 178be14..70a8675 100644 --- a/src/ytstudio/api.py +++ b/src/ytstudio/api.py @@ -7,6 +7,7 @@ from google_auth_oauthlib.flow import InstalledAppFlow from googleapiclient.discovery import build from googleapiclient.errors import HttpError +from oauthlib.oauth2 import AccessDeniedError, OAuth2Error from rich.prompt import Prompt from ytstudio.config import ( @@ -37,7 +38,7 @@ def handle_api_error(error: HttpError) -> None: console.print("[red]Access denied. You may not have permission for this action.[/red]") raise SystemExit(1) from None - # Re-raise for other errors + # Re-raise: command handlers or the CLI boundary (main.cli) clean it up. raise error @@ -168,6 +169,22 @@ def _authenticate_headless() -> Credentials: return flow.credentials +def _authenticate_local_server() -> Credentials: + flow = _create_flow() + try: + return flow.run_local_server(port=9876, prompt="consent", open_browser=True) + except AccessDeniedError: + console.print( + "[red]Authorization denied.[/red] " + "Approve the requested (read-only) scopes and try again." + ) + raise SystemExit(1) from None + except OAuth2Error as error: + message = getattr(error, "description", "") or str(error) + console.print(f"[red]Authorization failed: {message}[/red]") + raise SystemExit(1) from None + + def authenticate(headless: bool = False, profile: str | None = None) -> None: if not CLIENT_SECRETS_FILE.exists(): console.print("[red]No client secrets found. Run 'ytstudio init' first.[/red]") @@ -179,15 +196,7 @@ def authenticate(headless: bool = False, profile: str | None = None) -> None: console.print("[bold]Authenticating with YouTube...[/bold]\n") - if headless: - credentials = _authenticate_headless() - else: - flow = _create_flow() - credentials = flow.run_local_server( - port=9876, - prompt="consent", - open_browser=True, - ) + credentials = _authenticate_headless() if headless else _authenticate_local_server() _save_credentials(credentials, profile) _show_login_success(credentials, profile) diff --git a/src/ytstudio/main.py b/src/ytstudio/main.py index 6de2269..a2ed3b0 100644 --- a/src/ytstudio/main.py +++ b/src/ytstudio/main.py @@ -1,6 +1,8 @@ import atexit import typer +from googleapiclient.errors import HttpError +from oauthlib.oauth2 import OAuth2Error from rich.console import Console from ytstudio.api import authenticate, get_status @@ -87,5 +89,20 @@ def main( _update_state["registered"] = True +def cli(): + # Error boundary: turn expected API/OAuth failures into one clean line. + # Unexpected exceptions keep their traceback. + try: + app() + except HttpError as error: + message = error.reason or f"YouTube API request failed (HTTP {error.resp.status})." + console.print(f"[red]{message}[/red]") + raise SystemExit(1) from None + except OAuth2Error as error: + message = getattr(error, "description", "") or str(error) + console.print(f"[red]Authorization failed: {message}[/red]") + raise SystemExit(1) from None + + if __name__ == "__main__": - app() + cli() diff --git a/tests/test_api.py b/tests/test_api.py index 35f9883..1d14b7c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,12 +3,13 @@ import pytest from google.auth.exceptions import RefreshError from googleapiclient.errors import HttpError +from oauthlib.oauth2 import AccessDeniedError, OAuth2Error from typer import Exit from typer.testing import CliRunner from ytstudio import api as api_module from ytstudio.api import api, get_authenticated_service, handle_api_error -from ytstudio.main import app +from ytstudio.main import app, cli runner = CliRunner() @@ -42,6 +43,7 @@ def test_forbidden_exits(self): handle_api_error(error) def test_other_errors_reraise(self): + # Unknown errors re-raise so command handlers / the CLI boundary handle them. error = make_http_error(404) with pytest.raises(HttpError): @@ -178,6 +180,33 @@ def test_status_not_authenticated(self): assert "Not authenticated" in result.stdout +class TestAuthenticateLocalServer: + def test_access_denied_exits_cleanly(self, capsys): + flow = MagicMock() + flow.run_local_server.side_effect = AccessDeniedError() + + with ( + patch("ytstudio.api._create_flow", return_value=flow), + pytest.raises(SystemExit), + ): + api_module._authenticate_local_server() + + assert "Authorization denied" in capsys.readouterr().out + + def test_other_oauth_error_exits_cleanly(self, capsys): + flow = MagicMock() + flow.run_local_server.side_effect = OAuth2Error(description="boom") + + with ( + patch("ytstudio.api._create_flow", return_value=flow), + pytest.raises(SystemExit), + ): + api_module._authenticate_local_server() + + out = capsys.readouterr().out + assert "Authorization failed" in out and "boom" in out + + class TestHelpers: def test_create_flow_uses_client_secrets_and_scopes(self): with patch("ytstudio.api.InstalledAppFlow.from_client_secrets_file") as factory: @@ -251,6 +280,28 @@ def test_logout_clears_credentials(self): clear_credentials.assert_called_once() +class TestCliBoundary: + def test_unhandled_http_error_prints_clean_message(self, capsys): + resp = MagicMock() + resp.status = 400 + error = HttpError(resp, b'{"error": {"message": "Date range does not align."}}') + with patch("ytstudio.main.app", side_effect=error), pytest.raises(SystemExit): + cli() + + out = capsys.readouterr().out + assert "Date range does not align." in out + assert "Traceback" not in out + + def test_unhandled_oauth_error_prints_clean_message(self, capsys): + with ( + patch("ytstudio.main.app", side_effect=OAuth2Error(description="boom")), + pytest.raises(SystemExit), + ): + cli() + + assert "Authorization failed" in capsys.readouterr().out + + class TestAuthenticate: def test_normal_login_uses_local_server(self): credentials = MagicMock() From 2ff235a0e8af47cdef3c58e12a647e589baf2b3e Mon Sep 17 00:00:00 2001 From: Jelmer de Wit <1598297+jdwit@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:50:08 +0200 Subject: [PATCH 2/2] chore: exclude __main__ guard from coverage --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index c9add0e..389f4e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", "raise NotImplementedError", + "if __name__ == .__main__.:", ] [project.scripts]