refactor: unify exception handling, add ISO 8601 time, dedup commands, expand tests

- Replace _error_code_for_message() string matching with error_code attribute on exception classes
- Add error_code to all TwitterError subclasses (AuthenticationError, RateLimitError, etc.)
- Add InvalidInputError exception class
- TwitterAPIError derives error_code from HTTP status code automatically
- auth.py: use AuthenticationError instead of RuntimeError
- cli.py: catch (TwitterError, RuntimeError) for backward compat
- Extract _fetch_and_display_users() to deduplicate followers/following commands
- Add format_iso8601() to timeutil.py
- Add createdAtISO field to tweet and user profile serialization
- New test files: test_output.py, test_cache.py, test_timeutil.py
- Expand test_filter.py (topN, score mode, custom weights, empty input)
- Tests: 152 → 194 unit tests, all passing
This commit is contained in:
jackwener
2026-03-16 18:24:35 +08:00
parent 0b91e66998
commit e496d8f870
11 changed files with 482 additions and 73 deletions

101
tests/test_cache.py Normal file
View File

@@ -0,0 +1,101 @@
"""Tests for twitter_cli.cache module."""
from __future__ import annotations
import json
import time
from twitter_cli.cache import resolve_cached_tweet, save_tweet_cache
from twitter_cli.models import Author, Metrics, Tweet
def _make_tweet(tweet_id: str, text: str = "hello") -> Tweet:
return Tweet(
id=tweet_id,
text=text,
author=Author(id="u1", name="Alice", screen_name="alice"),
metrics=Metrics(likes=1),
created_at="2025-01-01",
)
class TestSaveAndResolve:
"""save_tweet_cache → resolve_cached_tweet round-trip."""
def test_round_trip(self, tmp_path, monkeypatch) -> None:
cache_file = tmp_path / "last_results.json"
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
monkeypatch.setattr("twitter_cli.cache._CACHE_DIR", tmp_path)
tweets = [_make_tweet("100"), _make_tweet("200"), _make_tweet("300")]
save_tweet_cache(tweets)
assert cache_file.exists()
tweet_id, size = resolve_cached_tweet(1)
assert tweet_id == "100"
assert size == 3
tweet_id, size = resolve_cached_tweet(3)
assert tweet_id == "300"
assert size == 3
def test_out_of_range_returns_none(self, tmp_path, monkeypatch) -> None:
cache_file = tmp_path / "last_results.json"
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
monkeypatch.setattr("twitter_cli.cache._CACHE_DIR", tmp_path)
save_tweet_cache([_make_tweet("100")])
tweet_id, size = resolve_cached_tweet(99)
assert tweet_id is None
assert size == 1
class TestCacheExpiry:
"""TTL expiration behavior."""
def test_expired_cache_returns_none(self, tmp_path, monkeypatch) -> None:
cache_file = tmp_path / "last_results.json"
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
# Write cache with old timestamp
payload = {
"created_at": time.time() - 7200, # 2 hours ago
"tweets": [{"index": 1, "id": "100", "author": "alice", "text": "hi"}],
}
cache_file.write_text(json.dumps(payload), encoding="utf-8")
tweet_id, size = resolve_cached_tweet(1)
assert tweet_id is None
assert size == 0
class TestCacheEdgeCases:
"""Corrupted and missing cache files."""
def test_missing_file(self, tmp_path, monkeypatch) -> None:
cache_file = tmp_path / "does_not_exist.json"
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
tweet_id, size = resolve_cached_tweet(1)
assert tweet_id is None
assert size == 0
def test_corrupted_json(self, tmp_path, monkeypatch) -> None:
cache_file = tmp_path / "last_results.json"
cache_file.write_text("{{invalid json", encoding="utf-8")
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
tweet_id, size = resolve_cached_tweet(1)
assert tweet_id is None
assert size == 0
def test_wrong_structure(self, tmp_path, monkeypatch) -> None:
cache_file = tmp_path / "last_results.json"
cache_file.write_text('"just a string"', encoding="utf-8")
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
tweet_id, size = resolve_cached_tweet(1)
assert tweet_id is None
assert size == 0

View File

