chore: add ruff linter, fix mypy errors, and write README

- Add ruff linter configuration to pyproject.toml with modern Python 3.12 rules
- Add ruff~=0.9.4 to dev dependencies
- Fix all mypy type errors (Optional[] hints, Token types, Any returns)
- Auto-fix 54 ruff issues (import sorting, Optional -> X | None syntax)
- Create ProjectMeta dataclass for single source of truth from pyproject.toml
- Write comprehensive README.md with setup, config, and development docs
- Update main.py to use settings.app_description from pyproject.toml

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-09 22:58:24 +01:00
co-authored by Claude Opus 4.5
parent 34b68621c6
commit 667e2ca8e4
13 changed files with 289 additions and 65 deletions
+172
View File
@@ -0,0 +1,172 @@
# Webber
Multi-Agent AI Development System - a FastAPI-based service that orchestrates local LLM agents for code exploration, planning, and task execution.
## Overview
Webber provides autonomous AI agents similar to Claude Code but running locally with configurable models via Ollama. It's designed for:
- **Explore Agent** - Fast codebase navigation and code search
- **Plan Agent** - Implementation design and step-by-step planning
- **Task Agent** - Autonomous multi-step code generation and modification
Built on [PydanticAI](https://ai.pydantic.dev/) for structured LLM interactions.
## Quick Start
### Prerequisites
- Python 3.12+
- [Ollama](https://ollama.ai/) with models installed
- (Optional) Tatlock for multi-tenant authentication
### Installation
```bash
# Clone the repository
git clone https://git.schweitz.internal/jpmschweitzer/webber.git
cd webber
# Create virtual environment
python -m venv .venv
source .venv/bin/activate
# Install dependencies
pip install -r requirements.txt
# For development (includes testing and linting tools)
pip install -r requirements-dev.txt
```
### Configuration
```bash
# Copy example config
cp .env.example .env
# Edit .env with your settings
# At minimum, configure OLLAMA_URL to point to your Ollama instance
```
### Running
```bash
# Development (with auto-reload)
./wakeup.sh
# Or manually
uvicorn src.main:app --host 0.0.0.0 --port 8086 --reload
```
The service will be available at `http://localhost:8086`. API docs at `/docs`.
## Configuration
All settings via environment variables or `.env` file:
| Variable | Default | Description |
|----------|---------|-------------|
| `DEBUG` | `false` | Enable debug mode |
| `LOG_LEVEL` | `INFO` | Logging level |
| `PORT` | `8086` | Server port |
| `OLLAMA_URL` | `http://192.168.86.149:11434` | Ollama API URL |
| `OLLAMA_AGENT_MODEL` | `mistral-nemo-large:latest` | Model for agent reasoning |
| `OLLAMA_EMBED_MODEL` | `nomic-embed-text:latest` | Model for embeddings |
| `TOOL_TIMEOUT_SECONDS` | `120` | Tool execution timeout |
| `SANDBOX_ENABLED` | `true` | Sandbox tool execution |
| `ALLOWED_PATHS` | `[]` | Paths accessible to tools |
See [.env.example](.env.example) for full configuration options.
## Development
### Code Quality
```bash
# Type checking
mypy src/
# Linting
ruff check src/ tests/
# Auto-fix lint issues
ruff check src/ tests/ --fix
# Format code
ruff format src/ tests/
```
### Testing
```bash
# Run all tests
pytest tests/ -v
# With coverage
pytest tests/ --cov=src --cov-report=html
```
### Security Audit
```bash
# Check dependencies for CVEs
pip-audit
```
## Architecture
Webber uses a domain-based architecture with clean separation of concerns:
```
src/
├── main.py # FastAPI app entry point
├── shared/ # Cross-cutting infrastructure
│ ├── base.py # BaseController, BaseSchema
│ ├── config.py # Settings from pyproject.toml + env
│ ├── logging.py # @logged decorator with timing
│ └── exceptions.py # Exception hierarchy
└── domains/ # Feature domains
├── health/ # Health check endpoints
├── agents/ # Agent orchestration
└── tools/ # Tool execution (file, shell, search)
```
See [docs/architecture.md](docs/architecture.md) for detailed patterns and conventions.
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/` | GET | Service information |
| `/health` | GET | Health check for monitoring |
| `/docs` | GET | Interactive API documentation |
## Docker
```bash
# Build
docker build -t webber .
# Run
docker run -p 8086:8086 --env-file .env webber
```
The container includes a healthcheck that pings `/health` every 30 seconds.
## Deployment
Deployed via Gitea Actions CI/CD:
1. Tag a release (`git tag v0.x.x && git push --tags`)
2. Workflow builds and pushes Docker image
3. Watchtower auto-deploys to production
Production runs in Portainer `agents` stack on the `docker-dataplane` network.
## Status
**Alpha** - Core infrastructure is complete. Agent and tool implementations are in progress.
## License
MIT
+32
View File
@@ -35,3 +35,35 @@ warn_return_any = true
warn_unused_ignores = true warn_unused_ignores = true
strict = false strict = false
ignore_missing_imports = true ignore_missing_imports = true
[tool.ruff]
target-version = "py312"
line-length = 100
src = ["src", "tests"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"SIM", # flake8-simplify
"TCH", # flake8-type-checking
"RUF", # Ruff-specific rules
]
ignore = [
"E501", # line too long (handled by formatter)
"B008", # function call in default argument (FastAPI Depends)
"B904", # raise without from (sometimes intentional)
]
[tool.ruff.lint.isort]
known-first-party = ["src"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
+3
View File
@@ -15,3 +15,6 @@ pip-audit~=2.9.0
# Type checking # Type checking
mypy~=1.19.1 mypy~=1.19.1
# Linting and formatting
ruff~=0.9.4
-1
View File
@@ -1,7 +1,6 @@
""" """
Health check routes. Health check routes.
""" """
from fastapi import APIRouter
from src.domains.health.controller import health_controller from src.domains.health.controller import health_controller
+1
View File
@@ -7,6 +7,7 @@ main.py only includes this root_router.
from fastapi import APIRouter from fastapi import APIRouter
from src.domains.health.router import router as health_router from src.domains.health.router import router as health_router
# from src.domains.auth.router import router as auth_router # from src.domains.auth.router import router as auth_router
# from src.domains.agents.router import router as agents_router # from src.domains.agents.router import router as agents_router
# from src.domains.tools.router import router as tools_router # from src.domains.tools.router import router as tools_router
+6 -6
View File
@@ -10,12 +10,12 @@ from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from src.shared.config import get_settings
from src.shared.logging import setup_logging, get_logger
from src.shared.exceptions import AppException
from src.shared.context import user_provider
from src.shared.auth import validate_api_key
from src.domains.router import root_router from src.domains.router import root_router
from src.shared.auth import validate_api_key
from src.shared.config import get_settings
from src.shared.context import user_provider
from src.shared.exceptions import AppException
from src.shared.logging import get_logger, setup_logging
settings = get_settings() settings = get_settings()
setup_logging(settings.log_level) setup_logging(settings.log_level)
@@ -44,7 +44,7 @@ async def lifespan(app: FastAPI):
app = FastAPI( app = FastAPI(
title=settings.app_name, title=settings.app_name,
version=settings.app_version, version=settings.app_version,
description="Multi-Agent AI Development System", description=settings.app_description,
docs_url="/docs", docs_url="/docs",
redoc_url=None, redoc_url=None,
openapi_url="/openapi.json", openapi_url="/openapi.json",
+8 -9
View File
@@ -3,13 +3,12 @@ Authentication utilities.
Provides API key validation and integration with external auth services. Provides API key validation and integration with external auth services.
""" """
from typing import Optional
from fastapi import Request, HTTPException, Depends from fastapi import Depends, HTTPException
from fastapi.security import APIKeyHeader from fastapi.security import APIKeyHeader
from src.shared.config import get_settings from src.shared.config import get_settings
from src.shared.context import User, user_provider from src.shared.context import User
from src.shared.logging import get_logger from src.shared.logging import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -19,7 +18,7 @@ settings = get_settings()
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
async def validate_api_key(api_key: str) -> Optional[User]: async def validate_api_key(api_key: str) -> User | None:
""" """
Validate API key and return User if valid. Validate API key and return User if valid.
@@ -54,15 +53,15 @@ async def validate_api_key(api_key: str) -> Optional[User]:
async def get_api_key( async def get_api_key(
api_key: Optional[str] = Depends(api_key_header), api_key: str | None = Depends(api_key_header),
) -> Optional[str]: ) -> str | None:
"""FastAPI dependency to extract API key from header.""" """FastAPI dependency to extract API key from header."""
return api_key return api_key
async def get_current_user_dep( async def get_current_user_dep(
api_key: Optional[str] = Depends(get_api_key), api_key: str | None = Depends(get_api_key),
) -> Optional[User]: ) -> User | None:
""" """
FastAPI dependency to get current user from API key. FastAPI dependency to get current user from API key.
@@ -74,7 +73,7 @@ async def get_current_user_dep(
async def require_auth( async def require_auth(
user: Optional[User] = Depends(get_current_user_dep), user: User | None = Depends(get_current_user_dep),
) -> User: ) -> User:
""" """
FastAPI dependency that requires authentication. FastAPI dependency that requires authentication.
+8 -4
View File
@@ -4,12 +4,16 @@ Base classes for controllers and schemas.
All domain controllers and Pydantic models should inherit from these. All domain controllers and Pydantic models should inherit from these.
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Sequence
from datetime import datetime from datetime import datetime
from typing import Any from typing import TYPE_CHECKING, Any
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
if TYPE_CHECKING:
from enum import Enum
class BaseController(ABC): class BaseController(ABC):
""" """
@@ -34,10 +38,10 @@ class BaseController(ABC):
users_controller = UsersController() users_controller = UsersController()
""" """
def __init__(self, prefix: str, tags: list[str]): def __init__(self, prefix: str, tags: Sequence[str]):
self.prefix = prefix self.prefix = prefix
self.tags = tags self.tags: list[str | Enum] = list(tags)
self._router = None self._router: APIRouter | None = None
@abstractmethod @abstractmethod
def create_router(self) -> APIRouter: def create_router(self) -> APIRouter:
+31 -15
View File
@@ -2,35 +2,51 @@
Application configuration via Pydantic Settings. Application configuration via Pydantic Settings.
All settings loaded from environment variables or .env file. All settings loaded from environment variables or .env file.
Project metadata (name, version, description) sourced from pyproject.toml.
""" """
import tomllib import tomllib
from pathlib import Path from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from typing import Optional from pathlib import Path
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
def _get_version() -> str: @dataclass(frozen=True)
"""Load version from pyproject.toml.""" class ProjectMeta:
"""Project metadata from pyproject.toml (single source of truth)."""
name: str
version: str
description: str
def _load_project_meta() -> ProjectMeta:
"""Load project metadata from pyproject.toml."""
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml" pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
try: try:
with open(pyproject_path, "rb") as f: with open(pyproject_path, "rb") as f:
return tomllib.load(f).get("project", {}).get("version", "0.0.0") data = tomllib.load(f)
project = data.get("project", {})
return ProjectMeta(
name=str(project.get("name", "webber")).title(),
version=str(project.get("version", "0.0.0")),
description=str(project.get("description", "")),
)
except FileNotFoundError: except FileNotFoundError:
return "0.0.0" return ProjectMeta(name="Webber", version="0.0.0", description="")
__version__ = _get_version() PROJECT = _load_project_meta()
__version__ = PROJECT.version
class Settings(BaseSettings): class Settings(BaseSettings):
"""Application settings loaded from environment.""" """Application settings loaded from environment."""
# Application # Application (from pyproject.toml)
app_name: str = "Webber" app_name: str = PROJECT.name
app_version: str = __version__ app_version: str = PROJECT.version
app_description: str = PROJECT.description
debug: bool = False debug: bool = False
# Server # Server
@@ -52,13 +68,13 @@ class Settings(BaseSettings):
ollama_embed_model: str = "nomic-embed-text:latest" ollama_embed_model: str = "nomic-embed-text:latest"
# Auth - Tatlock integration # Auth - Tatlock integration
tatlock_api_url: Optional[str] = "http://192.168.86.149:8000" tatlock_api_url: str | None = "http://192.168.86.149:8000"
internal_api_key: Optional[str] = None internal_api_key: str | None = None
# Tool execution # Tool execution
tool_timeout_seconds: int = 120 tool_timeout_seconds: int = 120
sandbox_enabled: bool = True sandbox_enabled: bool = True
allowed_paths: Optional[list[str]] = None allowed_paths: list[str] | None = None
# Sessions # Sessions
session_ttl_hours: int = 24 session_ttl_hours: int = 24
@@ -77,7 +93,7 @@ class Settings(BaseSettings):
return self.allowed_paths or [] return self.allowed_paths or []
@lru_cache() @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:
"""Cached settings singleton.""" """Cached settings singleton."""
return Settings() return Settings()
+6 -7
View File
@@ -5,9 +5,8 @@ Provides UserProvider singleton for request-scoped user context.
Set once per request in middleware, accessible everywhere without Set once per request in middleware, accessible everywhere without
passing user through function parameters. passing user through function parameters.
""" """
from dataclasses import dataclass, field
from typing import Optional
from contextvars import ContextVar from contextvars import ContextVar
from dataclasses import dataclass, field
@dataclass @dataclass
@@ -16,11 +15,11 @@ class User:
id: str id: str
email: str email: str
api_key: str api_key: str
tenant_id: Optional[str] = None tenant_id: str | None = None
roles: list[str] = field(default_factory=list) roles: list[str] = field(default_factory=list)
_current_user: ContextVar[Optional[User]] = ContextVar('current_user', default=None) _current_user: ContextVar[User | None] = ContextVar('current_user', default=None)
class UserProvider: class UserProvider:
@@ -55,7 +54,7 @@ class UserProvider:
"""Set current user for this request context.""" """Set current user for this request context."""
_current_user.set(user) _current_user.set(user)
def get_user(self) -> Optional[User]: def get_user(self) -> User | None:
"""Get current user (may be None).""" """Get current user (may be None)."""
return _current_user.get() return _current_user.get()
@@ -64,7 +63,7 @@ class UserProvider:
_current_user.set(None) _current_user.set(None)
@property @property
def current_user(self) -> Optional[User]: def current_user(self) -> User | None:
"""Property access to current user.""" """Property access to current user."""
return self.get_user() return self.get_user()
@@ -73,7 +72,7 @@ class UserProvider:
user_provider = UserProvider() user_provider = UserProvider()
def get_current_user() -> Optional[User]: def get_current_user() -> User | None:
"""Get current user or None.""" """Get current user or None."""
return user_provider.get_user() return user_provider.get_user()
+5 -5
View File
@@ -3,7 +3,7 @@ Custom exception hierarchy.
All application exceptions inherit from AppException. All application exceptions inherit from AppException.
""" """
from typing import Any, Optional from typing import Any
class AppException(Exception): class AppException(Exception):
@@ -13,8 +13,8 @@ class AppException(Exception):
self, self,
message: str, message: str,
status_code: int = 500, status_code: int = 500,
error_code: Optional[str] = None, error_code: str | None = None,
details: Optional[dict[str, Any]] = None, details: dict[str, Any] | None = None,
): ):
self.message = message self.message = message
self.status_code = status_code self.status_code = status_code
@@ -45,7 +45,7 @@ class NotFoundError(AppException):
class ValidationError(AppException): class ValidationError(AppException):
"""Input validation failed.""" """Input validation failed."""
def __init__(self, message: str, field: Optional[str] = None): def __init__(self, message: str, field: str | None = None):
super().__init__( super().__init__(
message=message, message=message,
status_code=422, status_code=422,
@@ -88,7 +88,7 @@ class RateLimitError(AppException):
class ServiceUnavailableError(AppException): class ServiceUnavailableError(AppException):
"""External service unavailable.""" """External service unavailable."""
def __init__(self, service: str, message: Optional[str] = None): def __init__(self, service: str, message: str | None = None):
super().__init__( super().__init__(
message=message or f"Service unavailable: {service}", message=message or f"Service unavailable: {service}",
status_code=503, status_code=503,
+16 -17
View File
@@ -7,18 +7,17 @@ Provides:
- Trace ID correlation across nested calls - Trace ID correlation across nested calls
- Configurable slow/warn thresholds - Configurable slow/warn thresholds
""" """
import functools
import asyncio import asyncio
import time import functools
import logging import logging
import sys import sys
from pathlib import Path import time
from typing import Callable, Optional from collections.abc import Callable
from contextvars import ContextVar from contextvars import ContextVar, Token
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from uuid import uuid4 from uuid import uuid4
# === Trace Context === # === Trace Context ===
@dataclass @dataclass
@@ -26,10 +25,10 @@ class TraceSpan:
"""Represents a timed execution span.""" """Represents a timed execution span."""
name: str name: str
trace_id: str trace_id: str
parent_id: Optional[str] = None parent_id: str | None = None
span_id: str = field(default_factory=lambda: uuid4().hex[:8]) span_id: str = field(default_factory=lambda: uuid4().hex[:8])
start_time: float = field(default_factory=time.perf_counter) start_time: float = field(default_factory=time.perf_counter)
end_time: Optional[float] = None end_time: float | None = None
@property @property
def duration_ms(self) -> float: def duration_ms(self) -> float:
@@ -38,16 +37,16 @@ class TraceSpan:
return (end - self.start_time) * 1000 return (end - self.start_time) * 1000
_current_span: ContextVar[Optional[TraceSpan]] = ContextVar('current_span', default=None) _current_span: ContextVar[TraceSpan | None] = ContextVar('current_span', default=None)
_trace_id: ContextVar[Optional[str]] = ContextVar('trace_id', default=None) _trace_id: ContextVar[str | None] = ContextVar('trace_id', default=None)
def get_current_trace_id() -> Optional[str]: def get_current_trace_id() -> str | None:
"""Get current trace ID for log correlation.""" """Get current trace ID for log correlation."""
return _trace_id.get() return _trace_id.get()
def get_current_span() -> Optional[TraceSpan]: def get_current_span() -> TraceSpan | None:
"""Get current trace span.""" """Get current trace span."""
return _current_span.get() return _current_span.get()
@@ -82,7 +81,7 @@ def get_logger(name: str) -> logging.Logger:
# === Decorator === # === Decorator ===
def logged( def logged(
logger: logging.Logger = None, logger: logging.Logger | None = None,
slow_threshold_ms: float = 100.0, slow_threshold_ms: float = 100.0,
warn_threshold_ms: float = 500.0, warn_threshold_ms: float = 500.0,
include_args: bool = False, include_args: bool = False,
@@ -121,7 +120,7 @@ def logged(
parent_id=parent.span_id if parent else None, parent_id=parent.span_id if parent else None,
) )
def _log_completion(span: TraceSpan, error: Exception = None): def _log_completion(span: TraceSpan, error: Exception | None = None):
span.end_time = time.perf_counter() span.end_time = time.perf_counter()
duration = span.duration_ms duration = span.duration_ms
tid = span.trace_id[:8] tid = span.trace_id[:8]
@@ -198,11 +197,11 @@ class trace_span:
response = await agent.run(prompt) response = await agent.run(prompt)
""" """
def __init__(self, name: str, logger: logging.Logger = None): def __init__(self, name: str, logger: logging.Logger | None = None):
self.name = name self.name = name
self.logger = logger or logging.getLogger(__name__) self.logger = logger or logging.getLogger(__name__)
self.span: Optional[TraceSpan] = None self.span: TraceSpan | None = None
self.token = None self.token: Token[TraceSpan | None] | None = None
def __enter__(self) -> TraceSpan: def __enter__(self) -> TraceSpan:
parent = _current_span.get() parent = _current_span.get()
+1 -1
View File
@@ -2,7 +2,7 @@
Pytest configuration and fixtures. Pytest configuration and fixtures.
""" """
import pytest import pytest
from httpx import AsyncClient, ASGITransport from httpx import ASGITransport, AsyncClient
from src.main import app from src.main import app