diff --git a/tests/unit/test_cli_index.py b/tests/unit/test_cli_index.py index c425cd6a..2e25e9ea 100644 --- a/tests/unit/test_cli_index.py +++ b/tests/unit/test_cli_index.py @@ -1,10 +1,115 @@ import json +import re +import subprocess import sys import pytest from redisvl.cli.index import Index, _index_info_for_json +_INDEX_SUBCOMMANDS = ("info", "create", "delete", "destroy", "listall") + + +def _assert_index_help_contract(help_text: str) -> None: + """Assert that ``rvl index`` help lists every supported subcommand.""" + for name in _INDEX_SUBCOMMANDS: + # Each supported index subcommand appears on its own help line. + assert re.search(rf"^\s*{re.escape(name)}\s+", help_text, re.MULTILINE) + + +@pytest.mark.parametrize("argv", [["rvl", "index", "--help"], ["rvl", "index", "-h"]]) +def test_rvl_index_help(monkeypatch, capsys, argv: list[str]): + """Tests that ``rvl index --help`` and ``-h`` are discoverable. + + Expected behavior: ``SystemExit`` code is 0, stdout contains the documented + ``rvl index`` help contract, and stderr is empty. + """ + monkeypatch.setattr(sys, "argv", argv) + + with pytest.raises(SystemExit) as exc_info: + Index() + captured = capsys.readouterr() + + # Help requests terminate successfully. + assert exc_info.value.code == 0 + # Successful help output does not leak to stderr. + assert captured.err == "" + # stdout contains the documented ``rvl index`` help contract. + _assert_index_help_contract(captured.out) + + +@pytest.mark.parametrize("argv", [["rvl", "index"], ["rvl", "index", "--json"]]) +def test_rvl_index_no_subcommand(monkeypatch, capsys, argv: list[str]): + """Tests that ``rvl index`` without a subcommand fails with a usage error. + + Expected behavior: ``SystemExit`` code is 2, stdout is empty, and stderr + is non-empty. The ``--json`` case is parametrized so machine consumers + never see partial JSON when the subcommand is missing. + """ + monkeypatch.setattr(sys, "argv", argv) + + with pytest.raises(SystemExit) as exc_info: + Index() + captured = capsys.readouterr() + + # Argparse usage-error exit code for missing required positional. + assert exc_info.value.code == 2 + # No stdout output - critical so --json consumers do not see partial JSON either. + assert captured.out == "" + # Argparse emits a usage error on stderr + assert captured.err != "" + + +def test_rvl_index_unknown_subcommand(monkeypatch, capsys): + """Tests that ``rvl index `` reports the bad token via argparse. + + Expected behavior: ``SystemExit`` code is 2, stdout is empty, and stderr + lists every valid subcommands. + """ + monkeypatch.setattr(sys, "argv", ["rvl", "index", "notacommand"]) + + with pytest.raises(SystemExit) as exc_info: + Index() + captured = capsys.readouterr() + + # Documented usage-error exit for an unknown subcommand. + assert exc_info.value.code == 2 + # No normal output for the invalid subcommand path. + assert captured.out == "" + for name in _INDEX_SUBCOMMANDS: + # Stderr lists every valid subcommand. + assert name in captured.err + + +def test_rvl_index_subprocess_help(): + """End-to-end smoke test of ``rvl index --help`` via the runner module. + + Expected behavior: the subprocess exits 0, stdout matches the same help + contract as the in-process test, and stderr is empty - confirming the + installed entrypoint wires through to ``Index`` correctly. + """ + result = subprocess.run( + [sys.executable, "-m", "redisvl.cli.runner", "index", "--help"], + check=False, + capture_output=True, + text=True, + ) + # Process exited cleanly. + assert result.returncode == 0 + # Help is not emitted on stderr. + assert result.stderr == "" + # stdout includes the same help contract as the in-process help test. + _assert_index_help_contract(result.stdout) + + +def _raise(exc: BaseException): + """Return a zero-arg callable that raises ``exc``.""" + + def _do(): + raise exc + + return _do + class _FakeConn: def __init__(self, result, boom=False): @@ -12,102 +117,307 @@ def __init__(self, result, boom=False): self._boom = boom def execute_command(self, cmd): - assert cmd == "FT._LIST" # listall must query Redis with FT._LIST + # listall must query Redis with FT._LIST + assert cmd == "FT._LIST" if self._boom: raise RuntimeError("redis unavailable") return self._result -def test_listall_json(monkeypatch, capsys): - """Tests that ``listall --json`` prints machine-readable output only. +def _patch_redis_connection(monkeypatch, *, result=None, boom: bool = False) -> None: + """Patch ``RedisConnectionFactory.get_redis_connection`` to return a ``_FakeConn``. - Expected behavior: stdout is one JSON line with ``indices`` in order and no table text. + ``result`` is what ``execute_command("FT._LIST")`` returns on the success + path (defaults to ``[]``); ``boom=True`` makes ``execute_command`` raise a + ``RuntimeError`` instead, exercising the runtime-failure branch. """ - - def fake_get(*a, **k): - return _FakeConn([b"idx_a", b"idx_b"]) - + fake = _FakeConn([] if result is None else result, boom=boom) monkeypatch.setattr( - "redisvl.cli.index.RedisConnectionFactory.get_redis_connection", fake_get + "redisvl.cli.index.RedisConnectionFactory.get_redis_connection", + lambda *a, **k: fake, ) - monkeypatch.setattr(sys, "argv", ["rvl", "index", "listall", "--json"]) - Index() - out = capsys.readouterr().out.strip() - assert "Indices:" not in out # --json must not print the human banner - assert out.count("\n") == 0 # single machine-readable line, nothing else on stdout - payload = json.loads(out) - assert payload == { - "indices": ["idx_a", "idx_b"] - } # same order/encoding as table path would show -def test_listall_table(monkeypatch, capsys): - """Tests that default ``listall`` keeps the human-readable table output. +def _patch_search_index( + monkeypatch, + *, + create_behavior=None, + from_yaml_raises: BaseException | None = None, + index_name: str = "test-idx", +) -> None: + """Patch ``redisvl.cli.index.SearchIndex`` with a minimal fake for ``rvl index create`` tests. - Expected behavior: stdout matches header + numbered rows in FT._LIST order. + ``from_yaml_raises`` makes ``from_yaml`` raise that exception; ``create_behavior`` + is invoked inside ``FakeIndex.create()`` (use :func:`_raise` for failures); + ``index_name`` is surfaced via ``index.schema.index.name``. """ + if from_yaml_raises is not None: - def fake_get(*a, **k): - return _FakeConn([b"one", b"two"]) + class FakeSearchIndex: + @classmethod + def from_yaml(cls, *_args, **_kwargs): + raise from_yaml_raises - monkeypatch.setattr( - "redisvl.cli.index.RedisConnectionFactory.get_redis_connection", fake_get - ) - monkeypatch.setattr(sys, "argv", ["rvl", "index", "listall"]) - Index() - out = capsys.readouterr().out - lines = [ln.strip() for ln in out.strip().splitlines()] - assert lines == [ - "Indices:", - "1. one", - "2. two", - ] # exact table output: header then rows matching mock order and labels + else: + + class _FakeIndexInfo: + name = index_name + + class _FakeSchema: + index = _FakeIndexInfo() + + class FakeIndex: + schema = _FakeSchema() + + def create(self): + if create_behavior is not None: + create_behavior() + + class FakeSearchIndex: + @classmethod + def from_yaml(cls, *_args, **_kwargs): + return FakeIndex() + monkeypatch.setattr("redisvl.cli.index.SearchIndex", FakeSearchIndex) -def test_listall_json_empty(monkeypatch, capsys): - """Tests that ``listall --json`` handles an empty FT._LIST result. - Expected behavior: stdout is valid JSON with ``{"indices": []}``. +def _patch_search_index_for_info( + monkeypatch, + *, + info_behavior=None, + with_field_options: bool = False, + index_name: str = "test-idx", +) -> None: + """Patch ``redisvl.cli.index.SearchIndex`` with a minimal fake for ``rvl index info`` tests. + + By default builds a happy-path FakeIndex whose ``info()`` returns a fixed + FT.INFO payload (with an extra ``NOSTEM=1`` attribute when + ``with_field_options=True``). Pass ``info_behavior=_raise(SomeError())`` + to make ``info()`` raise instead. ``index_name`` is surfaced via + ``index.schema.index.name`` so ``exit_redis_search_error`` can format its + message verbatim. """ + if info_behavior is None: + attrs = [b"identifier", b"u", b"attribute", b"u", b"type", b"TAG"] + if with_field_options: + attrs.extend([b"NOSTEM", b"1"]) + payload = { + "index_name": b"test-idx", + "index_definition": ["key_type", b"HASH", "prefixes", [b"pre"]], + "attributes": [attrs], + } + + def info_behavior(): + return payload + + class _FakeIndexInfo: + name = index_name + + class _FakeSchema: + index = _FakeIndexInfo() + + class FakeIndex: + schema = _FakeSchema() + + def __init__(self, *a, **k): + # Absorb the ``schema=`` and ``redis_url=`` kwargs that + # ``_connect_to_index`` passes to ``SearchIndex(...)`` directly. + # Without this, Python's default ``__init__`` rejects them. + pass + + def info(self): + return info_behavior() + + monkeypatch.setattr("redisvl.cli.index.SearchIndex", FakeIndex) + - def fake_get(*a, **k): - return _FakeConn([]) +def test_create_missing_schema(monkeypatch, capsys): + """Tests that ``rvl index create`` without ``-s`` exits with a usage error. + + Expected behavior: ``SystemExit`` code is 2, stdout is empty, and stderr + is non-empty. + """ + monkeypatch.setattr(sys, "argv", ["rvl", "index", "create"]) + with pytest.raises(SystemExit) as exc_info: + Index() + captured = capsys.readouterr() + + # Documented usage-error exit when -s is not provided. + assert exc_info.value.code == 2 + # Nothing leaks on this error path. + assert captured.out == "" + # Some explanatory message reaches stderr. + assert captured.err != "" + + +def test_create_schema_input_error(monkeypatch, capsys): + """Tests that ``rvl index create`` reports schema-load failures on stderr. + + Expected behavior: ``SystemExit`` code is 2, stdout is empty, and stderr + is exactly the raised exception's message followed by a newline. + """ + schema_error_message = "schema file missing: /does/not/exist.yaml" + + _patch_search_index( + monkeypatch, from_yaml_raises=FileNotFoundError(schema_error_message) + ) monkeypatch.setattr( - "redisvl.cli.index.RedisConnectionFactory.get_redis_connection", fake_get + sys, "argv", ["rvl", "index", "create", "-s", "/does/not/exist.yaml"] ) - monkeypatch.setattr(sys, "argv", ["rvl", "index", "listall", "--json"]) - Index() - out = capsys.readouterr().out.strip() - assert json.loads(out) == {"indices": []} # empty array is a valid success payload + with pytest.raises(SystemExit) as exc_info: + Index() + captured = capsys.readouterr() + + # exit_schema_input_error uses exit code 2 when -s was provided. + assert exc_info.value.code == 2 + # Nothing on stdout when schema input fails. + assert captured.out == "" + # Exact stderr contract: exit_schema_input_error does print(str(exc), file=sys.stderr). + assert captured.err == f"{schema_error_message}\n" -def test_listall_json_error(monkeypatch, capsys): - """Tests that ``listall --json`` failure exits cleanly without stdout JSON. - Expected behavior: ``SystemExit`` code is 0 and stdout is empty. +def test_create_redis_search_error(monkeypatch, capsys): + """Tests that ``rvl index create`` reports Redis-side failures on stderr. + + Expected behavior: ``SystemExit`` code is 1, stdout is empty, and the + underlying error message reaches stderr. """ + from redisvl.exceptions import RedisSearchError - def fake_get(*a, **k): - return _FakeConn([], boom=True) + redis_error_message = "create failed" - monkeypatch.setattr( - "redisvl.cli.index.RedisConnectionFactory.get_redis_connection", fake_get + _patch_search_index( + monkeypatch, + create_behavior=_raise(RedisSearchError(redis_error_message)), + index_name="test-idx", ) + monkeypatch.setattr(sys, "argv", ["rvl", "index", "create", "-s", "fake.yaml"]) + + with pytest.raises(SystemExit) as exc_info: + Index() + captured = capsys.readouterr() + + # Documented Redis-failure exit code from exit_redis_search_error. + assert exc_info.value.code == 1 + # No partial output before the failure. + assert captured.out == "" + # The underlying error message reaches stderr so the user knows what failed. + assert redis_error_message in captured.err + + +@pytest.mark.parametrize( + "argv", + [ + ["rvl", "index", "create", "-s", "fake.yaml"], + ["rvl", "index", "create", "-s", "fake.yaml", "--json"], + ], +) +def test_create_success(monkeypatch, capsys, argv: list[str]): + """Tests that ``rvl index create`` succeeds with the documented banner. + + Expected behavior: no ``SystemExit`` is raised, stdout is exactly + ``Index created successfully\\n``, and stderr is empty. ``--json`` is + parametrized to confirm it does not invent a JSON contract for ``create``. + """ + _patch_search_index(monkeypatch) + monkeypatch.setattr(sys, "argv", argv) + + # Success path must return cleanly, not raise SystemExit. + Index() + captured = capsys.readouterr() + + # Exact stdout: the success banner with print()'s trailing newline and nothing else. + assert captured.out == "Index created successfully\n" + # Success path stays clean on stderr. + assert captured.err == "" + + +@pytest.mark.parametrize( + "ft_list_result, expected_payload", + [ + ([b"idx_a", b"idx_b"], {"indices": ["idx_a", "idx_b"]}), + ([], {"indices": []}), + ], +) +def test_listall_json(monkeypatch, capsys, ft_list_result, expected_payload): + """Tests that ``rvl index listall --json`` prints the documented JSON contract. + + Expected behavior: no ``SystemExit`` is raised, stdout is one JSON line + of the form ``{"indices": [...]}`` (no human banner), and stderr is empty. + Parametrized over a populated and an empty ``FT._LIST`` result. + """ + _patch_redis_connection(monkeypatch, result=ft_list_result) monkeypatch.setattr(sys, "argv", ["rvl", "index", "listall", "--json"]) - with pytest.raises( - SystemExit - ) as excinfo: # exit(0) in Index.__init__ is not a plain return + + try: + Index() + except SystemExit as exc: + # Success path must return cleanly, not call sys.exit. + pytest.fail(f"listall --json raised SystemExit({exc.code}) on the success path") + captured = capsys.readouterr() + out = captured.out.strip() + + # Single machine-readable line, nothing else on stdout. + assert out.count("\n") == 0 + # Stable top-level JSON contract: only the "indices" key, in FT._LIST order. + assert json.loads(out) == expected_payload + # JSON success path is silent on stderr. + assert captured.err == "" + + +def test_listall_table(monkeypatch, capsys): + """Tests that default ``rvl index listall`` prints the human-readable table. + + Expected behavior: stdout is exactly ``Indices:\\n1. one\\n2. two\\n`` + (header + 1-indexed rows in FT._LIST order), and stderr is empty. + """ + _patch_redis_connection(monkeypatch, result=[b"one", b"two"]) + monkeypatch.setattr(sys, "argv", ["rvl", "index", "listall"]) + Index() + captured = capsys.readouterr() + + # Exact stdout: header + numbered rows in FT._LIST order, each from a single print(). + assert captured.out == "Indices:\n1. one\n2. two\n" + # Table success is silent on stderr. + assert captured.err == "" + + +@pytest.mark.parametrize( + "argv", + [ + ["rvl", "index", "listall"], + ["rvl", "index", "listall", "--json"], + ], +) +def test_listall_runtime_error(monkeypatch, capsys, argv: list[str]): + """Tests that ``rvl index listall`` reports runtime failures on stderr. + + Expected behavior: ``SystemExit`` code is 1, stdout is empty (no + half-formed output), and stderr is non-empty. ``--json`` is parametrized + to confirm the contract is uniform. + """ + _patch_redis_connection(monkeypatch, boom=True) + monkeypatch.setattr(sys, "argv", argv) + + with pytest.raises(SystemExit) as exc_info: Index() - assert ( - capsys.readouterr().out == "" - ) # failure before cli_print_json — nothing on stdout + captured = capsys.readouterr() + + # Generic runtime failure exits 1 from __init__'s catch-all. + assert exc_info.value.code == 1 + # Failure happens before any rendering - nothing on stdout. + assert captured.out == "" + # The failure is surfaced on stderr. + assert captured.err != "" def test_info_json_normalize(): """Tests that ``_index_info_for_json`` maps FT.INFO lists to structured JSON. - Expected behavior: input is unchanged and output has ``index_information`` + ``index_fields``. + Expected behavior: the input dict is not mutated and the returned payload + is exactly the documented ``index_information`` + ``index_fields`` shape. """ raw = { "index_name": "test_index", @@ -130,7 +440,10 @@ def test_info_json_normalize(): } before = str(raw) out = _index_info_for_json(raw) - assert str(raw) == before # not mutated + + # Helper does not mutate its input. + assert str(raw) == before + # Exact summary + fields payload, matching what the table prints semantically. assert out == { "index_information": { "index_name": "test_index", @@ -146,13 +459,15 @@ def test_info_json_normalize(): "type": "TAG", } ], - } # exact summary+fields payload, matching what table prints semantically + } def test_info_json(monkeypatch, capsys): - """Tests that ``info --json`` returns normalized table-equivalent JSON. + """Tests that ``rvl index info --json`` prints the documented JSON contract. - Expected behavior: one parseable JSON line with decoded values and no table banners. + Expected behavior: no ``SystemExit`` is raised, stdout is one parseable + JSON line with the documented top-level sections (no table banners), + and stderr is empty. """ expected_index_information = { @@ -169,73 +484,138 @@ def test_info_json(monkeypatch, capsys): "field_options": {"NOSTEM": "1"}, } - class FakeIndex: - def __init__(self, *a, **k): - pass - - def info(self): - return { - "index_name": b"test-idx", - "index_definition": [ - "key_type", - b"HASH", - "prefixes", - [b"pre"], - ], - "attributes": [ - [ - b"identifier", - b"u", - b"attribute", - b"u", - b"type", - b"TAG", - b"NOSTEM", - b"1", - ], - ], - } - - monkeypatch.setattr("redisvl.cli.index.SearchIndex", FakeIndex) + _patch_search_index_for_info(monkeypatch, with_field_options=True) monkeypatch.setattr( sys, "argv", ["rvl", "index", "info", "-i", "test-idx", "--json"] ) - Index() - out = capsys.readouterr().out.strip() - assert out.count("\n") == 0 # single line for machine consumers + + try: + Index() + except SystemExit as exc: + # Success path must return cleanly, not call sys.exit. + pytest.fail(f"info --json raised SystemExit({exc.code}) on the success path") + captured = capsys.readouterr() + out = captured.out.strip() + + # Single line for machine consumers. + assert out.count("\n") == 0 payload = json.loads(out) - assert ( - "Index Information:" not in out and "Index Fields:" not in out - ) # --json must not emit table banner text - assert list(payload) == [ - "index_information", - "index_fields", - ] # top-level sections are stable and ordered - assert ( - payload["index_information"] == expected_index_information - ) # summary section matches table-derived values - assert payload["index_fields"] == [ - expected_field - ] # one normalized field row with options + # Top-level sections are stable and ordered. + assert list(payload) == ["index_information", "index_fields"] + # Summary section matches table-derived values. + assert payload["index_information"] == expected_index_information + # One normalized field row with options. + assert payload["index_fields"] == [expected_field] + # JSON success path is silent on stderr. + assert captured.err == "" + + +@pytest.mark.parametrize( + "argv", + [ + ["rvl", "index", "info", "-i", "test-idx"], + ["rvl", "index", "info", "-i", "test-idx", "--json"], + ], +) +def test_info_runtime_error(monkeypatch, capsys, argv: list[str]): + """Tests that ``rvl index info`` reports runtime failures on stderr. + + Expected behavior: ``SystemExit`` code is 1, stdout is empty (no + half-formed output), and stderr is non-empty. ``--json`` is parametrized + to confirm the contract is uniform. + """ + _patch_search_index_for_info( + monkeypatch, info_behavior=_raise(RuntimeError("boom")) + ) + monkeypatch.setattr(sys, "argv", argv) + with pytest.raises(SystemExit) as exc_info: + Index() + captured = capsys.readouterr() + + # Generic runtime failure exits 1 from __init__'s catch-all. + assert exc_info.value.code == 1 + # Failure happens before any rendering - nothing on stdout. + assert captured.out == "" + # The failure is surfaced on stderr. + assert captured.err != "" + + +@pytest.mark.parametrize( + "argv", + [ + ["rvl", "index", "info"], + ["rvl", "index", "info", "--json"], + ], +) +def test_info_no_target(monkeypatch, capsys, argv: list[str]): + """Tests that ``rvl index info`` without ``-i`` or ``-s`` exits with a usage error. + + Expected behavior: ``SystemExit`` code is 2, stdout is empty, and stderr + is non-empty. ``--json`` is parametrized to confirm no JSON contract is + invented for usage errors. + """ + monkeypatch.setattr(sys, "argv", argv) + + with pytest.raises(SystemExit) as exc_info: + Index() + captured = capsys.readouterr() -def test_info_json_error(monkeypatch, capsys): - """Tests that ``info --json`` errors do not emit partial stdout JSON. + # _connect_to_index uses argparse-style usage exit code + assert exc_info.value.code == 2 + # Usage errors must not pollute stdout, even with --json. + assert captured.out == "" + # Some explanatory message reaches stderr; exact wording is not part of the contract. + assert captured.err != "" - Expected behavior: command exits with code 0 and stdout is empty. + +def test_info_table(monkeypatch, capsys): + """Tests that default ``rvl index info`` runs to completion on the table path. + + Expected behavior: no ``SystemExit`` is raised, stdout is non-empty, and + stderr is empty. Exact rendering is a tabulate-library detail; data + correctness is pinned by ``test_info_json``. """ + _patch_search_index_for_info(monkeypatch) + monkeypatch.setattr(sys, "argv", ["rvl", "index", "info", "-i", "test-idx"]) - class BoomIndex: - def __init__(self, *a, **k): - pass + Index() + captured = capsys.readouterr() + + # Table path produces some output - the renderer ran and printed cells. + assert captured.out != "" + # Table success is silent on stderr. + assert captured.err == "" + + +@pytest.mark.parametrize( + "argv", + [ + ["rvl", "index", "info", "-i", "test-idx"], + ["rvl", "index", "info", "-i", "test-idx", "--json"], + ], +) +def test_info_missing_index(monkeypatch, capsys, argv: list[str]): + """Tests that ``rvl index info -i `` reports the failure on stderr. + + Expected behavior: ``SystemExit`` code is 1, stdout is empty, and the + underlying error message reaches stderr. ``--json`` is parametrized to + confirm the contract does not change. + """ + from redisvl.exceptions import RedisSearchError - def info(self): - raise RuntimeError("boom") + underlying_error = "Unknown index name" - monkeypatch.setattr("redisvl.cli.index.SearchIndex", BoomIndex) - monkeypatch.setattr( - sys, "argv", ["rvl", "index", "info", "-i", "test-idx", "--json"] + _patch_search_index_for_info( + monkeypatch, info_behavior=_raise(RedisSearchError(underlying_error)) ) - with pytest.raises(SystemExit) as excinfo: + monkeypatch.setattr(sys, "argv", argv) + + with pytest.raises(SystemExit) as exc_info: Index() - assert capsys.readouterr().out == "" # no partial JSON before the exception + captured = capsys.readouterr() + + # Documented exit code for a Redis search-side failure. + assert exc_info.value.code == 1 + # Nothing leaks on this error path. + assert captured.out == "" diff --git a/tests/unit/test_cli_main.py b/tests/unit/test_cli_main.py new file mode 100644 index 00000000..8359ab43 --- /dev/null +++ b/tests/unit/test_cli_main.py @@ -0,0 +1,75 @@ +import re +import subprocess +import sys + +import pytest + +from redisvl.cli.main import RedisVlCLI + +_COMMANDS = ("index", "mcp", "version", "stats") + + +def _assert_help_contract(help_text: str) -> None: + """Assert that ``rvl`` help lists every supported top-level command.""" + for name in _COMMANDS: + # Each supported top-level command appears on its own help line. + assert re.search(rf"^\s*{re.escape(name)}\s+", help_text, re.MULTILINE) + + +@pytest.mark.parametrize("argv", [["rvl"], ["rvl", "--help"], ["rvl", "-h"]]) +def test_rvl_help(monkeypatch, capsys, argv: list[str]): + """Help paths (`rvl`, `--help`, `-h`) exit 0 and print to stdout.""" + monkeypatch.setattr(sys, "argv", argv) + + with pytest.raises(SystemExit) as exc_info: + RedisVlCLI() + out = capsys.readouterr() + + # Help requests terminate successfully. + assert exc_info.value.code == 0 + + # Successful help output does not leak to stderr. + assert out.err == "" + + # stdout contains the expected top-level help contract. + _assert_help_contract(out.out) + + +def test_unknown_command(monkeypatch, capsys): + """Unknown commands exit 2, write error/help to stderr, and keep stdout empty.""" + monkeypatch.setattr(sys, "argv", ["rvl", "notacommand"]) + + with pytest.raises(SystemExit) as exc_info: + RedisVlCLI() + out = capsys.readouterr() + + # Unknown commands use the CLI usage-error exit code. + assert exc_info.value.code == 2 + + # stdout stays empty on this error path. + assert out.out == "" + + # stderr identifies the rejected command token. + assert "Unknown command: notacommand" in out.err + for name in _COMMANDS: + # stderr help still lists every valid top-level command. + assert name in out.err + + +def test_subprocess_module_help(): + """Run ``python -m redisvl.cli.runner --help`` and verify it exits 0 with help on stdout. + + Acts as an end-to-end check that the installed CLI entrypoint actually works. + """ + result = subprocess.run( + [sys.executable, "-m", "redisvl.cli.runner", "--help"], + check=False, + capture_output=True, + text=True, + ) + # Help subprocess exits successfully. + assert result.returncode == 0 + # No stderr output for help. + assert result.stderr == "" + # Stdout includes the same help contract as in-process tests. + _assert_help_contract(result.stdout) diff --git a/tests/unit/test_cli_stats.py b/tests/unit/test_cli_stats.py index ec769706..4d5a42a4 100644 --- a/tests/unit/test_cli_stats.py +++ b/tests/unit/test_cli_stats.py @@ -1,4 +1,5 @@ import json +import subprocess import sys import pytest @@ -6,105 +7,288 @@ from redisvl.cli.stats import STATS_KEYS, Stats, _stats_rows -def test_stats_rows_includes_all_stable_top_level_keys_in_order(): - """``_stats_rows({})`` returns the full ordered row list for an empty index info. +@pytest.mark.parametrize("argv", [["rvl", "stats", "--help"], ["rvl", "stats", "-h"]]) +def test_rvl_stats_help(monkeypatch, capsys, argv: list[str]): + """Tests that ``rvl stats --help`` and ``-h`` are discoverable. - Expected behavior: produces a complete, ``STATS_KEYS``-ordered set of rows - regardless of input; preserves value types at this layer; and represents - missing keys as ``None`` so JSON output remains machine-readable. + Expected behavior: ``SystemExit`` code is 0, stdout is non-empty, and + stderr is empty. """ - data = dict(_stats_rows({})) - assert list(data.keys()) == list(STATS_KEYS) # column order matches STATS_KEYS - assert all(data[k] is None for k in STATS_KEYS) # missing index_info keys -> None + monkeypatch.setattr(sys, "argv", argv) + with pytest.raises(SystemExit) as exc_info: + Stats() + captured = capsys.readouterr() -def test_stats_json_prints_only_json_to_stdout(monkeypatch, capsys): - """``rvl stats -i --json`` writes only a JSON object to stdout. + # Help requests terminate successfully. + assert exc_info.value.code == 0 + # Help is rendered to stdout, not stderr - critical for shell redirection. + assert captured.out != "" + # Successful help output does not leak to stderr. + assert captured.err == "" - Uses a fake ``SearchIndex`` so no Redis is required. - Expected behavior: ``--json`` skips ``_display_stats`` and emits one - single-line JSON document with the full ``STATS_KEYS`` schema and - native values (e.g. ``num_docs=7`` -> ``7``). +def test_rvl_stats_subprocess_help(): + """End-to-end smoke test of ``rvl stats --help`` via the runner module. - Row order is covered by ``test_stats_rows_*``; JSON key order is covered - by ``test_cli_print_json_preserves_key_order``. + Expected behavior: the subprocess exits 0, stdout is non-empty, and + stderr is empty. """ + result = subprocess.run( + [sys.executable, "-m", "redisvl.cli.runner", "stats", "--help"], + check=False, + capture_output=True, + text=True, + ) + # Process exited cleanly. + assert result.returncode == 0 + # Help is rendered to stdout, not stderr. + assert result.stdout != "" + # Help is not emitted on stderr. + assert result.stderr == "" - class FakeIndex: - def __init__(self, *a, **k): - pass - def info(self): - return {"num_docs": 7} +def _raise(exc: BaseException): + """Return a zero-arg callable that raises ``exc``.""" - monkeypatch.setattr("redisvl.cli.stats.SearchIndex", FakeIndex) - monkeypatch.setattr(sys, "argv", ["rvl", "stats", "-i", "test-idx", "--json"]) - Stats() - out = capsys.readouterr().out.strip() - assert "Statistics" not in out # --json must not emit the table UI text - assert out.count("\n") == 0 # exactly one JSON object on stdout, no extra lines - payload = json.loads(out) - assert set(payload) == set(STATS_KEYS) # same stat keys as the shared schema list - assert payload["num_docs"] == 7 # numbers remain numbers for machine consumers - assert payload["num_terms"] is None # missing values become JSON null, not "None" + def _do(): + raise exc + return _do -def test_stats_default_prints_table(monkeypatch, capsys): - """``rvl stats -i `` without ``--json`` still renders the ASCII table. - Expected behavior: ``Stats.stats`` selects the human-readable branch and - delegates to ``_display_stats``; the ``Statistics:`` banner is the signal - that the table path ran. Guards against the ``--json`` plumbing regressing - the default mode. +def _patch_search_index_for_stats( + monkeypatch, + *, + info_behavior=None, + index_name: str = "test-idx", +) -> None: + """Patch ``redisvl.cli.stats.SearchIndex`` with a minimal fake for ``rvl stats`` tests. + + By default builds a happy-path FakeIndex whose ``info()`` returns + ``{"num_docs": 7}`` - one populated stat key, the rest absent so the + missing-key -> ``None`` path is exercised. Pass + ``info_behavior=_raise(SomeError())`` to make ``info()`` raise instead. + ``index_name`` is surfaced via ``index.schema.index.name`` so + ``exit_redis_search_error`` can format its message verbatim. """ + if info_behavior is None: + + def info_behavior(): + return {"num_docs": 7} + + class _FakeIndexInfo: + name = index_name + + class _FakeSchema: + index = _FakeIndexInfo() class FakeIndex: + schema = _FakeSchema() + def __init__(self, *a, **k): + # Absorb the ``schema=`` and ``redis_url=`` kwargs that + # ``_connect_to_index`` passes to ``SearchIndex(...)`` directly. + # Without this, Python's default ``__init__`` rejects them. pass + @classmethod + def from_yaml(cls, *_args, **_kwargs): + return cls() + def info(self): - return {"num_docs": 1} + return info_behavior() monkeypatch.setattr("redisvl.cli.stats.SearchIndex", FakeIndex) - monkeypatch.setattr(sys, "argv", ["rvl", "stats", "-i", "test-idx"]) - Stats() - out = capsys.readouterr().out - assert "Statistics:" in out # non-JSON path prints the table header line -def test_stats_missing_index_and_schema_exits_zero_without_json(monkeypatch, capsys): - """Without -i/-s, ``_connect_to_index`` logs and ``exit(0)`` s; no JSON leaks. +def _patch_search_index_from_yaml_raises(monkeypatch, exc: BaseException) -> None: + """Patch ``redisvl.cli.stats.SearchIndex.from_yaml`` to raise ``exc``. + + Used by the schema-input-error path where ``-s `` is provided but + loading fails (e.g. file missing, malformed YAML). + """ + + class FakeSearchIndex: + @classmethod + def from_yaml(cls, *_args, **_kwargs): + raise exc + + monkeypatch.setattr("redisvl.cli.stats.SearchIndex", FakeSearchIndex) + - Expected behavior: invalid input follows the standard ``rvl`` "log + exit 0" - pattern. ``--json`` does not relax that contract — stdout stays empty so - machine consumers never see a half-formed JSON object. +@pytest.mark.parametrize( + "argv", + [ + ["rvl", "stats"], + ["rvl", "stats", "--json"], + ], +) +def test_stats_no_target(monkeypatch, capsys, argv: list[str]): + """Tests that ``rvl stats`` without ``-i`` or ``-s`` exits with a usage error. + + Expected behavior: ``SystemExit`` code is 2, stdout is empty, and stderr + is non-empty. ``--json`` is parametrized to confirm no JSON contract is + invented for usage errors. """ - monkeypatch.setattr(sys, "argv", ["rvl", "stats", "--json"]) - with pytest.raises(SystemExit) as excinfo: + monkeypatch.setattr(sys, "argv", argv) + + with pytest.raises(SystemExit) as exc_info: Stats() - assert excinfo.value.code == 2 - assert capsys.readouterr().out == "" # no JSON object emitted on error + captured = capsys.readouterr() + # _connect_to_index uses argparse-style usage-error exit code. + assert exc_info.value.code == 2 + # Usage errors must not pollute stdout, even with --json. + assert captured.out == "" + # Some explanatory message reaches stderr; exact wording is not part of the contract. + assert captured.err != "" -def test_stats_info_failure_exits_zero_without_json(monkeypatch, capsys): - """If ``index.info()`` raises, ``Stats.__init__`` logs and ``exit(0)`` s; no JSON leaks. - Expected behavior: ``try/except Exception`` converts backend failures into - ``exit(0)`` (no traceback). With ``--json``, stdout stays empty so "exit 0 - + empty stdout" means "no result", never "malformed result". +def test_stats_schema_input_error(monkeypatch, capsys): + """Tests that ``rvl stats -s `` reports schema-load failures on stderr. + + Expected behavior: ``SystemExit`` code is 2, stdout is empty, and stderr + is non-empty. """ + _patch_search_index_from_yaml_raises( + monkeypatch, FileNotFoundError("schema file missing: /does/not/exist.yaml") + ) + monkeypatch.setattr(sys, "argv", ["rvl", "stats", "-s", "/does/not/exist.yaml"]) - class BoomIndex: - def __init__(self, *a, **k): - pass + with pytest.raises(SystemExit) as exc_info: + Stats() + captured = capsys.readouterr() + + # exit_schema_input_error uses exit code 2 when -s was provided. + assert exc_info.value.code == 2 + # Nothing on stdout when schema input fails. + assert captured.out == "" + # The failure is surfaced on stderr. + assert captured.err != "" - def info(self): - raise RuntimeError("boom") - monkeypatch.setattr("redisvl.cli.stats.SearchIndex", BoomIndex) +@pytest.mark.parametrize( + "argv", + [ + ["rvl", "stats", "-i", "test-idx"], + ["rvl", "stats", "-i", "test-idx", "--json"], + ], +) +def test_stats_redis_search_error(monkeypatch, capsys, argv: list[str]): + """Tests that ``rvl stats`` reports Redis-side failures on stderr. + + Expected behavior: ``SystemExit`` code is 1, stdout is empty, and stderr + is non-empty. ``--json`` is parametrized to confirm the contract is + uniform. + """ + from redisvl.exceptions import RedisSearchError + + _patch_search_index_for_stats( + monkeypatch, info_behavior=_raise(RedisSearchError("Unknown index name")) + ) + monkeypatch.setattr(sys, "argv", argv) + + with pytest.raises(SystemExit) as exc_info: + Stats() + captured = capsys.readouterr() + + # Documented Redis-failure exit code from exit_redis_search_error. + assert exc_info.value.code == 1 + # No partial output before the failure. + assert captured.out == "" + # The failure is surfaced on stderr. + assert captured.err != "" + + +@pytest.mark.parametrize( + "argv", + [ + ["rvl", "stats", "-i", "test-idx"], + ["rvl", "stats", "-i", "test-idx", "--json"], + ], +) +def test_stats_runtime_error(monkeypatch, capsys, argv: list[str]): + """Tests that ``rvl stats`` reports runtime failures on stderr. + + Expected behavior: ``SystemExit`` code is 1, stdout is empty (no + half-formed output), and stderr is non-empty. ``--json`` is parametrized + to confirm the contract is uniform. + """ + _patch_search_index_for_stats( + monkeypatch, info_behavior=_raise(RuntimeError("boom")) + ) + monkeypatch.setattr(sys, "argv", argv) + + with pytest.raises(SystemExit) as exc_info: + Stats() + captured = capsys.readouterr() + + # Generic runtime failure exits 1 from __init__'s catch-all. + assert exc_info.value.code == 1 + # Failure happens before any rendering - nothing on stdout. + assert captured.out == "" + # The failure is surfaced on stderr; exact wording is not part of the contract. + assert captured.err != "" + + +def test_stats_json(monkeypatch, capsys): + """Tests that ``rvl stats --json`` prints the documented JSON contract. + + Expected behavior: no ``SystemExit`` is raised, stdout is one parseable + JSON line whose keys are exactly ``STATS_KEYS`` in order, native value + types are preserved, and missing input keys become JSON ``null``. + Stderr is empty. + """ + _patch_search_index_for_stats(monkeypatch) monkeypatch.setattr(sys, "argv", ["rvl", "stats", "-i", "test-idx", "--json"]) - with pytest.raises(SystemExit) as excinfo: + + try: Stats() - assert excinfo.value.code == 1 - assert capsys.readouterr().out == "" + except SystemExit as exc: + # Success path must return cleanly, not call sys.exit. + pytest.fail(f"stats --json raised SystemExit({exc.code}) on the success path") + captured = capsys.readouterr() + out = captured.out.strip() + + # Single machine-readable line, nothing else on stdout. + assert out.count("\n") == 0 + payload = json.loads(out) + # Stable top-level JSON contract: every STATS_KEY in order, no extras. + assert list(payload) == list(STATS_KEYS) + # Native types are preserved for machine consumers. + assert payload["num_docs"] == 7 + # Missing input keys deserialize to None and is not mistyped. + assert payload["num_terms"] is None + # JSON success path is silent on stderr. + assert captured.err == "" + + +def test_stats_table(monkeypatch, capsys): + """Tests that default ``rvl stats`` runs the human-readable path to completion. + + Expected behavior: no ``SystemExit`` is raised, stdout is non-empty + (the table renderer ran), and stderr is empty. + """ + _patch_search_index_for_stats(monkeypatch) + monkeypatch.setattr(sys, "argv", ["rvl", "stats", "-i", "test-idx"]) + Stats() + captured = capsys.readouterr() + assert captured.out != "" + assert captured.err == "" + + +def test_stats_rows_shape(): + """Tests that ``_stats_rows`` produces the documented row contract. + + Expected behavior: for an empty ``index_info`` dict, the helper returns + ordered ``(key, None)`` pairs whose keys are exactly ``STATS_KEYS`` in + order - the contract the JSON output relies on. + """ + rows = _stats_rows({}) + data = dict(rows) + + # Every STATS_KEY appears, in declared order. + assert list(data.keys()) == list(STATS_KEYS) + # Missing input keys become None at this layer (serialized as JSON null). + assert all(data[k] is None for k in STATS_KEYS)