@@ -104,10 +104,11 @@ def test_cli_commands_wrap_client_creation_errors(monkeypatch, args) -> None:
def test_cli_user_error_yaml(monkeypatch) -> None: def test_cli_user_error_yaml(monkeypatch) -> None:
from twitter_cli.exceptions import NotFoundError
monkeypatch.setenv("OUTPUT", "auto") monkeypatch.setenv("OUTPUT", "auto")
monkeypatch.setattr( monkeypatch.setattr(
"twitter_cli.cli._get_client", "twitter_cli.cli._get_client",
lambda config=None, quiet=False: (_ for _ in ()).throw(RuntimeError("User not found")), lambda config=None, quiet=False: (_ for _ in ()).throw(NotFoundError("User not found")),
) )
runner = CliRunner() runner = CliRunner()

View File

@@ -1,6 +1,8 @@
"""Tests for twitter_cli.filter module."""
from __future__ import annotations from __future__ import annotations
from twitter_cli.filter import filter_tweets from twitter_cli.filter import filter_tweets, score_tweet
def test_filter_tweets_does_not_mutate_input(tweet_factory) -> None: def test_filter_tweets_does_not_mutate_input(tweet_factory) -> None:
@@ -29,3 +31,58 @@ def test_filter_tweets_applies_language_and_retweet_filters(tweet_factory) -> No
) )
assert [tweet.id for tweet in output] == ["1"] assert [tweet.id for tweet in output] == ["1"]
def test_filter_topn_mode(tweet_factory) -> None:
tweets = [tweet_factory(str(i)) for i in range(10)]
output = filter_tweets(tweets, {"mode": "topN", "topN": 3})
assert len(output) == 3
def test_filter_topn_default(tweet_factory) -> None:
"""Default topN is 20, so 5 tweets should all be returned."""
tweets = [tweet_factory(str(i)) for i in range(5)]
output = filter_tweets(tweets, {"mode": "topN"})
assert len(output) == 5
def test_filter_score_mode(tweet_factory) -> None:
from twitter_cli.models import Metrics
tweets = [
tweet_factory("high", metrics=Metrics(likes=1000, retweets=500, replies=200, views=100000, bookmarks=50)),
tweet_factory("low", metrics=Metrics(likes=0, retweets=0, replies=0, views=1, bookmarks=0)),
]
output = filter_tweets(tweets, {"mode": "score", "minScore": 100.0})
assert len(output) == 1
assert output[0].id == "high"
def test_filter_empty_input() -> None:
output = filter_tweets([], {"mode": "all"})
assert output == []
def test_score_tweet_basic(tweet_factory) -> None:
tweet = tweet_factory("1")
score = score_tweet(tweet)
assert isinstance(score, float)
assert score > 0
def test_score_tweet_custom_weights(tweet_factory) -> None:
tweet = tweet_factory("1")
weights = {"likes": 0, "retweets": 0, "replies": 0, "bookmarks": 0, "views_log": 0}
assert score_tweet(tweet, weights) == 0.0
def test_filter_all_mode_sorts_by_score(tweet_factory) -> None:
from twitter_cli.models import Metrics
tweets = [
tweet_factory("low", metrics=Metrics(likes=1)),
tweet_factory("high", metrics=Metrics(likes=100)),
]
output = filter_tweets(tweets, {"mode": "all"})
assert output[0].id == "high"
assert output[1].id == "low"

147
tests/test_output.py Normal file
View File

