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." ] }, { diff --git a/redisvl/cli/index.py b/redisvl/cli/index.py index 621e765f..e7ac9bbc 100644 --- a/redisvl/cli/index.py +++ b/redisvl/cli/index.py @@ -52,40 +52,132 @@ def exit_redis_search_error( 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 (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 (use --json for machine output)", - "\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 = add_json_output_flag(parser) + parser = self._build_parser() args = parser.parse_args(sys.argv[2:]) - if not hasattr(self, args.command): - print(f"Unknown command: {args.command}\n", file=sys.stderr) + if not args.command: parser.print_help(sys.stderr) sys.exit(2) try: - getattr(self, args.command)(args) + args.handler(args) except Exception as e: logger.error(e, exc_info=True) print(str(e), file=sys.stderr) sys.exit(1) + 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) + add_json_output_flag(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 (use --json for machine output)", + 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 --json", + ] + ), + 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 (use --json for machine output)", + 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 --json", + ] + ), + 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 b986bd57..0cacbe57 100644 --- a/redisvl/cli/main.py +++ b/redisvl/cli/main.py @@ -1,33 +1,48 @@ 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(sys.stdout) @@ -35,7 +50,7 @@ def __init__(self): args = parser.parse_args(sys.argv[1:2]) - if not hasattr(self, args.command): + if not args.command or not hasattr(self, args.command): print(f"Unknown command: {args.command}\n", file=sys.stderr) parser.print_help(sys.stderr) sys.exit(2) @@ -47,6 +62,8 @@ def __init__(self): sys.exit(1) def index(self): + from redisvl.cli.index import Index + Index() sys.exit(0) @@ -57,9 +74,13 @@ def mcp(self): sys.exit(0) def version(self): + from redisvl.cli.version import Version + Version() sys.exit(0) def stats(self): + from redisvl.cli.stats import Stats + Stats() sys.exit(0) diff --git a/redisvl/cli/mcp.py b/redisvl/cli/mcp.py index 822efc77..3eb8d814 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 bfa1c668..2614bfae 100644 --- a/redisvl/cli/stats.py +++ b/redisvl/cli/stats.py @@ -84,14 +84,28 @@ def _stats_rows(index_info: dict) -> list[tuple[str, object]]: 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) parser = add_json_output_flag(parser) args = parser.parse_args(sys.argv[2:]) diff --git a/redisvl/cli/utils.py b/redisvl/cli/utils.py index 14fb6ab9..6e7130dc 100644 --- a/redisvl/cli/utils.py +++ b/redisvl/cli/utils.py @@ -76,19 +76,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, ) - 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( + 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, + ) + 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_index.py b/tests/unit/test_cli_index.py index d655f6cf..c425cd6a 100644 --- a/tests/unit/test_cli_index.py +++ b/tests/unit/test_cli_index.py @@ -36,7 +36,9 @@ def fake_get(*a, **k): 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 + assert payload == { + "indices": ["idx_a", "idx_b"] + } # same order/encoding as table path would show def test_listall_table(monkeypatch, capsys): @@ -93,10 +95,13 @@ def fake_get(*a, **k): "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 + 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 + assert ( + capsys.readouterr().out == "" + ) # failure before cli_print_json — nothing on stdout def test_info_json_normalize(): @@ -199,10 +204,20 @@ def info(self): 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 + 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. @@ -223,5 +238,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 diff --git a/tests/unit/test_cli_mcp.py b/tests/unit/test_cli_mcp.py index 4beb5cf0..f167cfbd 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 diff --git a/tests/unit/test_cli_stats.py b/tests/unit/test_cli_stats.py index d624258e..ec769706 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 == 2 + assert excinfo.value.code == 2 assert capsys.readouterr().out == "" # no JSON object emitted on error diff --git a/tests/unit/test_cli_utils.py b/tests/unit/test_cli_utils.py index 0007730c..8d80d6e6 100644 --- a/tests/unit/test_cli_utils.py +++ b/tests/unit/test_cli_utils.py @@ -4,7 +4,12 @@ import pytest -from redisvl.cli.utils import add_index_parsing_options, add_json_output_flag, cli_print_json, create_redis_url +from redisvl.cli.utils import ( + add_index_parsing_options, + add_json_output_flag, + cli_print_json, + create_redis_url, +) @pytest.fixture