-
Notifications
You must be signed in to change notification settings - Fork 76
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 all commits
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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -335,6 +337,10 @@ def storage_type(self) -> StorageType: | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 +353,26 @@ 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) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return cls.from_dict(data, **kwargs) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @classmethod | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def from_dict(cls, schema_dict: Dict[str, Any], **kwargs): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Create a SearchIndex from a dictionary. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Args: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| schema_dict (Dict[str, Any]): A dictionary containing the schema. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| schema_dict (Dict[str, Any]): A dictionary containing the schema | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| and optionally ``_redis_url`` and ``_connection_kwargs`` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (produced by ``to_dict(include_connection=True)``). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Returns: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| SearchIndex: A RedisVL SearchIndex object. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -376,9 +393,97 @@ def from_dict(cls, schema_dict: Dict[str, Any], **kwargs): | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, redis_url="redis://localhost:6379") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| schema = IndexSchema.from_dict(schema_dict) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Extract connection metadata so it reaches the constructor | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # instead of being silently dropped by IndexSchema validation. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| copy = dict(schema_dict) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if "_redis_url" in copy: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| url = copy.pop("_redis_url") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Skip sanitized URLs (contain ****) to avoid silent auth | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # failures — these are for inspection only, not round-trip. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if url and "****" not in url: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| kwargs.setdefault("redis_url", url) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if "_connection_kwargs" in copy: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| kwargs.setdefault("connection_kwargs", copy.pop("_connection_kwargs")) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| schema = IndexSchema.from_dict(copy) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return cls(schema=schema, **kwargs) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _sanitize_redis_url(self) -> Optional[str]: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Sanitize a Redis URL by masking passwords. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Handles both ``user:password@`` and ``:password@`` URL patterns. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Returns: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Optional[str]: The sanitized URL, or None if ``_redis_url`` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| is not set. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not self._redis_url: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from urllib.parse import urlparse | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| parsed = urlparse(self._redis_url) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Only mask when a password component is present (including | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 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}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IPv6 addresses produce malformed URLs after sanitizationLow Severity
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return parsed._replace(netloc=netloc).geturl() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return self._redis_url | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+426
to
+436
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 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() |
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,208 @@ | ||||||
| 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_username_only_url(self): | ||||||
| """Username-only URLs (no password) are left unchanged.""" | ||||||
| idx = _make_index(redis_url="redis://readonly@localhost:6379") | ||||||
| d = idx.to_dict(include_connection=True) | ||||||
| assert d["_redis_url"] == "redis://readonly@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.
from_yamlcatches a broadExceptionaroundPath.resolve(), while otherfrom_yamlhelpers in this repo catchOSErrorspecifically (e.g.,IndexSchema.from_yamlandSemanticRouter.from_yaml). Narrowing this toexcept OSErrorwould avoid masking unexpected errors and keep error-handling consistent.