@@ -0,0 +1,147 @@
"""Tests for twitter_cli.output module."""
from __future__ import annotations
import json
import click
import pytest
import yaml
from twitter_cli.output import (
default_structured_format,
emit_structured,
ensure_utf8_streams,
error_payload,
success_payload,
use_rich_output,
)
# ── ensure_utf8_streams ─────────────────────────────────────────────────
def test_ensure_utf8_streams_no_error() -> None:
"""ensure_utf8_streams should not raise on any platform."""
ensure_utf8_streams()
# ── success_payload / error_payload ──────────────────────────────────────
def test_success_payload_structure() -> None:
payload = success_payload({"key": "value"})
assert payload["ok"] is True
assert payload["schema_version"] == "1"
assert payload["data"] == {"key": "value"}
def test_success_payload_with_list() -> None:
payload = success_payload([1, 2, 3])
assert payload["ok"] is True
assert payload["data"] == [1, 2, 3]
def test_error_payload_structure() -> None:
payload = error_payload("not_found", "User not found")
assert payload["ok"] is False
assert payload["schema_version"] == "1"
assert payload["error"]["code"] == "not_found"
assert payload["error"]["message"] == "User not found"
assert "details" not in payload["error"]
def test_error_payload_with_details() -> None:
payload = error_payload("api_error", "oops", details={"id": "123"})
assert payload["error"]["details"] == {"id": "123"}
# ── default_structured_format ────────────────────────────────────────────
def test_format_json_flag() -> None:
assert default_structured_format(as_json=True, as_yaml=False) == "json"
def test_format_yaml_flag() -> None:
assert default_structured_format(as_json=False, as_yaml=True) == "yaml"
def test_format_both_flags_raises() -> None:
with pytest.raises(click.UsageError):
default_structured_format(as_json=True, as_yaml=True)
def test_format_env_yaml(monkeypatch) -> None:
monkeypatch.setenv("OUTPUT", "yaml")
assert default_structured_format(as_json=False, as_yaml=False) == "yaml"
def test_format_env_json(monkeypatch) -> None:
monkeypatch.setenv("OUTPUT", "json")
assert default_structured_format(as_json=False, as_yaml=False) == "json"
def test_format_env_rich(monkeypatch) -> None:
monkeypatch.setenv("OUTPUT", "rich")
assert default_structured_format(as_json=False, as_yaml=False) is None
def test_format_auto_non_tty(monkeypatch) -> None:
monkeypatch.setenv("OUTPUT", "auto")
monkeypatch.setattr("sys.stdout", type("FakeStdout", (), {"isatty": lambda self: False})())
assert default_structured_format(as_json=False, as_yaml=False) == "yaml"
# ── use_rich_output ──────────────────────────────────────────────────────
def test_use_rich_when_no_structured(monkeypatch) -> None:
monkeypatch.setenv("OUTPUT", "rich")
assert use_rich_output(as_json=False, as_yaml=False) is True
def test_no_rich_when_json() -> None:
assert use_rich_output(as_json=True, as_yaml=False) is False
def test_no_rich_when_compact(monkeypatch) -> None:
monkeypatch.setenv("OUTPUT", "rich")
assert use_rich_output(as_json=False, as_yaml=False, compact=True) is False
# ── emit_structured ──────────────────────────────────────────────────────
def test_emit_structured_json(capsys) -> None:
result = emit_structured({"key": "val"}, as_json=True, as_yaml=False)
assert result is True
captured = capsys.readouterr()
parsed = json.loads(captured.out)
assert parsed["ok"] is True
assert parsed["data"]["key"] == "val"
def test_emit_structured_yaml(capsys) -> None:
result = emit_structured({"key": "val"}, as_json=False, as_yaml=True)
assert result is True
captured = capsys.readouterr()
parsed = yaml.safe_load(captured.out)
assert parsed["ok"] is True
assert parsed["data"]["key"] == "val"
def test_emit_structured_returns_false_when_rich(monkeypatch) -> None:
monkeypatch.setenv("OUTPUT", "rich")
result = emit_structured({"key": "val"}, as_json=False, as_yaml=False)
assert result is False
def test_emit_structured_wraps_already_wrapped_payload(capsys) -> None:
"""If data is already in the agent schema, it should not be double-wrapped."""
already_wrapped = {"ok": True, "schema_version": "1", "data": "hello"}
emit_structured(already_wrapped, as_json=True, as_yaml=False)
captured = capsys.readouterr()
parsed = json.loads(captured.out)
assert parsed["data"] == "hello"
assert "data" not in str(parsed.get("data", {}) if isinstance(parsed.get("data"), dict) else "")

73
tests/test_timeutil.py Normal file
View File

