feat: scaffold Search Service Python project (#45) #143
7
.gitignore
vendored
7
.gitignore
vendored
@@ -6,6 +6,13 @@
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# Python virtual environments
|
||||
.venv/
|
||||
|
||||
# Python eggs/dist
|
||||
*.egg-info/
|
||||
dist/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
@@ -4,9 +4,8 @@ version = "0.1.0"
|
||||
description = "Generated protobuf types and gRPC stubs for llm-multiverse services"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"protobuf>=5.29",
|
||||
"protobuf>=7.34",
|
||||
"grpcio>=1.69",
|
||||
"grpcio-tools>=1.69",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
| #42 | Implement Inference + GenerateEmbedding endpoints | Phase 5 | `COMPLETED` | Rust | [issue-042.md](issue-042.md) |
|
||||
| #43 | Integration tests for Model Gateway | Phase 5 | `COMPLETED` | Rust | [issue-043.md](issue-043.md) |
|
||||
| #44 | Set up SearXNG Docker container | Phase 6 | `COMPLETED` | Docker / YAML | [issue-044.md](issue-044.md) |
|
||||
| #45 | Scaffold Search Service Python project | Phase 6 | `COMPLETED` | Python | [issue-045.md](issue-045.md) |
|
||||
|
||||
## Status Legend
|
||||
|
||||
|
||||
140
implementation-plans/issue-045.md
Normal file
140
implementation-plans/issue-045.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# Implementation Plan — Issue #45: Scaffold Search Service Python project
|
||||
|
||||
## Metadata
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Issue | [#45](https://git.shahondin1624.de/llm-multiverse/llm-multiverse/issues/45) |
|
||||
| Title | Scaffold Search Service Python project |
|
||||
| Milestone | Phase 6: Search Service |
|
||||
| Labels | — |
|
||||
| Status | `COMPLETED` |
|
||||
| Language | Python |
|
||||
| Related Plans | issue-044.md |
|
||||
| Blocked by | #17 |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Python project created (`services/search/`)
|
||||
- [ ] Dependency on generated Python proto stubs
|
||||
- [ ] grpcio server boilerplate runs
|
||||
- [ ] Configuration loading (address, port, SearXNG URL, Model Gateway URL)
|
||||
- [ ] Health check endpoint responds
|
||||
- [ ] Poetry/uv for dependency management
|
||||
|
||||
## Architecture Analysis
|
||||
|
||||
### Service Context
|
||||
- **Service:** Search Service (`services/search/`)
|
||||
- **Language:** Python (first Python service in the project)
|
||||
- **gRPC endpoint:** `SearchService::Search` (unary)
|
||||
- **Proto:** `SearchRequest` → `SearchResponse` with `repeated SearchResult`
|
||||
- **Dependencies:** SearXNG (HTTP, from #44), Model Gateway (gRPC, for summarization)
|
||||
|
||||
### Existing Patterns
|
||||
- **Python proto stubs:** Generated in `gen/python/` as `llm-multiverse-proto` package with `grpcio>=1.69`, `protobuf>=5.29`, Python 3.11+
|
||||
- **gRPC stubs:** `search_pb2_grpc.SearchServiceServicer` for server implementation, `add_SearchServiceServicer_to_server` for registration
|
||||
- **Rust service pattern:** Config loading (TOML), `listen_addr()`, tracing/logging, graceful shutdown. Python equivalent uses `grpc.aio` for async server.
|
||||
|
||||
### Dependencies
|
||||
- `llm-multiverse-proto` (local path dependency from `gen/python/`)
|
||||
- `grpcio>=1.69` (gRPC server)
|
||||
- `protobuf>=5.29` (proto message types)
|
||||
- `pyyaml` or `tomli` (config loading)
|
||||
- `structlog` or stdlib `logging` (structured logging)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Project Structure
|
||||
|
||||
```
|
||||
services/search/
|
||||
├── pyproject.toml
|
||||
├── src/
|
||||
│ └── search_service/
|
||||
│ ├── __init__.py
|
||||
│ ├── __main__.py # Entry point
|
||||
│ ├── config.py # Configuration loading
|
||||
│ └── service.py # SearchServiceServicer implementation (stub)
|
||||
└── tests/
|
||||
├── __init__.py
|
||||
└── test_config.py
|
||||
```
|
||||
|
||||
### 2. `pyproject.toml`
|
||||
|
||||
Use modern Python packaging with `uv` as the dependency manager:
|
||||
- Project metadata (name, version, Python >=3.11)
|
||||
- Dependencies: grpcio, protobuf, pyyaml
|
||||
- Dev dependencies: pytest, pytest-asyncio
|
||||
- Local dependency on `gen/python` (path reference)
|
||||
- Entry point: `search-service = "search_service.__main__:main"`
|
||||
|
||||
### 3. Configuration (`config.py`)
|
||||
|
||||
Dataclass-based config with YAML loading:
|
||||
```python
|
||||
@dataclass
|
||||
class Config:
|
||||
host: str = "[::]"
|
||||
port: int = 50056
|
||||
searxng_url: str = "http://localhost:8888"
|
||||
model_gateway_addr: str = "localhost:50055"
|
||||
audit_addr: str | None = None
|
||||
```
|
||||
|
||||
- Load from `SEARCH_SERVICE_CONFIG` env var (YAML file path)
|
||||
- Default values if no config file
|
||||
- `listen_addr()` property returns `{host}:{port}`
|
||||
|
||||
### 4. Service Stub (`service.py`)
|
||||
|
||||
Implement `SearchServiceServicer` with `Search` method returning `Unimplemented` for now (actual logic in #46-#49):
|
||||
```python
|
||||
class SearchServiceImpl(search_pb2_grpc.SearchServiceServicer):
|
||||
async def Search(self, request, context):
|
||||
await context.abort(grpc.StatusCode.UNIMPLEMENTED, "Search not yet implemented")
|
||||
```
|
||||
|
||||
### 5. Server Entry Point (`__main__.py`)
|
||||
|
||||
Async gRPC server using `grpc.aio`:
|
||||
- Load config
|
||||
- Create server on configured address
|
||||
- Register `SearchServiceServicer`
|
||||
- Add reflection (optional)
|
||||
- Graceful shutdown on SIGINT/SIGTERM
|
||||
- Structured logging
|
||||
|
||||
### 6. Tests
|
||||
|
||||
- `test_config.py`: Test default config values, YAML loading, listen_addr format
|
||||
- Verify imports from proto stubs work
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
| File | Action | Purpose |
|
||||
|---|---|---|
|
||||
| `services/search/pyproject.toml` | Create | Project metadata, dependencies |
|
||||
| `services/search/src/search_service/__init__.py` | Create | Package init |
|
||||
| `services/search/src/search_service/__main__.py` | Create | gRPC server entry point |
|
||||
| `services/search/src/search_service/config.py` | Create | Configuration loading |
|
||||
| `services/search/src/search_service/service.py` | Create | SearchServiceServicer stub |
|
||||
| `services/search/tests/__init__.py` | Create | Test package |
|
||||
| `services/search/tests/test_config.py` | Create | Config unit tests |
|
||||
|
||||
## Risks and Edge Cases
|
||||
|
||||
- **Python proto stubs path:** The `gen/python/` package needs to be importable. Using a path dependency in pyproject.toml with `uv` workspace or editable install.
|
||||
- **grpc.aio vs synchronous:** Using async gRPC server (`grpc.aio.server()`) for consistency with the async pipeline that will query SearXNG and Model Gateway.
|
||||
- **Port assignment:** Using 50056 for Search Service (audit=50052, secrets=50053, memory=50054, model-gateway=50055).
|
||||
|
||||
## Deviation Log
|
||||
|
||||
_(Filled during implementation if deviations from plan occur)_
|
||||
|
||||
| Deviation | Reason |
|
||||
|---|---|
|
||||
| Fixed gen/python protobuf dep to >=7.34 | Generated stubs require protobuf 7.x (gencode 7.34.0), not 5.x as previously specified |
|
||||
| Removed grpcio-tools from gen/python runtime deps | grpcio-tools is only needed for code generation, not runtime |
|
||||
| Added .venv and *.egg-info to .gitignore | First Python service needed venv/packaging entries in gitignore |
|
||||
33
services/search/pyproject.toml
Normal file
33
services/search/pyproject.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[project]
|
||||
name = "search-service"
|
||||
version = "0.1.0"
|
||||
description = "Search Service for llm-multiverse — wraps SearXNG via gRPC"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"llm-multiverse-proto",
|
||||
"grpcio>=1.69",
|
||||
"protobuf>=7.34",
|
||||
"pyyaml>=6.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
search-service = "search_service.__main__:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=75.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
include = ["search_service*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"ruff>=0.8",
|
||||
]
|
||||
3
services/search/src/search_service/__init__.py
Normal file
3
services/search/src/search_service/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Search Service for llm-multiverse."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
62
services/search/src/search_service/__main__.py
Normal file
62
services/search/src/search_service/__main__.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Search Service entry point — async gRPC server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import signal
|
||||
|
||||
import grpc
|
||||
from llm_multiverse.v1 import search_pb2_grpc
|
||||
|
||||
from .config import Config
|
||||
from .service import SearchServiceImpl
|
||||
|
||||
logger = logging.getLogger("search_service")
|
||||
|
||||
|
||||
async def serve(config: Config) -> None:
|
||||
"""Start the gRPC server and wait for shutdown."""
|
||||
server = grpc.aio.server()
|
||||
service = SearchServiceImpl(config)
|
||||
search_pb2_grpc.add_SearchServiceServicer_to_server(service, server)
|
||||
|
||||
server.add_insecure_port(config.listen_addr)
|
||||
logger.info(
|
||||
"Starting Search Service on %s (searxng=%s, model_gateway=%s)",
|
||||
config.listen_addr,
|
||||
config.searxng_url,
|
||||
config.model_gateway_addr,
|
||||
)
|
||||
|
||||
await server.start()
|
||||
|
||||
# Graceful shutdown on SIGINT/SIGTERM
|
||||
loop = asyncio.get_running_loop()
|
||||
shutdown_event = asyncio.Event()
|
||||
|
||||
def _signal_handler() -> None:
|
||||
logger.info("Shutdown signal received")
|
||||
shutdown_event.set()
|
||||
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
loop.add_signal_handler(sig, _signal_handler)
|
||||
|
||||
await shutdown_event.wait()
|
||||
logger.info("Shutting down gracefully...")
|
||||
await server.stop(grace=5)
|
||||
logger.info("Search Service shut down")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point for the search-service command."""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
config = Config.load()
|
||||
asyncio.run(serve(config))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
45
services/search/src/search_service/config.py
Normal file
45
services/search/src/search_service/config.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Configuration loading for the Search Service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Search Service configuration."""
|
||||
|
||||
host: str = "[::]"
|
||||
port: int = 50056
|
||||
searxng_url: str = "http://localhost:8888"
|
||||
model_gateway_addr: str = "localhost:50055"
|
||||
audit_addr: str | None = None
|
||||
|
||||
@property
|
||||
def listen_addr(self) -> str:
|
||||
"""Return the gRPC listen address."""
|
||||
return f"{self.host}:{self.port}"
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str | None = None) -> Config:
|
||||
"""Load configuration from a YAML file.
|
||||
|
||||
Falls back to defaults if no path is provided or the file doesn't exist.
|
||||
"""
|
||||
config_path = path or os.environ.get("SEARCH_SERVICE_CONFIG")
|
||||
if config_path is None:
|
||||
return cls()
|
||||
|
||||
with open(config_path) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
return cls(
|
||||
host=data.get("host", cls.host),
|
||||
port=data.get("port", cls.port),
|
||||
searxng_url=data.get("searxng_url", cls.searxng_url),
|
||||
model_gateway_addr=data.get("model_gateway_addr", cls.model_gateway_addr),
|
||||
audit_addr=data.get("audit_addr"),
|
||||
)
|
||||
28
services/search/src/search_service/service.py
Normal file
28
services/search/src/search_service/service.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""SearchService gRPC implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import grpc
|
||||
from llm_multiverse.v1 import search_pb2, search_pb2_grpc
|
||||
|
||||
from .config import Config
|
||||
|
||||
|
||||
class SearchServiceImpl(search_pb2_grpc.SearchServiceServicer):
|
||||
"""Implementation of the SearchService gRPC interface."""
|
||||
|
||||
def __init__(self, config: Config) -> None:
|
||||
self.config = config
|
||||
|
||||
async def Search(
|
||||
self,
|
||||
request: search_pb2.SearchRequest,
|
||||
context: grpc.aio.ServicerContext,
|
||||
) -> search_pb2.SearchResponse:
|
||||
"""Execute a search query.
|
||||
|
||||
Currently a stub — real implementation in issues #46-#49.
|
||||
"""
|
||||
await context.abort(
|
||||
grpc.StatusCode.UNIMPLEMENTED, "Search not yet implemented"
|
||||
)
|
||||
0
services/search/tests/__init__.py
Normal file
0
services/search/tests/__init__.py
Normal file
102
services/search/tests/test_config.py
Normal file
102
services/search/tests/test_config.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Tests for Search Service configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import yaml
|
||||
|
||||
from search_service.config import Config
|
||||
|
||||
|
||||
def test_default_config() -> None:
|
||||
config = Config()
|
||||
assert config.host == "[::]"
|
||||
assert config.port == 50056
|
||||
assert config.searxng_url == "http://localhost:8888"
|
||||
assert config.model_gateway_addr == "localhost:50055"
|
||||
assert config.audit_addr is None
|
||||
|
||||
|
||||
def test_listen_addr() -> None:
|
||||
config = Config()
|
||||
assert config.listen_addr == "[::]:50056"
|
||||
|
||||
|
||||
def test_listen_addr_custom() -> None:
|
||||
config = Config(host="127.0.0.1", port=9000)
|
||||
assert config.listen_addr == "127.0.0.1:9000"
|
||||
|
||||
|
||||
def test_load_no_file_uses_defaults() -> None:
|
||||
config = Config.load(None)
|
||||
assert config.port == 50056
|
||||
assert config.searxng_url == "http://localhost:8888"
|
||||
|
||||
|
||||
def test_load_from_yaml() -> None:
|
||||
data = {
|
||||
"host": "0.0.0.0",
|
||||
"port": 60000,
|
||||
"searxng_url": "http://searxng:8080",
|
||||
"model_gateway_addr": "gateway:50055",
|
||||
"audit_addr": "audit:50052",
|
||||
}
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f:
|
||||
yaml.dump(data, f)
|
||||
f.flush()
|
||||
config = Config.load(f.name)
|
||||
|
||||
os.unlink(f.name)
|
||||
|
||||
assert config.host == "0.0.0.0"
|
||||
assert config.port == 60000
|
||||
assert config.searxng_url == "http://searxng:8080"
|
||||
assert config.model_gateway_addr == "gateway:50055"
|
||||
assert config.audit_addr == "audit:50052"
|
||||
|
||||
|
||||
def test_load_partial_yaml_uses_defaults() -> None:
|
||||
data = {"port": 7777}
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f:
|
||||
yaml.dump(data, f)
|
||||
f.flush()
|
||||
config = Config.load(f.name)
|
||||
|
||||
os.unlink(f.name)
|
||||
|
||||
assert config.port == 7777
|
||||
assert config.host == "[::]"
|
||||
assert config.searxng_url == "http://localhost:8888"
|
||||
assert config.audit_addr is None
|
||||
|
||||
|
||||
def test_load_from_env_var(tmp_path: object) -> None:
|
||||
"""Test loading config via SEARCH_SERVICE_CONFIG env var."""
|
||||
import pathlib
|
||||
|
||||
config_file = pathlib.Path(str(tmp_path)) / "config.yml"
|
||||
config_file.write_text(yaml.dump({"port": 12345}))
|
||||
|
||||
old_val = os.environ.get("SEARCH_SERVICE_CONFIG")
|
||||
os.environ["SEARCH_SERVICE_CONFIG"] = str(config_file)
|
||||
try:
|
||||
config = Config.load()
|
||||
assert config.port == 12345
|
||||
finally:
|
||||
if old_val is None:
|
||||
del os.environ["SEARCH_SERVICE_CONFIG"]
|
||||
else:
|
||||
os.environ["SEARCH_SERVICE_CONFIG"] = old_val
|
||||
|
||||
|
||||
def test_load_empty_yaml() -> None:
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f:
|
||||
f.write("")
|
||||
f.flush()
|
||||
config = Config.load(f.name)
|
||||
|
||||
os.unlink(f.name)
|
||||
|
||||
assert config.port == 50056
|
||||
Reference in New Issue
Block a user