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
strict = false
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
mypy~=1.19.1
# Linting and formatting
ruff~=0.9.4
-1
View File
@@ -1,7 +1,6 @@
"""
Health check routes.
"""
from fastapi import APIRouter
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 src.domains.health.router import router as health_router
# from src.domains.auth.router import router as auth_router
# from src.domains.agents.router import router as agents_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.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.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()
setup_logging(settings.log_level)
@@ -44,7 +44,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
description="Multi-Agent AI Development System",
description=settings.app_description,
docs_url="/docs",
redoc_url=None,
openapi_url="/openapi.json",
+8 -9
View File
@@ -3,13 +3,12 @@ Authentication utilities.
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 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
logger = get_logger(__name__)
@@ -19,7 +18,7 @@ settings = get_settings()
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.
@@ -54,15 +53,15 @@ async def validate_api_key(api_key: str) -> Optional[User]:
async def get_api_key(
api_key: Optional[str] = Depends(api_key_header),
) -> Optional[str]:
api_key: str | None = Depends(api_key_header),
) -> str | None:
"""FastAPI dependency to extract API key from header."""
return api_key
async def get_current_user_dep(
api_key: Optional[str] = Depends(get_api_key),
) -> Optional[User]:
api_key: str | None = Depends(get_api_key),
) -> User | None:
"""
FastAPI dependency to get current user from API key.
@@ -74,7 +73,7 @@ async def get_current_user_dep(
async def require_auth(
user: Optional[User] = Depends(get_current_user_dep),
user: User | None = Depends(get_current_user_dep),
) -> User:
"""
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.
"""
from abc import ABC, abstractmethod
from collections.abc import Sequence
from datetime import datetime
from typing import Any
from typing import TYPE_CHECKING, Any
from fastapi import APIRouter
from pydantic import BaseModel, ConfigDict
if TYPE_CHECKING:
from enum import Enum
class BaseController(ABC):
"""
@@ -34,10 +38,10 @@ class BaseController(ABC):
users_controller = UsersController()
"""
def __init__(self, prefix: str, tags: list[str]):
def __init__(self, prefix: str, tags: Sequence[str]):
self.prefix = prefix
self.tags = tags
self._router = None
self.tags: list[str | Enum] = list(tags)
self._router: APIRouter | None = None
@abstractmethod
def create_router(self) -> APIRouter:
+31 -15
View File
@@ -2,35 +2,51 @@
Application configuration via Pydantic Settings.
All settings loaded from environment variables or .env file.
Project metadata (name, version, description) sourced from pyproject.toml.
"""
import tomllib
from pathlib import Path
from dataclasses import dataclass
from functools import lru_cache
from typing import Optional
from pathlib import Path
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
def _get_version() -> str:
"""Load version from pyproject.toml."""
@dataclass(frozen=True)
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"
try:
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:
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):
"""Application settings loaded from environment."""
# Application
app_name: str = "Webber"
app_version: str = __version__
# Application (from pyproject.toml)
app_name: str = PROJECT.name
app_version: str = PROJECT.version
app_description: str = PROJECT.description
debug: bool = False
# Server
@@ -52,13 +68,13 @@ class Settings(BaseSettings):
ollama_embed_model: str = "nomic-embed-text:latest"
# Auth - Tatlock integration
tatlock_api_url: Optional[str] = "http://192.168.86.149:8000"
internal_api_key: Optional[str] = None
tatlock_api_url: str | None = "http://192.168.86.149:8000"
internal_api_key: str | None = None
# Tool execution
tool_timeout_seconds: int = 120
sandbox_enabled: bool = True
allowed_paths: Optional[list[str]] = None
allowed_paths: list[str] | None = None
# Sessions
session_ttl_hours: int = 24
@@ -77,7 +93,7 @@ class Settings(BaseSettings):
return self.allowed_paths or []
@lru_cache()
@lru_cache
def get_settings() -> Settings:
"""Cached settings singleton."""
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
passing user through function parameters.
"""
from dataclasses import dataclass, field
from typing import Optional
from contextvars import ContextVar
from dataclasses import dataclass, field
@dataclass
@@ -16,11 +15,11 @@ class User:
id: str
email: str
api_key: str
tenant_id: Optional[str] = None
tenant_id: str | None = None
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:
@@ -55,7 +54,7 @@ class UserProvider:
"""Set current user for this request context."""
_current_user.set(user)
def get_user(self) -> Optional[User]:
def get_user(self) -> User | None:
"""Get current user (may be None)."""
return _current_user.get()
@@ -64,7 +63,7 @@ class UserProvider:
_current_user.set(None)
@property
def current_user(self) -> Optional[User]:
def current_user(self) -> User | None:
"""Property access to current user."""
return self.get_user()
@@ -73,7 +72,7 @@ class UserProvider:
user_provider = UserProvider()
def get_current_user() -> Optional[User]:
def get_current_user() -> User | None:
"""Get current user or None."""
return user_provider.get_user()
+5 -5
View File
@@ -3,7 +3,7 @@ Custom exception hierarchy.
All application exceptions inherit from AppException.
"""
from typing import Any, Optional
from typing import Any
class AppException(Exception):
@@ -13,8 +13,8 @@ class AppException(Exception):
self,
message: str,
status_code: int = 500,
error_code: Optional[str] = None,
details: Optional[dict[str, Any]] = None,
error_code: str | None = None,
details: dict[str, Any] | None = None,
):
self.message = message
self.status_code = status_code
@@ -45,7 +45,7 @@ class NotFoundError(AppException):
class ValidationError(AppException):
"""Input validation failed."""
def __init__(self, message: str, field: Optional[str] = None):
def __init__(self, message: str, field: str | None = None):
super().__init__(
message=message,
status_code=422,
@@ -88,7 +88,7 @@ class RateLimitError(AppException):
class ServiceUnavailableError(AppException):
"""External service unavailable."""
def __init__(self, service: str, message: Optional[str] = None):
def __init__(self, service: str, message: str | None = None):
super().__init__(
message=message or f"Service unavailable: {service}",
status_code=503,
+16 -17
View File
@@ -7,18 +7,17 @@ Provides:
- Trace ID correlation across nested calls
- Configurable slow/warn thresholds
"""
import functools
import asyncio
import time
import functools
import logging
import sys
from pathlib import Path
from typing import Callable, Optional
from contextvars import ContextVar
import time
from collections.abc import Callable
from contextvars import ContextVar, Token
from dataclasses import dataclass, field
from pathlib import Path
from uuid import uuid4
# === Trace Context ===
@dataclass
@@ -26,10 +25,10 @@ class TraceSpan:
"""Represents a timed execution span."""
name: str
trace_id: str
parent_id: Optional[str] = None
parent_id: str | None = None
span_id: str = field(default_factory=lambda: uuid4().hex[:8])
start_time: float = field(default_factory=time.perf_counter)
end_time: Optional[float] = None
end_time: float | None = None
@property
def duration_ms(self) -> float:
@@ -38,16 +37,16 @@ class TraceSpan:
return (end - self.start_time) * 1000
_current_span: ContextVar[Optional[TraceSpan]] = ContextVar('current_span', default=None)
_trace_id: ContextVar[Optional[str]] = ContextVar('trace_id', default=None)
_current_span: ContextVar[TraceSpan | None] = ContextVar('current_span', 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."""
return _trace_id.get()
def get_current_span() -> Optional[TraceSpan]:
def get_current_span() -> TraceSpan | None:
"""Get current trace span."""
return _current_span.get()
@@ -82,7 +81,7 @@ def get_logger(name: str) -> logging.Logger:
# === Decorator ===
def logged(
logger: logging.Logger = None,
logger: logging.Logger | None = None,
slow_threshold_ms: float = 100.0,
warn_threshold_ms: float = 500.0,
include_args: bool = False,
@@ -121,7 +120,7 @@ def logged(
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()
duration = span.duration_ms
tid = span.trace_id[:8]
@@ -198,11 +197,11 @@ class trace_span:
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.logger = logger or logging.getLogger(__name__)
self.span: Optional[TraceSpan] = None
self.token = None
self.span: TraceSpan | None = None
self.token: Token[TraceSpan | None] | None = None
def __enter__(self) -> TraceSpan:
parent = _current_span.get()
+1 -1
View File
@@ -2,7 +2,7 @@
Pytest configuration and fixtures.
"""
import pytest
from httpx import AsyncClient, ASGITransport
from httpx import ASGITransport, AsyncClient
from src.main import app