@@ -0,0 +1,73 @@
"""Tests for twitter_cli.timeutil module."""
from __future__ import annotations
from twitter_cli.timeutil import format_iso8601, format_local_time, format_relative_time
SAMPLE_TIMESTAMP = "Sat Mar 08 12:00:00 +0000 2026"
# ── format_local_time ────────────────────────────────────────────────────
def test_format_local_time_valid() -> None:
result = format_local_time(SAMPLE_TIMESTAMP)
# Should be in YYYY-MM-DD HH:MM format (local timezone)
assert result.startswith("2026-03-")
assert ":" in result
def test_format_local_time_empty() -> None:
assert format_local_time("") == ""
def test_format_local_time_invalid() -> None:
assert format_local_time("not a date") == "not a date"
# ── format_relative_time ─────────────────────────────────────────────────
def test_format_relative_time_old() -> None:
# A timestamp from 2020 should show years ago
old_ts = "Sat Jan 01 00:00:00 +0000 2020"
result = format_relative_time(old_ts)
assert result.endswith("ago")
assert "y" in result or "mo" in result or "d" in result
def test_format_relative_time_empty() -> None:
assert format_relative_time("") == ""
def test_format_relative_time_invalid() -> None:
assert format_relative_time("garbage") == "garbage"
# ── format_iso8601 ───────────────────────────────────────────────────────
def test_format_iso8601_valid() -> None:
result = format_iso8601(SAMPLE_TIMESTAMP)
assert result.startswith("2026-03-08T12:00:00")
assert "+00:00" in result or "Z" in result
def test_format_iso8601_empty() -> None:
assert format_iso8601("") == ""
def test_format_iso8601_invalid() -> None:
assert format_iso8601("not a date") == "not a date"
def test_format_iso8601_roundtrip() -> None:
"""ISO 8601 output should be parseable by datetime.fromisoformat."""
from datetime import datetime
result = format_iso8601(SAMPLE_TIMESTAMP)
dt = datetime.fromisoformat(result)
assert dt.year == 2026
assert dt.month == 3
assert dt.day == 8

View File

