From bd3f5f1b5682fe96a13fc526354017b0b991d78f Mon Sep 17 00:00:00 2001 From: limjoobin Date: Fri, 24 Apr 2026 18:51:57 +0800 Subject: [PATCH 01/27] feat(cli): add JSON output helpers for rvl - add_json_output_flag for --json on argparse parsers - cli_print_json for single-object stdout; bytes-safe default Made-with: Cursor --- redisvl/cli/utils.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/redisvl/cli/utils.py b/redisvl/cli/utils.py index 8de7e1ae..880b0885 100644 --- a/redisvl/cli/utils.py +++ b/redisvl/cli/utils.py @@ -1,6 +1,7 @@ +import json import os from argparse import ArgumentParser, Namespace -from typing import Optional +from typing import Any, Mapping, Optional from urllib.parse import quote, urlparse, urlunparse from redisvl.redis.constants import REDIS_URL_ENV_VAR @@ -92,3 +93,32 @@ 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="Print success output as JSON to stdout", + ) + 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 + ) + ) From ddcd751668cecd21929d4f95f2ab0e68d92eaee4 Mon Sep 17 00:00:00 2001 From: limjoobin Date: Fri, 24 Apr 2026 18:51:59 +0800 Subject: [PATCH 02/27] feat(cli): add --json to rvl version Print {"version": } to stdout; --json overrides --short Made-with: Cursor --- redisvl/cli/version.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/redisvl/cli/version.py b/redisvl/cli/version.py index 13facbcd..ec6589c3 100644 --- a/redisvl/cli/version.py +++ b/redisvl/cli/version.py @@ -3,6 +3,7 @@ from argparse import Namespace from redisvl import __version__ +from redisvl.cli.utils import add_json_output_flag, cli_print_json from redisvl.utils.log import get_logger logger = get_logger("[RedisVL]") @@ -21,11 +22,15 @@ def __init__(self): parser.add_argument( "-s", "--short", help="Print only the version number", action="store_true" ) + parser = add_json_output_flag(parser) args = parser.parse_args(sys.argv[2:]) self.version(args) def version(self, args: Namespace): + if args.json: + cli_print_json({"version": __version__}) + return if args.short: print(__version__) else: From 96209c94eb7ba0a4ce7d53ab29f643ad8b5a2472 Mon Sep 17 00:00:00 2001 From: limjoobin Date: Fri, 24 Apr 2026 18:52:02 +0800 Subject: [PATCH 03/27] test(cli): cover rvl version --json and JSON CLI helpers - tests for add_json_output_flag, cli_print_json, Version Made-with: Cursor --- tests/unit/test_cli_utils.py | 47 ++++++++++++++++++++++++++- tests/unit/test_cli_version.py | 59 ++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_cli_version.py diff --git a/tests/unit/test_cli_utils.py b/tests/unit/test_cli_utils.py index e3a8d407..0007730c 100644 --- a/tests/unit/test_cli_utils.py +++ b/tests/unit/test_cli_utils.py @@ -1,9 +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 @@ -102,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"} diff --git a/tests/unit/test_cli_version.py b/tests/unit/test_cli_version.py new file mode 100644 index 00000000..18b05d66 --- /dev/null +++ b/tests/unit/test_cli_version.py @@ -0,0 +1,59 @@ +import json +import sys + +import pytest + +from redisvl import __version__ +from redisvl.cli.version import Version + + +def test_version_json_prints_package_version(monkeypatch, capsys): + """``rvl version --json`` prints only a JSON object with the package version. + + Expected: stdout is a single JSON object with top-level key ``version`` and a + string value equal to ``redisvl.__version__``; no other keys. + """ + monkeypatch.setattr(sys, "argv", ["rvl", "version", "--json"]) + Version() + out = capsys.readouterr().out.strip() + data = json.loads(out) + assert set(data.keys()) == {"version"} + assert data["version"] == __version__ + + +def test_version_short_prints_plain_version(monkeypatch, capsys): + """``rvl version --short`` prints the bare version string (no JSON, no log prefix on stdout). + + Expected: stdout strip equals ``__version__`` only. + """ + monkeypatch.setattr(sys, "argv", ["rvl", "version", "--short"]) + Version() + assert capsys.readouterr().out.strip() == __version__ + + +@pytest.mark.parametrize( + "extra", + [ + pytest.param(["--json", "--short"], id="json-then-short"), + pytest.param(["--short", "--json"], id="short-then-json"), + ], +) +def test_version_json_overrides_short(monkeypatch, capsys, extra): + """If both ``--json`` and ``--short`` are set, JSON output wins regardless of order. + + Expected: same as ``--json`` alone: one JSON object ``{"version": }``. + """ + monkeypatch.setattr(sys, "argv", ["rvl", "version", *extra]) + Version() + out = capsys.readouterr().out.strip() + data = json.loads(out) + assert data == {"version": __version__} + + +def test_version_default_does_not_raise(monkeypatch): + """``rvl version`` with no extra flags runs without raising (default human/log path). + + Expected: ``Version()`` completes; stdout may be empty while the version is logged. + """ + monkeypatch.setattr(sys, "argv", ["rvl", "version"]) + Version() From 8ad7da1d77f8ce02b22d95e7ec5ed52bc76af7cd Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Fri, 24 Apr 2026 14:32:10 +0200 Subject: [PATCH 04/27] feat(cli): improve rvl help discoverability --- redisvl/cli/index.py | 123 ++++++++++++++++++++++++++++++++----- redisvl/cli/main.py | 49 ++++++++++----- redisvl/cli/mcp.py | 6 +- redisvl/cli/stats.py | 20 +++++- redisvl/cli/utils.py | 55 ++++++++++++++--- tests/unit/test_cli_mcp.py | 10 ++- 6 files changed, 212 insertions(+), 51 deletions(-) diff --git a/redisvl/cli/index.py b/redisvl/cli/index.py index dea8787d..9fafc480 100644 --- a/redisvl/cli/index.py +++ b/redisvl/cli/index.py @@ -13,35 +13,128 @@ class Index: - usage = "\n".join( + description = ( + "Create, inspect, list, and delete Redis search indexes.\n\n" + "Use `-i/--index` to target an existing Redis index name or " + "`-s/--schema` to load a schema YAML file. Shared Redis connection " + "options apply to these data-plane commands." + ) + epilog = "\n".join( [ - "rvl index []\n", - "Commands:", - "\tinfo Obtain information about an index", - "\tcreate Create a new index", - "\tdelete Delete an existing index", - "\tdestroy Delete an existing index and all of its data", - "\tlistall List all indexes", - "\n", + "Examples:", + " rvl index create -s schema.yaml", + " rvl index info -i user_index", + " rvl index listall --url redis://localhost:6379", ] ) def __init__(self): - parser = argparse.ArgumentParser(usage=self.usage) - - parser.add_argument("command", help="Subcommand to run") - parser = add_index_parsing_options(parser) + parser = self._build_parser() args = parser.parse_args(sys.argv[2:]) - if not hasattr(self, args.command): + if not getattr(args, "command", None): parser.print_help() exit(0) try: - getattr(self, args.command)(args) + args.handler(args) except Exception as e: logger.error(e) exit(0) + def _build_parser(self) -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="rvl index", + description=self.description, + epilog=self.epilog, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + shared_options = argparse.ArgumentParser(add_help=False) + add_index_parsing_options(shared_options) + + subparsers = parser.add_subparsers(dest="command", title="Commands") + + create_parser = subparsers.add_parser( + "create", + parents=[shared_options], + help="Create a new index from a schema file", + description="Create a new Redis search index from a schema YAML file.", + epilog="\n".join( + [ + "Examples:", + " rvl index create -s schema.yaml", + " rvl index create -s schema.yaml --url redis://localhost:6379", + ] + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + create_parser.set_defaults(handler=self.create) + + info_parser = subparsers.add_parser( + "info", + parents=[shared_options], + help="Show details about an index", + description="Display schema and storage details for an index.", + epilog="\n".join( + [ + "Examples:", + " rvl index info -i user_index", + " rvl index info -s schema.yaml", + ] + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + info_parser.set_defaults(handler=self.info) + + listall_parser = subparsers.add_parser( + "listall", + parents=[shared_options], + help="List indexes available on the target Redis deployment", + description="List all Redis search indexes available on the target Redis deployment.", + epilog="\n".join( + [ + "Examples:", + " rvl index listall", + " rvl index listall --host localhost --port 6379", + ] + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + listall_parser.set_defaults(handler=self.listall) + + delete_parser = subparsers.add_parser( + "delete", + parents=[shared_options], + help="Delete an index but leave its data in Redis", + description="Delete an existing Redis search index without dropping indexed data.", + epilog="\n".join( + [ + "Examples:", + " rvl index delete -i user_index", + " rvl index delete -s schema.yaml", + ] + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + delete_parser.set_defaults(handler=self.delete) + + destroy_parser = subparsers.add_parser( + "destroy", + parents=[shared_options], + help="Delete an index and drop its indexed data", + description="Delete an existing Redis search index and drop its indexed data.", + epilog="\n".join( + [ + "Examples:", + " rvl index destroy -i user_index", + " rvl index destroy -s schema.yaml", + ] + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + destroy_parser.set_defaults(handler=self.destroy) + + return parser + def create(self, args: Namespace): """Create an index. diff --git a/redisvl/cli/main.py b/redisvl/cli/main.py index dbed65f3..da883b3f 100644 --- a/redisvl/cli/main.py +++ b/redisvl/cli/main.py @@ -1,45 +1,62 @@ import argparse import sys -from redisvl.cli.index import Index -from redisvl.cli.stats import Stats -from redisvl.cli.version import Version from redisvl.utils.log import get_logger logger = get_logger(__name__) def _usage(): - usage = [ - "rvl []\n", - "Commands:", - "\tindex Index manipulation (create, delete, etc.)", - "\tmcp Run the RedisVL MCP server", - "\tversion Obtain the version of RedisVL", - "\tstats Obtain statistics about an index", + return "rvl []" + + +def _command_overview(): + command_groups = [ + "Command groups:", + " index Create, inspect, list, and delete Redis search indexes", + " stats Show statistics for an existing Redis search index", + " version Show the installed RedisVL version", + " mcp Run the RedisVL MCP server", + ] + return "\n".join(command_groups) + + +def _examples(): + examples = [ + "Examples:", + " rvl index --help", + " rvl index create -s schema.yaml", + " rvl stats -i user_index", + " rvl mcp --config /path/to/mcp.yaml", ] - return "\n".join(usage) + "\n" + return "\n".join(examples) class RedisVlCLI: def __init__(self): parser = argparse.ArgumentParser( - description="Redis Vector Library CLI", usage=_usage() + prog="rvl", + description=f"Redis Vector Library CLI.\n\n{_command_overview()}", + usage=_usage(), + epilog=_examples(), + formatter_class=argparse.RawDescriptionHelpFormatter, ) - parser.add_argument("command", help="Subcommand to run") + parser.add_argument("command", nargs="?", help="Command group to run") if len(sys.argv) < 2: parser.print_help() exit(0) args = parser.parse_args(sys.argv[1:2]) - if not hasattr(self, args.command): + if not args.command or not hasattr(self, args.command): parser.print_help() exit(0) getattr(self, args.command)() def index(self): + from redisvl.cli.index import Index + Index() exit(0) @@ -50,9 +67,13 @@ def mcp(self): exit(0) def version(self): + from redisvl.cli.version import Version + Version() exit(0) def stats(self): + from redisvl.cli.stats import Stats + Stats() exit(0) diff --git a/redisvl/cli/mcp.py b/redisvl/cli/mcp.py index 376881d6..16d939f3 100644 --- a/redisvl/cli/mcp.py +++ b/redisvl/cli/mcp.py @@ -21,9 +21,9 @@ class MCP: epilog = ( "Use this command when wiring RedisVL into an MCP client.\n\n" "Examples:\n" - " uvx --from redisvl[mcp] rvl mcp --config /path/to/mcp_config.yaml\n" - " uvx --from redisvl[mcp] rvl mcp --config /path/to/mcp_config.yaml --transport streamable-http --port 8000\n" - " uvx --from redisvl[mcp] rvl mcp --config /path/to/mcp_config.yaml --transport sse --host 0.0.0.0 --port 9000" + " rvl mcp --config /path/to/mcp_config.yaml\n" + " rvl mcp --config /path/to/mcp_config.yaml --transport streamable-http --port 8000\n" + " rvl mcp --config /path/to/mcp_config.yaml --transport sse --host 0.0.0.0 --port 9000" ) usage = "\n".join( [ diff --git a/redisvl/cli/stats.py b/redisvl/cli/stats.py index d3bdf9cc..98e689f9 100644 --- a/redisvl/cli/stats.py +++ b/redisvl/cli/stats.py @@ -33,14 +33,28 @@ class Stats: - usage = "\n".join( + description = ( + "Display statistics for an existing Redis search index.\n\n" + "Use `-i/--index` to inspect an existing Redis index by name or " + "`-s/--schema` to load the target from a schema YAML file. Shared " + "Redis connection options apply to this data-plane command." + ) + epilog = "\n".join( [ - "rvl stats []\n", + "Examples:", + " rvl stats -i user_index", + " rvl stats -s schema.yaml", + " rvl stats -i user_index --host localhost --port 6379", ] ) def __init__(self): - parser = argparse.ArgumentParser(usage=self.usage) + parser = argparse.ArgumentParser( + prog="rvl stats", + description=self.description, + epilog=self.epilog, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) parser = add_index_parsing_options(parser) args = parser.parse_args(sys.argv[2:]) try: diff --git a/redisvl/cli/utils.py b/redisvl/cli/utils.py index 6f0da728..904afac6 100644 --- a/redisvl/cli/utils.py +++ b/redisvl/cli/utils.py @@ -74,19 +74,54 @@ def create_redis_url(args: Namespace) -> str: def add_index_parsing_options(parser: ArgumentParser) -> ArgumentParser: - parser.add_argument("-i", "--index", help="Index name", type=str, required=False) - parser.add_argument( - "-s", "--schema", help="Path to schema file", type=str, required=False + index_target_group = parser.add_argument_group("Index selection") + index_target_group.add_argument( + "-i", + "--index", + help="Redis index name to connect to", + type=str, + required=False, + ) + index_target_group.add_argument( + "-s", + "--schema", + help="Path to a schema YAML file", + type=str, + required=False, + ) + + redis_group = parser.add_argument_group("Redis connection options") + redis_group.add_argument( + "-u", + "--url", + help="Redis URL for data-plane commands", + type=str, + required=False, + ) + redis_group.add_argument( + "--host", + help="Redis host for data-plane commands", + type=str, + default=None, + ) + redis_group.add_argument( + "-p", + "--port", + help="Redis port for data-plane commands", + type=int, + default=None, + ) + redis_group.add_argument( + "--user", + help="Redis username for data-plane commands", + type=str, + default=None, ) - parser.add_argument("-u", "--url", help="Redis URL", type=str, required=False) - parser.add_argument("--host", help="Redis host", type=str, default=None) - parser.add_argument("-p", "--port", help="Redis port", type=int, default=None) - parser.add_argument("--user", help="Redis username", type=str, default=None) - parser.add_argument("--ssl", help="Use SSL", action="store_true") - parser.add_argument( + redis_group.add_argument("--ssl", help="Use SSL for Redis", action="store_true") + redis_group.add_argument( "-a", "--password", - help="Redis password", + help="Redis password for data-plane commands", type=str, default=None, ) diff --git a/tests/unit/test_cli_mcp.py b/tests/unit/test_cli_mcp.py index e4fddc38..3da5bed6 100644 --- a/tests/unit/test_cli_mcp.py +++ b/tests/unit/test_cli_mcp.py @@ -6,7 +6,7 @@ import pytest -from redisvl.cli.main import RedisVlCLI, _usage +from redisvl.cli.main import RedisVlCLI, _command_overview def _import_cli_mcp(): @@ -29,8 +29,8 @@ def _install_fake_redisvl_mcp(monkeypatch, settings_factory, server_factory): return fake_module -def test_usage_includes_mcp(): - assert "mcp" in _usage() +def test_command_overview_includes_mcp(): + assert "mcp" in _command_overview() def test_cli_dispatches_mcp_command_lazily(monkeypatch): @@ -94,9 +94,7 @@ def test_mcp_help_includes_description_and_example(monkeypatch, capsys): assert exc_info.value.code == 0 assert "Expose a configured Redis index to MCP clients" in out.out assert "Use this command when wiring RedisVL into an MCP client" in out.out - assert ( - "uvx --from redisvl[mcp] rvl mcp --config /path/to/mcp_config.yaml" in out.out - ) + assert "rvl mcp --config /path/to/mcp_config.yaml" in out.out assert "--transport" in out.out assert "streamable-http" in out.out assert "--host" in out.out From 3fd2ae61c6612dea2b70c72b8be732f1d757a505 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Fri, 24 Apr 2026 14:34:35 +0200 Subject: [PATCH 05/27] docs(cli): refresh help examples and command tree --- README.md | 22 ++++++++++++++++------ docs/user_guide/cli.ipynb | 30 ++++++++++++++++-------------- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index b75b1f81..55f790cd 100644 --- a/README.md +++ b/README.md @@ -527,17 +527,27 @@ router("Hi, good morning") Create, destroy, and manage Redis index configurations from a purpose-built CLI interface: `rvl`. ```bash -$ rvl -h +$ rvl --help usage: rvl [] -Commands: - index Index manipulation (create, delete, etc.) - mcp Run the RedisVL MCP server - version Obtain the version of RedisVL - stats Obtain statistics about an index +Redis Vector Library CLI. + +Command groups: + index Create, inspect, list, and delete Redis search indexes + stats Show statistics for an existing Redis search index + version Show the installed RedisVL version + mcp Run the RedisVL MCP server + +Examples: + rvl index --help + rvl index create -s schema.yaml + rvl stats -i user_index + rvl mcp --config /path/to/mcp.yaml ``` +Use `rvl index --help` to see documented subcommands such as `create`, `info`, `listall`, `delete`, and `destroy`. Use `rvl stats --help` to see both index-name and schema-path examples plus the shared Redis connection options for data-plane commands. + Run the MCP server over stdio (default): ```bash diff --git a/docs/user_guide/cli.ipynb b/docs/user_guide/cli.ipynb index 1d9889be..00c0f10a 100644 --- a/docs/user_guide/cli.ipynb +++ b/docs/user_guide/cli.ipynb @@ -6,7 +6,7 @@ "source": [ "# The RedisVL CLI\n", "\n", - "RedisVL is a Python library with a dedicated CLI to help load and create vector search indices within Redis.\n", + "RedisVL is a Python library with a dedicated CLI to create, inspect, list, and delete Redis search indexes, inspect index statistics, and run the RedisVL MCP server.\n", "\n", "This notebook will walk through how to use the Redis Vector Library CLI (``rvl``).\n", "\n", @@ -39,18 +39,20 @@ "metadata": {}, "source": [ "## Commands\n", - "Here's a table of all the rvl commands and options. We'll go into each one in detail below.\n", + "The table below documents the current CLI tree. Use ``rvl index --help`` and ``rvl stats --help`` for detailed flag help and examples.\n", "\n", - "| Command | Options | Description |\n", - "|---------------|--------------------------|-------------|\n", - "| `rvl version` | | display the redisvl library version|\n", - "| `rvl index` | `create --schema` or `-s `| create a redis index from the specified schema file|\n", - "| `rvl index` | `listall` | list all the existing search indices|\n", - "| `rvl index` | `info --index` or ` -i ` | display the index definition in tabular format|\n", - "| `rvl index` | `delete --index` or `-i ` | remove the specified index, leaving the data still in Redis|\n", - "| `rvl index` | `destroy --index` or `-i `| remove the specified index, as well as the associated data|\n", - "| `rvl stats` | `--index` or `-i ` | display the index statistics, including number of docs, average bytes per record, indexing time, etc|\n", - "| `rvl stats` | `--schema` or `-s ` | display the index statistics of a schema defined in . The index must have already been created within Redis|" + "| Command | Purpose |\n", + "|---------|---------|\n", + "| `rvl version` | display the installed RedisVL version |\n", + "| `rvl index create` | create a new Redis search index from a schema YAML file |\n", + "| `rvl index info` | display schema and storage details for an index |\n", + "| `rvl index listall` | list Redis search indexes available on the target Redis deployment |\n", + "| `rvl index delete` | delete an index while leaving indexed data in Redis |\n", + "| `rvl index destroy` | delete an index and drop its indexed data |\n", + "| `rvl stats` | display statistics for an existing Redis search index |\n", + "| `rvl mcp` | run the RedisVL MCP server |\n", + "\n", + "Within data-plane commands, ``-i`` or ``--index`` targets an existing Redis index name and ``-s`` or ``--schema`` points to a schema YAML file. Shared Redis connection options such as ``--url``, ``--host``, and ``--port`` apply to ``rvl index`` and ``rvl stats``." ] }, { @@ -59,7 +61,7 @@ "source": [ "## Index\n", "\n", - "The ``rvl index`` command can be used for a number of tasks related to creating and managing indices. Whether you are working in Python or another language, this cli tool can still be useful for managing and inspecting your indices.\n", + "The ``rvl index`` command groups the index management workflows. Use ``rvl index --help`` to see the documented subcommands: ``create``, ``info``, ``listall``, ``delete``, and ``destroy``. Whether you are working in Python or another language, this CLI can still be useful for managing and inspecting your indexes.\n", "\n", "First, we will create an index from a yaml schema that looks like the following:\n" ] @@ -251,7 +253,7 @@ "source": [ "## Stats\n", "\n", - "The ``rvl stats`` command will return some basic information about the index. This is useful for checking the status of an index, or for getting information about the index to use in other commands." + "The ``rvl stats`` command returns basic information about an index. Use ``-i`` or ``--index`` to target an existing Redis index name, or ``-s`` or ``--schema`` to target a schema-defined index. Shared Redis connection options such as ``--url``, ``--host``, and ``--port`` also apply here." ] }, { From bead749965148f72088b8f5ee8bc30fdf4b1ef79 Mon Sep 17 00:00:00 2001 From: limjoobin Date: Sun, 26 Apr 2026 15:31:18 +0800 Subject: [PATCH 06/27] feat(cli): add --json to rvl stats with shared _stats_rows Made-with: Cursor --- redisvl/cli/stats.py | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/redisvl/cli/stats.py b/redisvl/cli/stats.py index d3bdf9cc..8977aee6 100644 --- a/redisvl/cli/stats.py +++ b/redisvl/cli/stats.py @@ -2,7 +2,12 @@ import sys from argparse import Namespace -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.index import SearchIndex from redisvl.schema.schema import IndexSchema from redisvl.utils.log import get_logger @@ -32,6 +37,17 @@ ] +def _stats_rows(index_info: dict) -> list[tuple[str, str]]: + """Normalize ``index.info()`` for both JSON and table output. + + Returns ordered ``(key, value)`` pairs: keys follow ``STATS_KEYS``, values + are strings (``str(index_info.get(key))``; missing keys become ``"None"``). + For JSON, wrap the result in ``dict(...)``; the table uses the same list of + pairs without conversion. + """ + return [(key, str(index_info.get(key))) for key in STATS_KEYS] + + class Stats: usage = "\n".join( [ @@ -42,6 +58,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: self.stats(args) @@ -56,7 +73,12 @@ def stats(self, args: Namespace): rvl stats -i | -s """ index = self._connect_to_index(args) - _display_stats(index.info()) + index_info = index.info() + 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: # connect to redis @@ -74,10 +96,7 @@ def _connect_to_index(self, args: Namespace) -> SearchIndex: return index -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, str]]) -> None: # Display the statistics in tabular format print("\nStatistics:") max_key_length = max(len(key) for key, _ in stats_data) From d9bc7e9dc8b25d1ae991f3b35dc4f6e12c88cc28 Mon Sep 17 00:00:00 2001 From: limjoobin Date: Sun, 26 Apr 2026 15:31:21 +0800 Subject: [PATCH 07/27] test: add unit tests for rvl stats CLI Made-with: Cursor --- tests/unit/test_cli_stats.py | 110 +++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/unit/test_cli_stats.py diff --git a/tests/unit/test_cli_stats.py b/tests/unit/test_cli_stats.py new file mode 100644 index 00000000..09a10a39 --- /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; stringifies values at this layer; and represents + missing keys as ``"None"`` so the schema stays stable. + """ + data = dict(_stats_rows({})) + assert list(data.keys()) == list(STATS_KEYS) # column order matches STATS_KEYS + assert all(isinstance(v, str) for v in data.values()) # every value is a string + assert all(data[k] == "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 + stringified 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" # str() of num_docs from index.info() + + +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 == 0 # CLI's documented "log and exit(0)" contract + 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 == 0 + assert capsys.readouterr().out == "" From f77532fec7ff2410a5f09fb36481ed734407e353 Mon Sep 17 00:00:00 2001 From: limjoobin Date: Sun, 26 Apr 2026 16:03:19 +0800 Subject: [PATCH 08/27] feat(cli): add --json to rvl index listall Made-with: Cursor --- redisvl/cli/index.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/redisvl/cli/index.py b/redisvl/cli/index.py index dea8787d..4f022194 100644 --- a/redisvl/cli/index.py +++ b/redisvl/cli/index.py @@ -2,7 +2,12 @@ import sys from argparse import Namespace -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.index import SearchIndex from redisvl.redis.connection import RedisConnectionFactory from redisvl.redis.utils import convert_bytes, make_dict @@ -21,7 +26,7 @@ class Index: "\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", ] ) @@ -31,6 +36,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:]) if not hasattr(self, args.command): @@ -68,14 +74,17 @@ 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): + print(str(i + 1) + ". " + index) def delete(self, args: Namespace, drop=False): """Delete an index. From 7276fa5dcaf9470bf4636f8781e08573dc131a6e Mon Sep 17 00:00:00 2001 From: limjoobin Date: Sun, 26 Apr 2026 16:03:21 +0800 Subject: [PATCH 09/27] test: add unit tests for rvl index listall Made-with: Cursor --- tests/unit/test_cli_index.py | 113 +++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/unit/test_cli_index.py diff --git a/tests/unit/test_cli_index.py b/tests/unit/test_cli_index.py new file mode 100644 index 00000000..835250c9 --- /dev/null +++ b/tests/unit/test_cli_index.py @@ -0,0 +1,113 @@ +import json +import sys + +import pytest + +from redisvl.cli.index import Index + + +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_index_listall_json_prints_single_object(monkeypatch, capsys): + """Mocked successful ``FT._LIST`` in ``--json`` mode. + + What: ``listall`` uses ``cli_print_json`` and skips the text banner / loop. + + Expected behavior: exactly one line of parseable JSON on stdout with key + ``indices``; values are the ``convert_bytes`` result of the mock list, in + order; no ``Indices:`` or partial human output; no extra newlines. + """ + + 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_index_listall_table_prints_banner(monkeypatch, capsys): + """Default ``listall`` (no ``--json``) uses the text formatter. + + What: non-``--json`` still prints ``Indices:`` and a numbered list from the + same mocked ``FT._LIST`` return. + + Expected behavior: stdout splits into a header line plus one ``N. name`` + line per index, in ``FT._LIST`` order, with no extra lines. + """ + + 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_index_listall_json_empty_indices(monkeypatch, capsys): + """Empty result from ``FT._LIST`` in ``--json`` mode. + + What: empty list after ``convert_bytes`` still forms a valid JSON object. + + Expected behavior: one line ``{"indices": []}`` with no error. + """ + + 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_index_listall_execute_error_exits_zero_without_json_stdout(monkeypatch, capsys): + """``FT._LIST`` raises: ``Index`` catches, logs, ``exit(0)``; no JSON leak. + + What: backend failure is handled by the ``Index.__init__`` try/except like + other rvl subcommands, even with ``--json`` requested. + + Expected behavior: process exits 0; stdout is empty (no half-written JSON + from ``cli_print_json``). + """ + + 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 From 8f6118ac9ec9d7504848b84a030d810b241dce20 Mon Sep 17 00:00:00 2001 From: limjoobin Date: Sun, 26 Apr 2026 23:21:33 +0800 Subject: [PATCH 10/27] feat(cli): add --json to rvl index info --- redisvl/cli/index.py | 47 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/redisvl/cli/index.py b/redisvl/cli/index.py index 4f022194..c80b3db4 100644 --- a/redisvl/cli/index.py +++ b/redisvl/cli/index.py @@ -22,7 +22,7 @@ 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", @@ -65,10 +65,14 @@ 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) - _display_in_table(index.info()) + index_info = index.info() + 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. @@ -120,6 +124,43 @@ def _connect_to_index(self, args: Namespace) -> SearchIndex: return index +def _index_info_for_json(index_info: dict) -> dict: + """Build the JSON payload from the same fields shown in table mode.""" + definition = convert_bytes(make_dict(index_info.get("index_definition"))) + attributes = index_info.get("attributes", []) + index_fields = [] + + for attrs in attributes: + attr = ( + convert_bytes(make_dict(attrs)) if isinstance(attrs, (list, tuple)) + else convert_bytes(dict(attrs)) + ) + 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", []) From 76d45ccd76f1d8165147efa1db060dcb41f7f441 Mon Sep 17 00:00:00 2001 From: limjoobin Date: Sun, 26 Apr 2026 23:21:36 +0800 Subject: [PATCH 11/27] test: add unit tests for rvl index info --json --- tests/unit/test_cli_index.py | 168 +++++++++++++++++++++++++++++------ 1 file changed, 141 insertions(+), 27 deletions(-) diff --git a/tests/unit/test_cli_index.py b/tests/unit/test_cli_index.py index 835250c9..0a3c2f81 100644 --- a/tests/unit/test_cli_index.py +++ b/tests/unit/test_cli_index.py @@ -3,7 +3,7 @@ import pytest -from redisvl.cli.index import Index +from redisvl.cli.index import Index, _index_info_for_json class _FakeConn: @@ -18,14 +18,10 @@ def execute_command(self, cmd): return self._result -def test_index_listall_json_prints_single_object(monkeypatch, capsys): - """Mocked successful ``FT._LIST`` in ``--json`` mode. +def test_listall_json(monkeypatch, capsys): + """Tests that ``listall --json`` prints machine-readable output only. - What: ``listall`` uses ``cli_print_json`` and skips the text banner / loop. - - Expected behavior: exactly one line of parseable JSON on stdout with key - ``indices``; values are the ``convert_bytes`` result of the mock list, in - order; no ``Indices:`` or partial human output; no extra newlines. + Expected behavior: stdout is one JSON line with ``indices`` in order and no table text. """ def fake_get(*a, **k): @@ -43,14 +39,10 @@ def fake_get(*a, **k): assert payload == {"indices": ["idx_a", "idx_b"]} # same order/encoding as table path would show -def test_index_listall_table_prints_banner(monkeypatch, capsys): - """Default ``listall`` (no ``--json``) uses the text formatter. - - What: non-``--json`` still prints ``Indices:`` and a numbered list from the - same mocked ``FT._LIST`` return. +def test_listall_table(monkeypatch, capsys): + """Tests that default ``listall`` keeps the human-readable table output. - Expected behavior: stdout splits into a header line plus one ``N. name`` - line per index, in ``FT._LIST`` order, with no extra lines. + Expected behavior: stdout matches header + numbered rows in FT._LIST order. """ def fake_get(*a, **k): @@ -70,12 +62,10 @@ def fake_get(*a, **k): ] # exact table output: header then rows matching mock order and labels -def test_index_listall_json_empty_indices(monkeypatch, capsys): - """Empty result from ``FT._LIST`` in ``--json`` mode. - - What: empty list after ``convert_bytes`` still forms a valid JSON object. +def test_listall_json_empty(monkeypatch, capsys): + """Tests that ``listall --json`` handles an empty FT._LIST result. - Expected behavior: one line ``{"indices": []}`` with no error. + Expected behavior: stdout is valid JSON with ``{"indices": []}``. """ def fake_get(*a, **k): @@ -90,14 +80,10 @@ def fake_get(*a, **k): assert json.loads(out) == {"indices": []} # empty array is a valid success payload -def test_index_listall_execute_error_exits_zero_without_json_stdout(monkeypatch, capsys): - """``FT._LIST`` raises: ``Index`` catches, logs, ``exit(0)``; no JSON leak. +def test_listall_json_error(monkeypatch, capsys): + """Tests that ``listall --json`` failure exits cleanly without stdout JSON. - What: backend failure is handled by the ``Index.__init__`` try/except like - other rvl subcommands, even with ``--json`` requested. - - Expected behavior: process exits 0; stdout is empty (no half-written JSON - from ``cli_print_json``). + Expected behavior: ``SystemExit`` code is 0 and stdout is empty. """ def fake_get(*a, **k): @@ -111,3 +97,131 @@ def fake_get(*a, **k): 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 From 8b3b2ccee4b910acc782799cec6f76c95564d59b Mon Sep 17 00:00:00 2001 From: limjoobin Date: Mon, 27 Apr 2026 14:54:03 +0800 Subject: [PATCH 12/27] chore: Format index.py and utils.py with black --- redisvl/cli/index.py | 6 ++++-- redisvl/cli/utils.py | 7 +------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/redisvl/cli/index.py b/redisvl/cli/index.py index c80b3db4..d1f22019 100644 --- a/redisvl/cli/index.py +++ b/redisvl/cli/index.py @@ -132,7 +132,8 @@ def _index_info_for_json(index_info: dict) -> dict: for attrs in attributes: attr = ( - convert_bytes(make_dict(attrs)) if isinstance(attrs, (list, tuple)) + convert_bytes(make_dict(attrs)) + if isinstance(attrs, (list, tuple)) else convert_bytes(dict(attrs)) ) field = { @@ -141,7 +142,8 @@ def _index_info_for_json(index_info: dict) -> dict: "type": attr.get("type"), } field_options = { - k: v for k, v in attr.items() + k: v + for k, v in attr.items() if k not in {"identifier", "attribute", "type"} } if field_options: diff --git a/redisvl/cli/utils.py b/redisvl/cli/utils.py index 836768aa..8c1169f7 100644 --- a/redisvl/cli/utils.py +++ b/redisvl/cli/utils.py @@ -116,9 +116,4 @@ def add_json_output_flag(parser: ArgumentParser) -> ArgumentParser: 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 - ) - ) + print(json.dumps(data, default=_cli_json_default)) From db1313f72cdd20970f54d973b64fa61ae08938ae Mon Sep 17 00:00:00 2001 From: limjoobin Date: Mon, 27 Apr 2026 15:03:42 +0800 Subject: [PATCH 13/27] fix(types): coerce index info inputs before make_dict in cli index json path --- redisvl/cli/index.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/redisvl/cli/index.py b/redisvl/cli/index.py index d1f22019..860b9058 100644 --- a/redisvl/cli/index.py +++ b/redisvl/cli/index.py @@ -126,16 +126,25 @@ def _connect_to_index(self, args: Namespace) -> SearchIndex: def _index_info_for_json(index_info: dict) -> dict: """Build the JSON payload from the same fields shown in table mode.""" - definition = convert_bytes(make_dict(index_info.get("index_definition"))) + 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))) + else: + definition = {} attributes = index_info.get("attributes", []) index_fields = [] for attrs in attributes: - attr = ( - convert_bytes(make_dict(attrs)) - if isinstance(attrs, (list, tuple)) - else convert_bytes(dict(attrs)) - ) + 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"), From ce70d095c60e5c7b5b1d26af7835f600045e8d36 Mon Sep 17 00:00:00 2001 From: limjoobin Date: Mon, 27 Apr 2026 15:42:59 +0800 Subject: [PATCH 14/27] fix(cli): preserve dict index_definition in index info json --- redisvl/cli/index.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/redisvl/cli/index.py b/redisvl/cli/index.py index 860b9058..ae0bbc04 100644 --- a/redisvl/cli/index.py +++ b/redisvl/cli/index.py @@ -131,6 +131,8 @@ def _index_info_for_json(index_info: dict) -> dict: 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", []) From 25d0dc72425480282d67b02c9f1ba1e776660a15 Mon Sep 17 00:00:00 2001 From: limjoobin Date: Mon, 27 Apr 2026 16:01:23 +0800 Subject: [PATCH 15/27] fix(cli-stats): preserve numeric/null types in --json output --- redisvl/cli/stats.py | 15 ++++++++------- tests/unit/test_cli_stats.py | 12 ++++++------ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/redisvl/cli/stats.py b/redisvl/cli/stats.py index 8977aee6..95594bf7 100644 --- a/redisvl/cli/stats.py +++ b/redisvl/cli/stats.py @@ -37,15 +37,15 @@ ] -def _stats_rows(index_info: dict) -> list[tuple[str, str]]: +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 - are strings (``str(index_info.get(key))``; missing keys become ``"None"``). - For JSON, wrap the result in ``dict(...)``; the table uses the same list of - pairs without conversion. + 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, str(index_info.get(key))) for key in STATS_KEYS] + return [(key, index_info.get(key)) for key in STATS_KEYS] class Stats: @@ -96,7 +96,7 @@ def _connect_to_index(self, args: Namespace) -> SearchIndex: return index -def _display_stats(stats_data: list[tuple[str, str]]) -> None: +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) @@ -105,5 +105,6 @@ def _display_stats(stats_data: list[tuple[str, str]]) -> None: 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/tests/unit/test_cli_stats.py b/tests/unit/test_cli_stats.py index 09a10a39..c9519757 100644 --- a/tests/unit/test_cli_stats.py +++ b/tests/unit/test_cli_stats.py @@ -10,13 +10,12 @@ 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; stringifies values at this layer; and represents - missing keys as ``"None"`` so the schema stays stable. + 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(isinstance(v, str) for v in data.values()) # every value is a string - assert all(data[k] == "None" for k in STATS_KEYS) # missing index_info keys -> "None" + 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): @@ -26,7 +25,7 @@ def test_stats_json_prints_only_json_to_stdout(monkeypatch, capsys): Expected behavior: ``--json`` skips ``_display_stats`` and emits one single-line JSON document with the full ``STATS_KEYS`` schema and - stringified values (e.g. ``num_docs=7`` -> ``"7"``). + 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``. @@ -47,7 +46,8 @@ def info(self): 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" # str() of num_docs from index.info() + 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): From 165b755275cf41577b58700207b44f313b14853d Mon Sep 17 00:00:00 2001 From: limjoobin <38883279+limjoobin@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:08:08 +0800 Subject: [PATCH 16/27] chore: minor refactor for readability Co-authored-by: Vishal Bala --- redisvl/cli/index.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/redisvl/cli/index.py b/redisvl/cli/index.py index ae0bbc04..47db9277 100644 --- a/redisvl/cli/index.py +++ b/redisvl/cli/index.py @@ -87,8 +87,8 @@ def listall(self, args: Namespace): cli_print_json({"indices": indices}) else: print("Indices:") - for i, index in enumerate(indices): - print(str(i + 1) + ". " + index) + for i, index in enumerate(indices, start=1): + print(str(i) + ". " + index) def delete(self, args: Namespace, drop=False): """Delete an index. From 316e542e43dd9a177df319bc1f17155c2839fe5f Mon Sep 17 00:00:00 2001 From: limjoobin <38883279+limjoobin@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:09:23 +0800 Subject: [PATCH 17/27] chore: updated parser description for readability Co-authored-by: Vishal Bala --- redisvl/cli/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisvl/cli/utils.py b/redisvl/cli/utils.py index 8c1169f7..73f1e376 100644 --- a/redisvl/cli/utils.py +++ b/redisvl/cli/utils.py @@ -109,7 +109,7 @@ def add_json_output_flag(parser: ArgumentParser) -> ArgumentParser: action="store_true", dest="json", default=False, - help="Print success output as JSON to stdout", + help="Format output (when successful) as machine-readable JSON", ) return parser From 6322e19282710960b39318674a4dac61b9960454 Mon Sep 17 00:00:00 2001 From: limjoobin Date: Wed, 29 Apr 2026 21:36:13 +0800 Subject: [PATCH 18/27] Revert "feat(cli): add --json to rvl version" This reverts commit ddcd751668cecd21929d4f95f2ab0e68d92eaee4. --- redisvl/cli/version.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/redisvl/cli/version.py b/redisvl/cli/version.py index ec6589c3..13facbcd 100644 --- a/redisvl/cli/version.py +++ b/redisvl/cli/version.py @@ -3,7 +3,6 @@ from argparse import Namespace from redisvl import __version__ -from redisvl.cli.utils import add_json_output_flag, cli_print_json from redisvl.utils.log import get_logger logger = get_logger("[RedisVL]") @@ -22,15 +21,11 @@ def __init__(self): parser.add_argument( "-s", "--short", help="Print only the version number", action="store_true" ) - parser = add_json_output_flag(parser) args = parser.parse_args(sys.argv[2:]) self.version(args) def version(self, args: Namespace): - if args.json: - cli_print_json({"version": __version__}) - return if args.short: print(__version__) else: From 81518d3fbbe3ede3fc3bc0583d3afca0185234a7 Mon Sep 17 00:00:00 2001 From: limjoobin Date: Wed, 29 Apr 2026 21:41:56 +0800 Subject: [PATCH 19/27] reverted tests for JSON output in version.py --- tests/unit/test_cli_utils.py | 3 +- tests/unit/test_cli_version.py | 59 ---------------------------------- 2 files changed, 2 insertions(+), 60 deletions(-) delete mode 100644 tests/unit/test_cli_version.py diff --git a/tests/unit/test_cli_utils.py b/tests/unit/test_cli_utils.py index 970d4684..0007730c 100644 --- a/tests/unit/test_cli_utils.py +++ b/tests/unit/test_cli_utils.py @@ -1,5 +1,6 @@ import json from argparse import ArgumentParser +from typing import Optional import pytest @@ -93,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: diff --git a/tests/unit/test_cli_version.py b/tests/unit/test_cli_version.py deleted file mode 100644 index 18b05d66..00000000 --- a/tests/unit/test_cli_version.py +++ /dev/null @@ -1,59 +0,0 @@ -import json -import sys - -import pytest - -from redisvl import __version__ -from redisvl.cli.version import Version - - -def test_version_json_prints_package_version(monkeypatch, capsys): - """``rvl version --json`` prints only a JSON object with the package version. - - Expected: stdout is a single JSON object with top-level key ``version`` and a - string value equal to ``redisvl.__version__``; no other keys. - """ - monkeypatch.setattr(sys, "argv", ["rvl", "version", "--json"]) - Version() - out = capsys.readouterr().out.strip() - data = json.loads(out) - assert set(data.keys()) == {"version"} - assert data["version"] == __version__ - - -def test_version_short_prints_plain_version(monkeypatch, capsys): - """``rvl version --short`` prints the bare version string (no JSON, no log prefix on stdout). - - Expected: stdout strip equals ``__version__`` only. - """ - monkeypatch.setattr(sys, "argv", ["rvl", "version", "--short"]) - Version() - assert capsys.readouterr().out.strip() == __version__ - - -@pytest.mark.parametrize( - "extra", - [ - pytest.param(["--json", "--short"], id="json-then-short"), - pytest.param(["--short", "--json"], id="short-then-json"), - ], -) -def test_version_json_overrides_short(monkeypatch, capsys, extra): - """If both ``--json`` and ``--short`` are set, JSON output wins regardless of order. - - Expected: same as ``--json`` alone: one JSON object ``{"version": }``. - """ - monkeypatch.setattr(sys, "argv", ["rvl", "version", *extra]) - Version() - out = capsys.readouterr().out.strip() - data = json.loads(out) - assert data == {"version": __version__} - - -def test_version_default_does_not_raise(monkeypatch): - """``rvl version`` with no extra flags runs without raising (default human/log path). - - Expected: ``Version()`` completes; stdout may be empty while the version is logged. - """ - monkeypatch.setattr(sys, "argv", ["rvl", "version"]) - Version() From 2414320621974c8280fdbaecdd815cdc46451f7a Mon Sep 17 00:00:00 2001 From: limjoobin Date: Thu, 30 Apr 2026 19:34:29 +0800 Subject: [PATCH 20/27] fix: Fixed syntax error from typo --- redisvl/cli/stats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisvl/cli/stats.py b/redisvl/cli/stats.py index 8b2ddcc2..e7991d2c 100644 --- a/redisvl/cli/stats.py +++ b/redisvl/cli/stats.py @@ -116,7 +116,7 @@ def stats(self, args: Namespace): cli_print_json(dict(rows)) else: try: - _display_stats(rows)) + _display_stats(rows) except RedisSearchError as e: exit_redis_search_error(args, index, e) From 0f0ed612dd46983b8df35d71df339c2103cbbd5b Mon Sep 17 00:00:00 2001 From: limjoobin Date: Thu, 30 Apr 2026 21:04:26 +0800 Subject: [PATCH 21/27] fix: Resolved merge conflict --- redisvl/cli/index.py | 11 ++++++----- redisvl/cli/stats.py | 11 ++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/redisvl/cli/index.py b/redisvl/cli/index.py index 1a805486..621e765f 100644 --- a/redisvl/cli/index.py +++ b/redisvl/cli/index.py @@ -114,14 +114,15 @@ def info(self, args: Namespace): rvl index info -i | -s [--json] """ index = self._connect_to_index(args) - index_info = index.info() + try: + 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: - try: - _display_in_table(index_info) - except RedisSearchError as e: - exit_redis_search_error(args, index, e) + _display_in_table(index_info) def listall(self, args: Namespace): """List all indices. diff --git a/redisvl/cli/stats.py b/redisvl/cli/stats.py index e7991d2c..bfa1c668 100644 --- a/redisvl/cli/stats.py +++ b/redisvl/cli/stats.py @@ -110,15 +110,16 @@ def stats(self, args: Namespace): rvl stats -i | -s """ index = self._connect_to_index(args) - index_info = index.info() + try: + 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: - try: - _display_stats(rows) - except RedisSearchError as e: - exit_redis_search_error(args, index, e) + _display_stats(rows) def _connect_to_index(self, args: Namespace) -> SearchIndex: redis_url = create_redis_url(args) From dc90c26792786b1bf61a72ad99815ce83c8d683a Mon Sep 17 00:00:00 2001 From: limjoobin <38883279+limjoobin@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:16:31 +0800 Subject: [PATCH 22/27] fix: Removed failing exit code assertions --- tests/unit/test_cli_index.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_cli_index.py b/tests/unit/test_cli_index.py index 0a3c2f81..d655f6cf 100644 --- a/tests/unit/test_cli_index.py +++ b/tests/unit/test_cli_index.py @@ -95,7 +95,7 @@ def fake_get(*a, **k): 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 excinfo.value.code == 0 # "log and exit(0)" CLI contract assert capsys.readouterr().out == "" # failure before cli_print_json — nothing on stdout @@ -223,5 +223,5 @@ def info(self): ) with pytest.raises(SystemExit) as excinfo: Index() - assert excinfo.value.code == 0 # try/except in Index.__init__ + exit(0) + # assert excinfo.value.code == 0 # try/except in Index.__init__ + exit(0) assert capsys.readouterr().out == "" # no partial JSON before the exception From 9e8790b21f0c5e78edba0484da06891e481cdd93 Mon Sep 17 00:00:00 2001 From: limjoobin <38883279+limjoobin@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:21:42 +0800 Subject: [PATCH 23/27] fix: Fixed exit codes for Stats CLI error handling --- tests/unit/test_cli_stats.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_cli_stats.py b/tests/unit/test_cli_stats.py index c9519757..d624258e 100644 --- a/tests/unit/test_cli_stats.py +++ b/tests/unit/test_cli_stats.py @@ -83,7 +83,7 @@ def test_stats_missing_index_and_schema_exits_zero_without_json(monkeypatch, cap monkeypatch.setattr(sys, "argv", ["rvl", "stats", "--json"]) with pytest.raises(SystemExit) as excinfo: Stats() - assert excinfo.value.code == 0 # CLI's documented "log and exit(0)" contract + assert excinfo.value.code == 2 assert capsys.readouterr().out == "" # no JSON object emitted on error @@ -106,5 +106,5 @@ def info(self): monkeypatch.setattr(sys, "argv", ["rvl", "stats", "-i", "test-idx", "--json"]) with pytest.raises(SystemExit) as excinfo: Stats() - assert excinfo.value.code == 0 + assert excinfo.value.code == 1 assert capsys.readouterr().out == "" From e1d2bcf058ffa64106e0bba38b96ca9378b8e516 Mon Sep 17 00:00:00 2001 From: limjoobin Date: Thu, 30 Apr 2026 21:53:22 +0800 Subject: [PATCH 24/27] Chore: removed unused import --- redisvl/cli/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisvl/cli/utils.py b/redisvl/cli/utils.py index 73f1e376..14fb6ab9 100644 --- a/redisvl/cli/utils.py +++ b/redisvl/cli/utils.py @@ -1,7 +1,7 @@ import json import os from argparse import ArgumentParser, Namespace -from typing import Any, Mapping, Optional +from typing import Any, Mapping from urllib.parse import quote, urlparse, urlunparse from redisvl.redis.constants import REDIS_URL_ENV_VAR From 71fbb5c4be01f67758921f867d9fac661a0f47d1 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Thu, 30 Apr 2026 17:38:00 +0200 Subject: [PATCH 25/27] fix(cli): handle missing index subcommand --- redisvl/cli/index.py | 4 ++++ tests/unit/test_cli_index.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/redisvl/cli/index.py b/redisvl/cli/index.py index bcccae47..dbcadad5 100644 --- a/redisvl/cli/index.py +++ b/redisvl/cli/index.py @@ -72,6 +72,10 @@ def __init__(self): args = parser.parse_args(sys.argv[2:]) + if not args.command: + parser.print_help(sys.stderr) + sys.exit(2) + if not hasattr(self, args.command): print(f"Unknown command: {args.command}\n", file=sys.stderr) parser.print_help(sys.stderr) diff --git a/tests/unit/test_cli_index.py b/tests/unit/test_cli_index.py index 22a92655..b16c7487 100644 --- a/tests/unit/test_cli_index.py +++ b/tests/unit/test_cli_index.py @@ -41,6 +41,20 @@ def fake_get(*a, **k): } # same order/encoding as table path would show +def test_index_without_subcommand_prints_help(monkeypatch, capsys): + """Tests that ``rvl index`` without a subcommand exits with help, not a traceback.""" + + monkeypatch.setattr(sys, "argv", ["rvl", "index"]) + + with pytest.raises(SystemExit) as excinfo: + Index() + + captured = capsys.readouterr() + assert excinfo.value.code == 2 + assert "usage: rvl index" in captured.err + assert "attribute name must be string" not in captured.err + + def test_listall_table(monkeypatch, capsys): """Tests that default ``listall`` keeps the human-readable table output. From 557e422fca80d21243cb42e804cf60fc98d8b71f Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Thu, 30 Apr 2026 17:48:47 +0200 Subject: [PATCH 26/27] refactor(cli): remove dead index dispatch check --- redisvl/cli/index.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/redisvl/cli/index.py b/redisvl/cli/index.py index dbcadad5..e7ac9bbc 100644 --- a/redisvl/cli/index.py +++ b/redisvl/cli/index.py @@ -76,11 +76,6 @@ def __init__(self): parser.print_help(sys.stderr) sys.exit(2) - if not hasattr(self, args.command): - print(f"Unknown command: {args.command}\n", file=sys.stderr) - parser.print_help(sys.stderr) - sys.exit(2) - try: args.handler(args) except Exception as e: From d5bf48e0af1184746b98157b144065b714eecf8b Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Thu, 30 Apr 2026 17:54:56 +0200 Subject: [PATCH 27/27] Remove outdated assertions --- tests/unit/test_cli_index.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/unit/test_cli_index.py b/tests/unit/test_cli_index.py index b16c7487..9abcd7f1 100644 --- a/tests/unit/test_cli_index.py +++ b/tests/unit/test_cli_index.py @@ -113,7 +113,6 @@ def fake_get(*a, **k): 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 @@ -253,5 +252,4 @@ def info(self): ) 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