diff --git a/parallel_web_tools/cli/commands.py b/parallel_web_tools/cli/commands.py index 2467ac2..3fdb195 100644 --- a/parallel_web_tools/cli/commands.py +++ b/parallel_web_tools/cli/commands.py @@ -880,18 +880,14 @@ def parse_bool(v: str) -> bool: # Search Command # ============================================================================= -# Beta -> V1 mode mapping. Beta had three modes; V1 has three (turbo/basic/ -# advanced). We keep the old values as accepted CLI inputs and translate them so -# existing scripts work. -_SEARCH_MODE_MAP = { +# V1 modes are listed first in CLI help. Beta modes remain accepted as +# deprecated aliases so existing scripts keep working. +_SEARCH_MODES = ("turbo", "basic", "advanced") +_DEPRECATED_SEARCH_MODE_ALIASES = { "fast": "basic", "one-shot": "basic", "agentic": "advanced", - "turbo": "turbo", - "basic": "basic", - "advanced": "advanced", } -_DEPRECATED_SEARCH_MODES = {"fast", "one-shot", "agentic"} def _emit_deprecation(message: str) -> None: @@ -926,7 +922,7 @@ def build_search_v1_kwargs( if objective: kwargs["objective"] = objective if mode: - kwargs["mode"] = _SEARCH_MODE_MAP.get(mode, mode) + kwargs["mode"] = _DEPRECATED_SEARCH_MODE_ALIASES.get(mode, mode) if excerpt_max_chars_total is not None: kwargs["max_chars_total"] = excerpt_max_chars_total if session_id: @@ -956,16 +952,19 @@ def build_search_v1_kwargs( @click.option("-q", "--query", multiple=True, help="Keyword search query (can be repeated)") @click.option( "--mode", - type=click.Choice(list(_SEARCH_MODE_MAP.keys())), + type=click.Choice([*_SEARCH_MODES, *_DEPRECATED_SEARCH_MODE_ALIASES]), default="basic", - help="Search mode: turbo (fastest), basic, or advanced (one-shot/fast → basic, agentic → advanced)", + help=( + "Search mode: turbo (fastest), basic, or advanced. " + "Deprecated aliases: one-shot/fast → basic, agentic → advanced" + ), show_default=True, ) @click.option("--max-results", type=int, help="Maximum results (defaults to server-side default of 10)") @click.option("--include-domains", multiple=True, help="Only search these domains (comma-separated or repeated)") @click.option("--exclude-domains", multiple=True, help="Exclude these domains (comma-separated or repeated)") -@click.option("--after-date", help="Only results after this date (YYYY-MM-DD)") -@click.option("--excerpt-max-chars-per-result", type=int, help="Max characters per result for excerpts (min 1000)") +@click.option("--after-date", help="Only results published on or after this date (YYYY-MM-DD)") +@click.option("--excerpt-max-chars-per-result", type=int, help="Max characters per result for excerpts") @click.option( "--excerpt-max-chars-total", type=int, default=60000, help="Max total characters for excerpts", show_default=True ) @@ -1012,12 +1011,9 @@ def search( if not objective and not query: raise click.UsageError("Provide an OBJECTIVE argument or at least one --query option.") - if mode in _DEPRECATED_SEARCH_MODES: - new_mode = _SEARCH_MODE_MAP[mode] - _emit_deprecation( - f"--mode {mode} is a Beta value and will stop working after the Beta API sunset (June 2026). " - f"Use --mode {new_mode} instead." - ) + if mode in _DEPRECATED_SEARCH_MODE_ALIASES: + new_mode = _DEPRECATED_SEARCH_MODE_ALIASES[mode] + _emit_deprecation(f"--mode {mode} is a deprecated alias. Use --mode {new_mode} instead.") source_policy: dict[str, Any] = {} if include_domains: @@ -1154,7 +1150,7 @@ def build_extract_v1_kwargs( @click.option("--full-content", is_flag=True, help="Include complete page content") @click.option("--full-content-max-chars", type=int, help="Max characters per result for full content") @click.option("--no-excerpts", is_flag=True, help="Strip excerpts from output (V1 always returns them server-side)") -@click.option("--excerpt-max-chars-per-result", type=int, help="Max characters per result for excerpts (min 1000)") +@click.option("--excerpt-max-chars-per-result", type=int, help="Max characters per result for excerpts") @click.option("--excerpt-max-chars-total", type=int, help="Max total characters for excerpts across all URLs") @click.option("--max-age-seconds", type=int, help="Max age in seconds before fetching live content (min 600)") @click.option("--timeout-seconds", type=float, help="Timeout in seconds for fetching live content") diff --git a/tests/test_cli.py b/tests/test_cli.py index 78e45d6..1f1ca71 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -358,6 +358,26 @@ def test_search_help_shows_comma_separated(self, runner): assert "--exclude-domains" in result.output assert "comma-separated" in result.output + def test_search_help_prioritizes_native_modes(self, runner): + """Should list V1-native modes before deprecated aliases.""" + result = runner.invoke(main, ["search", "--help"]) + assert result.exit_code == 0 + assert "--mode [turbo|basic|advanced|fast|one-shot|agentic]" in result.output + assert "Deprecated aliases" in result.output + + def test_search_help_describes_after_date_as_inclusive(self, runner): + """Should reflect the API's inclusive after_date behavior.""" + result = runner.invoke(main, ["search", "--help"]) + assert result.exit_code == 0 + assert "published on or after" in result.output + + def test_search_help_does_not_claim_excerpt_minimum(self, runner): + """Search accepts excerpt sizes below the removed 1000-character minimum.""" + result = runner.invoke(main, ["search", "--help"]) + assert result.exit_code == 0 + assert "--excerpt-max-chars-per-result" in result.output + assert "min 1000" not in result.output + def test_search_no_args(self, runner): """Should error without objective or query.""" result = runner.invoke(main, ["search"]) @@ -375,6 +395,13 @@ def test_extract_help(self, runner): assert "Extract content" in result.output assert "--json" in result.output + def test_extract_help_does_not_claim_excerpt_minimum(self, runner): + """Extract accepts excerpt sizes below the removed 1000-character minimum.""" + result = runner.invoke(main, ["extract", "--help"]) + assert result.exit_code == 0 + assert "--excerpt-max-chars-per-result" in result.output + assert "min 1000" not in result.output + class TestFetchCommand: """Tests for the fetch command (alias for extract).""" @@ -1635,16 +1662,16 @@ def test_deprecated_modes_emit_warning_to_stderr(self, runner, mock_cli_client, ) assert result.exit_code == 0 - assert "[deprecated]" in result.stderr - assert deprecated_mode in result.stderr - assert expected_new in result.stderr + assert result.stderr.strip() == ( + f"[deprecated] --mode {deprecated_mode} is a deprecated alias. Use --mode {expected_new} instead." + ) # JSON stdout must remain clean json.loads(result.stdout) # SDK call uses translated mode call_kwargs = mock_cli_client.search.call_args.kwargs assert call_kwargs["mode"] == expected_new - @pytest.mark.parametrize("new_mode", ["basic", "advanced"]) + @pytest.mark.parametrize("new_mode", ["turbo", "basic", "advanced"]) def test_new_modes_do_not_emit_warning(self, runner, mock_cli_client, new_mode): """Should not warn when V1-native mode values are used.""" self._setup_mock_search(mock_cli_client)