Skip to content

Commit 270b48f

Browse files
Add JSON output for RedisVL CLI read commands (#593)
### Changes This PR adds JSON output for RedisVL CLI read commands to make it more machine-readable. Features added: - added the `--json` flag to `rvl version`, `rvl index listall`, `rvl index info`, and `rvl stats`. - Add shared JSON CLI helpers (`add_json_output_flag`, `cli_print_json`) in `redisvl/cli/utils.py` for consistent one-object stdout behavior. - Add/update unit tests for all new JSON paths and error contracts. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Adds optional output formatting for CLI read commands without changing index/data operations. Main risk is minor: downstream scripts may depend on exact stdout text when `--json` is used, but default behavior is preserved. > > **Overview** > Adds a shared CLI JSON output mode via `--json`, including new helpers `add_json_output_flag` and `cli_print_json` (with safe `bytes` encoding) to ensure **one JSON object on stdout** on success. > > Extends `rvl index info`, `rvl index listall`, and `rvl stats` to support JSON output: `index info` now normalizes `FT.INFO`-style structures into a stable `{index_information, index_fields}` payload, `listall` emits `{indices: [...]}`, and `stats` emits a full `STATS_KEYS`-shaped object with missing keys as `null`, while keeping existing human-readable output when `--json` is not used. > > Adds unit tests covering JSON vs table behavior, key ordering/normalization, bytes decoding, and ensuring **no partial JSON is printed on error paths**. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e1d2bcf. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Vishal Bala <vishal-bala@users.noreply.github.com>
1 parent df4d64c commit 270b48f

6 files changed

Lines changed: 512 additions & 18 deletions

File tree

redisvl/cli/index.py

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55
import yaml
66
from pydantic import ValidationError
77

8-
from redisvl.cli.utils import add_index_parsing_options, create_redis_url
8+
from redisvl.cli.utils import (
9+
add_index_parsing_options,
10+
add_json_output_flag,
11+
cli_print_json,
12+
create_redis_url,
13+
)
914
from redisvl.exceptions import RedisSearchError
1015
from redisvl.index import SearchIndex
1116
from redisvl.redis.connection import RedisConnectionFactory
@@ -51,11 +56,11 @@ class Index:
5156
[
5257
"rvl index <command> [<args>]\n",
5358
"Commands:",
54-
"\tinfo Obtain information about an index",
59+
"\tinfo Obtain information about an index (use --json for machine output)",
5560
"\tcreate Create a new index",
5661
"\tdelete Delete an existing index",
5762
"\tdestroy Delete an existing index and all of its data",
58-
"\tlistall List all indexes",
63+
"\tlistall List all indexes (use --json for machine output)",
5964
"\n",
6065
]
6166
)
@@ -65,6 +70,7 @@ def __init__(self):
6570

6671
parser.add_argument("command", help="Subcommand to run")
6772
parser = add_index_parsing_options(parser)
73+
parser = add_json_output_flag(parser)
6874

6975
args = parser.parse_args(sys.argv[2:])
7076

@@ -105,26 +111,34 @@ def info(self, args: Namespace):
105111
"""Obtain information about an index.
106112
107113
Usage:
108-
rvl index info -i <index_name> | -s <schema_path>
114+
rvl index info -i <index_name> | -s <schema_path> [--json]
109115
"""
110116
index = self._connect_to_index(args)
111117
try:
112-
_display_in_table(index.info())
118+
index_info = index.info()
113119
except RedisSearchError as e:
114120
exit_redis_search_error(args, index, e)
115121

122+
if args.json:
123+
cli_print_json(_index_info_for_json(index_info))
124+
else:
125+
_display_in_table(index_info)
126+
116127
def listall(self, args: Namespace):
117128
"""List all indices.
118129
119130
Usage:
120-
rvl index listall
131+
rvl index listall [--json]
121132
"""
122133
redis_url = create_redis_url(args)
123134
conn = RedisConnectionFactory.get_redis_connection(redis_url=redis_url)
124135
indices = convert_bytes(conn.execute_command("FT._LIST"))
125-
print("Indices:")
126-
for i, index in enumerate(indices):
127-
print(str(i + 1) + ". " + index)
136+
if args.json:
137+
cli_print_json({"indices": indices})
138+
else:
139+
print("Indices:")
140+
for i, index in enumerate(indices, start=1):
141+
print(str(i) + ". " + index)
128142

