From 4c2c02efd582cb2759e5947797e9ad132a2f94a0 Mon Sep 17 00:00:00 2001 From: jackwener Date: Tue, 10 Mar 2026 21:18:38 +0800 Subject: [PATCH] feat: unify structured error output --- tests/test_cli.py | 16 ++++++++++++++++ twitter_cli/cli.py | 3 +++ twitter_cli/output.py | 27 +++++++++++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8b00b78..46f4bd0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -55,6 +55,22 @@ def test_cli_commands_wrap_client_creation_errors(monkeypatch, args) -> None: assert type(result.exception).__name__ == "SystemExit" +def test_cli_user_error_yaml(monkeypatch) -> None: + monkeypatch.setenv("OUTPUT", "auto") + monkeypatch.setattr( + "twitter_cli.cli._get_client", + lambda config=None: (_ for _ in ()).throw(RuntimeError("User not found")), + ) + runner = CliRunner() + + result = runner.invoke(cli, ["user", "alice", "--yaml"]) + + assert result.exit_code == 1 + payload = yaml.safe_load(result.output) + assert payload["ok"] is False + assert payload["error"]["code"] == "api_error" + + def test_cli_tweet_accepts_shared_url_with_query(monkeypatch) -> None: class FakeClient: def fetch_tweet_detail(self, tweet_id: str, max_count: int): diff --git a/twitter_cli/cli.py b/twitter_cli/cli.py index 9a839a5..e455c41 100644 --- a/twitter_cli/cli.py +++ b/twitter_cli/cli.py @@ -52,6 +52,7 @@ from .formatter import ( from .models import UserProfile from .output import ( default_structured_format, + emit_error, emit_structured, error_payload, structured_output_options, @@ -144,6 +145,8 @@ def _get_client_for_output(config=None, quiet=False): def _exit_with_error(exc): # type: (RuntimeError) -> None + if emit_error("api_error", str(exc)): + sys.exit(1) console.print("[red]❌ %s[/red]" % exc) sys.exit(1) diff --git a/twitter_cli/output.py b/twitter_cli/output.py index b43c08e..2a1d51e 100644 --- a/twitter_cli/output.py +++ b/twitter_cli/output.py @@ -99,3 +99,30 @@ def _normalize_success_payload(data: Any) -> Any: if isinstance(data, dict) and data.get("schema_version") == _SCHEMA_VERSION and "ok" in data: return data return success_payload(data) + + +def emit_error( + code: str, + message: str, + *, + as_json: bool | None = None, + as_yaml: bool | None = None, + details: Any | None = None, +) -> bool: + """Emit a structured error when the active output mode is machine-readable.""" + if as_json is None or as_yaml is None: + ctx = click.get_current_context(silent=True) + params = ctx.params if ctx is not None else {} + as_json = bool(params.get("as_json", False)) if as_json is None else as_json + as_yaml = bool(params.get("as_yaml", False)) if as_yaml is None else as_yaml + + fmt = default_structured_format(as_json=bool(as_json), as_yaml=bool(as_yaml)) + if fmt is None: + return False + + payload = error_payload(code, message, details=details) + if fmt == "json": + click.echo(json.dumps(payload, ensure_ascii=False, indent=2)) + else: + click.echo(yaml.safe_dump(payload, allow_unicode=True, sort_keys=False, default_flow_style=False)) + return True