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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user