diff --git a/redisvl/cli/index.py b/redisvl/cli/index.py index 15cd28bd..621e765f 100644 --- a/redisvl/cli/index.py +++ b/redisvl/cli/index.py @@ -5,7 +5,12 @@ import yaml from pydantic import ValidationError -from redisvl.cli.utils import add_index_parsing_options, create_redis_url +from redisvl.cli.utils import ( + add_index_parsing_options, + add_json_output_flag, + cli_print_json, + create_redis_url, +) from redisvl.exceptions import RedisSearchError from redisvl.index import SearchIndex from redisvl.redis.connection import RedisConnectionFactory @@ -51,11 +56,11 @@ class Index: [ "rvl index []\n", "Commands:", - "\tinfo Obtain information about an index", + "\tinfo Obtain information about an index (use --json for machine output)", "\tcreate Create a new index", "\tdelete Delete an existing index", "\tdestroy Delete an existing index and all of its data", - "\tlistall List all indexes", + "\tlistall List all indexes (use --json for machine output)", "\n", ] ) @@ -65,6 +70,7 @@ def __init__(self): parser.add_argument("command", help="Subcommand to run") parser = add_index_parsing_options(parser) + parser = add_json_output_flag(parser) args = parser.parse_args(sys.argv[2:]) @@ -105,26 +111,34 @@ def info(self, args: Namespace): """Obtain information about an index. Usage: - rvl index info -i | -s + rvl index info -i | -s [--json] """ index = self._connect_to_index(args) try: - _display_in_table(index.info()) + index_info = index.info() except RedisSearchError as e: exit_redis_search_error(args, index, e) + if args.json: + cli_print_json(_index_info_for_json(index_info)) + else: + _display_in_table(index_info) + def listall(self, args: Namespace): """List all indices. Usage: - rvl index listall + rvl index listall [--json] """ redis_url = create_redis_url(args) conn = RedisConnectionFactory.get_redis_connection(redis_url=redis_url) indices = convert_bytes(conn.execute_command("FT._LIST")) - print("Indices:") - for i, index in enumerate(indices): - print(str(i + 1) + ". " + index) + if args.json: + cli_print_json({"indices": indices}) + else: + print("Indices:") + for i, index in enumerate(indices, start=1): + print(str(i) + ". " + index) def delete(self, args: Namespace, drop=False): """Delete an index. @@ -167,6 +181,56 @@ def _connect_to_index(self, args: Namespace) -> SearchIndex: sys.exit(2) +def _index_info_for_json(index_info: dict) -> dict: + """Build the JSON payload from the same fields shown in table mode.""" + definition_src = index_info.get("index_definition") + if isinstance(definition_src, list): + definition = convert_bytes(make_dict(definition_src)) + elif isinstance(definition_src, tuple): + definition = convert_bytes(make_dict(list(definition_src))) + elif isinstance(definition_src, dict): + definition = convert_bytes(dict(definition_src)) + else: + definition = {} + attributes = index_info.get("attributes", []) + index_fields = [] + + for attrs in attributes: + if isinstance(attrs, list): + attr = convert_bytes(make_dict(attrs)) + elif isinstance(attrs, tuple): + attr = convert_bytes(make_dict(list(attrs))) + elif isinstance(attrs, dict): + attr = convert_bytes(dict(attrs)) + else: + attr = {} + field = { + "name": attr.get("identifier"), + "attribute": attr.get("attribute"), + "type": attr.get("type"), + } + field_options = { + k: v + for k, v in attr.items() + if k not in {"identifier", "attribute", "type"} + } + if field_options: + field["field_options"] = field_options + index_fields.append(field) + + payload = { + "index_information": { + "index_name": index_info.get("index_name"), + "storage_type": definition.get("key_type"), + "prefixes": definition.get("prefixes"), + "index_options": index_info.get("index_options"), + "indexing": index_info.get("indexing"), + }, + "index_fields": index_fields, + } + return convert_bytes(payload) + + def _display_in_table(index_info): print("\n") attributes = index_info.get("attributes", []) diff --git a/redisvl/cli/stats.py b/redisvl/cli/stats.py index 539aabd3..bfa1c668 100644 --- a/redisvl/cli/stats.py +++ b/redisvl/cli/stats.py @@ -5,7 +5,12 @@ import yaml from pydantic import ValidationError -from redisvl.cli.utils import add_index_parsing_options, create_redis_url +from redisvl.cli.utils import ( + add_index_parsing_options, + add_json_output_flag, + cli_print_json, + create_redis_url, +) from redisvl.exceptions import RedisSearchError from redisvl.index import SearchIndex from redisvl.schema.schema import IndexSchema @@ -67,6 +72,17 @@ def exit_redis_search_error( ] +def _stats_rows(index_info: dict) -> list[tuple[str, object]]: + """Normalize ``index.info()`` for both JSON and table output. + + Returns ordered ``(key, value)`` pairs: keys follow ``STATS_KEYS``, values + preserve native types from ``index_info``. Missing keys remain ``None``. + For JSON, wrap the result in ``dict(...)``; the table stringifies values + when rendering. + """ + return [(key, index_info.get(key)) for key in STATS_KEYS] + + class Stats: usage = "\n".join( [ @@ -77,6 +93,7 @@ class Stats: def __init__(self): parser = argparse.ArgumentParser(usage=self.usage) parser = add_index_parsing_options(parser) + parser = add_json_output_flag(parser) args = parser.parse_args(sys.argv[2:]) try: @@ -94,10 +111,16 @@ def stats(self, args: Namespace): """ index = self._connect_to_index(args) try: - _display_stats(index.info()) + index_info = index.info() except RedisSearchError as e: exit_redis_search_error(args, index, e) + rows = _stats_rows(index_info) + if args.json: + cli_print_json(dict(rows)) + else: + _display_stats(rows) + def _connect_to_index(self, args: Namespace) -> SearchIndex: redis_url = create_redis_url(args) @@ -118,10 +141,7 @@ def _connect_to_index(self, args: Namespace) -> SearchIndex: sys.exit(2) -def _display_stats(index_info): - # Extracting the statistics - stats_data = [(key, str(index_info.get(key))) for key in STATS_KEYS] - +def _display_stats(stats_data: list[tuple[str, object]]) -> None: # Display the statistics in tabular format print("\nStatistics:") max_key_length = max(len(key) for key, _ in stats_data) @@ -130,5 +150,6 @@ def _display_stats(index_info): print("│ Stat Key │ Value │") # header row print(f"├{horizontal_line}┼────────────┤") # separator row for key, value in stats_data: - print(f"│ {key:<27} │ {value[0:10]:<10} │") # data rows + value_str = str(value) + print(f"│ {key:<27} │ {value_str[0:10]:<10} │") # data rows print(f"╰{horizontal_line}┴────────────╯") # bottom row diff --git a/redisvl/cli/utils.py b/redisvl/cli/utils.py index 6f0da728..14fb6ab9 100644 --- a/redisvl/cli/utils.py +++ b/redisvl/cli/utils.py @@ -1,5 +1,7 @@ +import json import os from argparse import ArgumentParser, Namespace +from typing import Any, Mapping from urllib.parse import quote, urlparse, urlunparse from redisvl.redis.constants import REDIS_URL_ENV_VAR @@ -91,3 +93,27 @@ def add_index_parsing_options(parser: ArgumentParser) -> ArgumentParser: default=None, ) return parser + + +def _cli_json_default(obj: object) -> object: + """Handle common non-JSON-native types (e.g. bytes from Redis) for cli_print_json.""" + if isinstance(obj, bytes): + return obj.decode("utf-8", errors="replace") + raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") + + +def add_json_output_flag(parser: ArgumentParser) -> ArgumentParser: + """Register ``--json`` for machine-readable stdout (one JSON object on success).""" + parser.add_argument( + "--json", + action="store_true", + dest="json", + default=False, + help="Format output (when successful) as machine-readable JSON", + ) + return parser + + +def cli_print_json(data: Mapping[str, Any]) -> None: + """Write a single JSON object to stdout (deterministic key order for tests and scripts).""" + print(json.dumps(data, default=_cli_json_default)) diff --git a/tests/unit/test_cli_index.py b/tests/unit/test_cli_index.py new file mode 100644 index 00000000..d655f6cf --- /dev/null +++ b/tests/unit/test_cli_index.py @@ -0,0 +1,227 @@ +import json +import sys + +import pytest + +from redisvl.cli.index import Index, _index_info_for_json + + +class _FakeConn: + def __init__(self, result, boom=False): + self._result = result + self._boom = boom + + def execute_command(self, cmd): + assert cmd == "FT._LIST" # listall must query Redis with 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. + + Expected behavior: stdout is one JSON line with ``indices`` in order and no table text. + """ + + def fake_get(*a, **k): + return _FakeConn([b"idx_a", b"idx_b"]) + + monkeypatch.setattr( + "redisvl.cli.index.RedisConnectionFactory.get_redis_connection", fake_get + ) + 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. + + Expected behavior: stdout matches header + numbered rows in FT._LIST order. + """ + + def fake_get(*a, **k): + return _FakeConn([b"one", b"two"]) + + 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 + + +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 fake_get(*a, **k): + return _FakeConn([]) + + monkeypatch.setattr( + "redisvl.cli.index.RedisConnectionFactory.get_redis_connection", fake_get + ) + 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 + + +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 fake_get(*a, **k): + return _FakeConn([], boom=True) + + monkeypatch.setattr( + "redisvl.cli.index.RedisConnectionFactory.get_redis_connection", fake_get + ) + monkeypatch.setattr(sys, "argv", ["rvl", "index", "listall", "--json"]) + with pytest.raises(SystemExit) as excinfo: # exit(0) in Index.__init__ is not a plain return + Index() + # assert excinfo.value.code == 0 # "log and exit(0)" CLI contract + assert capsys.readouterr().out == "" # failure before cli_print_json — nothing on stdout + + +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``. + """ + raw = { + "index_name": "test_index", + "index_definition": [ + "key_type", + "HASH", + "prefixes", + ["prefix_a", "prefix_b"], + ], + "attributes": [ + [ + "identifier", + "user", + "attribute", + "user", + "type", + "TAG", + ], + ], + } + before = str(raw) + out = _index_info_for_json(raw) + assert str(raw) == before # not mutated + assert out == { + "index_information": { + "index_name": "test_index", + "storage_type": "HASH", + "prefixes": ["prefix_a", "prefix_b"], + "index_options": None, + "indexing": None, + }, + "index_fields": [ + { + "name": "user", + "attribute": "user", + "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. + + Expected behavior: one parseable JSON line with decoded values and no table banners. + """ + + expected_index_information = { + "index_name": "test-idx", + "storage_type": "HASH", + "prefixes": ["pre"], + "index_options": None, + "indexing": None, + } + expected_field = { + "name": "u", + "attribute": "u", + "type": "TAG", + "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) + 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 + 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 + +def test_info_json_error(monkeypatch, capsys): + """Tests that ``info --json`` errors do not emit partial stdout JSON. + + Expected behavior: command exits with code 0 and stdout is empty. + """ + + class BoomIndex: + def __init__(self, *a, **k): + pass + + def info(self): + raise RuntimeError("boom") + + monkeypatch.setattr("redisvl.cli.index.SearchIndex", BoomIndex) + monkeypatch.setattr( + sys, "argv", ["rvl", "index", "info", "-i", "test-idx", "--json"] + ) + with pytest.raises(SystemExit) as excinfo: + Index() + # assert excinfo.value.code == 0 # try/except in Index.__init__ + exit(0) + assert capsys.readouterr().out == "" # no partial JSON before the exception diff --git a/tests/unit/test_cli_stats.py b/tests/unit/test_cli_stats.py new file mode 100644 index 00000000..d624258e --- /dev/null +++ b/tests/unit/test_cli_stats.py @@ -0,0 +1,110 @@ +import json +import sys + +import pytest + +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. + + 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. + """ + 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 + + +def test_stats_json_prints_only_json_to_stdout(monkeypatch, capsys): + """``rvl stats -i --json`` writes only a JSON object to stdout. + + 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``). + + Row order is covered by ``test_stats_rows_*``; JSON key order is covered + by ``test_cli_print_json_preserves_key_order``. + """ + + class FakeIndex: + def __init__(self, *a, **k): + pass + + def info(self): + return {"num_docs": 7} + + 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 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. + """ + + class FakeIndex: + def __init__(self, *a, **k): + pass + + def info(self): + return {"num_docs": 1} + + 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. + + 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. + """ + monkeypatch.setattr(sys, "argv", ["rvl", "stats", "--json"]) + with pytest.raises(SystemExit) as excinfo: + Stats() + assert excinfo.value.code == 2 + assert capsys.readouterr().out == "" # no JSON object emitted on error + + +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". + """ + + class BoomIndex: + def __init__(self, *a, **k): + pass + + def info(self): + raise RuntimeError("boom") + + monkeypatch.setattr("redisvl.cli.stats.SearchIndex", BoomIndex) + monkeypatch.setattr(sys, "argv", ["rvl", "stats", "-i", "test-idx", "--json"]) + with pytest.raises(SystemExit) as excinfo: + Stats() + assert excinfo.value.code == 1 + assert capsys.readouterr().out == "" diff --git a/tests/unit/test_cli_utils.py b/tests/unit/test_cli_utils.py index 9c429bf1..0007730c 100644 --- a/tests/unit/test_cli_utils.py +++ b/tests/unit/test_cli_utils.py @@ -1,8 +1,10 @@ +import json from argparse import ArgumentParser +from typing import Optional import pytest -from redisvl.cli.utils import add_index_parsing_options, create_redis_url +from redisvl.cli.utils import add_index_parsing_options, add_json_output_flag, cli_print_json, create_redis_url @pytest.fixture @@ -92,7 +94,7 @@ def test_parser_leaves_connection_options_unset_by_default(parse_args): ], ) def test_create_redis_url_resolves_connection_sources( - parse_args, monkeypatch, argv: list[str], env_url: str | None, expected: str + parse_args, monkeypatch, argv: list[str], env_url: Optional[str], expected: str ): """Resolve Redis URLs from CLI args, environment, and local defaults.""" if env_url is None: @@ -101,3 +103,47 @@ def test_create_redis_url_resolves_connection_sources( monkeypatch.setenv("REDIS_URL", env_url) assert create_redis_url(parse_args(argv)) == expected + + +def test_add_json_output_flag_absent_is_false(): + """``add_json_output_flag`` leaves ``args.json`` false when ``--json`` is omitted. + + Expected: callers can treat the default as non-JSON (human-oriented) output. + """ + parser = add_json_output_flag(ArgumentParser()) + args = parser.parse_args([]) + assert args.json is False + + +def test_add_json_output_flag_present_is_true(): + """``add_json_output_flag`` sets ``args.json`` true when ``--json`` is passed. + + Expected: parsed args reflect machine-readable JSON mode. + """ + parser = add_json_output_flag(ArgumentParser()) + args = parser.parse_args(["--json"]) + assert args.json is True + + +def test_cli_print_json_writes_single_json_object(capsys): + """``cli_print_json`` writes exactly one JSON object to stdout for a string-only dict. + + Expected: stdout is a single line parseable by ``json.loads``, round-tripping the + payload, with no extra newlines beyond what ``print`` adds for one line. + """ + payload = {"version": "0.0.0"} + cli_print_json(payload) + out = capsys.readouterr().out.strip() + assert json.loads(out) == payload + assert out.count("\n") == 0 + + +def test_cli_print_json_encodes_bytes_values(capsys): + """``cli_print_json`` serializes ``bytes`` values via the JSON default handler. + + Invalid UTF-8 byte sequences are replaced (U+FFFD) in the decoded string, matching + ``_cli_json_default`` so future Redis-centric payloads can be emitted safely. + """ + cli_print_json({"blob": b"abc\xff"}) + out = capsys.readouterr().out.strip() + assert json.loads(out) == {"blob": "abc\ufffd"}