Skip to content

Commit 0f6b011

Browse files
committed
feat: add framework integrations and improve CLI with Session API
- Add LangChain and LlamaIndex compatibility modules with retriever implementations - Introduce high-level Session API as primary interface replacing Engine - Add comprehensive test suite for configuration, events, and type wrappers with shared fixtures - Implement batch indexing support in CLI with directory traversal - Add interactive REPL with document targeting, stats tracking, and navigation commands - Update pyproject.toml with new dependencies and optional extras for different integration frameworks - Refactor CLI commands to use Session-based workflow instead of low-level Engine operations
1 parent 26e1cf9 commit 0f6b011

37 files changed

Lines changed: 3214 additions & 137 deletions

pyproject.toml

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,38 @@ classifiers = [
2828
]
2929
keywords = ["rag", "document", "retrieval", "llm", "document-intelligence"]
3030

31-
dependencies = []
31+
dependencies = [
32+
"pydantic>=2.0",
33+
"click>=8.0",
34+
"tomli>=2.0; python_version < '3.11'",
35+
]
3236

3337
[project.optional-dependencies]
3438
dev = [
3539
"pytest>=7.0",
3640
"pytest-asyncio>=0.21",
3741
"mypy>=1.0",
42+
"rich>=13.0",
43+
]
44+
cli = [
45+
"rich>=13.0",
46+
]
47+
langchain = [
48+
"langchain-core>=0.1.0",
49+
]
50+
llamaindex = [
51+
"llama-index-core>=0.10.0",
3852
]
53+
jupyter = [
54+
"rich>=13.0",
55+
"ipywidgets>=8.0",
56+
]
57+
all = [
58+
"vectorless[cli,langchain,llamaindex,jupyter]",
59+
]
60+
61+
[project.scripts]
62+
vectorless = "vectorless.cli.main:app"
3963

4064
[project.urls]
4165
Homepage = "https://vectorless.dev"

python/tests/__init__.py

Whitespace-only changes.

python/tests/conftest.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Shared test fixtures."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
from unittest.mock import AsyncMock, MagicMock
7+
8+
9+
@pytest.fixture
10+
def mock_engine():
11+
"""Mock Rust Engine for testing without LLM."""
12+
engine = MagicMock()
13+
14+
# Mock index result
15+
index_result = MagicMock()
16+
index_result.doc_id = "test-doc-id"
17+
index_item = MagicMock()
18+
index_item.doc_id = "test-doc-id"
19+
index_item.name = "test.md"
20+
index_item.format = "markdown"
21+
index_item.description = None
22+
index_item.source_path = "/path/to/test.md"
23+
index_item.page_count = None
24+
index_item.metrics = None
25+
index_result.items = [index_item]
26+
index_result.failed = []
27+
index_result.has_failures.return_value = False
28+
index_result.total.return_value = 1
29+
index_result.__len__ = lambda self: 1
30+
31+
engine.index = AsyncMock(return_value=index_result)
32+
33+
# Mock query result
34+
query_item = MagicMock()
35+
query_item.doc_id = "test-doc-id"
36+
query_item.content = "Test answer content"
37+
query_item.score = 0.85
38+
query_item.confidence = 0.85
39+
query_item.node_ids = ["node-1"]
40+
query_item.evidence = []
41+
query_item.metrics = None
42+
43+
query_result = MagicMock()
44+
query_result.items = [query_item]
45+
query_result.failed = []
46+
query_result.single.return_value = query_item
47+
query_result.has_failures.return_value = False
48+
query_result.__len__ = lambda self: 1
49+
50+
engine.query = AsyncMock(return_value=query_result)
51+
52+
# Mock list
53+
doc_info = MagicMock()
54+
doc_info.id = "test-doc-id"
55+
doc_info.name = "test.md"
56+
doc_info.format = "markdown"
57+
doc_info.description = None
58+
doc_info.source_path = "/path/to/test.md"
59+
doc_info.page_count = None
60+
doc_info.line_count = 42
61+
engine.list = AsyncMock(return_value=[doc_info])
62+
63+
# Mock other operations
64+
engine.remove = AsyncMock(return_value=True)
65+
engine.clear = AsyncMock(return_value=1)
66+
engine.exists = AsyncMock(return_value=True)
67+
68+
# Mock graph
69+
engine.get_graph = AsyncMock(return_value=None)
70+
71+
# Mock metrics
72+
metrics_report = MagicMock()
73+
metrics_report.total_cost_usd.return_value = 0.001
74+
engine.metrics_report.return_value = metrics_report
75+
76+
return engine
77+
78+
79+
@pytest.fixture
80+
def sample_config_dict():
81+
"""Sample configuration dict."""
82+
return {
83+
"llm": {
84+
"model": "gpt-4o",
85+
"api_key": "sk-test-key",
86+
"endpoint": "https://api.openai.com/v1",
87+
},
88+
"retrieval": {"top_k": 5},
89+
"storage": {"workspace_dir": "/tmp/test-vectorless"},
90+
}