129143
def delete(self, args: Namespace, drop=False):
130144
"""Delete an index.
@@ -167,6 +181,56 @@ def _connect_to_index(self, args: Namespace) -> SearchIndex:
167181
sys.exit(2)
168182

169183

184+
def _index_info_for_json(index_info: dict) -> dict:
185+
"""Build the JSON payload from the same fields shown in table mode."""
186+
definition_src = index_info.get("index_definition")
187+
if isinstance(definition_src, list):
188+
definition = convert_bytes(make_dict(definition_src))
189+
elif isinstance(definition_src, tuple):
190+
definition = convert_bytes(make_dict(list(definition_src)))
191+
elif isinstance(definition_src, dict):
192+
definition = convert_bytes(dict(definition_src))
193+
else:
194+
definition = {}
195+
attributes = index_info.get("attributes", [])
196+
index_fields = []
197+
198+
for attrs in attributes:
199+
if isinstance(attrs, list):
200+
attr = convert_bytes(make_dict(attrs))
201+
elif isinstance(attrs, tuple):
202+
attr = convert_bytes(make_dict(list(attrs)))
203+
elif isinstance(attrs, dict):
204+
attr = convert_bytes(dict(attrs))
205+
else:
206+
attr = {}
207+
field = {
208+
"name": attr.get("identifier"),
209+
"attribute": attr.get("attribute"),
210+
"type": attr.get("type"),
211+
}
212+
field_options = {
213+
k: v
214+
for k, v in attr.items()
215+
if k not in {"identifier", "attribute", "type"}
216+
}
217+
if field_options:
218+
field["field_options"] = field_options
219+
index_fields.append(field)
220+
221+
payload = {
222+
"index_information": {
223+
"index_name": index_info.get("index_name"),
224+
"storage_type": definition.get("key_type"),
225+
"prefixes": definition.get("prefixes"),
226+
"index_options": index_info.get("index_options"),
227+
"indexing": index_info.get("indexing"),
228+
},
229+
"index_fields": index_fields,
230+
}
231+
return convert_bytes(payload)
232+
233+
170234
def _display_in_table(index_info):
171235
print("\n")
172236
attributes = index_info.get("attributes", [])

redisvl/cli/stats.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55
import yaml
66
from pydantic import ValidationError
77

8-
from redisvl.cli.utils import add_index_parsing_options, create_redis_url
8+
from redisvl.cli.utils import (
9+
add_index_parsing_options,
10+
add_json_output_flag,
11+
cli_print_json,
12+
create_redis_url,
13+
)
914
from redisvl.exceptions import RedisSearchError
1015
from redisvl.index import SearchIndex
1116
from redisvl.schema.schema import IndexSchema
@@ -67,6 +72,17 @@ def exit_redis_search_error(
6772
]
6873

6974

75+
def _stats_rows(index_info: dict) -> list[tuple[str, object]]:
76+
"""Normalize ``index.info()`` for both JSON and table output.
77+
78+
Returns ordered ``(key, value)`` pairs: keys follow ``STATS_KEYS``, values
79+
preserve native types from ``index_info``. Missing keys remain ``None``.
80+
For JSON, wrap the result in ``dict(...)``; the table stringifies values
81+
when rendering.
82+
"""
83+
return [(key, index_info.get(key)) for key in STATS_KEYS]
84+
85+
7086
class Stats:
7187
usage = "\n".join(
7288
[
@@ -77,6 +93,7 @@ class Stats:
7793
def __init__(self):
7894
parser = argparse.ArgumentParser(usage=self.usage)
7995
parser = add_index_parsing_options(parser)
96+
parser = add_json_output_flag(parser)
8097
args = parser.parse_args(sys.argv[2:])
8198

8299
try:
@@ -94,10 +111,16 @@ def stats(self, args: Namespace):
94111
"""
95112
index = self._connect_to_index(args)
96113
try:
97-
_display_stats(index.info())
114+
index_info = index.info()
98115
except RedisSearchError as e:
99116
exit_redis_search_error(args, index, e)
100117

118+
rows = _stats_rows(index_info)
119+
if args.json:
120+
cli_print_json(dict(rows))
121+
else:
122+
_display_stats(rows)
123+
101124
def _connect_to_index(self, args: Namespace) -> SearchIndex:
102125
redis_url = create_redis_url(args)
103126

@@ -118,10 +141,7 @@ def _connect_to_index(self, args: Namespace) -> SearchIndex:
118141
sys.exit(2)
119142

120143

121-
def _display_stats(index_info):
122-
# Extracting the statistics
123-
stats_data = [(key, str(index_info.get(key))) for key in STATS_KEYS]
124-
144+
def _display_stats(stats_data: list[tuple[str, object]]) -> None:
125145
# Display the statistics in tabular format
126146
print("\nStatistics:")
127147
max_key_length = max(len(key) for key, _ in stats_data)
@@ -130,5 +150,6 @@ def _display_stats(index_info):
130150
print("│ Stat Key │ Value │") # header row
131151
print(f"├{horizontal_line}┼────────────┤") # separator row
132152
for key, value in stats_data:
133-
print(f"│ {key:<27}{value[0:10]:<10} │") # data rows
153+
value_str = str(value)
154+
print(f"│ {key:<27}{value_str[0:10]:<10} │") # data rows
134155
print(f"╰{horizontal_line}┴────────────╯") # bottom row

redisvl/cli/utils.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import json
12
import os
23
from argparse import ArgumentParser, Namespace
4+
from typing import Any, Mapping
35
from urllib.parse import quote, urlparse, urlunparse
46

57
from redisvl.redis.constants import REDIS_URL_ENV_VAR
@@ -91,3 +93,27 @@ def add_index_parsing_options(parser: ArgumentParser) -> ArgumentParser:
9193
default=None,
9294
)
9395
return parser
96+
97+
98+
def _cli_json_default(obj: object) -> object:
99+
"""Handle common non-JSON-native types (e.g. bytes from Redis) for cli_print_json."""
100+
if isinstance(obj, bytes):
101+
return obj.decode("utf-8", errors="replace")
102+
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
103+
104+
105+
def add_json_output_flag(parser: ArgumentParser) -> ArgumentParser:
106+
"""Register ``--json`` for machine-readable stdout (one JSON object on success)."""
107+
parser.add_argument(
108+
"--json",
109+
action="store_true",
110+
dest="json",
111+
default=False,
112+
help="Format output (when successful) as machine-readable JSON",
113+
)
114+
return parser
115+
116+
117+
def cli_print_json(data: Mapping[str, Any]) -> None:
118+
"""Write a single JSON object to stdout (deterministic key order for tests and scripts)."""
119+
print(json.dumps(data, default=_cli_json_default))

0 commit comments

Comments
 (0)