@@ -19,6 +19,7 @@ import sys
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from .constants import BEARER_TOKEN, get_user_agent from .constants import BEARER_TOKEN, get_user_agent
from .exceptions import AuthenticationError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -136,7 +137,7 @@ def verify_cookies(auth_token: str, ct0: str, cookie_string: Optional[str] = Non
try: try:
resp = session.get(url, headers=headers, timeout=5) resp = session.get(url, headers=headers, timeout=5)
if resp.status_code in (401, 403): if resp.status_code in (401, 403):
raise RuntimeError( raise AuthenticationError(
"Cookie expired or invalid (HTTP %d). Please re-login to x.com in your browser." % resp.status_code "Cookie expired or invalid (HTTP %d). Please re-login to x.com in your browser." % resp.status_code
) )
if resp.status_code == 200: if resp.status_code == 200:
@@ -616,7 +617,7 @@ def get_cookies() -> Dict[str, str]:
lines.append("Option 2: Make sure you are logged into x.com in your browser (Arc/Chrome/Edge/Firefox/Brave)") lines.append("Option 2: Make sure you are logged into x.com in your browser (Arc/Chrome/Edge/Firefox/Brave)")
lines.append("") lines.append("")
lines.append("Run 'twitter -v <command>' for debug diagnostics.") lines.append("Run 'twitter -v <command>' for debug diagnostics.")
raise RuntimeError("\n".join(lines)) raise AuthenticationError("\n".join(lines))
# Verify only for explicit auth failures; transient endpoint issues are tolerated. # Verify only for explicit auth failures; transient endpoint issues are tolerated.
try: try:

View File

@@ -44,6 +44,7 @@ from rich.console import Console
from . import __version__ from . import __version__
from .auth import get_cookies from .auth import get_cookies
from .cache import resolve_cached_tweet, save_tweet_cache from .cache import resolve_cached_tweet, save_tweet_cache
from .exceptions import TwitterError
from .client import TwitterClient from .client import TwitterClient
from .config import load_config from .config import load_config
from .filter import filter_tweets from .filter import filter_tweets
@@ -156,9 +157,13 @@ def _get_client(config=None, quiet=False):
def _exit_with_error(exc): def _error_code_from_exc(exc: Exception) -> str:
# type: (RuntimeError) -> None """Extract structured error code from an exception."""
if emit_error(_error_code_for_message(str(exc)), str(exc)): return getattr(exc, "error_code", "api_error")
def _exit_with_error(exc: Exception) -> None:
if emit_error(_error_code_from_exc(exc), str(exc)):
sys.exit(1) sys.exit(1)
console.print("[red]❌ %s[/red]" % exc) console.print("[red]❌ %s[/red]" % exc)
sys.exit(1) sys.exit(1)
@@ -168,24 +173,10 @@ def _run_guarded(action):
# type: (Callable[[], Any]) -> Any # type: (Callable[[], Any]) -> Any
try: try:
return action() return action()
except RuntimeError as exc: except (TwitterError, RuntimeError) as exc:
_exit_with_error(exc) _exit_with_error(exc)
def _error_code_for_message(message):
# type: (str) -> str
lowered = message.lower()
if "cookie expired" in lowered or "no twitter cookies found" in lowered or "invalid cookie" in lowered:
return "not_authenticated"
if "rate limited" in lowered or "http 429" in lowered:
return "rate_limited"
if "invalid tweet" in lowered or "required" in lowered or "--max must" in lowered:
return "invalid_input"
if "not found" in lowered:
return "not_found"
return "api_error"
def _resolve_fetch_count(max_count, configured): def _resolve_fetch_count(max_count, configured):
# type: (Optional[int], int) -> int # type: (Optional[int], int) -> int
"""Resolve fetch count with bounds checks.""" """Resolve fetch count with bounds checks."""
@@ -258,13 +249,13 @@ def _print_lines(lines: List[str], mode: StructuredMode) -> None:
def _handle_structured_runtime_error( def _handle_structured_runtime_error(
exc: RuntimeError, exc: Exception,
*, *,
mode: StructuredMode, mode: StructuredMode,
details: Optional[Dict[str, Any]] = None, details: Optional[Dict[str, Any]] = None,
) -> None: ) -> None:
if _emit_mode_payload( if _emit_mode_payload(
error_payload(_error_code_for_message(str(exc)), str(exc), details=details), error_payload(_error_code_from_exc(exc), str(exc), details=details),
mode, mode,
): ):
raise SystemExit(1) from None raise SystemExit(1) from None
@@ -285,7 +276,7 @@ def _run_write_command(
client = _get_client(load_config()) client = _get_client(load_config())
_print_lines(progress_lines or [], mode) _print_lines(progress_lines or [], mode)
payload = operation(client) payload = operation(client)
except RuntimeError as exc: except (TwitterError, RuntimeError) as exc:
_handle_structured_runtime_error(exc, mode=mode, details=error_details) _handle_structured_runtime_error(exc, mode=mode, details=error_details)
return None return None
@@ -325,7 +316,7 @@ def _fetch_and_display(fetch_fn, label, emoji, max_count, as_json, as_yaml, outp
elapsed = time.time() - start elapsed = time.time() - start
if rich_output: if rich_output:
console.print("✅ Fetched %d %s in %.1fs\n" % (len(tweets), label, elapsed)) console.print("✅ Fetched %d %s in %.1fs\n" % (len(tweets), label, elapsed))
except RuntimeError as exc: except (TwitterError, RuntimeError) as exc:
_exit_with_error(exc) _exit_with_error(exc)
filtered = _apply_filter(tweets, do_filter, config, rich_output=rich_output) filtered = _apply_filter(tweets, do_filter, config, rich_output=rich_output)
@@ -420,7 +411,7 @@ def feed(ctx, feed_type, max_count, as_json, as_yaml, input_file, output_file, d
elapsed = time.time() - start elapsed = time.time() - start
if rich_output: if rich_output:
console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed)) console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed))
except RuntimeError as exc: except (TwitterError, RuntimeError) as exc:
_exit_with_error(exc) _exit_with_error(exc)
filtered = _apply_filter(tweets, do_filter, config, rich_output=rich_output) filtered = _apply_filter(tweets, do_filter, config, rich_output=rich_output)
@@ -502,7 +493,7 @@ def user(screen_name, as_json, as_yaml):
if rich_output: if rich_output:
console.print("👤 Fetching user @%s..." % screen_name) console.print("👤 Fetching user @%s..." % screen_name)
profile = client.fetch_user(screen_name) profile = client.fetch_user(screen_name)
except RuntimeError as exc: except (TwitterError, RuntimeError) as exc:
_exit_with_error(exc) _exit_with_error(exc)
if not emit_structured(user_profile_to_dict(profile), as_json=as_json, as_yaml=as_yaml): if not emit_structured(user_profile_to_dict(profile), as_json=as_json, as_yaml=as_yaml):
@@ -693,7 +684,7 @@ def tweet(ctx, tweet_id, max_count, full_text, as_json, as_yaml):
elapsed = time.time() - start elapsed = time.time() - start
if rich_output: if rich_output:
console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed)) console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed))
except RuntimeError as exc: except (TwitterError, RuntimeError) as exc:
_exit_with_error(exc) _exit_with_error(exc)
_emit_tweet_detail(tweets, compact=compact, as_json=as_json, as_yaml=as_yaml, full_text=full_text) _emit_tweet_detail(tweets, compact=compact, as_json=as_json, as_yaml=as_yaml, full_text=full_text)
@@ -757,7 +748,7 @@ def show(ctx, index, max_count, full_text, output_file, as_json, as_yaml):
elapsed = time.time() - start elapsed = time.time() - start
if rich_output: if rich_output:
console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed)) console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed))
except RuntimeError as exc: except (TwitterError, RuntimeError) as exc:
_exit_with_error(exc) _exit_with_error(exc)
if output_file: if output_file:
@@ -795,7 +786,7 @@ def article(ctx, tweet_id, as_json, as_yaml, as_markdown, output_file):
elapsed = time.time() - start elapsed = time.time() - start
if rich_output: if rich_output:
console.print("✅ Fetched article in %.1fs\n" % elapsed) console.print("✅ Fetched article in %.1fs\n" % elapsed)
except RuntimeError as exc: except (TwitterError, RuntimeError) as exc:
_exit_with_error(exc) _exit_with_error(exc)
markdown = article_to_markdown(article_tweet) markdown = article_to_markdown(article_tweet)
@@ -836,13 +827,15 @@ def list_timeline(ctx, list_id, max_count, as_json, as_yaml, do_filter, full_tex
_run_guarded(_run) _run_guarded(_run)
@cli.command() def _fetch_and_display_users(
@click.argument("screen_name") screen_name: str,
@click.option("--max", "-n", "max_count", type=int, default=None, help="Max users to fetch.") fetch_fn_name: str,
@structured_output_options label: str,
def followers(screen_name, max_count, as_json, as_yaml): max_count: Optional[int],
# type: (str, int, bool, bool) -> None as_json: bool,
"""List followers of a user. SCREEN_NAME is the @handle (without @).""" as_yaml: bool,
) -> None:
"""Shared fetch-and-display logic for followers/following commands."""
screen_name = screen_name.lstrip("@") screen_name = screen_name.lstrip("@")
config = load_config() config = load_config()
try: try:
@@ -853,22 +846,32 @@ def followers(screen_name, max_count, as_json, as_yaml):
profile = client.fetch_user(screen_name) profile = client.fetch_user(screen_name)
fetch_count = _resolve_configured_count(config, max_count) fetch_count = _resolve_configured_count(config, max_count)
if rich_output: if rich_output:
console.print("👥 Fetching followers (%d)...\n" % fetch_count) console.print("👥 Fetching %s (%d)...\n" % (label, fetch_count))
start = time.time() start = time.time()
users = client.fetch_followers(profile.id, fetch_count) users = getattr(client, fetch_fn_name)(profile.id, fetch_count)
elapsed = time.time() - start elapsed = time.time() - start
if rich_output: if rich_output:
console.print("✅ Fetched %d followers in %.1fs\n" % (len(users), elapsed)) console.print("✅ Fetched %d %s in %.1fs\n" % (len(users), label, elapsed))
except RuntimeError as exc: except (TwitterError, RuntimeError) as exc:
_exit_with_error(exc) _exit_with_error(exc)
if emit_structured(users_to_data(users), as_json=as_json, as_yaml=as_yaml): if emit_structured(users_to_data(users), as_json=as_json, as_yaml=as_yaml):
return return
print_user_table(users, console, title="👥 @%s followers%d" % (screen_name, len(users))) print_user_table(users, console, title="👥 @%s %s%d" % (screen_name, label, len(users)))
console.print() console.print()
@cli.command()
@click.argument("screen_name")
@click.option("--max", "-n", "max_count", type=int, default=None, help="Max users to fetch.")
@structured_output_options
def followers(screen_name, max_count, as_json, as_yaml):
# type: (str, int, bool, bool) -> None
"""List followers of a user. SCREEN_NAME is the @handle (without @)."""
_fetch_and_display_users(screen_name, "fetch_followers", "followers", max_count, as_json, as_yaml)
@cli.command() @cli.command()
@click.argument("screen_name") @click.argument("screen_name")
@click.option("--max", "-n", "max_count", type=int, default=None, help="Max users to fetch.") @click.option("--max", "-n", "max_count", type=int, default=None, help="Max users to fetch.")
@@ -876,30 +879,7 @@ def followers(screen_name, max_count, as_json, as_yaml):
def following(screen_name, max_count, as_json, as_yaml): def following(screen_name, max_count, as_json, as_yaml):
# type: (str, int, bool, bool) -> None # type: (str, int, bool, bool) -> None
"""List accounts a user is following. SCREEN_NAME is the @handle (without @).""" """List accounts a user is following. SCREEN_NAME is the @handle (without @)."""
screen_name = screen_name.lstrip("@") _fetch_and_display_users(screen_name, "fetch_following", "following", max_count, as_json, as_yaml)
config = load_config()
try:
rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml)
client = _get_client(config, quiet=not rich_output)
if rich_output:
console.print("👤 Fetching @%s's profile..." % screen_name)
profile = client.fetch_user(screen_name)
fetch_count = _resolve_configured_count(config, max_count)
if rich_output:
console.print("👥 Fetching following (%d)...\n" % fetch_count)
start = time.time()
users = client.fetch_following(profile.id, fetch_count)
elapsed = time.time() - start
if rich_output:
console.print("✅ Fetched %d following in %.1fs\n" % (len(users), elapsed))
except RuntimeError as exc:
_exit_with_error(exc)
if emit_structured(users_to_data(users), as_json=as_json, as_yaml=as_yaml):
return
print_user_table(users, console, title="👥 @%s following — %d" % (screen_name, len(users)))
console.print()
# ── Write commands ────────────────────────────────────────────────────── # ── Write commands ──────────────────────────────────────────────────────
@@ -1055,8 +1035,8 @@ def status(as_json, as_yaml):
rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml) rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml)
client = _get_client(config, quiet=not rich_output) client = _get_client(config, quiet=not rich_output)
profile = client.fetch_me() profile = client.fetch_me()
except RuntimeError as exc: except (TwitterError, RuntimeError) as exc:
payload = error_payload("not_authenticated", str(exc)) payload = error_payload(_error_code_from_exc(exc), str(exc))
if emit_structured(payload, as_json=as_json, as_yaml=as_yaml): if emit_structured(payload, as_json=as_json, as_yaml=as_yaml):
sys.exit(1) sys.exit(1)
_exit_with_error(exc) _exit_with_error(exc)
@@ -1082,8 +1062,8 @@ def whoami(as_json, as_yaml):
if rich_output: if rich_output:
console.print("👤 Fetching current user...") console.print("👤 Fetching current user...")
profile = client.fetch_me() profile = client.fetch_me()
except RuntimeError as exc: except (TwitterError, RuntimeError) as exc:
if emit_structured(error_payload("not_authenticated", str(exc)), as_json=as_json, as_yaml=as_yaml): if emit_structured(error_payload(_error_code_from_exc(exc), str(exc)), as_json=as_json, as_yaml=as_yaml):
raise SystemExit(1) from None raise SystemExit(1) from None
_exit_with_error(exc) _exit_with_error(exc)

