Skip to content

Commit 9ebc8c0

Browse files
committed
feat: add serialization helpers for SearchIndex and AsyncSearchIndex
Add to_dict() and to_yaml() methods to both SearchIndex and AsyncSearchIndex classes for improved automation and reproducibility. Features: - to_dict() serializes schema configuration to dictionary - to_yaml() writes configuration to YAML file - Optional include_connection parameter with security-safe defaults - Sensitive fields (passwords) are never serialized - Only safe connection keys are included (ssl, socket_timeout, etc.) Closes redis#493
1 parent c9f9a0d commit 9ebc8c0

File tree

2 files changed

+252
-0
lines changed

2 files changed

+252
-0
lines changed

redisvl/index/index.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1294,6 +1294,49 @@ def info(self, name: Optional[str] = None) -> Dict[str, Any]:
12941294
index_name = name or self.schema.index.name
12951295
return self._info(index_name, self._redis_client)
12961296

1297+
def to_dict(self, include_connection: bool = False) -> Dict[str, Any]:
1298+
"""Serialize the index configuration to a dictionary.
1299+
1300+
Args:
1301+
include_connection (bool, optional): Whether to include connection
1302+
parameters. Defaults to False for security (passwords/URLs
1303+
are excluded by default).
1304+
1305+
Returns:
1306+
Dict[str, Any]: Dictionary representation of the index configuration.
1307+
1308+
Example:
1309+
>>> config = index.to_dict()
1310+
>>> new_index = SearchIndex.from_dict(config)
1311+
"""
1312+
config = self.schema.to_dict()
1313+
if include_connection:
1314+
config["_redis_url"] = self._redis_url
1315+
# Note: connection_kwargs may contain sensitive info
1316+
# Only include non-sensitive keys
1317+
safe_keys = {"decode_responses", "ssl", "socket_timeout", "socket_connect_timeout"}
1318+
config["_connection_kwargs"] = {
1319+
k: v for k, v in self._connection_kwargs.items()
1320+
if k in safe_keys
1321+
}
1322+
return config
1323+
1324+
def to_yaml(self, path: str, include_connection: bool = False) -> None:
1325+
"""Serialize the index configuration to a YAML file.
1326+
1327+
Args:
1328+
path (str): Path to write the YAML file.
1329+
include_connection (bool, optional): Whether to include connection
1330+
parameters. Defaults to False for security.
1331+
1332+
Example:
1333+
>>> index.to_yaml("schemas/my_index.yaml")
1334+
"""
1335+
import yaml
1336+
config = self.to_dict(include_connection=include_connection)
1337+
with open(path, "w") as f:
1338+
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
1339+
12971340
def __enter__(self):
12981341
return self
12991342

@@ -2251,6 +2294,49 @@ def disconnect_sync(self):
22512294
return
22522295
sync_wrapper(self.disconnect)()
22532296

