feat: update codebase to use httpx2 and enhance docstrings for clarity

This commit is contained in:
grillazz
2026-10-22 13:42:10 +02:00
parent 96b383e391
commit 332324c861
10 changed files with 87 additions and 107 deletions
+1
View File
@@ -0,0 +1 @@
"""Application service implementations."""
+14 -9
View File
@@ -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
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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")
+5 -1
View File
@@ -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()),
+2
View File
@@ -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
View File
@@ -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",
] ]
+10 -16
View File
@@ -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"])
Generated
+4 -2
View File
@@ -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" },