-
Notifications
You must be signed in to change notification settings - Fork 87
feat: add serialization helpers (to_dict/to_yaml) to BaseSearchIndex #542
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import time | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import warnings | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import weakref | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from math import ceil | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from typing import ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| TYPE_CHECKING, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -28,6 +29,7 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from redis.asyncio import Redis as AsyncRedis | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from redis.asyncio.cluster import RedisCluster as AsyncRedisCluster | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from redis.cluster import RedisCluster | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import yaml | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from redisvl.query.hybrid import HybridQuery | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from redisvl.query.query import VectorQuery | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -331,10 +333,15 @@ def storage_type(self) -> StorageType: | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| hash or json.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return self.schema.index.storage_type | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @classmethod | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @classmethod | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def from_yaml(cls, schema_path: str, **kwargs): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Create a SearchIndex from a YAML schema file. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| If the YAML file contains ``_redis_url`` or ``_connection_kwargs`` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| keys (produced by ``to_yaml(include_connection=True)``), they are | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| automatically passed to the constructor. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Args: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| schema_path (str): Path to the YAML schema file. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -347,15 +354,18 @@ def from_yaml(cls, schema_path: str, **kwargs): | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| index = SearchIndex.from_yaml("schemas/schema.yaml", redis_url="redis://localhost:6379") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| schema = IndexSchema.from_yaml(schema_path) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return cls(schema=schema, **kwargs) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| with open(schema_path) as f: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| data = yaml.safe_load(f) or {} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| with open(schema_path) as f: | |
| data = yaml.safe_load(f) or {} | |
| path_obj = Path(schema_path) | |
| try: | |
| resolved_path = path_obj.resolve() | |
| except Exception as exc: | |
| raise ValueError(f"Invalid schema path: {schema_path}") from exc | |
| try: | |
| with resolved_path.open() as f: | |
| data = yaml.safe_load(f) or {} | |
| except FileNotFoundError as exc: | |
| raise FileNotFoundError(f"Schema file not found: {resolved_path}") from exc |
Copilot
AI
Mar 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_sanitize_redis_url masks the netloc when either parsed.password or parsed.username is present. This will incorrectly rewrite username-only URLs like redis://user@host:6379 into redis://user:****@host:6379, and to_dict(include_connection=True) will then round-trip an invalid/altered connection URL. Only mask when a password component is present (including empty-string passwords), while leaving username-only URLs unchanged.
| if parsed.password or parsed.username: | |
| host_info = parsed.hostname or "" | |
| if parsed.port: | |
| host_info += f":{parsed.port}" | |
| netloc = f"{parsed.username}:****@{host_info}" if parsed.username else f":****@{host_info}" | |
| # Only mask when a password component is present (including empty-string passwords), | |
| # so that username-only URLs like ``redis://user@host:6379`` are left unchanged. | |
| if parsed.password is not None: | |
| host_info = parsed.hostname or "" | |
| if parsed.port: | |
| host_info += f":{parsed.port}" | |
| user_part = f"{parsed.username}:" if parsed.username is not None else ":" | |
| netloc = f"{user_part}****@{host_info}" |
Copilot
AI
Mar 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_sanitize_redis_url() reconstructs the URL netloc using parsed.hostname/parsed.port, which can produce invalid or lossy URLs for valid Redis URLs that don’t fit the single-host pattern (e.g., IPv6 hosts that require brackets, or redis+sentinel:// URLs with comma-separated hosts). Consider sanitizing by operating on parsed.netloc (split on @, then mask only the password portion of the userinfo) so the host segment is preserved verbatim while still redacting credentials.
| # empty-string passwords). Username-only URLs like | |
| # ``redis://user@host:6379`` are left unchanged. | |
| if parsed.password is not None: | |
| host_info = parsed.hostname or "" | |
| if parsed.port: | |
| host_info += f":{parsed.port}" | |
| user_part = f"{parsed.username}:" if parsed.username is not None else ":" | |
| netloc = f"{user_part}****@{host_info}" | |
| return parsed._replace(netloc=netloc).geturl() | |
| return self._redis_url | |
| # empty-string passwords). Username-only URLs like | |
| # ``redis://user@host:6379`` are left unchanged. | |
| if parsed.password is None: | |
| return self._redis_url | |
| netloc = parsed.netloc | |
| # If there is no userinfo/host separator, leave unchanged. | |
| if "@" not in netloc: | |
| return self._redis_url | |
| userinfo, host_part = netloc.rsplit("@", 1) | |
| # userinfo may be "user:password", ":password", or just "password". | |
| if ":" in userinfo: | |
| username, _ = userinfo.split(":", 1) | |
| if username: | |
| masked_userinfo = f"{username}:****" | |
| else: | |
| # Password-only form ":password" | |
| masked_userinfo = ":****" | |
| else: | |
| # No ":" present; treat entire userinfo as password. | |
| masked_userinfo = "****" | |
| masked_netloc = f"{masked_userinfo}@{host_part}" | |
| return parsed._replace(netloc=masked_netloc).geturl() |
Copilot
AI
Mar 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
to_yaml uses yaml.dump(...) without sort_keys=False, while other YAML writers in this repo explicitly set sort_keys=False for stable output ordering. Consider adding sort_keys=False here as well to keep serialized configs deterministic and consistent with IndexSchema.to_yaml/SemanticRouter.to_yaml.
| yaml.dump(self.to_dict(include_connection=include_connection), f) | |
| yaml.dump(self.to_dict(include_connection=include_connection), f, sort_keys=False) |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,192 @@ | ||||||
| import os | ||||||
| import tempfile | ||||||
|
|
||||||
| import pytest | ||||||
| import yaml | ||||||
|
|
||||||
| from redisvl.index.index import AsyncSearchIndex, SearchIndex | ||||||
| from redisvl.schema.schema import IndexSchema | ||||||
|
|
||||||
|
|
||||||
| # --------------------------------------------------------------------------- | ||||||
| # Helpers | ||||||
| # --------------------------------------------------------------------------- | ||||||
|
|
||||||
| SAMPLE_SCHEMA = { | ||||||
| "index": { | ||||||
| "name": "test-index", | ||||||
| "prefix": "doc", | ||||||
| "storage_type": "hash", | ||||||
| }, | ||||||
| "fields": [ | ||||||
| {"name": "title", "type": "tag"}, | ||||||
| {"name": "body", "type": "text"}, | ||||||
| { | ||||||
| "name": "vector", | ||||||
| "type": "vector", | ||||||
| "attrs": { | ||||||
| "dims": 3, | ||||||
| "algorithm": "flat", | ||||||
| "datatype": "float32", | ||||||
| }, | ||||||
| }, | ||||||
| ], | ||||||
| } | ||||||
|
|
||||||
|
|
||||||
| def _make_index(cls=SearchIndex, **conn): | ||||||
| schema = IndexSchema.from_dict(SAMPLE_SCHEMA) | ||||||
| return cls(schema=schema, **conn) | ||||||
|
|
||||||
|
|
||||||
| # --------------------------------------------------------------------------- | ||||||
| # to_dict tests | ||||||
| # --------------------------------------------------------------------------- | ||||||
|
|
||||||
|
|
||||||
| class TestToDict: | ||||||
| def test_schema_only(self): | ||||||
| idx = _make_index() | ||||||
| d = idx.to_dict() | ||||||
| assert d["index"]["name"] == "test-index" | ||||||
| assert d["index"]["prefix"] == "doc" | ||||||
| assert len(d["fields"]) == 3 | ||||||
| assert "_redis_url" not in d | ||||||
| assert "_connection_kwargs" not in d | ||||||
|
|
||||||
| def test_include_connection_with_url(self): | ||||||
| idx = _make_index(redis_url="redis://:secret@localhost:6379") | ||||||
| d = idx.to_dict(include_connection=True) | ||||||
| assert d["_redis_url"] == "redis://:****@localhost:6379" | ||||||
|
|
||||||
| def test_include_connection_password_only_url(self): | ||||||
| """Password-only URLs (:pass@host) are sanitized correctly.""" | ||||||
| idx = _make_index(redis_url="redis://:mysecret@localhost:6379") | ||||||
| d = idx.to_dict(include_connection=True) | ||||||
| assert d["_redis_url"] == "redis://:****@localhost:6379" | ||||||
|
|
||||||
| def test_include_connection_user_pass_url(self): | ||||||
| idx = _make_index(redis_url="redis://admin:secret@localhost:6379") | ||||||
| d = idx.to_dict(include_connection=True) | ||||||
| assert d["_redis_url"] == "redis://admin:****@localhost:6379" | ||||||
|
|
||||||
| def test_include_connection_no_url(self): | ||||||
| """When initialized with a client, _redis_url is None — omit it.""" | ||||||
| idx = _make_index() | ||||||
| d = idx.to_dict(include_connection=True) | ||||||
| assert "_redis_url" not in d | ||||||
|
|
||||||
| def test_include_connection_filters_sensitive_kwargs(self): | ||||||
| idx = _make_index( | ||||||
| redis_url="redis://localhost:6379", | ||||||
| connection_kwargs={"password": "s3cret", "ssl_cert_reqs": "required"}, | ||||||
| ) | ||||||
| d = idx.to_dict(include_connection=True) | ||||||
| # password should NOT leak | ||||||
| assert "s3cret" not in d["_connection_kwargs"] | ||||||
|
||||||
| assert "s3cret" not in d["_connection_kwargs"] | |
| assert "s3cret" not in yaml.dump(d["_connection_kwargs"]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are two consecutive
@classmethoddecorators here. Applying@classmethodtwice can lead to incorrect descriptor behavior (and may break attribute access). Remove the duplicate decorator sofrom_yamlis decorated exactly once.