2297+
def to_dict(self, include_connection: bool = False) -> Dict[str, Any]:
2298+
"""Serialize the index configuration to a dictionary.
2299+
2300+
Args:
2301+
include_connection (bool, optional): Whether to include connection
2302+
parameters. Defaults to False for security (passwords/URLs
2303+
are excluded by default).
2304+
2305+
Returns:
2306+
Dict[str, Any]: Dictionary representation of the index configuration.
2307+
2308+
Example:
2309+
>>> config = index.to_dict()
2310+
>>> new_index = AsyncSearchIndex.from_dict(config)
2311+
"""
2312+
config = self.schema.to_dict()
2313+
if include_connection:
2314+
config["_redis_url"] = self._redis_url
2315+
# Note: connection_kwargs may contain sensitive info
2316+
# Only include non-sensitive keys
2317+
safe_keys = {"decode_responses", "ssl", "socket_timeout", "socket_connect_timeout"}
2318+
config["_connection_kwargs"] = {
2319+
k: v for k, v in self._connection_kwargs.items()
2320+
if k in safe_keys
2321+
}
2322+
return config
2323+
2324+
def to_yaml(self, path: str, include_connection: bool = False) -> None:
2325+
"""Serialize the index configuration to a YAML file.
2326+
2327+
Args:
2328+
path (str): Path to write the YAML file.
2329+
include_connection (bool, optional): Whether to include connection
2330+
parameters. Defaults to False for security.
2331+
2332+
Example:
2333+
>>> await index.to_yaml("schemas/my_index.yaml")
2334+
"""
2335+
import yaml
2336+
config = self.to_dict(include_connection=include_connection)
2337+
with open(path, "w") as f:
2338+
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
2339+
22542340
async def __aenter__(self):
22552341
return self
22562342

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""Tests for SearchIndex serialization helpers."""
2+
import tempfile
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
from redisvl.index import AsyncSearchIndex, SearchIndex
8+
from redisvl.schema import IndexSchema
9+
10+
11+
@pytest.fixture
12+
def sample_schema():
13+
"""Create a sample schema for testing."""
14+
return IndexSchema.from_dict({
15+
"index": {
16+
"name": "test_index",
17+
"prefix": "test:",
18+
"storage_type": "hash",
19+
},
20+
"fields": [
21+
{"name": "text", "type": "text"},
22+
{"name": "vector", "type": "vector", "attrs": {"dims": 128, "algorithm": "flat"}},
23+
]
24+
})
25+
26+
27+
class TestSearchIndexSerialization:
28+
"""Tests for SearchIndex serialization methods."""
29+
30+
def test_to_dict_without_connection(self, sample_schema):
31+
"""Test to_dict() excludes connection info by default."""
32+
index = SearchIndex(
33+
schema=sample_schema,
34+
redis_url="redis://localhost:6379",
35+
)
36+
37+
config = index.to_dict()
38+
39+
assert "index" in config
40+
assert config["index"]["name"] == "test_index"
41+
assert "_redis_url" not in config
42+
assert "_connection_kwargs" not in config
43+
44+
def test_to_dict_with_connection(self, sample_schema):
45+
"""Test to_dict() includes connection info when requested."""
46+
index = SearchIndex(
47+
schema=sample_schema,
48+
redis_url="redis://localhost:6379",
49+
connection_kwargs={"ssl": True, "socket_timeout": 30, "password": "secret"},
50+
)
51+
52+
config = index.to_dict(include_connection=True)
53+
54+
assert "_redis_url" in config
55+
assert config["_redis_url"] == "redis://localhost:6379"
56+
# Password should not be included (not in safe_keys)
57+
assert "password" not in config.get("_connection_kwargs", {})
58+
# Safe keys should be included
59+
assert config["_connection_kwargs"]["ssl"] is True
60+
assert config["_connection_kwargs"]["socket_timeout"] == 30
61+
62+
def test_to_yaml_without_connection(self, sample_schema):
63+
"""Test to_yaml() writes schema to YAML file."""
64+
index = SearchIndex(schema=sample_schema)
65+
66+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
67+
path = f.name
68+
69+
try:
70+
index.to_yaml(path)
71+
72+
content = Path(path).read_text()
73+
assert "test_index" in content
74+
assert "text" in content
75+
assert "vector" in content
76+
finally:
77+
Path(path).unlink()
78+
79+
def test_to_yaml_with_connection(self, sample_schema):
80+
"""Test to_yaml() includes connection when requested."""
81+
index = SearchIndex(
82+
schema=sample_schema,
83+
redis_url="redis://localhost:6379",
84+
)
85+
86+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
87+
path = f.name
88+
89+
try:
90+
index.to_yaml(path, include_connection=True)
91+
92+
content = Path(path).read_text()
93+
assert "_redis_url" in content
94+
finally:
95+
Path(path).unlink()
96+
97+
def test_roundtrip_dict(self, sample_schema):
98+
"""Test that to_dict() and from_dict() roundtrip correctly."""
99+
original_index = SearchIndex(
100+
schema=sample_schema,
101+
connection_kwargs={"ssl": True},
102+
)
103+
104+
config = original_index.to_dict()
105+
restored_index = SearchIndex.from_dict(config)
106+
107+
assert restored_index.schema.index.name == original_index.schema.index.name
108+
assert restored_index.schema.index.prefix == original_index.schema.index.prefix
109+
110+
111+
class TestAsyncSearchIndexSerialization:
112+
"""Tests for AsyncSearchIndex serialization methods."""
113+
114+
def test_to_dict_without_connection(self, sample_schema):
115+
"""Test to_dict() excludes connection info by default."""
116+
index = AsyncSearchIndex(
117+
schema=sample_schema,
118+
redis_url="redis://localhost:6379",
119+
)
120+
121+
config = index.to_dict()
122+
123+
assert "index" in config
124+
assert config["index"]["name"] == "test_index"
125+
assert "_redis_url" not in config
126+
127+
def test_to_dict_with_connection(self, sample_schema):
128+
"""Test to_dict() includes connection info when requested."""
129+
index = AsyncSearchIndex(
130+
schema=sample_schema,
131+
redis_url="redis://localhost:6379",
132+
connection_kwargs={"ssl": True, "password": "secret"},
133+
)
134+
135+
config = index.to_dict(include_connection=True)
136+
137+
assert "_redis_url" in config
138+
# Password should not be included (not in safe_keys)
139+
assert "password" not in config.get("_connection_kwargs", {})
140+
141+
def test_to_yaml(self, sample_schema):
142+
"""Test to_yaml() writes schema to YAML file."""
143+
index = AsyncSearchIndex(schema=sample_schema)
144+
145+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
146+
path = f.name
147+
148+
try:
149+
index.to_yaml(path)
150+
151+
content = Path(path).read_text()
152+
assert "test_index" in content
153+
finally:
154+
Path(path).unlink()
155+
156+
def test_roundtrip_dict(self, sample_schema):
157+
"""Test that to_dict() and from_dict() roundtrip correctly."""
158+
original_index = AsyncSearchIndex(
159+
schema=sample_schema,
160+
connection_kwargs={"ssl": True},
161+
)
162+
163+
config = original_index.to_dict()
164+
restored_index = AsyncSearchIndex.from_dict(config)
165+
166+
assert restored_index.schema.index.name == original_index.schema.index.name

0 commit comments

Comments
 (0)