From 25d5a7b73a5420f3977068a2781cce4fc6c2e8c0 Mon Sep 17 00:00:00 2001 From: jackwener Date: Tue, 17 Mar 2026 17:57:42 +0800 Subject: [PATCH] review: fix bookmark folders CLI option handling --- tests/test_cli.py | 89 +++++++++++++++++++++++++++++++++++++++++++++- twitter_cli/cli.py | 50 +++++++++++++++++++++----- 2 files changed, 129 insertions(+), 10 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 1e7e928..8e00eb9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,7 +10,7 @@ import yaml from twitter_cli.cli import cli from twitter_cli.formatter import article_to_markdown, print_tweet_table -from twitter_cli.models import Author, Metrics, Tweet, UserProfile +from twitter_cli.models import Author, BookmarkFolder, Metrics, Tweet, UserProfile from twitter_cli.serialization import tweets_to_json @@ -254,6 +254,93 @@ def test_cli_bookmark_alias_works(monkeypatch) -> None: assert calls == ["123"] +def test_cli_bookmarks_folders_inherits_parent_options(monkeypatch) -> None: + calls = [] + + def fake_folder_timeline( + folder_id: str, + max_count: int | None, + since: str | None, + as_json: bool, + as_yaml: bool, + output_file: str | None, + do_filter: bool, + compact: bool, + full_text: bool, + ) -> None: + calls.append( + ( + folder_id, + max_count, + since, + as_json, + as_yaml, + output_file, + do_filter, + compact, + full_text, + ) + ) + + monkeypatch.setattr("twitter_cli.cli._run_bookmark_folder_timeline", fake_folder_timeline) + runner = CliRunner() + + result = runner.invoke( + cli, + ["bookmarks", "--json", "--full-text", "-n", "7", "-o", "root.json", "--filter", "folders", "123"], + ) + + assert result.exit_code == 0 + assert calls == [("123", 7, None, True, False, "root.json", True, False, True)] + + +def test_cli_bookmarks_folders_list_inherits_parent_output_options(monkeypatch) -> None: + calls = [] + + def fake_list_bookmark_folders( + as_json: bool, + as_yaml: bool, + compact: bool, + output_file: str | None, + ) -> None: + calls.append((as_json, as_yaml, compact, output_file)) + + monkeypatch.setattr("twitter_cli.cli._run_list_bookmark_folders", fake_list_bookmark_folders) + runner = CliRunner() + + result = runner.invoke(cli, ["bookmarks", "--json", "-o", "folders.json", "folders"]) + + assert result.exit_code == 0 + assert calls == [(True, False, False, "folders.json")] + + +def test_cli_bookmarks_folders_list_writes_output_file(monkeypatch, tmp_path) -> None: + class FakeClient: + def fetch_bookmark_folders(self) -> list[BookmarkFolder]: + return [BookmarkFolder(id="f1", name="Reading"), BookmarkFolder(id="f2", name="Research")] + + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient()) + monkeypatch.setattr("twitter_cli.cli.load_config", lambda: {}) + output_path = tmp_path / "folders.json" + runner = CliRunner() + + result = runner.invoke( + cli, + ["bookmarks", "folders", "--json", "--output", str(output_path)], + ) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["ok"] is True + assert payload["data"][0]["id"] == "f1" + + saved = json.loads(output_path.read_text(encoding="utf-8")) + assert saved == [ + {"id": "f1", "name": "Reading"}, + {"id": "f2", "name": "Research"}, + ] + + def test_cli_whoami_command(monkeypatch) -> None: from twitter_cli.models import UserProfile diff --git a/twitter_cli/cli.py b/twitter_cli/cli.py index 12b843b..928b88d 100644 --- a/twitter_cli/cli.py +++ b/twitter_cli/cli.py @@ -370,6 +370,25 @@ def _run_bookmarks_command(max_count, as_json, as_yaml, output_file, do_filter, _run_guarded(_run) +def _inherit_option(ctx, name, value): + # type: (click.Context, str, Any) -> Any + """Allow parent group options to flow into subcommands when omitted locally.""" + if value is not None: + return value + parent = getattr(ctx, "parent", None) + if parent is None: + return value + return parent.params.get(name) + + +def _inherit_flag(ctx, name, value): + # type: (click.Context, str, bool) -> bool + parent = getattr(ctx, "parent", None) + if parent is None: + return value + return bool(value or parent.params.get(name, False)) + + @cli.command() @click.option( "--type", @@ -489,9 +508,10 @@ def bookmarks(ctx, max_count, as_json, as_yaml, output_file, do_filter, full_tex @structured_output_options @click.option("--output", "-o", "output_file", type=str, default=None, help="Save tweets to JSON file.") @click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.") +@click.option("--full-text", is_flag=True, help="Show full tweet text in table output.") @click.pass_context -def bookmarks_folders(ctx, folder_id, max_count, since, as_json, as_yaml, output_file, do_filter): - # type: (Any, Optional[str], Optional[int], Optional[str], bool, bool, Optional[str], bool) -> None +def bookmarks_folders(ctx, folder_id, max_count, since, as_json, as_yaml, output_file, do_filter, full_text): + # type: (Any, Optional[str], Optional[int], Optional[str], bool, bool, Optional[str], bool, bool) -> None """List bookmark folders, or fetch tweets from a folder. \b @@ -502,17 +522,23 @@ def bookmarks_folders(ctx, folder_id, max_count, since, as_json, as_yaml, output twitter bookmarks folders --since 2026-01-01 """ compact = ctx.obj.get("compact", False) + max_count = _inherit_option(ctx, "max_count", max_count) + as_json = _inherit_flag(ctx, "as_json", as_json) + as_yaml = _inherit_flag(ctx, "as_yaml", as_yaml) + output_file = _inherit_option(ctx, "output_file", output_file) + do_filter = _inherit_flag(ctx, "do_filter", do_filter) + full_text = _inherit_flag(ctx, "full_text", full_text) if folder_id is None: - _run_list_bookmark_folders(as_json, as_yaml, compact) + _run_list_bookmark_folders(as_json, as_yaml, compact, output_file) else: _run_bookmark_folder_timeline( - folder_id, max_count, since, as_json, as_yaml, output_file, do_filter, compact, + folder_id, max_count, since, as_json, as_yaml, output_file, do_filter, compact, full_text, ) -def _run_list_bookmark_folders(as_json, as_yaml, compact): - # type: (bool, bool, bool) -> None +def _run_list_bookmark_folders(as_json, as_yaml, compact, output_file=None): + # type: (bool, bool, bool, Optional[str]) -> None config = load_config() rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml, compact=compact) @@ -527,6 +553,12 @@ def _run_list_bookmark_folders(as_json, as_yaml, compact): from .serialization import bookmark_folders_to_data data = bookmark_folders_to_data(folders) + if output_file: + import json as _json + Path(output_file).write_text(_json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + if rich_output: + console.print("💾 Saved to %s\n" % output_file) + if compact: import json as _json click.echo(_json.dumps(data, ensure_ascii=False, indent=2)) @@ -561,7 +593,6 @@ def _parse_since_date(since_str): def _filter_tweets_since(tweets, since_str): # type: (List[Tweet], str) -> List[Tweet] """Filter tweets to only those created after the given date.""" - from datetime import datetime, timezone from email.utils import parsedate_to_datetime cutoff = _parse_since_date(since_str) filtered = [] @@ -577,8 +608,8 @@ def _filter_tweets_since(tweets, since_str): return filtered -def _run_bookmark_folder_timeline(folder_id, max_count, since, as_json, as_yaml, output_file, do_filter, compact): - # type: (str, Optional[int], Optional[str], bool, bool, Optional[str], bool, bool) -> None +def _run_bookmark_folder_timeline(folder_id, max_count, since, as_json, as_yaml, output_file, do_filter, compact, full_text=False): + # type: (str, Optional[int], Optional[str], bool, bool, Optional[str], bool, bool, bool) -> None config = load_config() def _run(): @@ -601,6 +632,7 @@ def _run_bookmark_folder_timeline(folder_id, max_count, since, as_json, as_yaml, do_filter, config, compact=compact, + full_text=full_text, ) _run_guarded(_run)