* feat: introduce hindsight-api-slim and hindsight-all-slim packages Closes #552 - Move all source code from hindsight-api/ to new hindsight-api-slim/ - hindsight-api-slim has heavy ML deps (torch, sentence-transformers, transformers, einops, flashrank, mlx, mlx-lm, safetensors) and pg0-embedded as optional extras: [local-ml], [embedded-db], [all] - hindsight-api becomes a zero-code meta-package depending on hindsight-api-slim[all] for full backward compatibility - Add hindsight-all-slim meta-package: hindsight-api-slim + client + embed - hindsight-all updated to depend on hindsight-api-slim[all] - pg0.py: lazy-import pg0 with clear ImportError pointing to [embedded-db] - Dockerfile: replace sed hack with proper uv sync --extra flags - Update release.yml, test.yml, lint.sh, release.sh, CLAUDE.md and all path references throughout the repo * refactor: rename hindsight/ directory to hindsight-all/ * docs: document hindsight-api-slim and hindsight-all-slim package variants Add package variants table and extras explanation to installation.md * docs: remove emojis from installation.md, use professional tone * docs: link Docker slim variant to pip package variants section * docs: consolidate Docker image variants into single table * ci: fix working-directory paths after package restructure - Replace all hindsight-api → hindsight-api-slim in test.yml - Replace hindsight → hindsight-all in test.yml - Add --extra embedded-db to test-embed API install step * ci: add local-ml and embedded-db extras to API sync steps These extras were previously implicit in the old hindsight-api package (which bundled everything). Now that hindsight-api-slim uses optional extras, we must explicitly request local-ml and embedded-db in CI. * ci: add API install step with embedded-db to test-embed smoke test The smoke test starts hindsight-api as a daemon, which requires pg0-embedded. Add a dedicated install step for hindsight-api-slim with embedded-db extra so the daemon can start successfully. * ci: remove --no-install-project when using optional extras When --no-install-project is combined with --extra, the optional deps are not installed because extras require the project to be active. Remove --no-install-project from steps that need local-ml or embedded-db. * ci: fix ordering of uv sync steps to preserve optional extras When uv sync runs for a different workspace member, it removes optional extras installed for other members. Fix by always running extra-requiring API sync last, after other workspace member syncs. Also remove --no-install-project from embedded-db sync in test-embed, as --no-install-project prevents optional extras from being active. * ci: add local-ml extra to test-embed API install for smoke test The smoke test starts the full API server which needs sentence-transformers for local embeddings (default provider). Add local-ml extra to the install. * ci: simplify extras with --all-extras and add slim pip smoke test - Replace explicit --extra local-ml --extra embedded-db with --all-extras for cleaner, more maintainable sync steps - Add test-pip-slim job: tests hindsight-api-slim[embedded-db] without local ML models, using Cohere for embeddings/reranking (mirrors Docker slim smoke test approach) * ci: simplify slim smoke test to health check only (mirrors Docker test)
257 lines
8.1 KiB
Python
257 lines
8.1 KiB
Python
"""
|
|
Integration tests for S3FileStorage against a SeaweedFS Docker container.
|
|
|
|
SeaweedFS (Apache 2.0) provides an S3-compatible API via `weed server -s3`.
|
|
Requires Docker to be running. Tests are skipped automatically if Docker is unavailable.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import uuid
|
|
|
|
import httpx
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
from testcontainers.core.container import DockerContainer
|
|
|
|
_has_testcontainers = True
|
|
except ImportError:
|
|
_has_testcontainers = False
|
|
|
|
_in_ci = os.getenv("CI") == "true"
|
|
|
|
pytestmark = [
|
|
pytest.mark.skipif(not _has_testcontainers, reason="testcontainers not installed"),
|
|
pytest.mark.skipif(_in_ci, reason="SeaweedFS Docker image pull too slow in CI"),
|
|
pytest.mark.timeout(300),
|
|
]
|
|
|
|
SEAWEEDFS_S3_PORT = 8333
|
|
TEST_BUCKET = "hindsight-test"
|
|
ACCESS_KEY = "test_access_key"
|
|
SECRET_KEY = "test_secret_key"
|
|
|
|
# SeaweedFS S3 IAM config granting full access to our test credentials
|
|
_S3_CONFIG = {
|
|
"identities": [
|
|
{
|
|
"name": "test-user",
|
|
"credentials": [{"accessKey": ACCESS_KEY, "secretKey": SECRET_KEY}],
|
|
"actions": ["Admin", "Read", "Write", "List"],
|
|
}
|
|
]
|
|
}
|
|
|
|
|
|
def _docker_available() -> bool:
|
|
"""Check if Docker daemon is running."""
|
|
try:
|
|
result = subprocess.run(
|
|
["docker", "info"],
|
|
capture_output=True,
|
|
timeout=5,
|
|
)
|
|
return result.returncode == 0
|
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
return False
|
|
|
|
|
|
def _wait_for_seaweedfs(endpoint: str, timeout: int = 30) -> None:
|
|
"""Poll SeaweedFS S3 endpoint until ready."""
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
try:
|
|
resp = httpx.get(endpoint, timeout=2)
|
|
# 200 = no auth, 403 = auth enabled but gateway is up — either means ready
|
|
if resp.status_code in (200, 403):
|
|
logger.info("SeaweedFS S3 is ready at %s", endpoint)
|
|
return
|
|
except httpx.HTTPError:
|
|
pass
|
|
time.sleep(0.5)
|
|
raise TimeoutError(f"SeaweedFS did not become ready at {endpoint} within {timeout}s")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def seaweedfs_container():
|
|
"""Start a SeaweedFS container for the test module, shared across all tests.
|
|
|
|
Mounts an s3.json config file to set up S3 credentials for the test user.
|
|
"""
|
|
if not _docker_available():
|
|
pytest.skip("Docker is not available")
|
|
|
|
# Write S3 IAM config to a temp file that persists for the module scope
|
|
s3_config_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
|
|
json.dump(_S3_CONFIG, s3_config_file)
|
|
s3_config_file.flush()
|
|
|
|
container = (
|
|
DockerContainer(image="chrislusf/seaweedfs:latest")
|
|
.with_exposed_ports(SEAWEEDFS_S3_PORT)
|
|
.with_volume_mapping(s3_config_file.name, "/etc/seaweedfs/s3.json", "ro")
|
|
.with_command(
|
|
f"server -s3 -s3.port={SEAWEEDFS_S3_PORT} -s3.config=/etc/seaweedfs/s3.json -ip.bind=0.0.0.0"
|
|
)
|
|
)
|
|
|
|
container.start()
|
|
|
|
try:
|
|
host = container.get_container_host_ip()
|
|
port = container.get_exposed_port(SEAWEEDFS_S3_PORT)
|
|
endpoint = f"http://{host}:{port}"
|
|
|
|
_wait_for_seaweedfs(endpoint, timeout=240)
|
|
|
|
# Create test bucket using obstore (proper SigV4 signing)
|
|
import obstore as obs
|
|
from obstore.store import S3Store
|
|
|
|
admin_store = S3Store(
|
|
TEST_BUCKET,
|
|
endpoint=endpoint,
|
|
region="us-east-1",
|
|
access_key_id=ACCESS_KEY,
|
|
secret_access_key=SECRET_KEY,
|
|
allow_http=True,
|
|
)
|
|
# SeaweedFS auto-creates buckets on first write
|
|
obs.put(admin_store, ".bucket-init", b"")
|
|
obs.delete(admin_store, ".bucket-init")
|
|
logger.info("Test bucket '%s' is ready", TEST_BUCKET)
|
|
|
|
yield {
|
|
"endpoint": endpoint,
|
|
"access_key": ACCESS_KEY,
|
|
"secret_key": SECRET_KEY,
|
|
"bucket": TEST_BUCKET,
|
|
}
|
|
finally:
|
|
container.stop()
|
|
import os
|
|
|
|
os.unlink(s3_config_file.name)
|
|
|
|
|
|
@pytest.fixture
|
|
def s3_storage(seaweedfs_container):
|
|
"""Create an S3FileStorage instance pointing at the SeaweedFS container."""
|
|
from hindsight_api.engine.storage.s3 import S3FileStorage
|
|
|
|
return S3FileStorage(
|
|
bucket=seaweedfs_container["bucket"],
|
|
region="us-east-1",
|
|
endpoint=seaweedfs_container["endpoint"],
|
|
access_key_id=seaweedfs_container["access_key"],
|
|
secret_access_key=seaweedfs_container["secret_key"],
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_s3_storage_store_and_retrieve(s3_storage):
|
|
"""Store a file, retrieve it, verify bytes match."""
|
|
content = b"Hello, SeaweedFS! This is a test file."
|
|
key = f"test/{uuid.uuid4()}.txt"
|
|
|
|
stored_key = await s3_storage.store(
|
|
file_data=content,
|
|
key=key,
|
|
metadata={"content_type": "text/plain"},
|
|
)
|
|
assert stored_key == key
|
|
|
|
retrieved = await s3_storage.retrieve(key)
|
|
assert retrieved == content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_s3_storage_exists_and_delete(s3_storage):
|
|
"""Store, check exists=True, delete, check exists=False."""
|
|
content = b"File to be deleted."
|
|
key = f"test/{uuid.uuid4()}.txt"
|
|
|
|
await s3_storage.store(file_data=content, key=key)
|
|
|
|
assert await s3_storage.exists(key) is True
|
|
|
|
await s3_storage.delete(key)
|
|
|
|
assert await s3_storage.exists(key) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_s3_storage_file_not_found(s3_storage):
|
|
"""Retrieve a non-existent key, expect FileNotFoundError."""
|
|
with pytest.raises(FileNotFoundError):
|
|
await s3_storage.retrieve(f"nonexistent/{uuid.uuid4()}.txt")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_s3_storage_get_download_url(s3_storage):
|
|
"""Store a file, get a presigned URL, verify it's a valid URL string."""
|
|
content = b"Presigned URL test content."
|
|
key = f"test/{uuid.uuid4()}.txt"
|
|
|
|
await s3_storage.store(file_data=content, key=key)
|
|
|
|
url = await s3_storage.get_download_url(key, expires_in=300)
|
|
assert isinstance(url, str)
|
|
assert url.startswith("http")
|
|
assert key in url
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_s3_file_retain_api_end_to_end(seaweedfs_container, memory_no_llm_verify):
|
|
"""Full HTTP API flow: upload file via /files/retain with S3 storage backend."""
|
|
from hindsight_api.api.http import create_app
|
|
from hindsight_api.engine.storage.s3 import S3FileStorage
|
|
|
|
# Swap the engine's file storage to use the SeaweedFS-backed S3 storage
|
|
original_storage = memory_no_llm_verify._file_storage
|
|
s3_storage = S3FileStorage(
|
|
bucket=seaweedfs_container["bucket"],
|
|
region="us-east-1",
|
|
endpoint=seaweedfs_container["endpoint"],
|
|
access_key_id=seaweedfs_container["access_key"],
|
|
secret_access_key=seaweedfs_container["secret_key"],
|
|
)
|
|
memory_no_llm_verify._file_storage = s3_storage
|
|
|
|
try:
|
|
app = create_app(memory_no_llm_verify, initialize_memory=False)
|
|
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
bank_id = f"test-s3-bank-{uuid.uuid4().hex[:8]}"
|
|
bank_response = await client.put(f"/v1/default/banks/{bank_id}", json={"name": "S3 Test Bank"})
|
|
assert bank_response.status_code in (200, 201)
|
|
|
|
txt_content = b"Alice works at Acme Corp. She joined in 2024."
|
|
request_data = {
|
|
"document_tags": ["s3-test"],
|
|
"async": True,
|
|
}
|
|
|
|
files = {"files": ("notes.txt", txt_content, "text/plain")}
|
|
data = {"request": json.dumps(request_data)}
|
|
|
|
response = await client.post(
|
|
f"/v1/default/banks/{bank_id}/files/retain",
|
|
files=files,
|
|
data=data,
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
assert "operation_ids" in result
|
|
assert len(result["operation_ids"]) == 1
|
|
finally:
|
|
memory_no_llm_verify._file_storage = original_storage
|