View File

@@ -0,0 +1,7 @@
"""CLI command sub-modules for twitter-cli.
Commands are split into three groups:
- read: feed, bookmarks, search, tweet, article, show, list, favorites
- write: post, reply, quote, delete, like/unlike, retweet/unretweet, bookmark/unbookmark
- user: user, user-posts, likes, followers, following, whoami, status, follow/unfollow
"""

View File

@@ -6,7 +6,7 @@ Provides a structured exception hierarchy for categorized error handling:
- Network errors - Network errors
- Query ID resolution failures - Query ID resolution failures
Modeled after bilibili-cli/xiaohongshu-cli exception patterns. Each exception carries an `error_code` attribute for structured output.
""" """
from __future__ import annotations from __future__ import annotations
@@ -15,30 +15,50 @@ from __future__ import annotations
class TwitterError(RuntimeError): class TwitterError(RuntimeError):
"""Base exception for twitter-cli errors.""" """Base exception for twitter-cli errors."""
error_code: str = "api_error"
class AuthenticationError(TwitterError): class AuthenticationError(TwitterError):
"""Raised when cookies are missing, expired, or invalid.""" """Raised when cookies are missing, expired, or invalid."""
error_code = "not_authenticated"
class RateLimitError(TwitterError): class RateLimitError(TwitterError):
"""Raised when Twitter rate limits the request (HTTP 429).""" """Raised when Twitter rate limits the request (HTTP 429)."""
error_code = "rate_limited"
class NotFoundError(TwitterError): class NotFoundError(TwitterError):
"""Raised when a user or tweet is not found.""" """Raised when a user or tweet is not found."""
error_code = "not_found"
class NetworkError(TwitterError): class NetworkError(TwitterError):
"""Raised when upstream network requests fail.""" """Raised when upstream network requests fail."""
error_code = "network_error"
class QueryIdError(TwitterError): class QueryIdError(TwitterError):
"""Raised when a GraphQL queryId cannot be resolved.""" """Raised when a GraphQL queryId cannot be resolved."""
error_code = "query_id_error"
class MediaUploadError(TwitterError): class MediaUploadError(TwitterError):
"""Raised when media upload fails (file not found, too large, unsupported format, API error).""" """Raised when media upload fails (file not found, too large, unsupported format, API error)."""
error_code = "media_upload_error"
class InvalidInputError(TwitterError):
"""Raised when user input is invalid (bad tweet ID, invalid options, etc.)."""
error_code = "invalid_input"
class TwitterAPIError(TwitterError): class TwitterAPIError(TwitterError):
"""Raised on non-OK Twitter API responses with HTTP status + message.""" """Raised on non-OK Twitter API responses with HTTP status + message."""
@@ -46,4 +66,13 @@ class TwitterAPIError(TwitterError):
def __init__(self, status_code: int, message: str): def __init__(self, status_code: int, message: str):
self.status_code = status_code self.status_code = status_code
self.message = message self.message = message
# Derive error_code from HTTP status
if status_code in (401, 403):
self.error_code = "not_authenticated"
elif status_code == 429:
self.error_code = "rate_limited"
elif status_code == 404:
self.error_code = "not_found"
else:
self.error_code = "api_error"
super().__init__("Twitter API error (HTTP %d): %s" % (status_code, message)) super().__init__("Twitter API error (HTTP %d): %s" % (status_code, message))