python/tests/test_cli/__init__.py

Whitespace-only changes.

python/tests/test_compat/__init__.py

Whitespace-only changes.

python/tests/test_config.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Tests for configuration models and loading."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import tempfile
7+
from pathlib import Path
8+
9+
import pytest
10+
11+
from vectorless.config.models import (
12+
EngineConfig,
13+
LlmConfig,
14+
MetricsConfig,
15+
RetrievalConfig,
16+
StorageConfig,
17+
)
18+
19+
20+
class TestEngineConfig:
21+
def test_defaults(self):
22+
config = EngineConfig()
23+
assert config.llm.model == ""
24+
assert config.llm.api_key is None
25+
assert config.retrieval.top_k == 3
26+
assert config.storage.workspace_dir == "~/.vectorless"
27+
assert config.metrics.enabled is True
28+
29+
def test_custom_values(self):
30+
config = EngineConfig(
31+
llm=LlmConfig(model="gpt-4o", api_key="sk-test"),
32+
retrieval=RetrievalConfig(top_k=10),
33+
storage=StorageConfig(workspace_dir="/data/vl"),
34+
)
35+
assert config.llm.model == "gpt-4o"
36+
assert config.llm.api_key == "sk-test"
37+
assert config.retrieval.top_k == 10
38+
assert config.storage.workspace_dir == "/data/vl"
39+
40+
def test_to_rust_config(self):
41+
config = EngineConfig(
42+
llm=LlmConfig(model="gpt-4o", api_key="sk-test"),
43+
retrieval=RetrievalConfig(top_k=5, max_iterations=20),
44+
storage=StorageConfig(workspace_dir="/tmp/vl"),
45+
metrics=MetricsConfig(enabled=False),
46+
)
47+
# to_rust_config should not raise
48+
rust_config = config.to_rust_config()
49+
assert rust_config is not None
50+
51+
def test_validation_top_k_minimum(self):
52+
with pytest.raises(Exception):
53+
RetrievalConfig(top_k=0)
54+
55+
def test_json_roundtrip(self):
56+
config = EngineConfig(
57+
llm=LlmConfig(model="gpt-4o", api_key="sk-test"),
58+
)
59+
data = config.model_dump()
60+
restored = EngineConfig(**data)
61+
assert restored.llm.model == "gpt-4o"
62+
assert restored.llm.api_key == "sk-test"
63+
64+
65+
class TestConfigLoading:
66+
def test_load_from_env(self):
67+
os.environ["VECTORLESS_API_KEY"] = "sk-env-test"
68+
os.environ["VECTORLESS_MODEL"] = "gpt-4o-mini"
69+
os.environ["VECTORLESS_TOP_K"] = "7"
70+
71+
try:
72+
from vectorless.config.loading import load_config_from_env
73+
74+
config = load_config_from_env()
75+
assert config.llm.api_key == "sk-env-test"
76+
assert config.llm.model == "gpt-4o-mini"
77+
assert config.retrieval.top_k == 7
78+
finally:
79+
del os.environ["VECTORLESS_API_KEY"]
80+
del os.environ["VECTORLESS_MODEL"]
81+
del os.environ["VECTORLESS_TOP_K"]
82+
83+
def test_load_from_file(self):
84+
with tempfile.NamedTemporaryFile(mode="wb", suffix=".toml", delete=False) as f:
85+
f.write(b'[llm]\nmodel = "gpt-4o"\napi_key = "sk-file"\n')
86+
f.flush()
87+
88+
try:
89+
from vectorless.config.loading import load_config_from_file
90+
91+
config = load_config_from_file(Path(f.name))
92+
assert config.llm.model == "gpt-4o"
93+
assert config.llm.api_key == "sk-file"
94+
finally:
95+
os.unlink(f.name)

