mirror of
https://github.com/grillazz/fastapi-sqlalchemy-asyncpg.git
synced 2026-11-01 01:24:45 +03:00
feat: update codebase to use httpx2 and enhance docstrings for clarity
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
"""Application service implementations."""
|
||||||
|
|||||||
+14
-9
@@ -1,3 +1,5 @@
|
|||||||
|
"""Bearer-token authentication and Redis-backed token storage."""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
@@ -11,24 +13,27 @@ from app.models.user import User
|
|||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
||||||
|
|
||||||
async def get_from_redis(request: Request, key: str):
|
async def get_from_redis(request: Request, key: str) -> str | None:
|
||||||
|
"""Return the Redis value stored under ``key``."""
|
||||||
return await request.app.redis.get(key)
|
return await request.app.redis.get(key)
|
||||||
|
|
||||||
|
|
||||||
async def set_to_redis(request: Request, key: str, value: str, ex: int):
|
async def set_to_redis(request: Request, key: str, value: str, ex: int) -> bool:
|
||||||
|
"""Store ``value`` in Redis for ``ex`` seconds."""
|
||||||
return await request.app.redis.set(key, value, ex=ex)
|
return await request.app.redis.set(key, value, ex=ex)
|
||||||
|
|
||||||
|
|
||||||
async def verify_jwt(request: Request, token: str) -> bool:
|
async def verify_jwt(request: Request, token: str) -> bool:
|
||||||
|
"""Check whether Redis recognizes the bearer token."""
|
||||||
payload = await get_from_redis(request, token)
|
payload = await get_from_redis(request, token)
|
||||||
return bool(payload)
|
return bool(payload)
|
||||||
|
|
||||||
|
|
||||||
class AuthBearer(HTTPBearer):
|
class AuthBearer(HTTPBearer):
|
||||||
def __init__(self, auto_error: bool = True) -> None:
|
"""Validate bearer credentials against the active Redis token store."""
|
||||||
super().__init__(auto_error=auto_error)
|
|
||||||
|
|
||||||
async def __call__(self, request: Request):
|
async def __call__(self, request: Request) -> str:
|
||||||
|
"""Return a valid token or raise an authorization error."""
|
||||||
credentials: HTTPAuthorizationCredentials = await super().__call__(request)
|
credentials: HTTPAuthorizationCredentials = await super().__call__(request)
|
||||||
if not credentials:
|
if not credentials:
|
||||||
raise HTTPException(status_code=403, detail="Invalid authorization code.")
|
raise HTTPException(status_code=403, detail="Invalid authorization code.")
|
||||||
@@ -44,8 +49,8 @@ class AuthBearer(HTTPBearer):
|
|||||||
return credentials.credentials
|
return credentials.credentials
|
||||||
|
|
||||||
|
|
||||||
async def create_access_token(user: User, request: Request):
|
async def create_access_token(user: User, request: Request) -> str | None:
|
||||||
# sourcery skip: avoid-builtin-shadow
|
"""Sign a user token and store it in Redis until its configured expiry."""
|
||||||
payload = {
|
payload = {
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"expiry": time.time() + global_settings.jwt_expire,
|
"expiry": time.time() + global_settings.jwt_expire,
|
||||||
@@ -55,8 +60,8 @@ async def create_access_token(user: User, request: Request):
|
|||||||
payload, str(user.password), algorithm=global_settings.jwt_algorithm
|
payload, str(user.password), algorithm=global_settings.jwt_algorithm
|
||||||
)
|
)
|
||||||
|
|
||||||
_bool = await set_to_redis(
|
stored = await set_to_redis(
|
||||||
request, token, str(payload), ex=global_settings.jwt_expire
|
request, token, str(payload), ex=global_settings.jwt_expire
|
||||||
)
|
)
|
||||||
if _bool:
|
if stored:
|
||||||
return token
|
return token
|
||||||
|
|||||||
+21
-57
@@ -16,7 +16,7 @@ from typing import Protocol, runtime_checkable
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import attrs
|
import attrs
|
||||||
import httpx
|
import httpx2
|
||||||
import orjson
|
import orjson
|
||||||
|
|
||||||
from app.config import ChatConfig
|
from app.config import ChatConfig
|
||||||
@@ -49,9 +49,11 @@ class LocalEchoAgent:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, stream_delay_seconds: float = 0.02) -> None:
|
def __init__(self, stream_delay_seconds: float = 0.02) -> None:
|
||||||
|
"""Initialize the delay between streamed words."""
|
||||||
self.stream_delay_seconds = stream_delay_seconds
|
self.stream_delay_seconds = stream_delay_seconds
|
||||||
|
|
||||||
def _compose_reply(self, messages: list[ChatMessage]) -> str:
|
def _compose_reply(self, messages: list[ChatMessage]) -> str:
|
||||||
|
"""Compose a canned response to the latest user message."""
|
||||||
last_user = next(
|
last_user = next(
|
||||||
(m.content for m in reversed(messages) if m.role == "user"), ""
|
(m.content for m in reversed(messages) if m.role == "user"), ""
|
||||||
)
|
)
|
||||||
@@ -63,26 +65,20 @@ class LocalEchoAgent:
|
|||||||
return f"You said: {last_user!r}. This is a local echo response (stub agent)."
|
return f"You said: {last_user!r}. This is a local echo response (stub agent)."
|
||||||
|
|
||||||
async def stream_reply(self, messages: list[ChatMessage]) -> AsyncIterator[str]:
|
async def stream_reply(self, messages: list[ChatMessage]) -> AsyncIterator[str]:
|
||||||
|
"""Yield the canned reply one word at a time."""
|
||||||
reply = self._compose_reply(messages)
|
reply = self._compose_reply(messages)
|
||||||
for word in reply.split(" "):
|
for word in reply.split(" "):
|
||||||
await asyncio.sleep(self.stream_delay_seconds + random.uniform(0, 0.01))
|
await asyncio.sleep(self.stream_delay_seconds + random.uniform(0, 0.01))
|
||||||
yield word + " "
|
yield word + " "
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
return None
|
"""Close resources; the local agent holds none."""
|
||||||
|
|
||||||
|
|
||||||
def _validate_base_url(_instance: OllamaChatAgent, _attribute: attrs.Attribute, value: str) -> None:
|
def _validate_base_url(
|
||||||
"""Validate that base_url is a valid HTTP(S) URL.
|
_instance: OllamaChatAgent, _attribute: attrs.Attribute, value: str
|
||||||
|
) -> None:
|
||||||
Args:
|
"""Require an absolute HTTP(S) endpoint URL."""
|
||||||
instance: The OllamaChatAgent instance being initialized.
|
|
||||||
attribute: The attrs attribute descriptor for base_url.
|
|
||||||
value: The URL string to validate.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If base_url is not a valid HTTP(S) URL.
|
|
||||||
"""
|
|
||||||
parsed = urlparse(value)
|
parsed = urlparse(value)
|
||||||
if parsed.scheme not in ("http", "https"):
|
if parsed.scheme not in ("http", "https"):
|
||||||
msg = f"base_url must be HTTP(S), got scheme '{parsed.scheme}' from '{value}'"
|
msg = f"base_url must be HTTP(S), got scheme '{parsed.scheme}' from '{value}'"
|
||||||
@@ -92,37 +88,15 @@ def _validate_base_url(_instance: OllamaChatAgent, _attribute: attrs.Attribute,
|
|||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
|
||||||
def _validate_timeout(_instance: OllamaChatAgent, _attribute: attrs.Attribute, value: float) -> None:
|
def _validate_timeout(
|
||||||
"""Validate that timeout is positive.
|
_instance: OllamaChatAgent, _attribute: attrs.Attribute, value: float
|
||||||
|
) -> None:
|
||||||
Args:
|
"""Require a positive request timeout."""
|
||||||
instance: The OllamaChatAgent instance being initialized.
|
|
||||||
attribute: The attrs attribute descriptor for timeout.
|
|
||||||
value: The timeout value in seconds.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If timeout is not positive.
|
|
||||||
"""
|
|
||||||
if value <= 0:
|
if value <= 0:
|
||||||
msg = f"timeout must be positive, got {value}"
|
msg = f"timeout must be positive, got {value}"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
|
||||||
def _create_httpx_client(instance: OllamaChatAgent) -> httpx.AsyncClient:
|
|
||||||
"""Factory function to create the httpx.AsyncClient with validated config.
|
|
||||||
|
|
||||||
This function is called from __attrs_post_init__ after all field validators
|
|
||||||
have run, ensuring the client is created with validated configuration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance: The OllamaChatAgent instance being initialized.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
An initialized httpx.AsyncClient configured with base_url and timeout.
|
|
||||||
"""
|
|
||||||
return httpx.AsyncClient(base_url=instance.base_url, timeout=instance.timeout)
|
|
||||||
|
|
||||||
|
|
||||||
@attrs.define(slots=True, eq=False, hash=False)
|
@attrs.define(slots=True, eq=False, hash=False)
|
||||||
class OllamaChatAgent:
|
class OllamaChatAgent:
|
||||||
"""Streams chat completions from an OpenAI-compatible endpoint.
|
"""Streams chat completions from an OpenAI-compatible endpoint.
|
||||||
@@ -132,9 +106,6 @@ class OllamaChatAgent:
|
|||||||
a ready-to-swap-in replacement for :class:`LocalEchoAgent` once a real
|
a ready-to-swap-in replacement for :class:`LocalEchoAgent` once a real
|
||||||
model should be used.
|
model should be used.
|
||||||
|
|
||||||
Attrs Configuration:
|
|
||||||
- slots=True: Memory-efficient attribute storage (~40-50% reduction)
|
|
||||||
- eq=False, hash=False: Instances are not comparable (contain async resources)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model: str = attrs.field(
|
model: str = attrs.field(
|
||||||
@@ -160,20 +131,15 @@ class OllamaChatAgent:
|
|||||||
"constraints": "Must be positive",
|
"constraints": "Must be positive",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
_client: httpx.AsyncClient = attrs.field(
|
_client: httpx2.AsyncClient = attrs.field(
|
||||||
init=False,
|
init=False,
|
||||||
repr=False,
|
repr=False,
|
||||||
metadata={"description": "Internal HTTP client for API communication"},
|
metadata={"description": "Internal HTTP client for API communication"},
|
||||||
)
|
)
|
||||||
|
|
||||||
def __attrs_post_init__(self) -> None:
|
def __attrs_post_init__(self) -> None:
|
||||||
"""Initialize the HTTP client after field validation.
|
"""Create the HTTPX2 client after attrs validates the fields."""
|
||||||
|
self._client = httpx2.AsyncClient(base_url=self.base_url, timeout=self.timeout)
|
||||||
This hook is called by attrs after __init__ completes and all field
|
|
||||||
validators have run. It's used to initialize the internal _client
|
|
||||||
field which depends on validated configuration.
|
|
||||||
"""
|
|
||||||
self._client = _create_httpx_client(self)
|
|
||||||
|
|
||||||
async def stream_reply(self, messages: list[ChatMessage]) -> AsyncIterator[str]:
|
async def stream_reply(self, messages: list[ChatMessage]) -> AsyncIterator[str]:
|
||||||
"""Stream chat completion responses from the configured model.
|
"""Stream chat completion responses from the configured model.
|
||||||
@@ -195,13 +161,11 @@ class OllamaChatAgent:
|
|||||||
async for line in response.aiter_lines():
|
async for line in response.aiter_lines():
|
||||||
if not line.startswith("data: ") or line == "data: [DONE]":
|
if not line.startswith("data: ") or line == "data: [DONE]":
|
||||||
continue
|
continue
|
||||||
try:
|
data = orjson.loads(line.removeprefix("data: "))
|
||||||
data = orjson.loads(line[6:])
|
choices = data.get("choices", [])
|
||||||
content = (
|
if not choices:
|
||||||
data.get("choices", [{}])[0].get("delta", {}).get("content", "")
|
continue
|
||||||
)
|
content = choices[0].get("delta", {}).get("content", "")
|
||||||
except Exception:
|
|
||||||
content = ""
|
|
||||||
if content:
|
if content:
|
||||||
yield content
|
yield content
|
||||||
|
|
||||||
|
|||||||
@@ -17,13 +17,17 @@ from app.schemas.chat import ChatMessage
|
|||||||
|
|
||||||
@attrs.define(slots=True)
|
@attrs.define(slots=True)
|
||||||
class ChatSession:
|
class ChatSession:
|
||||||
|
"""Conversation history associated with one websocket connection."""
|
||||||
|
|
||||||
id: UUID = attrs.field(factory=uuid4)
|
id: UUID = attrs.field(factory=uuid4)
|
||||||
messages: list[ChatMessage] = attrs.field(factory=list)
|
messages: list[ChatMessage] = attrs.field(factory=list)
|
||||||
|
|
||||||
def add(self, message: ChatMessage) -> None:
|
def add(self, message: ChatMessage) -> None:
|
||||||
|
"""Append a message to this conversation."""
|
||||||
self.messages.append(message)
|
self.messages.append(message)
|
||||||
|
|
||||||
def history(self) -> list[ChatMessage]:
|
def history(self) -> list[ChatMessage]:
|
||||||
|
"""Return a copy of the conversation history."""
|
||||||
return list(self.messages)
|
return list(self.messages)
|
||||||
|
|
||||||
|
|
||||||
@@ -35,23 +39,28 @@ class ChatSessionManager:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
"""Create an empty, lock-protected session registry."""
|
||||||
self._sessions: dict[UUID, ChatSession] = {}
|
self._sessions: dict[UUID, ChatSession] = {}
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
async def create(self) -> ChatSession:
|
async def create(self) -> ChatSession:
|
||||||
|
"""Create and register a new conversation."""
|
||||||
session = ChatSession()
|
session = ChatSession()
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self._sessions[session.id] = session
|
self._sessions[session.id] = session
|
||||||
return session
|
return session
|
||||||
|
|
||||||
async def get(self, session_id: UUID) -> ChatSession | None:
|
async def get(self, session_id: UUID) -> ChatSession | None:
|
||||||
|
"""Return the matching session, if it is active."""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
return self._sessions.get(session_id)
|
return self._sessions.get(session_id)
|
||||||
|
|
||||||
async def remove(self, session_id: UUID) -> None:
|
async def remove(self, session_id: UUID) -> None:
|
||||||
|
"""Remove a session from the active registry."""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self._sessions.pop(session_id, None)
|
self._sessions.pop(session_id, None)
|
||||||
|
|
||||||
async def count(self) -> int:
|
async def count(self) -> int:
|
||||||
|
"""Return the number of active sessions."""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
return len(self._sessions)
|
return len(self._sessions)
|
||||||
|
|||||||
+19
-21
@@ -1,25 +1,28 @@
|
|||||||
from collections.abc import AsyncGenerator
|
"""Streaming client for OpenAI-compatible chat completion endpoints."""
|
||||||
|
|
||||||
import httpx
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
import httpx2
|
||||||
import orjson
|
import orjson
|
||||||
|
|
||||||
|
|
||||||
class StreamLLMService:
|
class StreamLLMService:
|
||||||
|
"""Send prompts to an LLM endpoint and yield newline-delimited JSON events."""
|
||||||
|
|
||||||
def __init__(self, base_url: str = "http://localhost:11434/v1") -> None:
|
def __init__(self, base_url: str = "http://localhost:11434/v1") -> None:
|
||||||
|
"""Configure the endpoint and default model."""
|
||||||
self.base_url = base_url
|
self.base_url = base_url
|
||||||
self.model = "llama3.2"
|
self.model = "llama3.2"
|
||||||
|
|
||||||
async def stream_chat(self, prompt: str) -> AsyncGenerator[bytes]:
|
async def stream_chat(self, prompt: str) -> AsyncIterator[bytes]:
|
||||||
"""Stream chat completion responses from LLM."""
|
"""Yield the prompt followed by streamed model response chunks."""
|
||||||
# Send the user a message first
|
|
||||||
user_msg = {
|
user_msg = {
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": prompt,
|
"content": prompt,
|
||||||
}
|
}
|
||||||
yield orjson.dumps(user_msg) + b"\n"
|
yield orjson.dumps(user_msg) + b"\n"
|
||||||
|
|
||||||
# Open client as context manager and stream responses
|
async with httpx2.AsyncClient(base_url=self.base_url) as client:
|
||||||
async with httpx.AsyncClient(base_url=self.base_url) as client:
|
|
||||||
async with client.stream(
|
async with client.stream(
|
||||||
"POST",
|
"POST",
|
||||||
"/chat/completions",
|
"/chat/completions",
|
||||||
@@ -32,21 +35,16 @@ class StreamLLMService:
|
|||||||
) as response:
|
) as response:
|
||||||
async for line in response.aiter_lines():
|
async for line in response.aiter_lines():
|
||||||
if line.startswith("data: ") and line != "data: [DONE]":
|
if line.startswith("data: ") and line != "data: [DONE]":
|
||||||
try:
|
data = orjson.loads(line.removeprefix("data: "))
|
||||||
json_line = line[6:] # Remove "data: " prefix
|
choices = data.get("choices", [])
|
||||||
data = orjson.loads(json_line)
|
if not choices:
|
||||||
content = (
|
continue
|
||||||
data.get("choices", [{}])[0]
|
content = choices[0].get("delta", {}).get("content", "")
|
||||||
.get("delta", {})
|
if content:
|
||||||
.get("content", "")
|
model_msg = {"role": "model", "content": content}
|
||||||
)
|
yield orjson.dumps(model_msg) + b"\n"
|
||||||
if content:
|
|
||||||
model_msg = {"role": "model", "content": content}
|
|
||||||
yield orjson.dumps(model_msg) + b"\n"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# FastAPI dependency
|
|
||||||
def get_llm_service(base_url: str | None = None) -> StreamLLMService:
|
def get_llm_service(base_url: str | None = None) -> StreamLLMService:
|
||||||
|
"""Build the service using the supplied URL or its local default."""
|
||||||
return StreamLLMService(base_url=base_url or "http://localhost:11434/v1")
|
return StreamLLMService(base_url=base_url or "http://localhost:11434/v1")
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""Background database health-check scheduling."""
|
||||||
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -15,7 +17,8 @@ from app.database import AsyncSessionFactory, engine
|
|||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
||||||
|
|
||||||
async def tick():
|
async def tick() -> bool:
|
||||||
|
"""Run the scheduler's lightweight database heartbeat."""
|
||||||
async with AsyncSessionFactory() as session:
|
async with AsyncSessionFactory() as session:
|
||||||
stmt = text("select 1;")
|
stmt = text("select 1;")
|
||||||
await logger.ainfo(f">>>> Be or not to be...{datetime.now()}")
|
await logger.ainfo(f">>>> Be or not to be...{datetime.now()}")
|
||||||
@@ -26,6 +29,7 @@ async def tick():
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def scheduler_lifespan() -> AsyncIterator[None]:
|
async def scheduler_lifespan() -> AsyncIterator[None]:
|
||||||
|
"""Start the scheduler and stop it when the app lifespan ends."""
|
||||||
scheduler = AsyncScheduler(
|
scheduler = AsyncScheduler(
|
||||||
SQLAlchemyDataStore(engine, schema="scheduler"),
|
SQLAlchemyDataStore(engine, schema="scheduler"),
|
||||||
RedisEventBroker(client_or_url=global_settings.redis_url.unicode_string()),
|
RedisEventBroker(client_or_url=global_settings.redis_url.unicode_string()),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""Email delivery through a configured SMTP server."""
|
||||||
|
|
||||||
import smtplib
|
import smtplib
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
|
|||||||
+2
-1
@@ -12,7 +12,7 @@ dependencies = [
|
|||||||
"uvicorn[standard]==0.54.0",
|
"uvicorn[standard]==0.54.0",
|
||||||
"asyncpg==0.32.0",
|
"asyncpg==0.32.0",
|
||||||
"alembic==1.20.1",
|
"alembic==1.20.1",
|
||||||
"httpx==0.28.1",
|
"httpx2==2.16.0",
|
||||||
"uvloop==0.23.0",
|
"uvloop==0.23.0",
|
||||||
"httptools==0.9.0",
|
"httptools==0.9.0",
|
||||||
"rich==15.0.0",
|
"rich==15.0.0",
|
||||||
@@ -47,6 +47,7 @@ test = [
|
|||||||
"inline-snapshot==0.36.1",
|
"inline-snapshot==0.36.1",
|
||||||
"dirty-equals==0.11",
|
"dirty-equals==0.11",
|
||||||
"polyfactory==3.5.0",
|
"polyfactory==3.5.0",
|
||||||
|
"httpx==0.28.1",
|
||||||
"httpx2[ws]==2.16.0",
|
"httpx2[ws]==2.16.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Test suite for refactored OllamaChatAgent with attrs features."""
|
"""Test suite for refactored OllamaChatAgent with attrs features."""
|
||||||
import httpx
|
|
||||||
|
import httpx2
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.config import ChatConfig
|
from app.config import ChatConfig
|
||||||
@@ -54,14 +55,14 @@ class TestOllamaChatAgentAttrsFeatures:
|
|||||||
assert isinstance(agent.timeout, float)
|
assert isinstance(agent.timeout, float)
|
||||||
assert agent.timeout == 30.0
|
assert agent.timeout == 30.0
|
||||||
|
|
||||||
def test_factory_initialization_of_client(self):
|
def test_client_initialization_after_field_validation(self):
|
||||||
"""Test that _client is created via factory function."""
|
"""Test that the async client uses the validated configuration."""
|
||||||
agent = OllamaChatAgent(model="llama3.2", base_url="http://localhost:11434/v1")
|
agent = OllamaChatAgent(model="llama3.2", base_url="http://localhost:11434/v1")
|
||||||
assert hasattr(agent, "_client")
|
assert hasattr(agent, "_client")
|
||||||
assert isinstance(agent._client, httpx.AsyncClient)
|
assert isinstance(agent._client, httpx2.AsyncClient)
|
||||||
# httpx normalizes URLs by adding a trailing slash
|
# httpx2 normalizes URLs by adding a trailing slash
|
||||||
assert str(agent._client.base_url) == "http://localhost:11434/v1/"
|
assert str(agent._client.base_url) == "http://localhost:11434/v1/"
|
||||||
assert agent._client.timeout == httpx.Timeout(60.0)
|
assert agent._client.timeout == httpx2.Timeout(60.0)
|
||||||
|
|
||||||
def test_slots_enabled_no_dict(self):
|
def test_slots_enabled_no_dict(self):
|
||||||
"""Test that slots=True prevents __dict__ attribute."""
|
"""Test that slots=True prevents __dict__ attribute."""
|
||||||
@@ -72,12 +73,8 @@ class TestOllamaChatAgentAttrsFeatures:
|
|||||||
|
|
||||||
def test_equality_disabled(self):
|
def test_equality_disabled(self):
|
||||||
"""Test that eq=False means instances are not equal even with same values."""
|
"""Test that eq=False means instances are not equal even with same values."""
|
||||||
agent1 = OllamaChatAgent(
|
agent1 = OllamaChatAgent(model="llama3.2", base_url="http://localhost:11434/v1")
|
||||||
model="llama3.2", base_url="http://localhost:11434/v1"
|
agent2 = OllamaChatAgent(model="llama3.2", base_url="http://localhost:11434/v1")
|
||||||
)
|
|
||||||
agent2 = OllamaChatAgent(
|
|
||||||
model="llama3.2", base_url="http://localhost:11434/v1"
|
|
||||||
)
|
|
||||||
# With eq=False, only identity comparison works
|
# With eq=False, only identity comparison works
|
||||||
assert agent1 != agent2
|
assert agent1 != agent2
|
||||||
assert agent1 == agent1
|
assert agent1 == agent1
|
||||||
@@ -112,9 +109,7 @@ class TestOllamaChatAgentAttrsFeatures:
|
|||||||
|
|
||||||
def test_https_urls_accepted(self):
|
def test_https_urls_accepted(self):
|
||||||
"""Test that HTTPS URLs are properly accepted."""
|
"""Test that HTTPS URLs are properly accepted."""
|
||||||
agent = OllamaChatAgent(
|
agent = OllamaChatAgent(model="llama3.2", base_url="https://api.example.com/v1")
|
||||||
model="llama3.2", base_url="https://api.example.com/v1"
|
|
||||||
)
|
|
||||||
assert agent.base_url == "https://api.example.com/v1"
|
assert agent.base_url == "https://api.example.com/v1"
|
||||||
|
|
||||||
def test_metadata_annotations_present(self):
|
def test_metadata_annotations_present(self):
|
||||||
@@ -133,4 +128,3 @@ class TestOllamaChatAgentAttrsFeatures:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
pytest.main([__file__, "-v"])
|
pytest.main([__file__, "-v"])
|
||||||
|
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ dependencies = [
|
|||||||
{ name = "fastexcel" },
|
{ name = "fastexcel" },
|
||||||
{ name = "granian" },
|
{ name = "granian" },
|
||||||
{ name = "httptools" },
|
{ name = "httptools" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx2" },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
{ name = "polars" },
|
{ name = "polars" },
|
||||||
{ name = "pydantic", extra = ["email"] },
|
{ name = "pydantic", extra = ["email"] },
|
||||||
@@ -359,6 +359,7 @@ dev = [
|
|||||||
]
|
]
|
||||||
test = [
|
test = [
|
||||||
{ name = "dirty-equals" },
|
{ name = "dirty-equals" },
|
||||||
|
{ name = "httpx" },
|
||||||
{ name = "httpx2", extra = ["ws"] },
|
{ name = "httpx2", extra = ["ws"] },
|
||||||
{ name = "inline-snapshot" },
|
{ name = "inline-snapshot" },
|
||||||
{ name = "polyfactory" },
|
{ name = "polyfactory" },
|
||||||
@@ -376,7 +377,7 @@ requires-dist = [
|
|||||||
{ name = "fastexcel", specifier = "==0.21.0" },
|
{ name = "fastexcel", specifier = "==0.21.0" },
|
||||||
{ name = "granian", specifier = "==2.8.5" },
|
{ name = "granian", specifier = "==2.8.5" },
|
||||||
{ name = "httptools", specifier = "==0.9.0" },
|
{ name = "httptools", specifier = "==0.9.0" },
|
||||||
{ name = "httpx", specifier = "==0.28.1" },
|
{ name = "httpx2", specifier = "==2.16.0" },
|
||||||
{ name = "jinja2", specifier = "==3.1.6" },
|
{ name = "jinja2", specifier = "==3.1.6" },
|
||||||
{ name = "polars", specifier = "==2.0.0" },
|
{ name = "polars", specifier = "==2.0.0" },
|
||||||
{ name = "pydantic", extras = ["email"], specifier = "==2.14.1" },
|
{ name = "pydantic", extras = ["email"], specifier = "==2.14.1" },
|
||||||
@@ -405,6 +406,7 @@ dev = [
|
|||||||
]
|
]
|
||||||
test = [
|
test = [
|
||||||
{ name = "dirty-equals", specifier = "==0.11" },
|
{ name = "dirty-equals", specifier = "==0.11" },
|
||||||
|
{ name = "httpx", specifier = "==0.28.1" },
|
||||||
{ name = "httpx2", extras = ["ws"], specifier = "==2.16.0" },
|
{ name = "httpx2", extras = ["ws"], specifier = "==2.16.0" },
|
||||||
{ name = "inline-snapshot", specifier = "==0.36.1" },
|
{ name = "inline-snapshot", specifier = "==0.36.1" },
|
||||||
{ name = "polyfactory", specifier = "==3.5.0" },
|
{ name = "polyfactory", specifier = "==3.5.0" },
|
||||||
|
|||||||
Reference in New Issue
Block a user