From 667e2ca8e4c9b737be12707fdf236391cb8f56f3 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 9 Jan 2026 22:58:24 +0100 Subject: [PATCH] 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 --- README.md | 172 +++++++++++++++++++++++++++++++++++ pyproject.toml | 32 +++++++ requirements-dev.txt | 3 + src/domains/health/router.py | 1 - src/domains/router.py | 1 + src/main.py | 12 +-- src/shared/auth.py | 17 ++-- src/shared/base.py | 12 ++- src/shared/config.py | 46 +++++++--- src/shared/context.py | 13 ++- src/shared/exceptions.py | 10 +- src/shared/logging.py | 33 ++++--- tests/conftest.py | 2 +- 13 files changed, 289 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index e69de29..9ae7b22 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 47512cb..2d23b23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/requirements-dev.txt b/requirements-dev.txt index e4fbc3e..43c391e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -15,3 +15,6 @@ pip-audit~=2.9.0 # Type checking mypy~=1.19.1 + +# Linting and formatting +ruff~=0.9.4 diff --git a/src/domains/health/router.py b/src/domains/health/router.py index d749759..52e94d4 100644 --- a/src/domains/health/router.py +++ b/src/domains/health/router.py @@ -1,7 +1,6 @@ """ Health check routes. """ -from fastapi import APIRouter from src.domains.health.controller import health_controller diff --git a/src/domains/router.py b/src/domains/router.py index 64f56d5..521853d 100644 --- a/src/domains/router.py +++ b/src/domains/router.py @@ -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 diff --git a/src/main.py b/src/main.py index 452db37..22cd600 100644 --- a/src/main.py +++ b/src/main.py @@ -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", diff --git a/src/shared/auth.py b/src/shared/auth.py index b143619..66d8370 100644 --- a/src/shared/auth.py +++ b/src/shared/auth.py @@ -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. diff --git a/src/shared/base.py b/src/shared/base.py index ada4042..23d9c5d 100644 --- a/src/shared/base.py +++ b/src/shared/base.py @@ -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: diff --git a/src/shared/config.py b/src/shared/config.py index 4f5109d..b8ad55e 100644 --- a/src/shared/config.py +++ b/src/shared/config.py @@ -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() diff --git a/src/shared/context.py b/src/shared/context.py index dcbab7c..bc493a0 100644 --- a/src/shared/context.py +++ b/src/shared/context.py @@ -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() diff --git a/src/shared/exceptions.py b/src/shared/exceptions.py index 55fc0a5..d99c90d 100644 --- a/src/shared/exceptions.py +++ b/src/shared/exceptions.py @@ -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, diff --git a/src/shared/logging.py b/src/shared/logging.py index ad6e65f..c28bf99 100644 --- a/src/shared/logging.py +++ b/src/shared/logging.py @@ -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() diff --git a/tests/conftest.py b/tests/conftest.py index 871f7fd..9308dde 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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