feat: scaffold Search Service Python project (#45) #143

Merged
shahondin1624 merged 1 commits from feature/issue-45-scaffold-search-service into main 2026-03-10 15:25:57 +01:00
11 changed files with 422 additions and 2 deletions

7
.gitignore vendored
View File

@@ -6,6 +6,13 @@
*.pyc
*.pyo
# Python virtual environments
.venv/
# Python eggs/dist
*.egg-info/
dist/
# IDE
.idea/
.vscode/

View File

@@ -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]

View File

@@ -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

View 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 |

View 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",
]

View File

@@ -0,0 +1,3 @@
"""Search Service for llm-multiverse."""
__version__ = "0.1.0"

View 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()

View 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"),
)

View 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"
)

View File

View 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