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
|
||||
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
|
||||
|
||||
@@ -15,3 +15,6 @@ pip-audit~=2.9.0
|
||||
|
||||
# Type checking
|
||||
mypy~=1.19.1
|
||||
|
||||
# Linting and formatting
|
||||
ruff~=0.9.4
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
Health check routes.
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from src.domains.health.controller import health_controller
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user