python/tests/test_events.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Tests for the event system."""
2+
3+
from __future__ import annotations
4+
5+
from vectorless.events import (
6+
EventEmitter,
7+
IndexEventData,
8+
IndexEventType,
9+
QueryEventData,
10+
QueryEventType,
11+
)
12+
13+
14+
class TestEventEmitter:
15+
def test_index_events(self):
16+
received = []
17+
emitter = EventEmitter()
18+
19+
@emitter.on_index
20+
def handler(event):
21+
received.append(event)
22+
23+
event = IndexEventData(
24+
event_type=IndexEventType.STARTED,
25+
path="/test/doc.pdf",
26+
)
27+
emitter.emit_index(event)
28+
29+
assert len(received) == 1
30+
assert received[0].path == "/test/doc.pdf"
31+
assert received[0].event_type == IndexEventType.STARTED
32+
33+
def test_query_events(self):
34+
received = []
35+
emitter = EventEmitter()
36+
37+
@emitter.on_query
38+
def handler(event):
39+
received.append(event)
40+
41+
event = QueryEventData(
42+
event_type=QueryEventType.COMPLETE,
43+
query="What is revenue?",
44+
total_results=3,
45+
)
46+
emitter.emit_query(event)
47+
48+
assert len(received) == 1
49+
assert received[0].query == "What is revenue?"
50+
assert received[0].total_results == 3
51+
52+
def test_multiple_handlers(self):
53+
count = [0]
54+
emitter = EventEmitter()
55+
56+
emitter.on_index(lambda e: count.__setitem__(0, count[0] + 1))
57+
emitter.on_index(lambda e: count.__setitem__(0, count[0] + 1))
58+
59+
emitter.emit_index(
60+
IndexEventData(event_type=IndexEventType.COMPLETE)
61+
)
62+
63+
assert count[0] == 2
64+
65+
def test_chaining(self):
66+
emitter = EventEmitter()
67+
result = emitter.on_index(lambda e: None)
68+
assert result is emitter
69+
70+
def test_no_handlers(self):
71+
emitter = EventEmitter()
72+
# Should not raise
73+
emitter.emit_index(
74+
IndexEventData(event_type=IndexEventType.COMPLETE)
75+
)
76+
emitter.emit_query(
77+
QueryEventData(event_type=QueryEventType.COMPLETE)
78+
)

python/tests/test_session.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Tests for Session high-level API."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
from unittest.mock import AsyncMock, MagicMock, patch
7+
8+
9+
class TestSessionConstruction:
10+
def test_session_rejects_no_source(self):
11+
"""Session.index() should reject calls with no source."""
12+
# We can't fully test Session without a real Engine,
13+
# but we can test validation logic
14+
from vectorless.session import Session
15+
16+
# This will fail because no api_key/model provided
17+
# We just verify the source validation in index()
18+
pass
19+
20+
21+
class TestSessionIndex:
22+
@pytest.mark.asyncio
23+
async def test_index_requires_exactly_one_source(self):
24+
from vectorless.session import Session
25+
26+
# Patch Engine construction
27+
with patch("vectorless.session.Engine") as MockEngine:
28+
mock_engine = MagicMock()
29+
mock_result = MagicMock()
30+
mock_result.doc_id = "doc-1"
31+
mock_result.items = []
32+
mock_result.failed = []
33+
mock_engine.index = AsyncMock(return_value=mock_result)
34+
MockEngine.return_value = mock_engine
35+
36+
from vectorless.config import EngineConfig, LlmConfig
37+
38+
with patch(
39+
"vectorless.session.Session._resolve_config",
40+
return_value=EngineConfig(llm=LlmConfig(model="test", api_key="test")),
41+
):
42+
session = Session.__new__(Session)
43+
session._config = EngineConfig(
44+
llm=LlmConfig(model="test", api_key="test")
45+
)
46+
session._engine = mock_engine
47+
session._events = MagicMock()
48+
49+
# No source
50+
with pytest.raises(ValueError, match="exactly one source"):
51+
await session.index()
52+
53+
# Multiple sources
54+
with pytest.raises(ValueError, match="exactly one source"):
55+
await session.index(path="a.pdf", content="text")

0 commit comments

Comments
 (0)