Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
406110460d | ||
|
|
29b9059cea | ||
|
|
a84a565422 | ||
|
|
0b397eaf06 | ||
|
|
051551edb7 |
@@ -52,7 +52,7 @@ Here are the parameters available for configuring FAISS:
|
|||||||
| Parameter | Description | Default Value |
|
| Parameter | Description | Default Value |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `collection_name` | The name of the collection | `mem0` |
|
| `collection_name` | The name of the collection | `mem0` |
|
||||||
| `path` | Path to store FAISS index and metadata | `/tmp/faiss/<collection_name>` |
|
| `path` | Path to store FAISS index and metadata | `~/.local/share/mem0/faiss/<collection_name>` |
|
||||||
| `distance_strategy` | Distance metric strategy to use (options: 'euclidean', 'inner_product', 'cosine') | `euclidean` |
|
| `distance_strategy` | Distance metric strategy to use (options: 'euclidean', 'inner_product', 'cosine') | `euclidean` |
|
||||||
| `normalize_L2` | Whether to normalize L2 vectors (only applicable for euclidean distance) | `False` |
|
| `normalize_L2` | Whether to normalize L2 vectors (only applicable for euclidean distance) | `False` |
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ Let's see the available parameters for the `qdrant` config:
|
|||||||
| `client` | Custom client for qdrant | `None` |
|
| `client` | Custom client for qdrant | `None` |
|
||||||
| `host` | The host where the qdrant server is running | `None` |
|
| `host` | The host where the qdrant server is running | `None` |
|
||||||
| `port` | The port where the qdrant server is running | `None` |
|
| `port` | The port where the qdrant server is running | `None` |
|
||||||
| `path` | Path for the qdrant database | `/tmp/qdrant` |
|
| `path` | Path for the qdrant database | `~/.local/share/mem0/qdrant` |
|
||||||
| `url` | Full URL for the qdrant server | `None` |
|
| `url` | Full URL for the qdrant server | `None` |
|
||||||
| `api_key` | API key for the qdrant server | `None` |
|
| `api_key` | API key for the qdrant server | `None` |
|
||||||
| `on_disk` | For enabling persistent storage | `False` |
|
| `on_disk` | For enabling persistent storage | `False` |
|
||||||
@@ -85,7 +85,7 @@ Let's see the available parameters for the `qdrant` config:
|
|||||||
| `embeddingModelDims` | Dimensions of the embedding model | `1536` |
|
| `embeddingModelDims` | Dimensions of the embedding model | `1536` |
|
||||||
| `host` | The host where the Qdrant server is running | `None` |
|
| `host` | The host where the Qdrant server is running | `None` |
|
||||||
| `port` | The port where the Qdrant server is running | `None` |
|
| `port` | The port where the Qdrant server is running | `None` |
|
||||||
| `path` | Path for the Qdrant database | `/tmp/qdrant` |
|
| `path` | Path for the Qdrant database | `~/.local/share/mem0/qdrant` |
|
||||||
| `url` | Full URL for the Qdrant server | `None` |
|
| `url` | Full URL for the Qdrant server | `None` |
|
||||||
| `apiKey` | API key for the Qdrant server | `None` |
|
| `apiKey` | API key for the Qdrant server | `None` |
|
||||||
| `onDisk` | For enabling persistent storage | `False` |
|
| `onDisk` | For enabling persistent storage | `False` |
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ Mem0 Open Source delivers the same adaptive memory engine as the platform, but p
|
|||||||
Mem0 OSS works out of the box with sensible defaults:
|
Mem0 OSS works out of the box with sensible defaults:
|
||||||
- LLM: OpenAI `gpt-4.1-nano-2025-04-14` (via `OPENAI_API_KEY`)
|
- LLM: OpenAI `gpt-4.1-nano-2025-04-14` (via `OPENAI_API_KEY`)
|
||||||
- Embeddings: OpenAI `text-embedding-3-small`
|
- Embeddings: OpenAI `text-embedding-3-small`
|
||||||
- Vector store: Local Qdrant instance storing data at `/tmp/qdrant`
|
- Vector store: Local Qdrant instance storing data at `~/.local/share/mem0/qdrant`
|
||||||
- History store: SQLite database at `~/.mem0/history.db`
|
- History store: SQLite database at `~/.mem0/history.db`
|
||||||
- Reranker: Disabled until you configure a provider
|
- Reranker: Disabled until you configure a provider
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ print(results)
|
|||||||
By default `Memory()` wires up:
|
By default `Memory()` wires up:
|
||||||
- OpenAI `gpt-4.1-nano-2025-04-14` for fact extraction and updates
|
- OpenAI `gpt-4.1-nano-2025-04-14` for fact extraction and updates
|
||||||
- OpenAI `text-embedding-3-small` embeddings (1536 dimensions)
|
- OpenAI `text-embedding-3-small` embeddings (1536 dimensions)
|
||||||
- Qdrant vector store with on-disk data at `/tmp/qdrant`
|
- Qdrant vector store with on-disk data at `~/.local/share/mem0/qdrant`
|
||||||
- SQLite history at `~/.mem0/history.db`
|
- SQLite history at `~/.mem0/history.db`
|
||||||
- No reranker (add one in the config when you need it)
|
- No reranker (add one in the config when you need it)
|
||||||
</Note>
|
</Note>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ from typing import Any, ClassVar, Dict, Optional
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
from mem0.vector_stores.configs import get_default_vector_store_path
|
||||||
|
|
||||||
|
|
||||||
class ChromaDbConfig(BaseModel):
|
class ChromaDbConfig(BaseModel):
|
||||||
try:
|
try:
|
||||||
@@ -23,24 +25,25 @@ class ChromaDbConfig(BaseModel):
|
|||||||
def check_connection_config(cls, values):
|
def check_connection_config(cls, values):
|
||||||
host, port, path = values.get("host"), values.get("port"), values.get("path")
|
host, port, path = values.get("host"), values.get("port"), values.get("path")
|
||||||
api_key, tenant = values.get("api_key"), values.get("tenant")
|
api_key, tenant = values.get("api_key"), values.get("tenant")
|
||||||
|
default_path = get_default_vector_store_path("chroma")
|
||||||
|
|
||||||
# Check if cloud configuration is provided
|
# Check if cloud configuration is provided
|
||||||
cloud_config = bool(api_key and tenant)
|
cloud_config = bool(api_key and tenant)
|
||||||
|
|
||||||
# If cloud configuration is provided, remove any default path that might have been added
|
# If cloud configuration is provided, remove any default path that might have been added
|
||||||
if cloud_config and path == "/tmp/chroma":
|
if cloud_config and path == default_path:
|
||||||
values.pop("path", None)
|
values.pop("path", None)
|
||||||
return values
|
return values
|
||||||
|
|
||||||
# Check if local/server configuration is provided
|
# Check if local/server configuration is provided
|
||||||
local_config = bool(path) or bool(host and port)
|
local_config = bool(path) or bool(host and port)
|
||||||
|
|
||||||
if not cloud_config and not local_config:
|
if not cloud_config and not local_config:
|
||||||
raise ValueError("Either ChromaDB Cloud configuration (api_key, tenant) or local configuration (path or host/port) must be provided.")
|
raise ValueError("Either ChromaDB Cloud configuration (api_key, tenant) or local configuration (path or host/port) must be provided.")
|
||||||
|
|
||||||
if cloud_config and local_config:
|
if cloud_config and local_config:
|
||||||
raise ValueError("Cannot specify both cloud configuration and local configuration. Choose one.")
|
raise ValueError("Cannot specify both cloud configuration and local configuration. Choose one.")
|
||||||
|
|
||||||
return values
|
return values
|
||||||
|
|
||||||
@model_validator(mode="before")
|
@model_validator(mode="before")
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ from typing import Any, ClassVar, Dict, Optional
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
from mem0.vector_stores.configs import get_default_vector_store_path
|
||||||
|
|
||||||
|
|
||||||
class QdrantConfig(BaseModel):
|
class QdrantConfig(BaseModel):
|
||||||
from qdrant_client import QdrantClient
|
from qdrant_client import QdrantClient
|
||||||
@@ -13,7 +15,7 @@ class QdrantConfig(BaseModel):
|
|||||||
client: Optional[QdrantClient] = Field(None, description="Existing Qdrant client instance")
|
client: Optional[QdrantClient] = Field(None, description="Existing Qdrant client instance")
|
||||||
host: Optional[str] = Field(None, description="Host address for Qdrant server")
|
host: Optional[str] = Field(None, description="Host address for Qdrant server")
|
||||||
port: Optional[int] = Field(None, description="Port for Qdrant server")
|
port: Optional[int] = Field(None, description="Port for Qdrant server")
|
||||||
path: Optional[str] = Field("/tmp/qdrant", description="Path for local Qdrant database")
|
path: Optional[str] = Field(default_factory=lambda: get_default_vector_store_path("qdrant"), description="Path for local Qdrant database")
|
||||||
url: Optional[str] = Field(None, description="Full URL for Qdrant server")
|
url: Optional[str] = Field(None, description="Full URL for Qdrant server")
|
||||||
api_key: Optional[str] = Field(None, description="API key for Qdrant server")
|
api_key: Optional[str] = Field(None, description="API key for Qdrant server")
|
||||||
on_disk: Optional[bool] = Field(False,description="Enables persistent storage. Vectors are kept on disk (True) or in memory (False). Does not delete the local database path.")
|
on_disk: Optional[bool] = Field(False,description="Enables persistent storage. Vectors are kept on disk (True) or in memory (False). Does not delete the local database path.")
|
||||||
|
|||||||
@@ -1,7 +1,88 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_vector_store_path(provider: str) -> str:
|
||||||
|
"""Return a platform-appropriate default path for a vector store provider.
|
||||||
|
|
||||||
|
Preference order: MEM0_DATA_DIR → XDG_DATA_HOME (Linux) → APPDATA (Windows) → ~/.local/share
|
||||||
|
"""
|
||||||
|
mem0_data_dir = os.environ.get("MEM0_DATA_DIR")
|
||||||
|
if mem0_data_dir:
|
||||||
|
base = Path(mem0_data_dir)
|
||||||
|
else:
|
||||||
|
xdg = os.environ.get("XDG_DATA_HOME")
|
||||||
|
app_data = os.environ.get("APPDATA")
|
||||||
|
if xdg:
|
||||||
|
base = Path(xdg)
|
||||||
|
elif app_data:
|
||||||
|
base = Path(app_data)
|
||||||
|
else:
|
||||||
|
base = Path.home() / ".local" / "share"
|
||||||
|
base = base / "mem0"
|
||||||
|
return str(base / provider)
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_legacy_data(provider: str, new_path: str, legacy_path: Optional[str] = None):
|
||||||
|
"""Auto-migrate data from legacy /tmp/{provider} to the new persistent path.
|
||||||
|
|
||||||
|
Skips migration if:
|
||||||
|
- Legacy path doesn't exist, is empty, or is a symlink (security)
|
||||||
|
- New path already contains data (no clobbering)
|
||||||
|
- A .migration_complete sentinel exists at the new path (already migrated)
|
||||||
|
|
||||||
|
Falls back to a warning with manual instructions if the copy fails.
|
||||||
|
Note: On Windows /tmp doesn't exist, so this is effectively a no-op.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: Vector store provider name (e.g. 'qdrant').
|
||||||
|
new_path: The new persistent path to migrate data to.
|
||||||
|
legacy_path: Override the legacy path (default: /tmp/{provider}).
|
||||||
|
Exposed for testing; production callers should omit this.
|
||||||
|
"""
|
||||||
|
legacy_path = Path(legacy_path if legacy_path is not None else f"/tmp/{provider}")
|
||||||
|
if not legacy_path.exists() or legacy_path.is_symlink() or not any(legacy_path.iterdir()):
|
||||||
|
return
|
||||||
|
|
||||||
|
new_path_obj = Path(new_path)
|
||||||
|
|
||||||
|
# Skip if already migrated in a previous run
|
||||||
|
sentinel = new_path_obj / ".migration_complete"
|
||||||
|
if sentinel.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
if new_path_obj.exists() and any(new_path_obj.iterdir()):
|
||||||
|
logger.info(
|
||||||
|
"Both legacy path '%s' and new path '%s' contain data. "
|
||||||
|
"Using new path. Legacy data at '%s' is untouched.",
|
||||||
|
legacy_path, new_path, legacy_path,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
shutil.copytree(str(legacy_path), new_path, dirs_exist_ok=True)
|
||||||
|
# Write sentinel to prevent repeated migration attempts on future startups
|
||||||
|
sentinel.touch()
|
||||||
|
logger.info(
|
||||||
|
"Migrated vector store data from '%s' to '%s'. "
|
||||||
|
"You may remove the old directory once verified.",
|
||||||
|
legacy_path, new_path,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Could not auto-migrate data from '%s' to '%s': %s. "
|
||||||
|
"Please manually copy/move the data, or set path='/tmp/%s' in your config "
|
||||||
|
"to keep using the old location.",
|
||||||
|
legacy_path, new_path, e, provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class VectorStoreConfig(BaseModel):
|
class VectorStoreConfig(BaseModel):
|
||||||
provider: str = Field(
|
provider: str = Field(
|
||||||
@@ -61,7 +142,8 @@ class VectorStoreConfig(BaseModel):
|
|||||||
|
|
||||||
# also check if path in allowed kays for pydantic model, and whether config extra fields are allowed
|
# also check if path in allowed kays for pydantic model, and whether config extra fields are allowed
|
||||||
if "path" not in config and "path" in config_class.__annotations__:
|
if "path" not in config and "path" in config_class.__annotations__:
|
||||||
config["path"] = f"/tmp/{provider}"
|
config["path"] = get_default_vector_store_path(provider)
|
||||||
|
_migrate_legacy_data(provider, config["path"])
|
||||||
|
|
||||||
self.config = config_class(**config)
|
self.config = config_class(**config)
|
||||||
return self
|
return self
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from typing import Dict, List, Optional
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from mem0.vector_stores.configs import get_default_vector_store_path
|
||||||
|
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -58,7 +60,7 @@ class FAISS(VectorStoreBase):
|
|||||||
Defaults to False.
|
Defaults to False.
|
||||||
"""
|
"""
|
||||||
self.collection_name = collection_name
|
self.collection_name = collection_name
|
||||||
self.path = path or f"/tmp/faiss/{collection_name}"
|
self.path = path or f"{get_default_vector_store_path('faiss')}/{collection_name}"
|
||||||
self.distance_strategy = distance_strategy
|
self.distance_strategy = distance_strategy
|
||||||
self.normalize_L2 = normalize_L2
|
self.normalize_L2 = normalize_L2
|
||||||
self.embedding_model_dims = embedding_model_dims
|
self.embedding_model_dims = embedding_model_dims
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
"""Tests for the vector store default path fix (issue #4279).
|
||||||
|
|
||||||
|
Verifies that vector store defaults use platform-appropriate persistent directories
|
||||||
|
instead of /tmp, which is ephemeral and may be inaccessible in restricted environments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mem0.vector_stores.configs import (
|
||||||
|
VectorStoreConfig,
|
||||||
|
_migrate_legacy_data,
|
||||||
|
get_default_vector_store_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_default_vector_store_path unit tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGetDefaultVectorStorePath:
|
||||||
|
"""Tests for the get_default_vector_store_path helper function."""
|
||||||
|
|
||||||
|
def _clean_env(self, monkeypatch):
|
||||||
|
"""Remove all relevant env vars to get a clean baseline."""
|
||||||
|
for var in ("MEM0_DATA_DIR", "XDG_DATA_HOME", "APPDATA"):
|
||||||
|
monkeypatch.delenv(var, raising=False)
|
||||||
|
|
||||||
|
def test_default_falls_back_to_local_share(self, monkeypatch):
|
||||||
|
"""With no env vars set, path should be ~/.local/share/mem0/{provider}."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
result = get_default_vector_store_path("qdrant")
|
||||||
|
expected = str(Path.home() / ".local" / "share" / "mem0" / "qdrant")
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
def test_xdg_data_home_respected(self, monkeypatch):
|
||||||
|
"""XDG_DATA_HOME should be used as the base when set."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("XDG_DATA_HOME", "/custom/xdg/data")
|
||||||
|
result = get_default_vector_store_path("chroma")
|
||||||
|
assert result == "/custom/xdg/data/mem0/chroma"
|
||||||
|
|
||||||
|
def test_appdata_respected_on_windows(self, monkeypatch):
|
||||||
|
"""APPDATA should be used when XDG_DATA_HOME is not set (Windows)."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("APPDATA", "C:\\Users\\test\\AppData\\Roaming")
|
||||||
|
result = get_default_vector_store_path("faiss")
|
||||||
|
expected = str(Path("C:\\Users\\test\\AppData\\Roaming") / "mem0" / "faiss")
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
def test_xdg_takes_precedence_over_appdata(self, monkeypatch):
|
||||||
|
"""XDG_DATA_HOME should take precedence over APPDATA."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("XDG_DATA_HOME", "/xdg/path")
|
||||||
|
monkeypatch.setenv("APPDATA", "C:\\windows\\path")
|
||||||
|
result = get_default_vector_store_path("qdrant")
|
||||||
|
assert result == "/xdg/path/mem0/qdrant"
|
||||||
|
|
||||||
|
def test_mem0_data_dir_takes_highest_precedence(self, monkeypatch):
|
||||||
|
"""MEM0_DATA_DIR should override all other env vars."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("MEM0_DATA_DIR", "/custom/mem0/data")
|
||||||
|
monkeypatch.setenv("XDG_DATA_HOME", "/should/be/ignored")
|
||||||
|
monkeypatch.setenv("APPDATA", "C:\\also\\ignored")
|
||||||
|
result = get_default_vector_store_path("qdrant")
|
||||||
|
assert result == "/custom/mem0/data/qdrant"
|
||||||
|
|
||||||
|
def test_mem0_data_dir_no_extra_mem0_subdir(self, monkeypatch):
|
||||||
|
"""MEM0_DATA_DIR is already mem0-specific, so no extra 'mem0' subdir."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("MEM0_DATA_DIR", "/data/mem0")
|
||||||
|
result = get_default_vector_store_path("qdrant")
|
||||||
|
# Should be /data/mem0/qdrant, NOT /data/mem0/mem0/qdrant
|
||||||
|
assert result == "/data/mem0/qdrant"
|
||||||
|
|
||||||
|
def test_different_providers_get_different_paths(self, monkeypatch):
|
||||||
|
"""Each provider should get its own subdirectory."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
qdrant_path = get_default_vector_store_path("qdrant")
|
||||||
|
chroma_path = get_default_vector_store_path("chroma")
|
||||||
|
faiss_path = get_default_vector_store_path("faiss")
|
||||||
|
assert qdrant_path != chroma_path != faiss_path
|
||||||
|
assert qdrant_path.endswith("/qdrant")
|
||||||
|
assert chroma_path.endswith("/chroma")
|
||||||
|
assert faiss_path.endswith("/faiss")
|
||||||
|
|
||||||
|
def test_path_never_contains_tmp(self, monkeypatch):
|
||||||
|
"""Default path should never be under /tmp (the whole point of #4279)."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
for provider in ("qdrant", "chroma", "faiss"):
|
||||||
|
result = get_default_vector_store_path(provider)
|
||||||
|
assert not result.startswith("/tmp"), f"Provider {provider} still defaults to /tmp: {result}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# VectorStoreConfig integration tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestVectorStoreConfigPathInjection:
|
||||||
|
"""Tests that VectorStoreConfig correctly injects the new default path."""
|
||||||
|
|
||||||
|
def _clean_env(self, monkeypatch):
|
||||||
|
for var in ("MEM0_DATA_DIR", "XDG_DATA_HOME", "APPDATA"):
|
||||||
|
monkeypatch.delenv(var, raising=False)
|
||||||
|
|
||||||
|
def test_qdrant_gets_persistent_default_path(self, monkeypatch):
|
||||||
|
"""Qdrant should get a persistent default path, not /tmp."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
vc = VectorStoreConfig(provider="qdrant", config={})
|
||||||
|
assert "/tmp" not in vc.config.path
|
||||||
|
assert "mem0" in vc.config.path
|
||||||
|
assert vc.config.path.endswith("/qdrant")
|
||||||
|
|
||||||
|
def test_explicit_path_not_overridden(self, monkeypatch):
|
||||||
|
"""An explicitly provided path should be used as-is."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
custom_path = "/my/custom/qdrant/path"
|
||||||
|
vc = VectorStoreConfig(provider="qdrant", config={"path": custom_path})
|
||||||
|
assert vc.config.path == custom_path
|
||||||
|
|
||||||
|
def test_explicit_tmp_path_still_works(self, monkeypatch):
|
||||||
|
"""Users who explicitly want /tmp should still be able to use it."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
vc = VectorStoreConfig(provider="qdrant", config={"path": "/tmp/qdrant"})
|
||||||
|
assert vc.config.path == "/tmp/qdrant"
|
||||||
|
|
||||||
|
def test_mem0_data_dir_flows_through_config(self, monkeypatch):
|
||||||
|
"""MEM0_DATA_DIR should be respected through VectorStoreConfig."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("MEM0_DATA_DIR", "/custom/data")
|
||||||
|
vc = VectorStoreConfig(provider="qdrant", config={})
|
||||||
|
assert vc.config.path == "/custom/data/qdrant"
|
||||||
|
|
||||||
|
def test_xdg_data_home_flows_through_config(self, monkeypatch):
|
||||||
|
"""XDG_DATA_HOME should be respected through VectorStoreConfig."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("XDG_DATA_HOME", "/xdg/data")
|
||||||
|
vc = VectorStoreConfig(provider="qdrant", config={})
|
||||||
|
assert vc.config.path == "/xdg/data/mem0/qdrant"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Qdrant-specific config tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestQdrantConfigDefault:
|
||||||
|
"""Tests that QdrantConfig's own default_factory uses the new path."""
|
||||||
|
|
||||||
|
def _clean_env(self, monkeypatch):
|
||||||
|
for var in ("MEM0_DATA_DIR", "XDG_DATA_HOME", "APPDATA"):
|
||||||
|
monkeypatch.delenv(var, raising=False)
|
||||||
|
|
||||||
|
def test_qdrant_field_default_is_persistent(self, monkeypatch):
|
||||||
|
"""QdrantConfig's path Field default should not be /tmp."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
from mem0.configs.vector_stores.qdrant import QdrantConfig
|
||||||
|
|
||||||
|
# When path is explicitly provided, the before-validator sees it
|
||||||
|
config = QdrantConfig(path=get_default_vector_store_path("qdrant"))
|
||||||
|
assert "/tmp" not in config.path
|
||||||
|
assert config.path.endswith("/qdrant")
|
||||||
|
|
||||||
|
def test_qdrant_via_vector_store_config_no_path(self, monkeypatch):
|
||||||
|
"""Creating QdrantConfig via VectorStoreConfig with empty config should work."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
vc = VectorStoreConfig(provider="qdrant", config={})
|
||||||
|
assert vc.config.path is not None
|
||||||
|
assert "/tmp" not in vc.config.path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ChromaDB-specific config tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestChromaDbConfigDefault:
|
||||||
|
"""Tests that ChromaDB's sentinel check works with the new default path."""
|
||||||
|
|
||||||
|
def _clean_env(self, monkeypatch):
|
||||||
|
for var in ("MEM0_DATA_DIR", "XDG_DATA_HOME", "APPDATA"):
|
||||||
|
monkeypatch.delenv(var, raising=False)
|
||||||
|
|
||||||
|
def test_chroma_default_path_via_vector_store_config(self, monkeypatch):
|
||||||
|
"""ChromaDB via VectorStoreConfig should get a non-/tmp default path."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
vc = VectorStoreConfig(provider="chroma", config={})
|
||||||
|
assert "/tmp" not in vc.config.path
|
||||||
|
assert vc.config.path.endswith("/chroma")
|
||||||
|
|
||||||
|
def test_chroma_cloud_config_strips_default_path(self, monkeypatch):
|
||||||
|
"""ChromaDB cloud config should strip the injected default path."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
default_path = get_default_vector_store_path("chroma")
|
||||||
|
from mem0.configs.vector_stores.chroma import ChromaDbConfig
|
||||||
|
|
||||||
|
# Simulate what VectorStoreConfig does: inject default path, then create config
|
||||||
|
config = ChromaDbConfig(
|
||||||
|
path=default_path,
|
||||||
|
api_key="test-key",
|
||||||
|
tenant="test-tenant",
|
||||||
|
)
|
||||||
|
# When cloud config is provided and path matches default, path should be stripped
|
||||||
|
assert config.path is None
|
||||||
|
|
||||||
|
def test_chroma_explicit_path_with_cloud_raises(self, monkeypatch):
|
||||||
|
"""ChromaDB with both explicit custom path and cloud config should raise."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
from mem0.configs.vector_stores.chroma import ChromaDbConfig
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Cannot specify both"):
|
||||||
|
ChromaDbConfig(
|
||||||
|
path="/my/custom/path",
|
||||||
|
api_key="test-key",
|
||||||
|
tenant="test-tenant",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FAISS-specific tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestFaissDefaultPath:
|
||||||
|
"""Tests that FAISS uses the new default path."""
|
||||||
|
|
||||||
|
def _clean_env(self, monkeypatch):
|
||||||
|
for var in ("MEM0_DATA_DIR", "XDG_DATA_HOME", "APPDATA"):
|
||||||
|
monkeypatch.delenv(var, raising=False)
|
||||||
|
|
||||||
|
def test_faiss_default_path_via_vector_store_config(self, monkeypatch):
|
||||||
|
"""FAISS via VectorStoreConfig should get a non-/tmp default path."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
vc = VectorStoreConfig(provider="faiss", config={})
|
||||||
|
assert "/tmp" not in vc.config.path
|
||||||
|
assert vc.config.path.endswith("/faiss")
|
||||||
|
|
||||||
|
def test_faiss_implementation_uses_new_default(self, monkeypatch):
|
||||||
|
"""FAISS implementation's own fallback should not use /tmp."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
with patch("faiss.IndexFlatL2") as mock_index:
|
||||||
|
mock_index.return_value = MagicMock()
|
||||||
|
with patch("faiss.write_index"):
|
||||||
|
from mem0.vector_stores.faiss import FAISS
|
||||||
|
store = FAISS(collection_name="test")
|
||||||
|
assert "/tmp" not in store.path
|
||||||
|
assert "mem0" in store.path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Legacy path migration warning tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLegacyPathMigration:
|
||||||
|
"""Tests that legacy /tmp data is automatically migrated to the new path.
|
||||||
|
|
||||||
|
Uses the ``legacy_path`` parameter of ``_migrate_legacy_data`` so that every
|
||||||
|
test exercises real filesystem operations (via pytest ``tmp_path``) instead of
|
||||||
|
mocking ``Path`` and ``shutil.copytree``. This catches real issues like
|
||||||
|
permission errors, path resolution, and symlink handling that mocks silently
|
||||||
|
skip over.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_auto_migration_copies_legacy_data(self, tmp_path, caplog):
|
||||||
|
"""When legacy dir has data and new path doesn't exist, data is copied."""
|
||||||
|
legacy = tmp_path / "legacy_qdrant"
|
||||||
|
legacy.mkdir()
|
||||||
|
(legacy / "collection").mkdir()
|
||||||
|
(legacy / "collection" / "data.bin").write_bytes(b"\x00real-vector-data")
|
||||||
|
|
||||||
|
new = tmp_path / "new_qdrant"
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO, logger="mem0.vector_stores.configs"):
|
||||||
|
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
|
||||||
|
|
||||||
|
# Data was actually copied
|
||||||
|
assert (new / "collection" / "data.bin").exists()
|
||||||
|
assert (new / "collection" / "data.bin").read_bytes() == b"\x00real-vector-data"
|
||||||
|
# Sentinel was written
|
||||||
|
assert (new / ".migration_complete").exists()
|
||||||
|
# Legacy data is untouched (copy, not move)
|
||||||
|
assert (legacy / "collection" / "data.bin").exists()
|
||||||
|
assert "migrated" in caplog.text.lower()
|
||||||
|
|
||||||
|
def test_no_migration_when_legacy_path_missing(self, tmp_path):
|
||||||
|
"""No-op when the legacy directory doesn't exist at all."""
|
||||||
|
nonexistent = tmp_path / "does_not_exist"
|
||||||
|
new = tmp_path / "new"
|
||||||
|
|
||||||
|
_migrate_legacy_data("qdrant", str(new), legacy_path=str(nonexistent))
|
||||||
|
|
||||||
|
assert not new.exists()
|
||||||
|
|
||||||
|
def test_no_migration_when_legacy_path_empty_dir(self, tmp_path):
|
||||||
|
"""No-op when the legacy directory exists but is empty."""
|
||||||
|
legacy = tmp_path / "empty_qdrant"
|
||||||
|
legacy.mkdir()
|
||||||
|
|
||||||
|
new = tmp_path / "new"
|
||||||
|
|
||||||
|
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
|
||||||
|
|
||||||
|
assert not new.exists()
|
||||||
|
|
||||||
|
def test_no_migration_when_legacy_path_is_symlink(self, tmp_path):
|
||||||
|
"""Refuse to migrate if legacy path is a symlink (prevents symlink attacks)."""
|
||||||
|
real_dir = tmp_path / "real_data"
|
||||||
|
real_dir.mkdir()
|
||||||
|
(real_dir / "secret.key").write_text("sensitive")
|
||||||
|
|
||||||
|
symlink = tmp_path / "symlink_qdrant"
|
||||||
|
symlink.symlink_to(real_dir)
|
||||||
|
|
||||||
|
new = tmp_path / "new"
|
||||||
|
|
||||||
|
_migrate_legacy_data("qdrant", str(new), legacy_path=str(symlink))
|
||||||
|
|
||||||
|
assert not new.exists()
|
||||||
|
|
||||||
|
def test_no_migration_when_new_path_already_has_data(self, tmp_path, caplog):
|
||||||
|
"""Don't clobber existing data at the new path."""
|
||||||
|
legacy = tmp_path / "legacy"
|
||||||
|
legacy.mkdir()
|
||||||
|
(legacy / "old.bin").write_text("legacy data")
|
||||||
|
|
||||||
|
new = tmp_path / "new"
|
||||||
|
new.mkdir()
|
||||||
|
(new / "existing.bin").write_text("keep this")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO, logger="mem0.vector_stores.configs"):
|
||||||
|
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
|
||||||
|
|
||||||
|
# Existing data preserved, legacy data NOT copied
|
||||||
|
assert (new / "existing.bin").read_text() == "keep this"
|
||||||
|
assert not (new / "old.bin").exists()
|
||||||
|
assert "both legacy path" in caplog.text.lower()
|
||||||
|
|
||||||
|
def test_no_migration_when_sentinel_exists(self, tmp_path):
|
||||||
|
"""Skip migration when .migration_complete sentinel already exists."""
|
||||||
|
legacy = tmp_path / "legacy"
|
||||||
|
legacy.mkdir()
|
||||||
|
(legacy / "data.bin").write_text("legacy data")
|
||||||
|
|
||||||
|
new = tmp_path / "new"
|
||||||
|
new.mkdir()
|
||||||
|
(new / ".migration_complete").touch()
|
||||||
|
|
||||||
|
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
|
||||||
|
|
||||||
|
# Legacy data was NOT copied (sentinel prevented it)
|
||||||
|
assert not (new / "data.bin").exists()
|
||||||
|
|
||||||
|
def test_migration_failure_falls_back_to_warning(self, tmp_path, caplog):
|
||||||
|
"""If copytree fails, log a warning with manual instructions."""
|
||||||
|
legacy = tmp_path / "legacy"
|
||||||
|
legacy.mkdir()
|
||||||
|
(legacy / "data.bin").write_text("legacy data")
|
||||||
|
|
||||||
|
new = tmp_path / "new"
|
||||||
|
|
||||||
|
with patch("shutil.copytree", side_effect=PermissionError("denied")):
|
||||||
|
with caplog.at_level(logging.WARNING, logger="mem0.vector_stores.configs"):
|
||||||
|
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
|
||||||
|
|
||||||
|
# No sentinel on failure
|
||||||
|
assert not (new / ".migration_complete").exists()
|
||||||
|
assert "denied" in caplog.text
|
||||||
|
|
||||||
|
def test_migration_is_idempotent(self, tmp_path):
|
||||||
|
"""Running migration twice doesn't duplicate data (sentinel prevents re-run)."""
|
||||||
|
legacy = tmp_path / "legacy"
|
||||||
|
legacy.mkdir()
|
||||||
|
(legacy / "data.bin").write_text("original")
|
||||||
|
|
||||||
|
new = tmp_path / "new"
|
||||||
|
|
||||||
|
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
|
||||||
|
assert (new / "data.bin").read_text() == "original"
|
||||||
|
|
||||||
|
# Modify legacy data after first migration
|
||||||
|
(legacy / "data.bin").write_text("modified")
|
||||||
|
|
||||||
|
# Second call is a no-op because sentinel exists
|
||||||
|
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
|
||||||
|
assert (new / "data.bin").read_text() == "original"
|
||||||
|
|
||||||
|
def test_migration_preserves_nested_directory_structure(self, tmp_path):
|
||||||
|
"""Deep directory trees are faithfully copied."""
|
||||||
|
legacy = tmp_path / "legacy"
|
||||||
|
(legacy / "collections" / "default" / "segments").mkdir(parents=True)
|
||||||
|
(legacy / "collections" / "default" / "segments" / "index.bin").write_bytes(b"\x01\x02")
|
||||||
|
(legacy / "meta.json").write_text('{"version": 1}')
|
||||||
|
|
||||||
|
new = tmp_path / "new"
|
||||||
|
|
||||||
|
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
|
||||||
|
|
||||||
|
assert (new / "meta.json").read_text() == '{"version": 1}'
|
||||||
|
assert (new / "collections" / "default" / "segments" / "index.bin").read_bytes() == b"\x01\x02"
|
||||||
|
|
||||||
|
def test_no_migration_when_explicit_path_provided(self, monkeypatch, caplog):
|
||||||
|
"""VectorStoreConfig with explicit path should not trigger migration."""
|
||||||
|
for var in ("MEM0_DATA_DIR", "XDG_DATA_HOME", "APPDATA"):
|
||||||
|
monkeypatch.delenv(var, raising=False)
|
||||||
|
with caplog.at_level(logging.WARNING, logger="mem0.vector_stores.configs"):
|
||||||
|
VectorStoreConfig(provider="qdrant", config={"path": "/my/path"})
|
||||||
|
|
||||||
|
assert "migrat" not in caplog.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cross-platform / issue #4279 scenario tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestIssue4279Scenarios:
|
||||||
|
"""End-to-end tests for the specific scenarios described in issue #4279."""
|
||||||
|
|
||||||
|
def _clean_env(self, monkeypatch):
|
||||||
|
for var in ("MEM0_DATA_DIR", "XDG_DATA_HOME", "APPDATA"):
|
||||||
|
monkeypatch.delenv(var, raising=False)
|
||||||
|
|
||||||
|
def test_windows_no_tmp(self, monkeypatch):
|
||||||
|
"""Windows doesn't have /tmp — APPDATA should be used."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("APPDATA", "C:\\Users\\test\\AppData\\Roaming")
|
||||||
|
for provider in ("qdrant", "chroma", "faiss"):
|
||||||
|
path = get_default_vector_store_path(provider)
|
||||||
|
assert not path.startswith("/tmp"), f"Windows path for {provider} still uses /tmp"
|
||||||
|
assert "AppData" in path
|
||||||
|
|
||||||
|
def test_linux_systemd_with_xdg(self, monkeypatch):
|
||||||
|
"""Linux systemd services should respect XDG_DATA_HOME."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("XDG_DATA_HOME", "/var/lib/myservice")
|
||||||
|
path = get_default_vector_store_path("qdrant")
|
||||||
|
assert path == "/var/lib/myservice/mem0/qdrant"
|
||||||
|
|
||||||
|
def test_docker_with_custom_data_dir(self, monkeypatch):
|
||||||
|
"""Docker users can mount a volume and point MEM0_DATA_DIR to it."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("MEM0_DATA_DIR", "/data/mem0")
|
||||||
|
path = get_default_vector_store_path("qdrant")
|
||||||
|
assert path == "/data/mem0/qdrant"
|
||||||
|
|
||||||
|
def test_macos_default_is_persistent(self, monkeypatch):
|
||||||
|
"""macOS default should be under home directory, not /tmp."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
path = get_default_vector_store_path("qdrant")
|
||||||
|
assert path.startswith(str(Path.home()))
|
||||||
|
assert "/tmp" not in path
|
||||||
|
|
||||||
|
def test_all_affected_providers_fixed(self, monkeypatch):
|
||||||
|
"""All providers with a path field (qdrant, chroma, faiss) should be fixed."""
|
||||||
|
self._clean_env(monkeypatch)
|
||||||
|
for provider in ("qdrant", "chroma", "faiss"):
|
||||||
|
vc = VectorStoreConfig(provider=provider, config={})
|
||||||
|
assert "/tmp" not in vc.config.path, (
|
||||||
|
f"Provider '{provider}' still defaults to /tmp: {vc.config.path}"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user