View File

@@ -6,7 +6,7 @@ import json
from typing import Any, Dict, Iterable, List, Optional from typing import Any, Dict, Iterable, List, Optional
from .models import Author, Metrics, Tweet, TweetMedia, UserProfile from .models import Author, Metrics, Tweet, TweetMedia, UserProfile
from .timeutil import format_local_time from .timeutil import format_iso8601, format_local_time
def tweet_to_dict(tweet: Tweet) -> Dict[str, Any]: def tweet_to_dict(tweet: Tweet) -> Dict[str, Any]:
@@ -31,6 +31,7 @@ def tweet_to_dict(tweet: Tweet) -> Dict[str, Any]:
}, },
"createdAt": tweet.created_at, "createdAt": tweet.created_at,
"createdAtLocal": format_local_time(tweet.created_at), "createdAtLocal": format_local_time(tweet.created_at),
"createdAtISO": format_iso8601(tweet.created_at),
"media": [ "media": [
{ {
"type": media.type, "type": media.type,
@@ -190,6 +191,7 @@ def user_profile_to_dict(user: UserProfile) -> Dict[str, Any]:
"verified": user.verified, "verified": user.verified,
"profileImageUrl": user.profile_image_url, "profileImageUrl": user.profile_image_url,
"createdAt": user.created_at, "createdAt": user.created_at,
"createdAtISO": format_iso8601(user.created_at),
} }

View File

@@ -69,3 +69,14 @@ def format_relative_time(created_at: str) -> str:
return "%dmo ago" % months return "%dmo ago" % months
years = days // 365 years = days // 365
return "%dy ago" % years return "%dy ago" % years
def format_iso8601(created_at: str) -> str:
"""Convert Twitter timestamp to ISO 8601 format.
Returns e.g. "2026-03-08T12:00:00+00:00" or the original string on failure.
"""
dt = _parse_twitter_time(created_at)
if dt is None:
return created_at
return dt.isoformat()