From eb8acd7b7cc4ed5b7f3c7ddfadcb25403f32a245 Mon Sep 17 00:00:00 2001 From: grillazz Date: Mon, 13 Jul 2026 14:25:49 +0200 Subject: [PATCH 01/11] refactor: add type hints to function signatures across multiple files --- app/main.py | 3 ++- app/models/stuff.py | 2 +- app/models/user.py | 6 +++--- app/server.py | 4 ++-- app/services/auth.py | 2 +- app/services/llm.py | 2 +- app/services/smtp.py | 6 +++--- app/utils/decorators.py | 2 +- performance/locustfile.py | 4 ++-- pyproject.toml | 11 +++++++++++ 10 files changed, 27 insertions(+), 15 deletions(-) diff --git a/app/main.py b/app/main.py index 5a3f50a..a484155 100644 --- a/app/main.py +++ b/app/main.py @@ -1,3 +1,4 @@ +from starlette.templating import _TemplateResponse from contextlib import asynccontextmanager from pathlib import Path @@ -79,7 +80,7 @@ def create_app() -> FastAPI: register_exception_handlers(app) @app.get("/index", response_class=HTMLResponse) - def get_index(request: Request): + def get_index(request: Request) -> _TemplateResponse: return templates.TemplateResponse("index.html", {"request": request}) return app diff --git a/app/models/stuff.py b/app/models/stuff.py index 0954863..21332f8 100644 --- a/app/models/stuff.py +++ b/app/models/stuff.py @@ -35,7 +35,7 @@ class Stuff(Base): @classmethod @compile_sql_or_scalar - async def get_by_name(cls, db_session: AsyncSession, name: str, compile_sql=False): + async def get_by_name(cls, db_session: AsyncSession, name: str, compile_sql: bool=False): stmt = select(cls).options(joinedload(cls.nonsense)).where(cls.name == name) return stmt diff --git a/app/models/user.py b/app/models/user.py index 005c71d..5e6ce42 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -21,15 +21,15 @@ class User(Base): _password: bytes = Column(LargeBinary, nullable=False) @property - def password(self): + def password(self) -> str: return self._password.decode("utf-8") @password.setter - def password(self, password: SecretStr): + def password(self, password: SecretStr) -> None: _password_string = password.get_secret_value().encode("utf-8") self._password = bcrypt.hashpw(_password_string, bcrypt.gensalt()) - def check_password(self, password: SecretStr): + def check_password(self, password: SecretStr) -> bool: return bcrypt.checkpw( password.get_secret_value().encode("utf-8"), self._password ) diff --git a/app/server.py b/app/server.py index 744463d..eb30527 100644 --- a/app/server.py +++ b/app/server.py @@ -1,10 +1,10 @@ from granian import Granian -def startup(): +def startup() -> None: print("Server starting up...") -def shutdown(): +def shutdown() -> None: print("Server shutting down...") server = Granian( diff --git a/app/services/auth.py b/app/services/auth.py index 144384e..0a4f973 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -25,7 +25,7 @@ async def verify_jwt(request: Request, token: str) -> bool: class AuthBearer(HTTPBearer): - def __init__(self, auto_error: bool = True): + def __init__(self, auto_error: bool = True) -> None: super().__init__(auto_error=auto_error) async def __call__(self, request: Request): diff --git a/app/services/llm.py b/app/services/llm.py index b3b899a..ceaa455 100644 --- a/app/services/llm.py +++ b/app/services/llm.py @@ -5,7 +5,7 @@ class StreamLLMService: - def __init__(self, base_url: str = "http://localhost:11434/v1"): + def __init__(self, base_url: str = "http://localhost:11434/v1") -> None: self.base_url = base_url self.model = "llama3.2" diff --git a/app/services/smtp.py b/app/services/smtp.py index bb5ea24..3d09932 100644 --- a/app/services/smtp.py +++ b/app/services/smtp.py @@ -45,7 +45,7 @@ class SMTPEmailService(metaclass=SingletonMetaNoArgs): ) server: smtplib.SMTP = field(init=False) # Deferred initialization in post-init - def __attrs_post_init__(self): + def __attrs_post_init__(self) -> None: """ Initializes the SMTP server connection after the object is created. @@ -98,7 +98,7 @@ def send_email( subject: str, body_text: str = "", body_html: str = None, - ): + ) -> None: """ Sends an email to the specified recipients. @@ -130,7 +130,7 @@ def send_template_email( template: str, context: dict, sender: EmailStr, - ): + ) -> None: """ Sends an email using a Jinja2 template. diff --git a/app/utils/decorators.py b/app/utils/decorators.py index 2b71919..cccde01 100644 --- a/app/utils/decorators.py +++ b/app/utils/decorators.py @@ -15,7 +15,7 @@ def compile_sql_or_scalar(func): """ @wraps(func) - async def wrapper(cls, db_session, name, compile_sql=False, *args, **kwargs): + async def wrapper(cls, db_session, name, compile_sql: bool=False, *args, **kwargs): """ Wrapper function that either compiles the SQL statement or executes it. diff --git a/performance/locustfile.py b/performance/locustfile.py index a9a1bd3..c1f5122 100644 --- a/performance/locustfile.py +++ b/performance/locustfile.py @@ -5,9 +5,9 @@ class Stuff(HttpUser): wait_time = between(1, 3) @task - def find_stuff(self): + def find_stuff(self) -> None: self.client.get("/v1/stuff/string") @task - def find_stuff_with_pool(self): + def find_stuff_with_pool(self) -> None: self.client.get("/v1/stuff/pool/string") diff --git a/pyproject.toml b/pyproject.toml index cf6982c..bb6e282 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,9 @@ dev-dependencies = [ "tryceratops==2.4.1", ] +[tool.pyrefly.errors] +redundant-cast = "warn" + [tool.mypy] strict = true @@ -83,3 +86,11 @@ format-command="ruff format --stdin-filename {filename}" [tool.inline-snapshot.shortcuts] review=["review"] fix=["create","fix"] + +[tool.pyrefly] +project-excludes = [ + "**/venv*", + "**/.venv*", + "**/alembic*", +] +preset = "legacy" From 2cf22f89cc35b5931848e19efea6575d850dbe1c Mon Sep 17 00:00:00 2001 From: grillazz Date: Tue, 14 Jul 2026 11:12:45 +0200 Subject: [PATCH 02/11] wip: implement websocket chat service with Pydantic models and session management --- .env | 2 + app/config.py | 21 ++++++ app/main.py | 28 +++++--- app/schemas/chat.py | 124 +++++++++++++++++++++++++++++++++++ app/services/chat_agent.py | 116 ++++++++++++++++++++++++++++++++ app/services/chat_session.py | 52 +++++++++++++++ app/ws.py | 11 ++++ pyproject.toml | 3 +- uv.lock | 10 +-- 9 files changed, 353 insertions(+), 14 deletions(-) create mode 100644 app/schemas/chat.py create mode 100644 app/services/chat_agent.py create mode 100644 app/services/chat_session.py create mode 100644 app/ws.py diff --git a/.env b/.env index 842a23b..e2d9cab 100644 --- a/.env +++ b/.env @@ -22,4 +22,6 @@ EMAIL_HOST= EMAIL_HOST_USER= EMAIL_HOST_PASSWORD= +CHAT_BACKEND=ollama + diff --git a/app/config.py b/app/config.py index dbe4877..b25d9b2 100644 --- a/app/config.py +++ b/app/config.py @@ -1,4 +1,5 @@ import os +from typing import Literal from pydantic import BaseModel, PostgresDsn, RedisDsn, computed_field from pydantic_core import MultiHostUrl @@ -13,6 +14,25 @@ class SMTPConfig(BaseModel): template_path: str = os.getenv("EMAIL_TEMPLATE_PATH", "templates") +class ChatConfig(BaseModel): + """Configuration for the websocket chat service's model-client adapter. + + ``backend`` selects which :class:`~app.services.chat_agent.ChatAgent` + implementation is built by + :func:`~app.services.chat_agent.build_chat_agent` during app startup: + + - ``"stub"``: a local, dependency-free echo agent (default, no network + calls, ideal for local dev/tests). + - ``"ollama"``: streams completions from an OpenAI-compatible endpoint + (e.g. a local Ollama server), reusing the same adapter interface. + """ + + backend: Literal["stub", "ollama"] = os.getenv("CHAT_BACKEND", "stub") + base_url: str = os.getenv("CHAT_BASE_URL", "http://localhost:11434/v1") + model: str = os.getenv("CHAT_MODEL", "llama3.2") + stream_delay_seconds: float = float(os.getenv("CHAT_STREAM_DELAY", "0.02")) + + class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_ignore_empty=True, extra="ignore" @@ -21,6 +41,7 @@ class Settings(BaseSettings): jwt_expire: int = os.getenv("JWT_EXPIRE") smtp: SMTPConfig = SMTPConfig() + chat: ChatConfig = ChatConfig() REDIS_HOST: str REDIS_PORT: int diff --git a/app/main.py b/app/main.py index a484155..faa9e5b 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,3 @@ -from starlette.templating import _TemplateResponse from contextlib import asynccontextmanager from pathlib import Path @@ -9,7 +8,9 @@ from rotoger import get_logger from starlette.middleware import Middleware from starlette.middleware.gzip import GZipMiddleware +from starlette.templating import _TemplateResponse +from app.api.chat import router as chat_ws_router from app.api.health import router as health_router from app.api.ml import router as ml_router from app.api.nonsense import router as nonsense_router @@ -21,6 +22,8 @@ from app.middleware.profiler import ProfilingMiddleware from app.redis import get_redis from app.services.auth import AuthBearer +from app.services.chat_agent import build_chat_agent +from app.services.chat_session import ChatSessionManager templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates") @@ -30,21 +33,27 @@ async def lifespan(app: FastAPI): app.logger = get_logger() app.redis = await get_redis() postgres_dsn = global_settings.postgres_url.unicode_string() + # Chat service: initialize the pluggable model-client adapter and the + # in-memory session manager. See app/services/chat_agent.py to swap the + # local stub for a real model client (OpenAI, Ollama, etc.). + app.chat_agent = build_chat_agent(global_settings.chat) + app.chat_sessions = ChatSessionManager() try: - app.postgres_pool = await asyncpg.create_pool( - dsn=postgres_dsn, - min_size=5, - max_size=20, - ) - await app.logger.ainfo( - "Postgres pool created", idle_size=app.postgres_pool.get_idle_size() - ) + # app.postgres_pool = await asyncpg.create_pool( + # dsn=postgres_dsn, + # min_size=5, + # max_size=20, + # ) + # await app.logger.ainfo( + # "Postgres pool created", idle_size=app.postgres_pool.get_idle_size() + # ) yield except Exception as e: await app.logger.aerror("Error during app startup", error=repr(e)) raise finally: await app.redis.close() + await app.chat_agent.aclose() # await app.postgres_pool.close() @@ -66,6 +75,7 @@ def create_app() -> FastAPI: app.include_router(shakespeare_router) app.include_router(user_router) app.include_router(ml_router, prefix="/v1/ml", tags=["ML"]) + app.include_router(chat_ws_router, prefix="/v1/chat", tags=["Chat"]) app.include_router( health_router, prefix="/v1/public/health", tags=["Health, Public"] ) diff --git a/app/schemas/chat.py b/app/schemas/chat.py new file mode 100644 index 0000000..8f844f5 --- /dev/null +++ b/app/schemas/chat.py @@ -0,0 +1,124 @@ +"""Pydantic models for the websocket chat service. + +These models define the wire format exchanged over the ``/v1/chat/ws`` +websocket endpoint. The shapes intentionally mirror the conventions used by +the Pydantic AI chat-app example: messages carry a ``role`` and ``content``, +conversations are keyed by an opaque ``session_id``, and assistant replies +can be streamed as a sequence of chunks before a final aggregated message. + +Python 3.14 evaluates annotations lazily by default (PEP 649/749), so plain +modern typing (``str | None``, builtin generics, etc.) is used throughout +without needing ``from __future__ import annotations``. +""" + +from typing import Annotated, Literal +from uuid import UUID, uuid4 + +from pydantic import BaseModel, Field + +Role = Literal["system", "user", "assistant"] + + +class ChatMessage(BaseModel): + """A single role-labeled message, matching Pydantic AI message shapes.""" + + role: Role + content: str + + +# -------------------------------------------------------------------------- +# Client -> Server events +# -------------------------------------------------------------------------- + + +class StartConversation(BaseModel): + """Sent by the client to (re)start a conversation, optionally seeding it + with a system prompt.""" + + type: Literal["start"] = "start" + system_prompt: str | None = None + + +class SendUserMessage(BaseModel): + """Sent by the client with a new user message to append to history and + forward to the agent.""" + + type: Literal["user_message"] = "user_message" + content: str + + +class RequestHistory(BaseModel): + """Sent by the client to request the full message history for the + current session.""" + + type: Literal["history_request"] = "history_request" + + +class EndConversation(BaseModel): + """Sent by the client to gracefully close the conversation/websocket.""" + + type: Literal["end"] = "end" + + +ClientEvent = Annotated[ + StartConversation | SendUserMessage | RequestHistory | EndConversation, + Field(discriminator="type"), +] + + +# -------------------------------------------------------------------------- +# Server -> Client events +# -------------------------------------------------------------------------- + + +class Connected(BaseModel): + """First event sent right after the websocket handshake completes.""" + + type: Literal["connected"] = "connected" + session_id: UUID = Field(default_factory=uuid4) + + +class ConversationStarted(BaseModel): + """Acknowledges a ``start`` event.""" + + type: Literal["started"] = "started" + session_id: UUID + + +class AssistantChunk(BaseModel): + """A single streamed token/chunk of the assistant's reply.""" + + type: Literal["assistant_chunk"] = "assistant_chunk" + index: int + content: str + + +class AssistantMessage(BaseModel): + """The final, aggregated assistant message once streaming completes.""" + + type: Literal["assistant_message"] = "assistant_message" + message: ChatMessage + + +class HistoryResponse(BaseModel): + """Response to a ``history_request`` event.""" + + type: Literal["history"] = "history" + messages: list[ChatMessage] + + +class ChatError(BaseModel): + """Emitted whenever something goes wrong processing a client event.""" + + type: Literal["error"] = "error" + message: str + + +ServerEvent = ( + Connected + | ConversationStarted + | AssistantChunk + | AssistantMessage + | HistoryResponse + | ChatError +) diff --git a/app/services/chat_agent.py b/app/services/chat_agent.py new file mode 100644 index 0000000..93ebcae --- /dev/null +++ b/app/services/chat_agent.py @@ -0,0 +1,116 @@ +"""Pluggable async model-client adapter for the websocket chat service. + +``ChatAgent`` is the small interface every model connector must satisfy: +given the full message history (system/user/assistant, matching Pydantic AI +message-history semantics), yield the assistant's reply as a stream of text +chunks. Swapping the local stub for a real model (OpenAI, a local Ollama +server, etc.) only requires implementing this protocol and pointing +``build_chat_agent`` at it - no changes to the websocket endpoint or session +handling are needed. +""" + +import asyncio +import random +from collections.abc import AsyncIterator +from typing import Protocol, runtime_checkable + +import httpx +import orjson + +from app.config import ChatConfig +from app.schemas.chat import ChatMessage + + +@runtime_checkable +class ChatAgent(Protocol): + """Adapter interface implemented by every model connector.""" + + async def stream_reply(self, messages: list[ChatMessage]) -> AsyncIterator[str]: + """Yield the assistant reply for ``messages`` chunk by chunk. + + ``messages`` is the full conversation history (oldest first), + following Pydantic AI's role-labeled message-history convention. + """ + ... # pragma: no cover - protocol stub, never called directly + + async def aclose(self) -> None: + """Release any held resources (connections, clients, ...).""" + + +class LocalEchoAgent: + """Dependency-free stub agent used for local development and tests. + + It requires no API keys or network access: it "thinks" briefly, then + streams back a canned/echo response word by word, emulating the token + streaming behaviour of a real LLM backend closely enough to exercise the + full websocket flow end-to-end. + """ + + def __init__(self, stream_delay_seconds: float = 0.02) -> None: + self.stream_delay_seconds = stream_delay_seconds + + def _compose_reply(self, messages: list[ChatMessage]) -> str: + last_user = next( + (m.content for m in reversed(messages) if m.role == "user"), "" + ) + if not last_user: + return "Hello! I'm a local stub agent. Send me a message to get started." + greetings = ("hi", "hello", "hey") + if last_user.strip().lower() in greetings: + return "Hello there! How can I help you today?" + 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]: + reply = self._compose_reply(messages) + for word in reply.split(" "): + await asyncio.sleep(self.stream_delay_seconds + random.uniform(0, 0.01)) + yield word + " " + + async def aclose(self) -> None: + return None + + +class OllamaChatAgent: + """Streams chat completions from an OpenAI-compatible endpoint. + + Works out of the box with a local Ollama server (``ollama serve``) but + any OpenAI-compatible ``/chat/completions`` endpoint works too. This is + a ready-to-swap-in replacement for :class:`LocalEchoAgent` once a real + model should be used. + """ + + def __init__(self, base_url: str, model: str) -> None: + self.model = model + self._client = httpx.AsyncClient(base_url=base_url, timeout=60.0) + + async def stream_reply(self, messages: list[ChatMessage]) -> AsyncIterator[str]: + payload = { + "model": self.model, + "messages": [{"role": m.role, "content": m.content} for m in messages], + "stream": True, + } + async with self._client.stream( + "POST", "/chat/completions", json=payload + ) as response: + async for line in response.aiter_lines(): + if not line.startswith("data: ") or line == "data: [DONE]": + continue + try: + data = orjson.loads(line[6:]) + content = ( + data.get("choices", [{}])[0].get("delta", {}).get("content", "") + ) + except Exception: + content = "" + if content: + yield content + + async def aclose(self) -> None: + await self._client.aclose() + + +def build_chat_agent(config: ChatConfig) -> ChatAgent: + """Factory selecting the concrete :class:`ChatAgent` from ``config``.""" + if config.backend == "ollama": + return OllamaChatAgent(base_url=config.base_url, model=config.model) + return LocalEchoAgent(stream_delay_seconds=config.stream_delay_seconds) diff --git a/app/services/chat_session.py b/app/services/chat_session.py new file mode 100644 index 0000000..90c7579 --- /dev/null +++ b/app/services/chat_session.py @@ -0,0 +1,52 @@ +"""In-memory conversation/session management for the websocket chat service. + +Sessions are intentionally kept simple (a dict guarded by an ``asyncio.Lock``) +since each websocket connection owns exactly one session for its lifetime. +Swapping this for a Redis-backed store later (for multi-worker deployments) +only requires changing this module; the websocket endpoint only depends on +the small public API below. +""" + +import asyncio +from dataclasses import dataclass, field +from uuid import UUID, uuid4 + +from app.schemas.chat import ChatMessage + + +@dataclass(slots=True) +class ChatSession: + id: UUID = field(default_factory=uuid4) + messages: list[ChatMessage] = field(default_factory=list) + + def add(self, message: ChatMessage) -> None: + self.messages.append(message) + + def history(self) -> list[ChatMessage]: + return list(self.messages) + + +class ChatSessionManager: + """Tracks active chat sessions keyed by their opaque session id.""" + + def __init__(self) -> None: + self._sessions: dict[UUID, ChatSession] = {} + self._lock = asyncio.Lock() + + async def create(self) -> ChatSession: + session = ChatSession() + async with self._lock: + self._sessions[session.id] = session + return session + + async def get(self, session_id: UUID) -> ChatSession | None: + async with self._lock: + return self._sessions.get(session_id) + + async def remove(self, session_id: UUID) -> None: + async with self._lock: + self._sessions.pop(session_id, None) + + async def count(self) -> int: + async with self._lock: + return len(self._sessions) diff --git a/app/ws.py b/app/ws.py new file mode 100644 index 0000000..396a690 --- /dev/null +++ b/app/ws.py @@ -0,0 +1,11 @@ +"""Thin re-export module matching the suggested ``app/ws.py`` layout. + +The actual websocket router lives in :mod:`app.api.chat` (consistent with +this project's convention of keeping all routers under ``app/api``). This +module simply re-exports it so the chat service can also be wired up as +``from app.ws import router``. +""" + +from app.api.chat import router + +__all__ = ["router"] diff --git a/pyproject.toml b/pyproject.toml index bb6e282..6735c79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "A modern FastAPI application with SQLAlchemy 2.0 and AsyncPG for readme = "README.md" requires-python = "==3.14.4" dependencies = [ - "fastapi[all]==0.136.3", + "fastapi[all]==0.139.0", "pydantic==2.13.4", "pydantic-settings==2.14.1", "sqlalchemy==2.0.50", @@ -31,6 +31,7 @@ dependencies = [ "apscheduler[redis,sqlalchemy]>=4.0.0a6", "rotoger==0.3.0", "pyinstrument>=5.1.2", + "websockets>=15.0.1", ] [tool.uv] diff --git a/uv.lock b/uv.lock index 6e1a25d..ce793a3 100644 --- a/uv.lock +++ b/uv.lock @@ -308,7 +308,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.136.3" +version = "0.139.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -317,9 +317,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, ] [package.optional-dependencies] @@ -405,6 +405,7 @@ dependencies = [ { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, { name = "uvloop" }, + { name = "websockets" }, ] [package.dev-dependencies] @@ -423,7 +424,7 @@ requires-dist = [ { name = "asyncpg", specifier = "==0.31.0" }, { name = "bcrypt", specifier = "==5.0.0" }, { name = "dirty-equals", specifier = "==0.11" }, - { name = "fastapi", extras = ["all"], specifier = "==0.136.3" }, + { name = "fastapi", extras = ["all"], specifier = "==0.139.0" }, { name = "fastexcel", specifier = "==0.20.2" }, { name = "granian", specifier = "==2.7.6" }, { name = "httptools", specifier = "==0.8.0" }, @@ -444,6 +445,7 @@ requires-dist = [ { name = "sqlalchemy", specifier = "==2.0.50" }, { name = "uvicorn", extras = ["standard"], specifier = "==0.38.0" }, { name = "uvloop", specifier = "==0.22.1" }, + { name = "websockets", specifier = ">=15.0.1" }, ] [package.metadata.requires-dev] From 16a2e4e325ec9cb0453e7493decc37a1be241477 Mon Sep 17 00:00:00 2001 From: grillazz Date: Tue, 14 Jul 2026 11:14:25 +0200 Subject: [PATCH 03/11] wip: implement websocket chat endpoint with session management and event handling --- .gitignore | 3 ++ app/api/chat.py | 131 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 app/api/chat.py diff --git a/.gitignore b/.gitignore index d71e462..63fc181 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,6 @@ dmypy.json .pyre/ /requirements-dev.txt /.env.smtp + + +.idea diff --git a/app/api/chat.py b/app/api/chat.py new file mode 100644 index 0000000..12f7ced --- /dev/null +++ b/app/api/chat.py @@ -0,0 +1,131 @@ +"""Websocket endpoint implementing the chat flow. + +Mirrors the Pydantic AI chat-app interaction pattern: a client connects, +receives a ``connected`` event with its session id, may ``start`` a +conversation (optionally seeding a system prompt), then repeatedly sends +``user_message`` events. Each user message is appended to the session's +message history (role/content shaped like Pydantic AI's message history) and +forwarded to the pluggable :class:`~app.services.chat_agent.ChatAgent`, whose +reply is streamed back as ``assistant_chunk`` events followed by a single +aggregated ``assistant_message``. Clients can also request the full history +(``history_request``) or gracefully close the conversation (``end``). + +Example flow (JSON payloads, one per websocket text frame):: + + -> (connect) + <- {"type": "connected", "session_id": "..."} + -> {"type": "start", "system_prompt": "You are a helpful assistant."} + <- {"type": "started", "session_id": "..."} + -> {"type": "user_message", "content": "Hello there"} + <- {"type": "assistant_chunk", "index": 0, "content": "Hello "} + <- {"type": "assistant_chunk", "index": 1, "content": "there! "} + <- {"type": "assistant_message", "message": {"role": "assistant", "content": "Hello there! "}} + -> {"type": "history_request"} + <- {"type": "history", "messages": [...]} + -> {"type": "end"} + (connection closed by client/server) +""" + +from typing import Any + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from pydantic import TypeAdapter, ValidationError +from rotoger import get_logger + +from app.schemas.chat import ( + AssistantChunk, + AssistantMessage, + ChatError, + ChatMessage, + ClientEvent, + Connected, + ConversationStarted, + EndConversation, + HistoryResponse, + RequestHistory, + SendUserMessage, + StartConversation, +) +from app.services.chat_agent import ChatAgent +from app.services.chat_session import ChatSession, ChatSessionManager + +logger = get_logger() + +router = APIRouter() + +_client_event_adapter: TypeAdapter[Any] = TypeAdapter(ClientEvent) + + +async def _handle_start( + websocket: WebSocket, session: ChatSession, event: StartConversation +) -> None: + session.messages.clear() + if event.system_prompt: + session.add(ChatMessage(role="system", content=event.system_prompt)) + await websocket.send_json( + ConversationStarted(session_id=session.id).model_dump(mode="json") + ) + + +async def _handle_user_message( + websocket: WebSocket, session: ChatSession, agent: ChatAgent, event: SendUserMessage +) -> None: + session.add(ChatMessage(role="user", content=event.content)) + + chunks: list[str] = [] + index = 0 + async for chunk in agent.stream_reply(session.history()): + chunks.append(chunk) + await websocket.send_json( + AssistantChunk(index=index, content=chunk).model_dump(mode="json") + ) + index += 1 + + assistant_message = ChatMessage(role="assistant", content="".join(chunks)) + session.add(assistant_message) + await websocket.send_json( + AssistantMessage(message=assistant_message).model_dump(mode="json") + ) + + +async def _handle_history_request(websocket: WebSocket, session: ChatSession) -> None: + await websocket.send_json( + HistoryResponse(messages=session.history()).model_dump(mode="json") + ) + + +@router.websocket("/ws") +async def chat_websocket(websocket: WebSocket) -> None: + """Websocket chat endpoint. See module docstring for the event flow.""" + await websocket.accept() + + agent: ChatAgent = websocket.app.chat_agent + session_manager: ChatSessionManager = websocket.app.chat_sessions + session = await session_manager.create() + + await websocket.send_json(Connected(session_id=session.id).model_dump(mode="json")) + + try: + while True: + raw = await websocket.receive_json() + try: + event = _client_event_adapter.validate_python(raw) + except ValidationError as exc: + await websocket.send_json( + ChatError(message=f"Invalid event: {exc}").model_dump(mode="json") + ) + continue + + match event: + case StartConversation(): + await _handle_start(websocket, session, event) + case SendUserMessage(): + await _handle_user_message(websocket, session, agent, event) + case RequestHistory(): + await _handle_history_request(websocket, session) + case EndConversation(): + break + except WebSocketDisconnect: + await logger.ainfo("Chat websocket disconnected", session_id=str(session.id)) + finally: + await session_manager.remove(session.id) From 251aef0ec46e7192f70a477173f7732f19ff66a8 Mon Sep 17 00:00:00 2001 From: grillazz Date: Tue, 14 Jul 2026 11:41:15 +0200 Subject: [PATCH 04/11] refactor: update chat session management to use singleton pattern and enhance validation in chat agent --- app/api/chat.py | 2 +- app/main.py | 8 +-- app/services/chat_agent.py | 107 ++++++++++++++++++++++++++++++++++- app/services/chat_session.py | 18 ++++-- 4 files changed, 121 insertions(+), 14 deletions(-) diff --git a/app/api/chat.py b/app/api/chat.py index 12f7ced..a33247e 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -100,7 +100,7 @@ async def chat_websocket(websocket: WebSocket) -> None: await websocket.accept() agent: ChatAgent = websocket.app.chat_agent - session_manager: ChatSessionManager = websocket.app.chat_sessions + session_manager: ChatSessionManager = ChatSessionManager() session = await session_manager.create() await websocket.send_json(Connected(session_id=session.id).model_dump(mode="json")) diff --git a/app/main.py b/app/main.py index faa9e5b..b4e365b 100644 --- a/app/main.py +++ b/app/main.py @@ -33,11 +33,11 @@ async def lifespan(app: FastAPI): app.logger = get_logger() app.redis = await get_redis() postgres_dsn = global_settings.postgres_url.unicode_string() - # Chat service: initialize the pluggable model-client adapter and the - # in-memory session manager. See app/services/chat_agent.py to swap the - # local stub for a real model client (OpenAI, Ollama, etc.). + # Chat service: initialize the pluggable model-client adapter. The session + # manager is now a singleton and will be auto-instantiated on first access. + # See app/services/chat_agent.py to swap the local stub for a real model + # client (OpenAI, Ollama, etc.). app.chat_agent = build_chat_agent(global_settings.chat) - app.chat_sessions = ChatSessionManager() try: # app.postgres_pool = await asyncpg.create_pool( # dsn=postgres_dsn, diff --git a/app/services/chat_agent.py b/app/services/chat_agent.py index 93ebcae..994af9d 100644 --- a/app/services/chat_agent.py +++ b/app/services/chat_agent.py @@ -13,7 +13,9 @@ import random from collections.abc import AsyncIterator from typing import Protocol, runtime_checkable +from urllib.parse import urlparse +import attrs import httpx import orjson @@ -70,6 +72,58 @@ async def aclose(self) -> None: return None +def _validate_base_url(instance: OllamaChatAgent, attribute: attrs.Attribute, value: str) -> None: + """Validate that base_url is a valid HTTP(S) URL. + + Args: + 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) + if parsed.scheme not in ("http", "https"): + msg = f"base_url must be HTTP(S), got scheme '{parsed.scheme}' from '{value}'" + raise ValueError(msg) + if not parsed.netloc: + msg = f"base_url must include a host, got '{value}'" + raise ValueError(msg) + + +def _validate_timeout(instance: OllamaChatAgent, attribute: attrs.Attribute, value: float) -> None: + """Validate that timeout is positive. + + Args: + 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: + msg = f"timeout must be positive, got {value}" + 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) class OllamaChatAgent: """Streams chat completions from an OpenAI-compatible endpoint. @@ -77,13 +131,59 @@ class OllamaChatAgent: any OpenAI-compatible ``/chat/completions`` endpoint works too. This is a ready-to-swap-in replacement for :class:`LocalEchoAgent` once a real 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) """ - def __init__(self, base_url: str, model: str) -> None: - self.model = model - self._client = httpx.AsyncClient(base_url=base_url, timeout=60.0) + model: str = attrs.field( + metadata={ + "description": "LLM model identifier", + "examples": ["llama3.2", "mistral", "neural-chat"], + } + ) + base_url: str = attrs.field( + validator=_validate_base_url, + metadata={ + "description": "OpenAI-compatible API endpoint base URL", + "example": "http://localhost:11434/v1", + }, + ) + timeout: float = attrs.field( + default=60.0, + validator=_validate_timeout, + converter=float, + metadata={ + "description": "Request timeout in seconds", + "default": 60.0, + "constraints": "Must be positive", + }, + ) + _client: httpx.AsyncClient = attrs.field( + init=False, + repr=False, + metadata={"description": "Internal HTTP client for API communication"}, + ) + + def __attrs_post_init__(self) -> None: + """Initialize the HTTP client after field validation. + + 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]: + """Stream chat completion responses from the configured model. + + Args: + messages: Full message history (oldest first) following Pydantic AI convention. + + Yields: + Text chunks from the model's streaming response. + """ payload = { "model": self.model, "messages": [{"role": m.role, "content": m.content} for m in messages], @@ -106,6 +206,7 @@ async def stream_reply(self, messages: list[ChatMessage]) -> AsyncIterator[str]: yield content async def aclose(self) -> None: + """Release the internal HTTP client resources.""" await self._client.aclose() diff --git a/app/services/chat_session.py b/app/services/chat_session.py index 90c7579..4de6ab6 100644 --- a/app/services/chat_session.py +++ b/app/services/chat_session.py @@ -8,16 +8,18 @@ """ import asyncio -from dataclasses import dataclass, field from uuid import UUID, uuid4 +import attrs + from app.schemas.chat import ChatMessage +from app.utils.singleton import SingletonMetaNoArgs -@dataclass(slots=True) +@attrs.define(slots=True) class ChatSession: - id: UUID = field(default_factory=uuid4) - messages: list[ChatMessage] = field(default_factory=list) + id: UUID = attrs.field(factory=uuid4) + messages: list[ChatMessage] = attrs.field(factory=list) def add(self, message: ChatMessage) -> None: self.messages.append(message) @@ -26,8 +28,12 @@ def history(self) -> list[ChatMessage]: return list(self.messages) -class ChatSessionManager: - """Tracks active chat sessions keyed by their opaque session id.""" +class ChatSessionManager(metaclass=SingletonMetaNoArgs): + """Tracks active chat sessions keyed by their opaque session id. + + Implemented as a singleton to ensure exactly one instance per application, + maintaining a consistent registry of all active websocket chat sessions. + """ def __init__(self) -> None: self._sessions: dict[UUID, ChatSession] = {} From 0d1f681379af084c9e289e35c2f89b8d9a904b02 Mon Sep 17 00:00:00 2001 From: grillazz Date: Tue, 14 Jul 2026 11:45:58 +0200 Subject: [PATCH 05/11] feat: add manual testing script for websocket chat endpoint --- scripts/chit_chat_with_llm.py | 175 ++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 scripts/chit_chat_with_llm.py diff --git a/scripts/chit_chat_with_llm.py b/scripts/chit_chat_with_llm.py new file mode 100644 index 0000000..e27dc05 --- /dev/null +++ b/scripts/chit_chat_with_llm.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python +"""Manual testing script for the websocket chat endpoint. + +This script allows you to interact with the chat websocket endpoint manually. +It connects to the endpoint and provides an interactive interface to: +- Start conversations +- Send messages +- View full history +- See streamed responses in real-time + +Usage: + python test_websocket_manual.py + +Commands: + /start - Start a new conversation (optionally with a system prompt) + /history - Request the full message history + /end - End the conversation + /help - Show this help message + (any other text) - Send as a user message +""" + +import asyncio +import json +import sys +from typing import Any + +import websockets +from rich import print as rprint +from rich.console import Console +from rich.table import Table + +console = Console() + +# Configuration +WS_URL = "ws://localhost:8080/v1/chat/ws" + + +async def send_message(websocket: websockets.WebSocketClientProtocol, message: dict[str, Any]) -> None: + """Send a message to the websocket.""" + await websocket.send(json.dumps(message)) + + +async def receive_and_display_event(websocket: websockets.WebSocketClientProtocol) -> dict[str, Any]: + """Receive an event from websocket and display it.""" + data = await websocket.recv() + event = json.loads(data) + + if event["type"] == "connected": + rprint(f"[green]✓ Connected[/green] - Session ID: {event['session_id']}") + elif event["type"] == "started": + rprint(f"[green]✓ Conversation started[/green] - Session ID: {event['session_id']}") + elif event["type"] == "assistant_chunk": + # Print chunk without newline for streaming effect + console.print(event["content"], end="", soft_wrap=True) + elif event["type"] == "assistant_message": + console.print() # New line after chunks + rprint(f"[blue]✓ Message complete[/blue]") + elif event["type"] == "history": + rprint("\n[cyan]Message History:[/cyan]") + table = Table(show_header=True, header_style="bold magenta") + table.add_column("Role", style="cyan", width=12) + table.add_column("Content", style="green") + + for msg in event["messages"]: + role = msg["role"] + content = msg["content"][:100] + "..." if len(msg["content"]) > 100 else msg["content"] + table.add_row(role, content) + + console.print(table) + elif event["type"] == "error": + rprint(f"[red]✗ Error: {event.get('message', 'Unknown error')}[/red]") + else: + rprint(f"[yellow]? Received event: {event}[/yellow]") + + return event + + +async def get_user_input(prompt: str = "") -> str: + """Get user input asynchronously.""" + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, input, prompt) + + +async def main(): + """Main function for interactive websocket testing.""" + rprint("[bold cyan]FastAPI WebSocket Chat Tester[/bold cyan]") + rprint(f"Connecting to {WS_URL}...\n") + + try: + async with websockets.connect(WS_URL) as websocket: + # Receive the initial connected message + connected_event = await receive_and_display_event(websocket) + session_id = connected_event["session_id"] + + rprint("\n[yellow]Commands: /start, /history, /end, /help, or type a message[/yellow]\n") + + while True: + try: + # Get user input + user_input = await get_user_input("[bold]You:[/bold] ") + + if user_input.strip() == "": + continue + + # Handle special commands + if user_input.lower() == "/help": + rprint("\n[cyan]Available Commands:[/cyan]") + rprint("[green]/start[/green] - Start a new conversation") + rprint("[green]/history[/green] - Request the full message history") + rprint("[green]/end[/green] - End the conversation") + rprint("[green]/help[/green] - Show this help message\n") + continue + + elif user_input.lower() == "/start": + system_prompt = await get_user_input( + "Enter system prompt (or press Enter for default): " + ) + msg = { + "type": "start", + "system_prompt": system_prompt or "You are a helpful assistant.", + } + await send_message(websocket, msg) + event = await receive_and_display_event(websocket) + rprint() + continue + + elif user_input.lower() == "/history": + msg = {"type": "history_request"} + await send_message(websocket, msg) + event = await receive_and_display_event(websocket) + rprint() + continue + + elif user_input.lower() == "/end": + msg = {"type": "end"} + await send_message(websocket, msg) + rprint("[yellow]Conversation ended. Closing connection...[/yellow]") + break + + else: + # Send as a regular user message + msg = {"type": "user_message", "content": user_input} + await send_message(websocket, msg) + rprint("[cyan]Assistant:[/cyan] ", end="") + + # Receive chunks and the final message + while True: + event = await receive_and_display_event(websocket) + if event["type"] == "assistant_message": + break + + rprint() # New line after response + + except KeyboardInterrupt: + rprint("\n[yellow]Interrupted by user.[/yellow]") + msg = {"type": "end"} + await send_message(websocket, msg) + break + + except ConnectionRefusedError: + rprint( + f"[red]✗ Could not connect to {WS_URL}[/red]" + ) + rprint("[yellow]Make sure the FastAPI server is running on http://localhost:8080[/yellow]") + sys.exit(1) + except Exception as e: + rprint(f"[red]✗ Error: {e}[/red]") + sys.exit(1) + finally: + rprint("[cyan]Connection closed.[/cyan]") + + +if __name__ == "__main__": + asyncio.run(main()) + From de2ae691d485bd8c249818f9f2620882e91de510 Mon Sep 17 00:00:00 2001 From: grillazz Date: Fri, 17 Jul 2026 14:22:44 +0200 Subject: [PATCH 06/11] feat: update Dockerfile and Makefile for static files and compose configuration --- Dockerfile | 1 + Makefile | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index a4cb75f..629a6b1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,7 @@ WORKDIR /panettone COPY /app/ app/ COPY /tests/ tests/ COPY /templates/ templates/ +COPY /static/ /panettone/static/ COPY .env app/ COPY alembic.ini /panettone/alembic.ini COPY /alembic/ /panettone/alembic/ diff --git a/Makefile b/Makefile index c71dc53..e738138 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ help: ## Show this help # ==================================================================================== .PHONY: docker-build docker-build: ## Build project Docker images using compose - docker compose build + docker compose -f granian-compose.yml build .PHONY: docker-up docker-up: ## Run project with compose From 8c2a6fc8264bf986d77fe3b70b57625c6b8227e4 Mon Sep 17 00:00:00 2001 From: grillazz Date: Sat, 18 Jul 2026 14:21:23 +0200 Subject: [PATCH 07/11] feat: update Dockerfile and pyproject.toml for dependency upgrades and improved websocket client --- Dockerfile | 2 +- pyproject.toml | 48 +-- scripts/chit_chat_with_llm.py | 597 +++++++++++++++++++++++++--------- uv.lock | 499 +++++++++++----------------- 4 files changed, 659 insertions(+), 487 deletions(-) diff --git a/Dockerfile b/Dockerfile index 629a6b1..f53f50d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ ENV UV_LINK_MODE=copy \ COPY pyproject.toml /_lock/ COPY uv.lock /_lock/ -RUN cd /_lock && uv sync --locked --no-install-project +RUN cd /_lock && uv sync --group dev --group test --locked --no-install-project ########################################################################## FROM python:3.14.4-slim-trixie diff --git a/pyproject.toml b/pyproject.toml index 6735c79..9ccfcc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,48 +1,52 @@ [project] name = "fastapi-sqlalchemy-asyncpg" -version = "2.0.0" +version = "3.0.0" description = "A modern FastAPI application with SQLAlchemy 2.0 and AsyncPG for high-performance async database operations. Features include JWT authentication with Redis token storage, password hashing, connection pooling, data processing with Polars, Rich logging, task scheduling with APScheduler, and Shakespeare datasets integration." readme = "README.md" -requires-python = "==3.14.4" +requires-python = ">=3.14,<3.15" dependencies = [ - "fastapi[all]==0.139.0", - "pydantic==2.13.4", - "pydantic-settings==2.14.1", - "sqlalchemy==2.0.50", - "uvicorn[standard]==0.38.0", + "fastapi==0.139.2", + "pydantic[email]==2.13.4", + "pydantic-settings==2.14.2", + "sqlalchemy==2.0.51", + "uvicorn[standard]==0.51.0", "asyncpg==0.31.0", - "alembic==1.18.4", + "alembic==1.18.5", "httpx==0.28.1", - "pytest==9.0.3", - "pytest-cov==7.1.0", "uvloop==0.22.1", "httptools==0.8.0", "rich==15.0.0", "pyjwt==2.13.0", - "redis==7.4.1", + "redis==8.0.1", "bcrypt==5.0.0", - "polars==1.41.2", + "polars==1.42.1", "python-multipart==0.0.32", "fastexcel==0.20.2", - "inline-snapshot==0.34.1", - "dirty-equals==0.11", - "polyfactory==3.3.0", - "granian==2.7.6", - "apscheduler[redis,sqlalchemy]>=4.0.0a6", + "granian==2.7.9", + "apscheduler[redis,sqlalchemy]==4.0.0a6", "rotoger==0.3.0", "pyinstrument>=5.1.2", - "websockets>=15.0.1", + "websockets>=16.1.1", + "jinja2>=3.1.6", ] -[tool.uv] -dev-dependencies = [ - "ruff==0.14.10", +[dependency-groups] +dev = [ + "ruff==0.15.22", "devtools[pygments]==0.12.2", "pyupgrade==3.21.2", - "ipython==9.8.0", + "ipython==9.15.0", "tryceratops==2.4.1", ] +test = [ + "pytest==9.1.1", + "pytest-cov==7.1.0", + "inline-snapshot==0.35.2", + "dirty-equals==0.11", + "polyfactory==3.3.0", +] + [tool.pyrefly.errors] redundant-cast = "warn" diff --git a/scripts/chit_chat_with_llm.py b/scripts/chit_chat_with_llm.py index e27dc05..e5fa9a0 100644 --- a/scripts/chit_chat_with_llm.py +++ b/scripts/chit_chat_with_llm.py @@ -1,175 +1,454 @@ -#!/usr/bin/env python -"""Manual testing script for the websocket chat endpoint. - -This script allows you to interact with the chat websocket endpoint manually. -It connects to the endpoint and provides an interactive interface to: -- Start conversations -- Send messages -- View full history -- See streamed responses in real-time - -Usage: - python test_websocket_manual.py - -Commands: - /start - Start a new conversation (optionally with a system prompt) - /history - Request the full message history - /end - End the conversation - /help - Show this help message - (any other text) - Send as a user message +#!/usr/bin/env python3 +"""Interactive Rich client for the FastAPI websocket chat endpoint. + +The script keeps the original websocket wire format intact while making the +manual testing experience easier to discover and safer to operate. + +Examples: + python scripts/chit_chat_with_llm.py + python scripts/chit_chat_with_llm.py --url ws://localhost:8080/v1/chat/ws + python scripts/chit_chat_with_llm.py --debug --system-prompt "Be concise." + +Interactive commands: + /start Start or restart a conversation. + /history Show the full message history. + /end, /quit Gracefully close the websocket. + /help Show the interactive help table. + any other text Send text as a user message. """ +import argparse import asyncio import json -import sys -from typing import Any +import logging +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Literal, Protocol, TypedDict, cast import websockets -from rich import print as rprint -from rich.console import Console +from rich import box +from rich.console import Console, Group +from rich.logging import RichHandler +from rich.panel import Panel +from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn from rich.table import Table +from rich.text import Text +from rich.traceback import install as install_rich_traceback +from websockets.exceptions import ConnectionClosed, InvalidURI, WebSocketException -console = Console() +DEFAULT_WS_URL = "ws://localhost:8080/v1/chat/ws" +DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant." -# Configuration -WS_URL = "ws://localhost:8080/v1/chat/ws" - - -async def send_message(websocket: websockets.WebSocketClientProtocol, message: dict[str, Any]) -> None: - """Send a message to the websocket.""" - await websocket.send(json.dumps(message)) - - -async def receive_and_display_event(websocket: websockets.WebSocketClientProtocol) -> dict[str, Any]: - """Receive an event from websocket and display it.""" - data = await websocket.recv() - event = json.loads(data) - - if event["type"] == "connected": - rprint(f"[green]✓ Connected[/green] - Session ID: {event['session_id']}") - elif event["type"] == "started": - rprint(f"[green]✓ Conversation started[/green] - Session ID: {event['session_id']}") - elif event["type"] == "assistant_chunk": - # Print chunk without newline for streaming effect - console.print(event["content"], end="", soft_wrap=True) - elif event["type"] == "assistant_message": - console.print() # New line after chunks - rprint(f"[blue]✓ Message complete[/blue]") - elif event["type"] == "history": - rprint("\n[cyan]Message History:[/cyan]") - table = Table(show_header=True, header_style="bold magenta") - table.add_column("Role", style="cyan", width=12) - table.add_column("Content", style="green") - - for msg in event["messages"]: - role = msg["role"] - content = msg["content"][:100] + "..." if len(msg["content"]) > 100 else msg["content"] - table.add_row(role, content) - - console.print(table) - elif event["type"] == "error": - rprint(f"[red]✗ Error: {event.get('message', 'Unknown error')}[/red]") - else: - rprint(f"[yellow]? Received event: {event}[/yellow]") - - return event - - -async def get_user_input(prompt: str = "") -> str: - """Get user input asynchronously.""" - loop = asyncio.get_event_loop() - return await loop.run_in_executor(None, input, prompt) - - -async def main(): - """Main function for interactive websocket testing.""" - rprint("[bold cyan]FastAPI WebSocket Chat Tester[/bold cyan]") - rprint(f"Connecting to {WS_URL}...\n") - - try: - async with websockets.connect(WS_URL) as websocket: - # Receive the initial connected message - connected_event = await receive_and_display_event(websocket) - session_id = connected_event["session_id"] - - rprint("\n[yellow]Commands: /start, /history, /end, /help, or type a message[/yellow]\n") - - while True: - try: - # Get user input - user_input = await get_user_input("[bold]You:[/bold] ") - - if user_input.strip() == "": - continue - - # Handle special commands - if user_input.lower() == "/help": - rprint("\n[cyan]Available Commands:[/cyan]") - rprint("[green]/start[/green] - Start a new conversation") - rprint("[green]/history[/green] - Request the full message history") - rprint("[green]/end[/green] - End the conversation") - rprint("[green]/help[/green] - Show this help message\n") - continue - - elif user_input.lower() == "/start": - system_prompt = await get_user_input( - "Enter system prompt (or press Enter for default): " - ) - msg = { - "type": "start", - "system_prompt": system_prompt or "You are a helpful assistant.", - } - await send_message(websocket, msg) - event = await receive_and_display_event(websocket) - rprint() - continue - - elif user_input.lower() == "/history": - msg = {"type": "history_request"} - await send_message(websocket, msg) - event = await receive_and_display_event(websocket) - rprint() - continue - - elif user_input.lower() == "/end": - msg = {"type": "end"} - await send_message(websocket, msg) - rprint("[yellow]Conversation ended. Closing connection...[/yellow]") - break - - else: - # Send as a regular user message - msg = {"type": "user_message", "content": user_input} - await send_message(websocket, msg) - rprint("[cyan]Assistant:[/cyan] ", end="") - - # Receive chunks and the final message - while True: - event = await receive_and_display_event(websocket) - if event["type"] == "assistant_message": - break - - rprint() # New line after response - - except KeyboardInterrupt: - rprint("\n[yellow]Interrupted by user.[/yellow]") - msg = {"type": "end"} - await send_message(websocket, msg) +EventType = Literal[ + "connected", + "started", + "assistant_chunk", + "assistant_message", + "history", + "error", +] +ClientMessageType = Literal["start", "user_message", "history_request", "end"] + + +class WebSocketLike(Protocol): + """Minimal websocket protocol used by this client.""" + + async def send(self, message: str) -> None: ... + + async def recv(self) -> str | bytes: ... + + +class ChatMessage(TypedDict): + """Role/content message shape returned by the websocket API.""" + + role: Literal["system", "user", "assistant"] + content: str + + +class ServerEvent(TypedDict, total=False): + """Known server event fields. + + The websocket endpoint uses a discriminated JSON shape. ``total=False`` + keeps display code resilient to malformed or future server events. + """ + + type: EventType + session_id: str + index: int + content: str + message: ChatMessage + messages: list[ChatMessage] + + +@dataclass(frozen=True, slots=True) +class ChatClientConfig: + """Runtime settings parsed from the command line.""" + + url: str = DEFAULT_WS_URL + system_prompt: str = DEFAULT_SYSTEM_PROMPT + connect_timeout: float = 10.0 + open_on_connect: bool = False + debug: bool = False + + +@dataclass(slots=True) +class ChatClientState: + """Mutable client state for the active websocket session.""" + + session_id: str | None = None + conversation_started: bool = False + received_chunks: int = 0 + + +class ChatCli: + """UX-focused websocket chat client.""" + + def __init__(self, config: ChatClientConfig, console: Console) -> None: + self.config = config + self.console = console + self.state = ChatClientState() + + async def run(self) -> int: + """Connect to the websocket and start the interactive command loop.""" + self._show_welcome() + + try: + with self._progress(f"Connecting to [bold]{self.config.url}[/bold]..."): + websocket = await asyncio.wait_for( + websockets.connect(self.config.url), + timeout=self.config.connect_timeout, + ) + + async with websocket: + connected_event = await self.receive_event(websocket) + self.display_event(connected_event) + + if self.config.open_on_connect: + await self.start_conversation(websocket, self.config.system_prompt) + + await self.interactive_loop(websocket) + return 0 + except TimeoutError: + self.print_error( + "Connection timed out.", + f"No websocket accepted the connection within {self.config.connect_timeout:g} seconds. " + "Check that the FastAPI server is running and reachable.", + ) + except ConnectionRefusedError: + self.print_error( + "Connection refused.", + "Start the API server first, then try again. Expected default: http://localhost:8080", + ) + except InvalidURI as exc: + self.print_error( + "Invalid websocket URL.", + f"{exc}. Use a URL such as ws://localhost:8080/v1/chat/ws.", + ) + except ConnectionClosed as exc: + close_code = exc.rcvd.code if exc.rcvd else exc.sent.code if exc.sent else "unknown" + close_reason = exc.rcvd.reason if exc.rcvd else exc.sent.reason if exc.sent else "none" + self.print_warning( + f"The websocket closed unexpectedly (code={close_code}, reason={close_reason or 'none'})." + ) + except KeyboardInterrupt: + self.print_warning("Interrupted by user.") + except WebSocketException as exc: + self.print_error( + "Websocket error.", + f"{exc}. Verify the endpoint path and server logs for details.", + ) + + return 1 + + async def interactive_loop(self, websocket: WebSocketLike) -> None: + """Read commands from the terminal until the user exits.""" + self.print_info("Type [bold cyan]/help[/bold cyan] to see available commands.") + + while True: + user_input = (await self.get_user_input("[bold green]You[/bold green] › ")).strip() + + if not user_input: + continue + + command = user_input.casefold() + match command: + case "/help": + self.show_command_help() + case "/start": + system_prompt = await self.get_user_input( + "[bold yellow]System prompt[/bold yellow] (Enter for default) › " + ) + await self.start_conversation(websocket, system_prompt or self.config.system_prompt) + case "/history": + await self.request_history(websocket) + case "/end" | "/quit" | "/exit": + await self.end_conversation(websocket) break + case _: + await self.send_user_message(websocket, user_input) + + async def start_conversation(self, websocket: WebSocketLike, system_prompt: str) -> None: + """Start or restart the server-side conversation history.""" + await self.send_message(websocket, {"type": "start", "system_prompt": system_prompt}) + with self._progress("Waiting for conversation acknowledgement..."): + event = await self.receive_event(websocket) + self.display_event(event) + self.console.print() + + async def request_history(self, websocket: WebSocketLike) -> None: + """Ask the server for complete message history.""" + await self.send_message(websocket, {"type": "history_request"}) + with self._progress("Loading message history..."): + event = await self.receive_event(websocket) + self.display_event(event) + self.console.print() + + async def end_conversation(self, websocket: WebSocketLike) -> None: + """Gracefully close the server-side conversation.""" + await self.send_message(websocket, {"type": "end"}) + self.print_warning("Conversation ended. Closing connection...") + + async def send_user_message(self, websocket: WebSocketLike, content: str) -> None: + """Send one user message and stream the assistant response.""" + await self.send_message(websocket, {"type": "user_message", "content": content}) + self.console.print("[bold cyan]Assistant[/bold cyan] › ", end="") + + self.state.received_chunks = 0 + while True: + event = await self.receive_event(websocket) + event_type = event.get("type") + self.display_event(event) + + if event_type == "assistant_message": + break + if event_type == "error": + self.console.print() + break + if event_type != "assistant_chunk": + self.print_warning( + f"Expected assistant_chunk or assistant_message, received {event_type!r}." + ) + break + + self.console.print() + + async def send_message( + self, + websocket: WebSocketLike, + message: Mapping[str, Any], + ) -> None: + """Serialize and send one client event.""" + await websocket.send(json.dumps(message)) + logging.getLogger(__name__).debug("Sent websocket event: %s", message) + + async def receive_event(self, websocket: WebSocketLike) -> ServerEvent: + """Receive and decode one JSON server event.""" + raw_event = await websocket.recv() + if isinstance(raw_event, bytes): + raw_event = raw_event.decode() + + try: + decoded = json.loads(raw_event) + except json.JSONDecodeError as exc: + raise RuntimeError(f"Server returned invalid JSON: {raw_event!r}") from exc + + if not isinstance(decoded, dict): + raise RuntimeError(f"Server returned a non-object event: {decoded!r}") + + logging.getLogger(__name__).debug("Received websocket event: %s", decoded) + return cast(ServerEvent, cast(object, decoded)) + + def display_event(self, event: ServerEvent) -> None: + """Render one server event with semantic styling.""" + match event.get("type"): + case "connected": + self.state.session_id = event.get("session_id") + self.print_success(f"Connected. Session ID: [bold]{self.state.session_id}[/bold]") + case "started": + self.state.conversation_started = True + self.print_success(f"Conversation started. Session ID: [bold]{event.get('session_id')}[/bold]") + case "assistant_chunk": + self.state.received_chunks += 1 + self.console.print(event.get("content", ""), end="", soft_wrap=True) + case "assistant_message": + self.console.print() + message = event.get("message", {}) + content = message.get("content", "") if isinstance(message, dict) else "" + self.print_success( + f"Message complete ({self.state.received_chunks} chunk(s), {len(content)} character(s))." + ) + case "history": + self.render_history(event.get("messages", [])) + case "error": + self.print_error("Server returned an error.", str(event.get("message", "Unknown error"))) + case unknown: + self.console.print( + Panel.fit( + json.dumps(event, indent=2, default=str), + title=f"Unknown event: {unknown!r}", + border_style="yellow", + ) + ) + + def render_history(self, messages: list[ChatMessage] | object) -> None: + """Render conversation history as a Rich table.""" + if not isinstance(messages, list): + self.print_warning("History response did not include a valid messages list.") + return + + table = Table( + title=f"Message History ({len(messages)} message(s))", + box=box.ROUNDED, + header_style="bold magenta", + show_lines=True, + ) + table.add_column("#", justify="right", style="dim", width=4) + table.add_column("Role", style="cyan", width=12) + table.add_column("Content", style="green", overflow="fold") + + for index, message in enumerate(messages, start=1): + role = str(message.get("role", "unknown")) if isinstance(message, dict) else "unknown" + content = str(message.get("content", "")) if isinstance(message, dict) else repr(message) + table.add_row(str(index), role, content) + + self.console.print(table) + + async def get_user_input(self, prompt: str) -> str: + """Read terminal input without blocking the event loop.""" + return await asyncio.to_thread(self.console.input, prompt) + + def show_command_help(self) -> None: + """Display interactive command help.""" + table = Table(title="Interactive Commands", box=box.SIMPLE_HEAVY) + table.add_column("Command", style="bold cyan", no_wrap=True) + table.add_column("Action", style="white") + table.add_row("/start", "Start or restart a conversation; clears server-side history.") + table.add_row("/history", "Request and display the full role/content message history.") + table.add_row("/end, /quit, /exit", "Gracefully close the websocket connection.") + table.add_row("/help", "Show this help table.") + table.add_row("any text", "Send text as a user message and stream the reply.") + self.console.print(table) + + def _show_welcome(self) -> None: + command_summary = Table.grid(padding=(0, 2)) + command_summary.add_column(style="bold cyan", no_wrap=True) + command_summary.add_column(style="white") + command_summary.add_row("Endpoint", self.config.url) + command_summary.add_row("Default prompt", self.config.system_prompt) + command_summary.add_row("Start mode", "auto" if self.config.open_on_connect else "manual (/start)") + + self.console.print( + Panel( + Group( + Text("FastAPI WebSocket Chat Tester", style="bold cyan"), + Text("Stream replies, inspect history, and validate the chat websocket UX."), + command_summary, + ), + border_style="cyan", + padding=(1, 2), + ) + ) - except ConnectionRefusedError: - rprint( - f"[red]✗ Could not connect to {WS_URL}[/red]" + def _progress(self, message: str) -> Progress: + """Create a compact indeterminate Rich progress spinner.""" + progress = Progress( + SpinnerColumn(style="cyan"), + TextColumn(message), + TimeElapsedColumn(), + console=self.console, + transient=True, ) - rprint("[yellow]Make sure the FastAPI server is running on http://localhost:8080[/yellow]") - sys.exit(1) - except Exception as e: - rprint(f"[red]✗ Error: {e}[/red]") - sys.exit(1) - finally: - rprint("[cyan]Connection closed.[/cyan]") + progress.add_task("operation", total=None) + return progress + + def print_info(self, message: str) -> None: + self.console.print(f"[bold blue]ℹ[/bold blue] {message}") + + def print_success(self, message: str) -> None: + self.console.print(f"[bold green]✓[/bold green] {message}") + + def print_warning(self, message: str) -> None: + self.console.print(f"[bold yellow]⚠[/bold yellow] {message}") + + def print_error(self, title: str, detail: str) -> None: + self.console.print( + Panel( + detail, + title=f"[bold red]✗ {title}[/bold red]", + border_style="red", + expand=False, + ) + ) + + +def parse_args(argv: list[str] | None = None) -> ChatClientConfig: + """Parse CLI options into a typed configuration object.""" + parser = argparse.ArgumentParser( + description="Interactive Rich client for the FastAPI websocket chat endpoint.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "--url", + default=DEFAULT_WS_URL, + help="Websocket endpoint to connect to.", + ) + parser.add_argument( + "--system-prompt", + default=DEFAULT_SYSTEM_PROMPT, + help="Default system prompt used by /start or --start.", + ) + parser.add_argument( + "--connect-timeout", + type=float, + default=10.0, + help="Seconds to wait while opening the websocket connection.", + ) + parser.add_argument( + "--start", + action="store_true", + dest="open_on_connect", + help="Automatically send a start event after connecting.", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Enable Rich-formatted debug logging and local tracebacks.", + ) + + namespace = parser.parse_args(argv) + return ChatClientConfig( + url=namespace.url, + system_prompt=namespace.system_prompt, + connect_timeout=namespace.connect_timeout, + open_on_connect=namespace.open_on_connect, + debug=namespace.debug, + ) + + +def configure_logging(debug: bool) -> None: + """Install Rich tracebacks and configure optional debug logging.""" + install_rich_traceback(show_locals=debug, suppress=[websockets]) + logging.basicConfig( + level=logging.DEBUG if debug else logging.WARNING, + format="%(message)s", + datefmt="[%X]", + handlers=[RichHandler(rich_tracebacks=True, show_path=debug)], + ) + + +async def async_main(argv: list[str] | None = None) -> int: + """Async application entry point.""" + config = parse_args(argv) + configure_logging(config.debug) + console = Console() + return await ChatCli(config=config, console=console).run() + + +def main() -> None: + """Synchronous script entry point.""" + raise SystemExit(asyncio.run(async_main())) if __name__ == "__main__": - asyncio.run(main()) + main() diff --git a/uv.lock b/uv.lock index ce793a3..e6d1360 100644 --- a/uv.lock +++ b/uv.lock @@ -1,19 +1,19 @@ version = 1 revision = 3 -requires-python = "==3.14.4" +requires-python = "==3.14.*" [[package]] name = "alembic" -version = "1.18.4" +version = "1.18.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, ] [[package]] @@ -308,7 +308,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.139.0" +version = "0.139.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -317,87 +317,31 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428, upload-time = "2026-07-16T15:06:17.912Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, -] - -[package.optional-dependencies] -all = [ - { name = "email-validator" }, - { name = "fastapi-cli", extra = ["standard"] }, - { name = "httpx" }, - { name = "itsdangerous" }, - { name = "jinja2" }, - { name = "pydantic-extra-types" }, - { name = "pydantic-settings" }, - { name = "python-multipart" }, - { name = "pyyaml" }, - { name = "uvicorn", extra = ["standard"] }, -] - -[[package]] -name = "fastapi-cli" -version = "0.0.16" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "rich-toolkit" }, - { name = "typer" }, - { name = "uvicorn", extra = ["standard"] }, -] -sdist = { url = "https://files.pythonhosted.org/packages/99/75/9407a6b452be4c988feacec9c9d2f58d8f315162a6c7258d5a649d933ebe/fastapi_cli-0.0.16.tar.gz", hash = "sha256:e8a2a1ecf7a4e062e3b2eec63ae34387d1e142d4849181d936b23c4bdfe29073", size = 19447, upload-time = "2025-11-10T19:01:07.856Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/43/678528c19318394320ee43757648d5e0a8070cf391b31f69d931e5c840d2/fastapi_cli-0.0.16-py3-none-any.whl", hash = "sha256:addcb6d130b5b9c91adbbf3f2947fe115991495fdb442fe3e51b5fc6327df9f4", size = 12312, upload-time = "2025-11-10T19:01:06.728Z" }, -] - -[package.optional-dependencies] -standard = [ - { name = "fastapi-cloud-cli" }, - { name = "uvicorn", extra = ["standard"] }, -] - -[[package]] -name = "fastapi-cloud-cli" -version = "0.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpx" }, - { name = "pydantic", extra = ["email"] }, - { name = "rich-toolkit" }, - { name = "rignore" }, - { name = "sentry-sdk" }, - { name = "typer" }, - { name = "uvicorn", extra = ["standard"] }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f9/48/0f14d8555b750dc8c04382804e4214f1d7f55298127f3a0237ba566e69dd/fastapi_cloud_cli-0.3.1.tar.gz", hash = "sha256:8c7226c36e92e92d0c89827e8f56dbf164ab2de4444bd33aa26b6c3f7675db69", size = 24080, upload-time = "2025-10-09T11:32:58.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/79/7f5a5e5513e6a737e5fb089d9c59c74d4d24dc24d581d3aa519b326bedda/fastapi_cloud_cli-0.3.1-py3-none-any.whl", hash = "sha256:7d1a98a77791a9d0757886b2ffbf11bcc6b3be93210dd15064be10b216bf7e00", size = 19711, upload-time = "2025-10-09T11:32:57.118Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234, upload-time = "2026-07-16T15:06:19.557Z" }, ] [[package]] name = "fastapi-sqlalchemy-asyncpg" -version = "2.0.0" +version = "3.0.0" source = { virtual = "." } dependencies = [ { name = "alembic" }, { name = "apscheduler", extra = ["redis", "sqlalchemy"] }, { name = "asyncpg" }, { name = "bcrypt" }, - { name = "dirty-equals" }, - { name = "fastapi", extra = ["all"] }, + { name = "fastapi" }, { name = "fastexcel" }, { name = "granian" }, { name = "httptools" }, { name = "httpx" }, - { name = "inline-snapshot" }, + { name = "jinja2" }, { name = "polars" }, - { name = "polyfactory" }, - { name = "pydantic" }, + { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, { name = "pyinstrument" }, { name = "pyjwt" }, - { name = "pytest" }, - { name = "pytest-cov" }, { name = "python-multipart" }, { name = "redis" }, { name = "rich" }, @@ -416,46 +360,56 @@ dev = [ { name = "ruff" }, { name = "tryceratops" }, ] +test = [ + { name = "dirty-equals" }, + { name = "inline-snapshot" }, + { name = "polyfactory" }, + { name = "pytest" }, + { name = "pytest-cov" }, +] [package.metadata] requires-dist = [ - { name = "alembic", specifier = "==1.18.4" }, - { name = "apscheduler", extras = ["redis", "sqlalchemy"], specifier = ">=4.0.0a6" }, + { name = "alembic", specifier = "==1.18.5" }, + { name = "apscheduler", extras = ["redis", "sqlalchemy"], specifier = "==4.0.0a6" }, { name = "asyncpg", specifier = "==0.31.0" }, { name = "bcrypt", specifier = "==5.0.0" }, - { name = "dirty-equals", specifier = "==0.11" }, - { name = "fastapi", extras = ["all"], specifier = "==0.139.0" }, + { name = "fastapi", specifier = "==0.139.2" }, { name = "fastexcel", specifier = "==0.20.2" }, - { name = "granian", specifier = "==2.7.6" }, + { name = "granian", specifier = "==2.7.9" }, { name = "httptools", specifier = "==0.8.0" }, { name = "httpx", specifier = "==0.28.1" }, - { name = "inline-snapshot", specifier = "==0.34.1" }, - { name = "polars", specifier = "==1.41.2" }, - { name = "polyfactory", specifier = "==3.3.0" }, - { name = "pydantic", specifier = "==2.13.4" }, - { name = "pydantic-settings", specifier = "==2.14.1" }, + { name = "jinja2", specifier = ">=3.1.6" }, + { name = "polars", specifier = "==1.42.1" }, + { name = "pydantic", extras = ["email"], specifier = "==2.13.4" }, + { name = "pydantic-settings", specifier = "==2.14.2" }, { name = "pyinstrument", specifier = ">=5.1.2" }, { name = "pyjwt", specifier = "==2.13.0" }, - { name = "pytest", specifier = "==9.0.3" }, - { name = "pytest-cov", specifier = "==7.1.0" }, { name = "python-multipart", specifier = "==0.0.32" }, - { name = "redis", specifier = "==7.4.1" }, + { name = "redis", specifier = "==8.0.1" }, { name = "rich", specifier = "==15.0.0" }, { name = "rotoger", specifier = "==0.3.0" }, - { name = "sqlalchemy", specifier = "==2.0.50" }, - { name = "uvicorn", extras = ["standard"], specifier = "==0.38.0" }, + { name = "sqlalchemy", specifier = "==2.0.51" }, + { name = "uvicorn", extras = ["standard"], specifier = "==0.51.0" }, { name = "uvloop", specifier = "==0.22.1" }, - { name = "websockets", specifier = ">=15.0.1" }, + { name = "websockets", specifier = ">=16.1.1" }, ] [package.metadata.requires-dev] dev = [ { name = "devtools", extras = ["pygments"], specifier = "==0.12.2" }, - { name = "ipython", specifier = "==9.8.0" }, + { name = "ipython", specifier = "==9.15.0" }, { name = "pyupgrade", specifier = "==3.21.2" }, - { name = "ruff", specifier = "==0.14.10" }, + { name = "ruff", specifier = "==0.15.22" }, { name = "tryceratops", specifier = "==2.4.1" }, ] +test = [ + { name = "dirty-equals", specifier = "==0.11" }, + { name = "inline-snapshot", specifier = "==0.35.2" }, + { name = "polyfactory", specifier = "==3.3.0" }, + { name = "pytest", specifier = "==9.1.1" }, + { name = "pytest-cov", specifier = "==7.1.0" }, +] [[package]] name = "fastexcel" @@ -482,33 +436,33 @@ wheels = [ [[package]] name = "granian" -version = "2.7.6" +version = "2.7.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/4b/7c27442d6377607bec0802dcc1ee73554f1b3982ed6fca3dab253bee55d4/granian-2.7.6.tar.gz", hash = "sha256:52c8eaa5bdd636535c4c50b62591420612297f38151786cffd8c8cd39c738da3", size = 128698, upload-time = "2026-06-10T19:35:22.556Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/b9/3cd193896669cf737bfc099eb30459bfc3494a66c33f8a768e98563a513b/granian-2.7.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3e8929e235c80d8aa11fc502a4632d7fbb29ff7b4cbd17e5a05f7afc2e38de32", size = 6522686, upload-time = "2026-06-10T19:34:31.284Z" }, - { url = "https://files.pythonhosted.org/packages/ea/5d/f8a55dfc0de7263e7806726e87b6da725e7b0c11ebb12d95b5300141c3cf/granian-2.7.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0ba744daf241cdb52abf7ff1a5e3d53b04d470d4166880789ccfe3605cee5d52", size = 6152569, upload-time = "2026-06-10T19:34:32.955Z" }, - { url = "https://files.pythonhosted.org/packages/cf/79/3cc94f59fcc14d00c96800aa4d9385e91c051b0140a94fca7a6adcf2f5ec/granian-2.7.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2b284d7f5a1080fd50d5b0f3f335dc34a9cc5ebe3cafc291d25eea174d8c178", size = 7269408, upload-time = "2026-06-10T19:34:34.726Z" }, - { url = "https://files.pythonhosted.org/packages/ff/4b/64d0874c83d975ca7acf97f23f4d888a675d11d782d8af74bb4e8b41d528/granian-2.7.6-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f6b57ff48757a76b4c1253e892a5d55108fe868085b6edfe5ddb008d6b93e17", size = 6510992, upload-time = "2026-06-10T19:34:36.502Z" }, - { url = "https://files.pythonhosted.org/packages/93/bb/036c2b53f765842deaf64a3376310d129671e38d36e93b4b4531f91b1c7b/granian-2.7.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3fa6ebbad188f581f264f5b4f2e1bb2f3fa3b8b830d66703b225b4bc3ad9302a", size = 6967320, upload-time = "2026-06-10T19:34:38.114Z" }, - { url = "https://files.pythonhosted.org/packages/5c/63/6b8d2169d8094b5bdc01b0d108e1c2091bf3dba3f098ae8f57913f1a0cf8/granian-2.7.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:76a4f7ddacee73cf3c8baa06ebfcea1e591185687c45cdae6df7992578135f58", size = 7150567, upload-time = "2026-06-10T19:34:39.926Z" }, - { url = "https://files.pythonhosted.org/packages/c3/07/7c6d26e9b5e2dc694857bb2367ad19e155df97a3126b4a42f8d2da719ff5/granian-2.7.6-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:e3a9ef405e37fd78a753acbd4751f35a08a860d5572a6644aa5d7442fe1b3dc2", size = 7153936, upload-time = "2026-06-10T19:34:41.711Z" }, - { url = "https://files.pythonhosted.org/packages/44/1b/510d0c76c21ed188b281e243ef7d89efa79d6324b2d6962094651dc9c366/granian-2.7.6-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:a05a6c908ad3d98c2e3986f52f60083471a6304d63955465bca957d40fc55671", size = 7408687, upload-time = "2026-06-10T19:34:43.668Z" }, - { url = "https://files.pythonhosted.org/packages/45/b6/e0ba4b1e2a99c16affaa98a6617e16f2165ed8278b03a57987df9bf381fa/granian-2.7.6-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:58d33829649384f537f9c326688acf7ce9df9fc1044f4fad90058c71505cd51f", size = 7070293, upload-time = "2026-06-10T19:34:45.535Z" }, - { url = "https://files.pythonhosted.org/packages/b7/c0/2508d9a8c10f910b86c88af31e5ddce5a3be6eebe92e3938887e3a23291b/granian-2.7.6-cp314-cp314-win_amd64.whl", hash = "sha256:7c7b2643518369fed9442c727d28345c1d8f28f3b800966b49fd426ef46fc318", size = 4074996, upload-time = "2026-06-10T19:34:47.407Z" }, - { url = "https://files.pythonhosted.org/packages/18/82/335d6f8f41caee1f88c1d81c942318c58ad956c002dd0385b5fd1ef0576b/granian-2.7.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:75e1d9e47585eb1616664daced55271bd2d948ef5406d4fed0f909bb718a8a7f", size = 6360306, upload-time = "2026-06-10T19:34:49.195Z" }, - { url = "https://files.pythonhosted.org/packages/25/7f/feb6c399056662597c05322fdaa7680f02ca1c308511d961c40ee3da2e8d/granian-2.7.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:41394d5fd17623e10deb1ed54bde3395609ac9037774586d49c66793777fd5fe", size = 6003466, upload-time = "2026-06-10T19:34:51.072Z" }, - { url = "https://files.pythonhosted.org/packages/7d/1a/e48b29e7b0bc53498ad6d85b7fbff0d0601289fb6284e1ad89c5e565dc23/granian-2.7.6-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f43853e0f5be5e4c4e1b1f8bc58fa05feedfe622154faed84823b878d1b39d00", size = 6285354, upload-time = "2026-06-10T19:34:52.956Z" }, - { url = "https://files.pythonhosted.org/packages/5d/cc/c7e04b205f0c668fe68c21a8bfaaf775a6ec4067425cd694a947e19f7f9e/granian-2.7.6-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6de1316d1194a7dd759a8ca2bf0072e9a03c4f3021eca196aa0a41c1bcc6c4c", size = 7229062, upload-time = "2026-06-10T19:34:54.727Z" }, - { url = "https://files.pythonhosted.org/packages/2d/41/70d618ecd24493101f22e003a8172f038ed7dc2135a3dc7938bb8ecca0bb/granian-2.7.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7538056d589455d4fa56fee7380857e672c3684f0ab8227a6407678e62da8b32", size = 6732112, upload-time = "2026-06-10T19:34:56.383Z" }, - { url = "https://files.pythonhosted.org/packages/7c/b0/ce1fe2b72d43b4375df6fa3f011401815a2fa294f7140a77cc1000a0e004/granian-2.7.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:403e08987177fde21c54ad5129693045ea4c7dae47b664fcdd0afb406828d6ad", size = 6804818, upload-time = "2026-06-10T19:34:58.119Z" }, - { url = "https://files.pythonhosted.org/packages/fb/ef/d8299ddca1c687d8009624b7944e5840f2773f728f9f2e0eac301fc04f6d/granian-2.7.6-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ae666a6a3703986231b93df7fc44a4bca9ddb6283685eda07dc8e1235d7b05e5", size = 7025409, upload-time = "2026-06-10T19:35:00.111Z" }, - { url = "https://files.pythonhosted.org/packages/de/3c/1a4d2439ae4c1b86c69373a32b306da9e4f053f0ca6313a2b0709d604e8e/granian-2.7.6-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:a7364881df5a26e3df8ec1e22e02230b02817cb5585112182749fb4a5069146d", size = 7370731, upload-time = "2026-06-10T19:35:02.113Z" }, - { url = "https://files.pythonhosted.org/packages/4c/6f/2052643f726ee53bc9e66eec08cd69b622b88b0c60b435f245109b74b631/granian-2.7.6-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:535fe238f8a4d148a0d9bd9d28afb6eb6ab94f955de1d156d568258e5cd9dfb6", size = 6947803, upload-time = "2026-06-10T19:35:04.121Z" }, - { url = "https://files.pythonhosted.org/packages/1f/02/4f0ef1d8c86a2b23926853791ee54df8d97a70fe2ec027c1be0b0cddc40f/granian-2.7.6-cp314-cp314t-win_amd64.whl", hash = "sha256:2744c81401641b77d5a2957a683cdc21ef053fb74defa0f53b60cfc1134d2115", size = 3993160, upload-time = "2026-06-10T19:35:05.939Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/b0/cc/9c752e6173df02c5e37c0df7bffd50c1341109e4b4f8e5073bfd3a72dc82/granian-2.7.9.tar.gz", hash = "sha256:096d9a3396b13826bc63d2cf424ed04daf1ea077beed361d48dca2d55cb4b527", size = 129789, upload-time = "2026-07-03T12:42:03.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/fa/b388d36bef00f28f2c99df3ab7a08878116d4d1d654b6f93f7b0ef9cde5b/granian-2.7.9-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:17e6975266757b05fd59163a3b5ccd7a9d50c227b13e58b9e5b47eff0609c17f", size = 6451170, upload-time = "2026-07-03T12:41:18.484Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ac/2fdc222d98960c5d0a1d1970ee52f9f4e3a953b2862d7be8fb043e74e0c1/granian-2.7.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:554543e1085ff6bf60eb0f0e995a8a412e92165e92113f9bae9e1fdfeec2ca72", size = 6169769, upload-time = "2026-07-03T12:41:20.31Z" }, + { url = "https://files.pythonhosted.org/packages/b2/79/beaa9a25285aea37b7bf9c2fa7357871b51ae1a7ee79501d570386e188ab/granian-2.7.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1f06eb3a1c5ce9bf050e94f37310a6add0410ddee75fc65563e86d1619eea384", size = 7269037, upload-time = "2026-07-03T12:41:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/be/e4/ef1ca00cc17664548427908da36d9a16e29e7adb34318d5173d1c00ecd1c/granian-2.7.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d17720e112e40563bfefa7b8031d37372234b568784c07250322887631f168f8", size = 6539295, upload-time = "2026-07-03T12:41:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c5/80826359e26079665562b362f0a5f05261183a8b423e31c954f110313bb4/granian-2.7.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:607a02fbda1905cc0b0764f09fab1d601299e482ebb1d9722e61ca08742fb0df", size = 6981221, upload-time = "2026-07-03T12:41:24.861Z" }, + { url = "https://files.pythonhosted.org/packages/ab/77/d595c9698b7799e28c4dc9a3091d30dbea9e20e9e92926e47f9c974b7a77/granian-2.7.9-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e2eb74974682bf8635dff79f356d3fd86193c8c1ebed1baffa75ad1793fddbc7", size = 7131507, upload-time = "2026-07-03T12:41:26.347Z" }, + { url = "https://files.pythonhosted.org/packages/99/46/5200e2b09feae19b7a96371eb2f1523faf2e99aae60584f286db0263cea6/granian-2.7.9-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:33ba7e10bfd30b196b866a9b4b828c9a108a22325936f7d07def89a6b9f21dce", size = 7067731, upload-time = "2026-07-03T12:41:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/432aaa769de91176944d3210734e27c223c77b76da359699067cd90d4c65/granian-2.7.9-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:e2cf4c926e99718576e4ba201d884a56b0e62d8ede39c7468b26cbe2d98c30e5", size = 7449207, upload-time = "2026-07-03T12:41:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/d8/83/8e5e429ebb1465c998403a60e0078784d28b819954c387a9558d99b6c3c5/granian-2.7.9-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:665a82ce3f48a4b5e1f4cc83115d3b9a2647b3f222d072db6716223e6dfc86d2", size = 7031706, upload-time = "2026-07-03T12:41:31.209Z" }, + { url = "https://files.pythonhosted.org/packages/51/5c/b82a31436c740dd0a87e091998f9c350739cbd260aa3880db6f5418112a7/granian-2.7.9-cp314-cp314-win_amd64.whl", hash = "sha256:74d6e50277621e2faa41931d4ddaeae0735e39a20ebaccc49a2282549a4d5297", size = 4080721, upload-time = "2026-07-03T12:41:32.64Z" }, + { url = "https://files.pythonhosted.org/packages/11/fb/aa241630b4e987c0b343d08c71e20a89098f9c863a12842afa19930f82a0/granian-2.7.9-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:1a9b34e5d46a48fa6f017d6afa546f4d35aa6a2a10737e9c49c5c9108e6a4280", size = 6254314, upload-time = "2026-07-03T12:41:34.139Z" }, + { url = "https://files.pythonhosted.org/packages/76/1c/787f63df68e28b9e5793efd80c7ba9e1a0e77663c921525552ac3b0d9305/granian-2.7.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea97a1928b414a35903667440875e0f0f4d2f7a341e8d198ab57f0b2cb70af28", size = 6078452, upload-time = "2026-07-03T12:41:36.228Z" }, + { url = "https://files.pythonhosted.org/packages/b5/60/fcc41cd8f394c12785fc2f9d9843ad4329e90a2468d0aaf3474be3255e90/granian-2.7.9-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:57edbef65585ac69d6ada7b9d7cb6fcd6ebeb2e663833c246f72d3634851f5ac", size = 6298703, upload-time = "2026-07-03T12:41:37.906Z" }, + { url = "https://files.pythonhosted.org/packages/05/0f/555c2f436cf386614e8197ffc3a27c6e3a812149fd84367d2f988361b4fe/granian-2.7.9-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3e39cdfcd5a0ed8e9e8a5e21cd65085ac3a4c73ac5f258f804aedc729cdfa2bb", size = 7241919, upload-time = "2026-07-03T12:41:39.679Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4c/13bd13b0dcc2697b521e75efbeede7328c0809f3e93e964362fd334c0dd4/granian-2.7.9-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c254a1b2027612f0df6e47e8059fd304cda287be25421e1dced4d6b56fb054c", size = 6673295, upload-time = "2026-07-03T12:41:41.483Z" }, + { url = "https://files.pythonhosted.org/packages/02/0a/c0b977247bf3dd4efa92903dd316b8089f7e67cb21922e83cdcdd98201f7/granian-2.7.9-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f9669323b5cd90be7565711bc75363eccf75b6aecdbb0b286f1a860199ef800c", size = 6830753, upload-time = "2026-07-03T12:41:42.979Z" }, + { url = "https://files.pythonhosted.org/packages/08/b2/8f50b2d83452ee3100fcd1b26a5de5206b8befeb9d36b79e6c20d5f6dacd/granian-2.7.9-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:c6475981a0cbf766575a0146bdbe52439185a5040cef2a3969214ca1ef6a5e18", size = 6976795, upload-time = "2026-07-03T12:41:44.6Z" }, + { url = "https://files.pythonhosted.org/packages/92/3a/bec3533aaaff69a9a984f3fd578a2ca29548c9d8503770b1ba5c2260aecd/granian-2.7.9-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3022cde5c662716db8fe16838ba351e0897768435b9c732afc9a4a7322ed05d5", size = 7420697, upload-time = "2026-07-03T12:41:46.332Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d9/4526cc50a1fe3addcc29fe3c7c676d64046f724a4d8e4e29d76ba7dcbe04/granian-2.7.9-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c7e3193da47554561142be7a58520b36a01f6a2da57a7bcbe61f970fb53cc0c", size = 6950255, upload-time = "2026-07-03T12:41:48.023Z" }, + { url = "https://files.pythonhosted.org/packages/0a/62/aa89482ffbc3e56d533cf8c87a91cfb58c486e6a77c522fa34c9f8874113/granian-2.7.9-cp314-cp314t-win_amd64.whl", hash = "sha256:d2cef5a15afee90683944a3b23694e97d75adeafd59ef85ff3896bc9462695d2", size = 3992366, upload-time = "2026-07-03T12:41:49.523Z" }, ] [[package]] @@ -607,7 +561,7 @@ wheels = [ [[package]] name = "inline-snapshot" -version = "0.34.1" +version = "0.35.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asttokens" }, @@ -616,14 +570,14 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b8/2e/00c99468c7788bdb4f7d861bcfbfb091a5fe1bc475f9dd031f667c422a73/inline_snapshot-0.34.1.tar.gz", hash = "sha256:f8af37876c42069b7c0ed73f64357ace482955c3225ebcd27f243197ecfbcb65", size = 2638769, upload-time = "2026-06-05T16:58:47.921Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/b3/38a1b9c6323c5dff0ab6c74aa839f1c7d8b7057a861d80ec5bfe834be24e/inline_snapshot-0.35.2.tar.gz", hash = "sha256:6cfd2ea0b52d9cb9beb13c1e73d740ff86f630ba48d9ea6947e54e3ff8494876", size = 2534713, upload-time = "2026-07-16T10:51:58.826Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/83/21747d0ee2276e973a2889e960f2ec904fbbe83fac6fecd8a57995bb81df/inline_snapshot-0.34.1-py3-none-any.whl", hash = "sha256:4c043215866dfdbe6a3ead92d9d0a9294720c7deebddc346dc781654cb19daa9", size = 90039, upload-time = "2026-06-05T16:58:46.038Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ea/de85fc264e4c76bda3552eee42904e8b7079fd826902aa5fa044bc5bbc3c/inline_snapshot-0.35.2-py3-none-any.whl", hash = "sha256:91b230b310c95a8c3b91cdbc637e064c7c2f98c13935b64875a7ba9ef174e161", size = 94669, upload-time = "2026-07-16T10:51:57.457Z" }, ] [[package]] name = "ipython" -version = "9.8.0" +version = "9.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -633,13 +587,14 @@ dependencies = [ { name = "matplotlib-inline" }, { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, { name = "pygments" }, { name = "stack-data" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/51/a703c030f4928646d390b4971af4938a1b10c9dfce694f0d99a0bb073cb2/ipython-9.8.0.tar.gz", hash = "sha256:8e4ce129a627eb9dd221c41b1d2cdaed4ef7c9da8c17c63f6f578fe231141f83", size = 4424940, upload-time = "2025-12-03T10:18:24.353Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/df/8ee1c5dd1e3308b5d5b2f2dfea323bb2f3827da8d654abb6642051199049/ipython-9.8.0-py3-none-any.whl", hash = "sha256:ebe6d1d58d7d988fbf23ff8ff6d8e1622cfdb194daf4b7b73b792c4ec3b85385", size = 621374, upload-time = "2025-12-03T10:18:22.335Z" }, + { url = "https://files.pythonhosted.org/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e", size = 630895, upload-time = "2026-06-26T11:03:33.809Z" }, ] [[package]] @@ -654,15 +609,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, ] -[[package]] -name = "itsdangerous" -version = "2.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, -] - [[package]] name = "jedi" version = "0.19.2" @@ -826,30 +772,30 @@ wheels = [ [[package]] name = "polars" -version = "1.41.2" +version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "polars-runtime-32" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/f9/aeda46259b0669247a160315d2d51269de9504b9dd2f70acadbcb22f46b7/polars-1.41.2.tar.gz", hash = "sha256:256d6731162371b77f3f29a55eacb8c0fc740ddb1a293a01d2ef5b5393c5c708", size = 737996, upload-time = "2026-05-29T17:39:15.604Z" } +sdist = { url = "https://files.pythonhosted.org/packages/27/99/fe77f10a13a778705ef05b499fc708c9a0b0a3680d9eb6bc6e1b6a6b9914/polars-1.42.1.tar.gz", hash = "sha256:2fe94f3059334650bd850ae19a9c165dcd5d9cb12cd95ea04de2201662e70e8a", size = 741532, upload-time = "2026-06-30T04:57:51.504Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/22/28f62d24f7db56ac4343588f9362d49b7b4177e55ac47a466fe696b0099b/polars-1.41.2-py3-none-any.whl", hash = "sha256:23ce9a2910b6e3e8d4258770bf44aa17170958df7af6e85feedf4458a04d8d29", size = 833445, upload-time = "2026-05-29T17:37:05.576Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/edd939cc6fa04b6415aaa9bf19720fc74ead81234b3d38542e0005816d4d/polars-1.42.1-py3-none-any.whl", hash = "sha256:3c0c65cdfa21a621650c4bdcbbccf93964d052fd766c3e70e84a55d961c259fd", size = 837622, upload-time = "2026-06-30T04:56:34.686Z" }, ] [[package]] name = "polars-runtime-32" -version = "1.41.2" +version = "1.42.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/56/54e3ea0e9b64f327179049e4742241cc6b1d3e8fa414b05a057dd26df367/polars_runtime_32-1.41.2.tar.gz", hash = "sha256:7af09ec1ab053da2c9669e8d15f809a4083a29be05db57111688b8051062af56", size = 2989474, upload-time = "2026-05-29T17:39:17.257Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/59/15bcc4dac380c6d63efa5446d8317f22671cbd6c9dadd576bd17a334c45a/polars_runtime_32-1.42.1.tar.gz", hash = "sha256:4d4809e1c1b9a6611f6944f27b24abea902b5159e6b6fa262fd716e947af5afd", size = 3045460, upload-time = "2026-06-30T04:57:52.866Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/9b/fe72a3811c0357cdb06c67bdc7695fa1623ad47948fc523195f5ac31037f/polars_runtime_32-1.41.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:95a08346dac337357cdb825c8076df7d36da54c4caa59a5cb41d0a30691c5edd", size = 52265283, upload-time = "2026-05-29T17:37:09.407Z" }, - { url = "https://files.pythonhosted.org/packages/0a/93/fab9da803fd80d9e83ef88c20932f637a10bc611b20415fc322eec84bc44/polars_runtime_32-1.41.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:dedfaeec2c7f995298da7319dd9431d662e5dd1d0ec51b1459df4a0234ceff52", size = 46571222, upload-time = "2026-05-29T17:37:13.698Z" }, - { url = "https://files.pythonhosted.org/packages/c8/2a/8843f34a8ac57acd058a39b87b03b580dd352a490e9dae0415e02033bdd4/polars_runtime_32-1.41.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18eea22c5cc34e27f8a60950458ad81e6a9ea75e89363ca1367e14e7e7f781fc", size = 50409372, upload-time = "2026-05-29T17:37:17.875Z" }, - { url = "https://files.pythonhosted.org/packages/6c/c6/92b352fe88cf51bd0a19fb99e1c0cbe46aa26c14dcf7995b89869cd932ae/polars_runtime_32-1.41.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2630540dfdfb0f36f9b04a07c7c2e3f50bf2ad384113263c1c812007ee9141e0", size = 56405484, upload-time = "2026-05-29T17:37:22.684Z" }, - { url = "https://files.pythonhosted.org/packages/74/c4/bae3174c3b02f6b441d2e58594387abcd509f67a098f682a83b195f08966/polars_runtime_32-1.41.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:20e969e08f9b137e233c04cc04de73d9795f89eb77d34854e40a025965a43763", size = 50603512, upload-time = "2026-05-29T17:37:27.422Z" }, - { url = "https://files.pythonhosted.org/packages/f4/ed/f2d26ae02d92c2689056838ed59e2a626326ad23c2831d58637d25f6c82a/polars_runtime_32-1.41.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e7016a3deb641b64a31447abbbee0f34bd020a6a9ae34ee6b743837def15e2a4", size = 54328561, upload-time = "2026-05-29T17:37:32.587Z" }, - { url = "https://files.pythonhosted.org/packages/9b/c4/9c3831cc885dc7769e59abf8f583821a5fb4403fd0e4eba0ccc6d47a3d4b/polars_runtime_32-1.41.2-cp310-abi3-win_amd64.whl", hash = "sha256:1e5e5377c315e0dcafdfb2a31adc546abbaeb3f9cb1864e6536523d2af473265", size = 51978643, upload-time = "2026-05-29T17:37:37.443Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c6/79e9f3f270270d7ed5575d92b7bfef49f01abd9275447161275b23b553a8/polars_runtime_32-1.41.2-cp310-abi3-win_arm64.whl", hash = "sha256:843d96f69d18eca53429c1198e58891db7f18111f83b9c419bb45ad9d73eaed5", size = 46006901, upload-time = "2026-05-29T17:37:42.522Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/16ff6e4e91d71e530d3581f45e342a9cc35072ac6b31dcbc2fa33de2569e/polars_runtime_32-1.42.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:bbdc26d68ee5b23b0ce227fa0599220aa35b77c826b6b0a6b2d8e7f6c1c36974", size = 53117325, upload-time = "2026-06-30T04:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/04/8e/4f8296fcfd1347f1351342fecf13bf2430d7efbae2f1f45964ec7930a99e/polars_runtime_32-1.42.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f6c0288be940b607dc4a7476c01e67fb6bbee93f5f1dd42c64970274c71008ba", size = 47446251, upload-time = "2026-06-30T04:56:41.459Z" }, + { url = "https://files.pythonhosted.org/packages/88/2e/0d66a7deadc453b890c3391034ca8ab4b05d0beaebbb92a7d65199fba61b/polars_runtime_32-1.42.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635d9dbcae2302ae223afb395d5cd220bffa61a53d0ab6871d17c8bc830101cf", size = 51359402, upload-time = "2026-06-30T04:56:44.595Z" }, + { url = "https://files.pythonhosted.org/packages/5d/10/ffb85fa380bc9c9000dc35f40f44954dde49023018501c54faab94b3a39e/polars_runtime_32-1.42.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d059e8e53cc114ff82f9bd791fd341dc53534a2c745e6f6aa37594c3a93f01fe", size = 57302723, upload-time = "2026-06-30T04:56:47.609Z" }, + { url = "https://files.pythonhosted.org/packages/c2/63/ca50adc62e44224ca5c622a842ba6f35ee87d1d40ef0df7ea2ed6c6edb08/polars_runtime_32-1.42.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f91f0b13588324905682809d270e1de5f1990c908721c8527657d77a044c9919", size = 51515673, upload-time = "2026-06-30T04:56:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/63/c3/08fbbf38deaa17bf34a601d327cb7451074098673c78b7c1a8538dde9794/polars_runtime_32-1.42.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b8bf972d99d48aaaa2582e2bce966a6f43bc815bd8725d15f5cab9e2fb15d17", size = 55217259, upload-time = "2026-06-30T04:56:53.834Z" }, + { url = "https://files.pythonhosted.org/packages/d1/0e/51db89361668fe077a835fc579277f824ba526e7daf7b94d23d25439e0d0/polars_runtime_32-1.42.1-cp310-abi3-win_amd64.whl", hash = "sha256:e9364c26da389a8b7339e4d29e20a3d12af730247e6ed3b7804bddce2477f428", size = 52715432, upload-time = "2026-06-30T04:56:57.109Z" }, + { url = "https://files.pythonhosted.org/packages/76/c5/2fb8592d691bd114de25d9c84300b23541dca7060eac11d7b4bed0327786/polars_runtime_32-1.42.1-cp310-abi3-win_arm64.whl", hash = "sha256:7051226e6b42ffc395a7a9190377cd28649fbfb991b8f85c6271f4e1cfb736fb", size = 46718300, upload-time = "2026-06-30T04:56:59.855Z" }, ] [[package]] @@ -877,6 +823,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + [[package]] name = "ptyprocess" version = "0.7.0" @@ -956,31 +924,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, ] -[[package]] -name = "pydantic-extra-types" -version = "2.10.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3a/10/fb64987804cde41bcc39d9cd757cd5f2bb5d97b389d81aa70238b14b8a7e/pydantic_extra_types-2.10.6.tar.gz", hash = "sha256:c63d70bf684366e6bbe1f4ee3957952ebe6973d41e7802aea0b770d06b116aeb", size = 141858, upload-time = "2025-10-08T13:47:49.483Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/04/5c918669096da8d1c9ec7bb716bd72e755526103a61bc5e76a3e4fb23b53/pydantic_extra_types-2.10.6-py3-none-any.whl", hash = "sha256:6106c448316d30abf721b5b9fecc65e983ef2614399a24142d689c7546cc246a", size = 40949, upload-time = "2025-10-08T13:47:48.268Z" }, -] - [[package]] name = "pydantic-settings" -version = "2.14.1" +version = "2.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, ] [[package]] @@ -1027,7 +982,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1036,9 +991,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] @@ -1113,11 +1068,11 @@ wheels = [ [[package]] name = "redis" -version = "7.4.1" +version = "8.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/93/05e7d4a65285066a74f48697f9b9cde5cfce71398033d69ed83c3d98f5c9/redis-7.4.1.tar.gz", hash = "sha256:1a1df5067062cf7cbe677994e391f8ee0840f499d370f1a71266e0dd3aa9308e", size = 4945742, upload-time = "2026-06-05T09:10:06.703Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170, upload-time = "2026-06-23T14:52:37.728Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/2e/2677f3f93dae0497e7e33b6637302e7f3744efc553f34231183e32584885/redis-7.4.1-py3-none-any.whl", hash = "sha256:1fa4647af1c5e93a2c685aa248ee44cce092691146d41390518dabe9a99839b0", size = 410171, upload-time = "2026-06-05T09:10:05.128Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" }, ] [[package]] @@ -1133,58 +1088,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] -[[package]] -name = "rich-toolkit" -version = "0.15.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/67/33/1a18839aaa8feef7983590c05c22c9c09d245ada6017d118325bbfcc7651/rich_toolkit-0.15.1.tar.gz", hash = "sha256:6f9630eb29f3843d19d48c3bd5706a086d36d62016687f9d0efa027ddc2dd08a", size = 115322, upload-time = "2025-09-04T09:28:11.789Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/49/42821d55ead7b5a87c8d121edf323cb393d8579f63e933002ade900b784f/rich_toolkit-0.15.1-py3-none-any.whl", hash = "sha256:36a0b1d9a135d26776e4b78f1d5c2655da6e0ef432380b5c6b523c8d8ab97478", size = 29412, upload-time = "2025-09-04T09:28:10.587Z" }, -] - -[[package]] -name = "rignore" -version = "0.7.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/b9/1f5bd82b87e5550cd843ceb3768b4a8ef274eb63f29333cf2f29644b3d75/rignore-0.7.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8e41be9fa8f2f47239ded8920cc283699a052ac4c371f77f5ac017ebeed75732", size = 882632, upload-time = "2025-11-05T20:42:44.063Z" }, - { url = "https://files.pythonhosted.org/packages/e9/6b/07714a3efe4a8048864e8a5b7db311ba51b921e15268b17defaebf56d3db/rignore-0.7.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6dc1e171e52cefa6c20e60c05394a71165663b48bca6c7666dee4f778f2a7d90", size = 820760, upload-time = "2025-11-05T20:42:27.885Z" }, - { url = "https://files.pythonhosted.org/packages/ac/0f/348c829ea2d8d596e856371b14b9092f8a5dfbb62674ec9b3f67e4939a9d/rignore-0.7.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ce2268837c3600f82ab8db58f5834009dc638ee17103582960da668963bebc5", size = 899044, upload-time = "2025-11-05T20:40:55.336Z" }, - { url = "https://files.pythonhosted.org/packages/f0/30/2e1841a19b4dd23878d73edd5d82e998a83d5ed9570a89675f140ca8b2ad/rignore-0.7.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:690a3e1b54bfe77e89c4bacb13f046e642f8baadafc61d68f5a726f324a76ab6", size = 874144, upload-time = "2025-11-05T20:41:10.195Z" }, - { url = "https://files.pythonhosted.org/packages/c2/bf/0ce9beb2e5f64c30e3580bef09f5829236889f01511a125f98b83169b993/rignore-0.7.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09d12ac7a0b6210c07bcd145007117ebd8abe99c8eeb383e9e4673910c2754b2", size = 1168062, upload-time = "2025-11-05T20:41:26.511Z" }, - { url = "https://files.pythonhosted.org/packages/b9/8b/571c178414eb4014969865317da8a02ce4cf5241a41676ef91a59aab24de/rignore-0.7.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a2b2b74a8c60203b08452479b90e5ce3dbe96a916214bc9eb2e5af0b6a9beb0", size = 942542, upload-time = "2025-11-05T20:41:41.838Z" }, - { url = "https://files.pythonhosted.org/packages/19/62/7a3cf601d5a45137a7e2b89d10c05b5b86499190c4b7ca5c3c47d79ee519/rignore-0.7.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fc5a531ef02131e44359419a366bfac57f773ea58f5278c2cdd915f7d10ea94", size = 958739, upload-time = "2025-11-05T20:42:12.463Z" }, - { url = "https://files.pythonhosted.org/packages/5f/1f/4261f6a0d7caf2058a5cde2f5045f565ab91aa7badc972b57d19ce58b14e/rignore-0.7.6-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7a1f77d9c4cd7e76229e252614d963442686bfe12c787a49f4fe481df49e7a9", size = 984138, upload-time = "2025-11-05T20:41:56.775Z" }, - { url = "https://files.pythonhosted.org/packages/2b/bf/628dfe19c75e8ce1f45f7c248f5148b17dfa89a817f8e3552ab74c3ae812/rignore-0.7.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ead81f728682ba72b5b1c3d5846b011d3e0174da978de87c61645f2ed36659a7", size = 1079299, upload-time = "2025-11-05T21:40:16.639Z" }, - { url = "https://files.pythonhosted.org/packages/af/a5/be29c50f5c0c25c637ed32db8758fdf5b901a99e08b608971cda8afb293b/rignore-0.7.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:12ffd50f520c22ffdabed8cd8bfb567d9ac165b2b854d3e679f4bcaef11a9441", size = 1139618, upload-time = "2025-11-05T21:40:34.507Z" }, - { url = "https://files.pythonhosted.org/packages/2a/40/3c46cd7ce4fa05c20b525fd60f599165e820af66e66f2c371cd50644558f/rignore-0.7.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e5a16890fbe3c894f8ca34b0fcacc2c200398d4d46ae654e03bc9b3dbf2a0a72", size = 1117626, upload-time = "2025-11-05T21:40:51.494Z" }, - { url = "https://files.pythonhosted.org/packages/8c/b9/aea926f263b8a29a23c75c2e0d8447965eb1879d3feb53cfcf84db67ed58/rignore-0.7.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3abab3bf99e8a77488ef6c7c9a799fac22224c28fe9f25cc21aa7cc2b72bfc0b", size = 1128144, upload-time = "2025-11-05T21:41:09.169Z" }, - { url = "https://files.pythonhosted.org/packages/a4/f6/0d6242f8d0df7f2ecbe91679fefc1f75e7cd2072cb4f497abaab3f0f8523/rignore-0.7.6-cp314-cp314-win32.whl", hash = "sha256:eeef421c1782953c4375aa32f06ecae470c1285c6381eee2a30d2e02a5633001", size = 646385, upload-time = "2025-11-05T21:41:55.105Z" }, - { url = "https://files.pythonhosted.org/packages/d5/38/c0dcd7b10064f084343d6af26fe9414e46e9619c5f3224b5272e8e5d9956/rignore-0.7.6-cp314-cp314-win_amd64.whl", hash = "sha256:6aeed503b3b3d5af939b21d72a82521701a4bd3b89cd761da1e7dc78621af304", size = 725738, upload-time = "2025-11-05T21:41:39.736Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7a/290f868296c1ece914d565757ab363b04730a728b544beb567ceb3b2d96f/rignore-0.7.6-cp314-cp314-win_arm64.whl", hash = "sha256:104f215b60b3c984c386c3e747d6ab4376d5656478694e22c7bd2f788ddd8304", size = 656008, upload-time = "2025-11-05T21:41:29.028Z" }, - { url = "https://files.pythonhosted.org/packages/ca/d2/3c74e3cd81fe8ea08a8dcd2d755c09ac2e8ad8fe409508904557b58383d3/rignore-0.7.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bb24a5b947656dd94cb9e41c4bc8b23cec0c435b58be0d74a874f63c259549e8", size = 882835, upload-time = "2025-11-05T20:42:45.443Z" }, - { url = "https://files.pythonhosted.org/packages/77/61/a772a34b6b63154877433ac2d048364815b24c2dd308f76b212c408101a2/rignore-0.7.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b1e33c9501cefe24b70a1eafd9821acfd0ebf0b35c3a379430a14df089993e3", size = 820301, upload-time = "2025-11-05T20:42:29.226Z" }, - { url = "https://files.pythonhosted.org/packages/71/30/054880b09c0b1b61d17eeb15279d8bf729c0ba52b36c3ada52fb827cbb3c/rignore-0.7.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bec3994665a44454df86deb762061e05cd4b61e3772f5b07d1882a8a0d2748d5", size = 897611, upload-time = "2025-11-05T20:40:56.475Z" }, - { url = "https://files.pythonhosted.org/packages/1e/40/b2d1c169f833d69931bf232600eaa3c7998ba4f9a402e43a822dad2ea9f2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26cba2edfe3cff1dfa72bddf65d316ddebf182f011f2f61538705d6dbaf54986", size = 873875, upload-time = "2025-11-05T20:41:11.561Z" }, - { url = "https://files.pythonhosted.org/packages/55/59/ca5ae93d83a1a60e44b21d87deb48b177a8db1b85e82fc8a9abb24a8986d/rignore-0.7.6-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffa86694fec604c613696cb91e43892aa22e1fec5f9870e48f111c603e5ec4e9", size = 1167245, upload-time = "2025-11-05T20:41:28.29Z" }, - { url = "https://files.pythonhosted.org/packages/a5/52/cf3dce392ba2af806cba265aad6bcd9c48bb2a6cb5eee448d3319f6e505b/rignore-0.7.6-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48efe2ed95aa8104145004afb15cdfa02bea5cdde8b0344afeb0434f0d989aa2", size = 941750, upload-time = "2025-11-05T20:41:43.111Z" }, - { url = "https://files.pythonhosted.org/packages/ec/be/3f344c6218d779395e785091d05396dfd8b625f6aafbe502746fcd880af2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dcae43eb44b7f2457fef7cc87f103f9a0013017a6f4e62182c565e924948f21", size = 958896, upload-time = "2025-11-05T20:42:13.784Z" }, - { url = "https://files.pythonhosted.org/packages/c9/34/d3fa71938aed7d00dcad87f0f9bcb02ad66c85d6ffc83ba31078ce53646a/rignore-0.7.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cd649a7091c0dad2f11ef65630d30c698d505cbe8660dd395268e7c099cc99f", size = 983992, upload-time = "2025-11-05T20:41:58.022Z" }, - { url = "https://files.pythonhosted.org/packages/24/a4/52a697158e9920705bdbd0748d59fa63e0f3233fb92e9df9a71afbead6ca/rignore-0.7.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42de84b0289d478d30ceb7ae59023f7b0527786a9a5b490830e080f0e4ea5aeb", size = 1078181, upload-time = "2025-11-05T21:40:18.151Z" }, - { url = "https://files.pythonhosted.org/packages/ac/65/aa76dbcdabf3787a6f0fd61b5cc8ed1e88580590556d6c0207960d2384bb/rignore-0.7.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:875a617e57b53b4acbc5a91de418233849711c02e29cc1f4f9febb2f928af013", size = 1139232, upload-time = "2025-11-05T21:40:35.966Z" }, - { url = "https://files.pythonhosted.org/packages/08/44/31b31a49b3233c6842acc1c0731aa1e7fb322a7170612acf30327f700b44/rignore-0.7.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8703998902771e96e49968105207719f22926e4431b108450f3f430b4e268b7c", size = 1117349, upload-time = "2025-11-05T21:40:53.013Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ae/1b199a2302c19c658cf74e5ee1427605234e8c91787cfba0015f2ace145b/rignore-0.7.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:602ef33f3e1b04c1e9a10a3c03f8bc3cef2d2383dcc250d309be42b49923cabc", size = 1127702, upload-time = "2025-11-05T21:41:10.881Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d3/18210222b37e87e36357f7b300b7d98c6dd62b133771e71ae27acba83a4f/rignore-0.7.6-cp314-cp314t-win32.whl", hash = "sha256:c1d8f117f7da0a4a96a8daef3da75bc090e3792d30b8b12cfadc240c631353f9", size = 647033, upload-time = "2025-11-05T21:42:00.095Z" }, - { url = "https://files.pythonhosted.org/packages/3e/87/033eebfbee3ec7d92b3bb1717d8f68c88e6fc7de54537040f3b3a405726f/rignore-0.7.6-cp314-cp314t-win_amd64.whl", hash = "sha256:ca36e59408bec81de75d307c568c2d0d410fb880b1769be43611472c61e85c96", size = 725647, upload-time = "2025-11-05T21:41:44.449Z" }, - { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, -] - [[package]] name = "rotoger" version = "0.3.0" @@ -1202,50 +1105,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, - { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, - { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, - { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, - { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, - { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, - { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, - { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, - { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, - { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, - { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, - { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, - { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, - { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, - { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, - { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, -] - -[[package]] -name = "sentry-sdk" -version = "2.44.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/62/26/ff7d93a14a0ec309021dca2fb7c62669d4f6f5654aa1baf60797a16681e0/sentry_sdk-2.44.0.tar.gz", hash = "sha256:5b1fe54dfafa332e900b07dd8f4dfe35753b64e78e7d9b1655a28fd3065e2493", size = 371464, upload-time = "2025-11-11T09:35:56.075Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/56/c16bda4d53012c71fa1b588edde603c6b455bc8206bf6de7b83388fcce75/sentry_sdk-2.44.0-py2.py3-none-any.whl", hash = "sha256:9e36a0372b881e8f92fdbff4564764ce6cec4b7f25424d0a3a8d609c9e4651a7", size = 402352, upload-time = "2025-11-11T09:35:54.1Z" }, -] - -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +version = "0.15.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, + { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, + { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, + { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, + { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, ] [[package]] @@ -1268,29 +1148,29 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.50" +version = "2.0.51" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" }, - { url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" }, - { url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" }, - { url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" }, - { url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" }, - { url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" }, - { url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" }, - { url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" }, - { url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" }, - { url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" }, - { url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" }, - { url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" }, - { url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, ] [package.optional-dependencies] @@ -1373,21 +1253,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/b5/88caeba349f17b7fd37b61bc88f81c138b3b5149b4e2a9deec66eaad1bdb/tryceratops-2.4.1-py3-none-any.whl", hash = "sha256:271b92367be89b243918a56361618607d2a9aa4aa4ba766ecfabd5f98c00480c", size = 27378, upload-time = "2024-10-30T16:42:38.914Z" }, ] -[[package]] -name = "typer" -version = "0.20.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0" @@ -1430,31 +1295,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, ] -[[package]] -name = "urllib3" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, -] - [[package]] name = "uvicorn" -version = "0.38.0" +version = "0.51.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, ] [package.optional-dependencies] standard = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "httptools" }, { name = "python-dotenv" }, { name = "pyyaml" }, @@ -1528,11 +1383,45 @@ wheels = [ [[package]] name = "websockets" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +version = "16.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, + { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, + { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, + { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, + { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, + { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, + { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, + { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, + { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, + { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, ] [[package]] From a8e654f9d6599425ffc9bf7341b7d358e1054485 Mon Sep 17 00:00:00 2001 From: grillazz Date: Mon, 20 Jul 2026 10:19:56 +0200 Subject: [PATCH 08/11] feat: update chat backend to use stub and add websocket chat service tests --- .env | 2 +- app/main.py | 2 +- tests/api/test_chat_ws.py | 124 +++++++++++++++++++++++ tests/test_ollama_chat_agent_attrs.py | 136 ++++++++++++++++++++++++++ 4 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 tests/api/test_chat_ws.py create mode 100644 tests/test_ollama_chat_agent_attrs.py diff --git a/.env b/.env index e2d9cab..846e673 100644 --- a/.env +++ b/.env @@ -22,6 +22,6 @@ EMAIL_HOST= EMAIL_HOST_USER= EMAIL_HOST_PASSWORD= -CHAT_BACKEND=ollama +CHAT_BACKEND=stub diff --git a/app/main.py b/app/main.py index b4e365b..6ef3d91 100644 --- a/app/main.py +++ b/app/main.py @@ -52,7 +52,7 @@ async def lifespan(app: FastAPI): await app.logger.aerror("Error during app startup", error=repr(e)) raise finally: - await app.redis.close() + await app.redis.aclose() await app.chat_agent.aclose() # await app.postgres_pool.close() diff --git a/tests/api/test_chat_ws.py b/tests/api/test_chat_ws.py new file mode 100644 index 0000000..8867df6 --- /dev/null +++ b/tests/api/test_chat_ws.py @@ -0,0 +1,124 @@ +"""Tests for the websocket chat service using the local stub agent. + +Uses Starlette's synchronous ``TestClient`` websocket support (rather than +the ``httpx.AsyncClient`` fixture used elsewhere) since it triggers the +FastAPI lifespan on ``__enter__``/``__exit__``, wiring up +``app.chat_agent`` / ``app.state.chat_sessions`` exactly like a real +server run. +""" + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +pytestmark = pytest.mark.anyio + + +async def test_chat_ws_full_conversation_flow() -> None: + with ( + TestClient(app) as client, + client.websocket_connect("/v1/chat/ws") as websocket, + ): + # 1. connect + connected = websocket.receive_json() + assert connected["type"] == "connected" + session_id = connected["session_id"] + + # 2. start conversation with a system prompt + websocket.send_json( + {"type": "start", "system_prompt": "You are a helpful assistant."} + ) + started = websocket.receive_json() + print(f"{started=}") + assert started == {"type": "started", "session_id": session_id} + + # 3. send a user message and aggregate the streamed assistant chunks + websocket.send_json({"type": "user_message", "content": "hello"}) + + event = websocket.receive_json() + aggregated = "" + while event["type"] == "assistant_chunk": + assert event["index"] >= 0 + aggregated += event["content"] + event = websocket.receive_json() + + assert event["type"] == "assistant_message" + assert event["message"]["role"] == "assistant" + assert event["message"]["content"] == aggregated + print(f"{event=}") + print(f"{aggregated=}") + assert "hello" in aggregated.lower() + + # 4. request full history (system + user + assistant) + websocket.send_json({"type": "history_request"}) + history = websocket.receive_json() + assert history["type"] == "history" + roles = [m["role"] for m in history["messages"]] + assert roles == ["system", "user", "assistant"] + + # 5. gracefully end the conversation + websocket.send_json({"type": "end"}) + + +async def test_chat_ws_invalid_event_returns_error() -> None: + with ( + TestClient(app) as client, + client.websocket_connect("/v1/chat/ws") as websocket, + ): + websocket.receive_json() # connected + + websocket.send_json({"type": "not_a_real_event"}) + error = websocket.receive_json() + assert error["type"] == "error" + + websocket.send_json({"type": "end"}) + + +async def test_chat_ws_ask_question_and_print_model_answer() -> None: + """Ask a real question over the websocket chat and print the model's answer. + + Runs against whatever ``ChatAgent`` is wired up via ``app.state.chat_agent`` + (the local stub agent by default, or a real Ollama-backed agent when + ``CHAT_BACKEND=ollama`` is configured), exercising the full websocket + conversation flow end-to-end. + """ + question = "What is the capital of France?" + + with ( + TestClient(app) as client, + client.websocket_connect("/v1/chat/ws") as websocket, + ): + # 1. connect + connected = websocket.receive_json() + assert connected["type"] == "connected" + session_id = connected["session_id"] + + # 2. start conversation + websocket.send_json( + {"type": "start", "system_prompt": "You are a helpful assistant."} + ) + started = websocket.receive_json() + assert started == {"type": "started", "session_id": session_id} + + # 3. ask the question and aggregate the streamed answer + websocket.send_json({"type": "user_message", "content": question}) + + event = websocket.receive_json() + answer = "" + while event["type"] == "assistant_chunk": + answer += event["content"] + event = websocket.receive_json() + + assert event["type"] == "assistant_message" + answer = event["message"]["content"] + + print(f"\nQuestion: {question}") + print(f"Model answer: {answer}") + + assert isinstance(answer, str) + assert answer.strip() != "" + + # 4. gracefully end the conversation + websocket.send_json({"type": "end"}) + diff --git a/tests/test_ollama_chat_agent_attrs.py b/tests/test_ollama_chat_agent_attrs.py new file mode 100644 index 0000000..4fac694 --- /dev/null +++ b/tests/test_ollama_chat_agent_attrs.py @@ -0,0 +1,136 @@ +"""Test suite for refactored OllamaChatAgent with attrs features.""" +import httpx +import pytest + +from app.services.chat_agent import OllamaChatAgent, LocalEchoAgent, build_chat_agent +from app.config import ChatConfig + + +class TestOllamaChatAgentAttrsFeatures: + """Test the attrs library implementation and features.""" + + def test_instantiation_with_valid_config(self): + """Test basic instantiation with valid configuration.""" + agent = OllamaChatAgent(model="llama3.2", base_url="http://localhost:11434/v1") + assert agent.model == "llama3.2" + assert agent.base_url == "http://localhost:11434/v1" + assert agent.timeout == 60.0 + + def test_url_validation_rejects_non_http_schemes(self): + """Test that base_url validator rejects non-HTTP schemes.""" + with pytest.raises(ValueError, match="must be HTTP"): + OllamaChatAgent(model="llama3.2", base_url="ftp://invalid.com") + + def test_url_validation_rejects_missing_host(self): + """Test that base_url validator rejects URLs without a host.""" + with pytest.raises(ValueError, match="must include a host"): + OllamaChatAgent(model="llama3.2", base_url="http://") + + def test_timeout_validation_rejects_negative(self): + """Test that timeout validator rejects negative values.""" + with pytest.raises(ValueError, match="must be positive"): + OllamaChatAgent( + model="llama3.2", + base_url="http://localhost:11434/v1", + timeout=-5.0, + ) + + def test_timeout_validation_rejects_zero(self): + """Test that timeout validator rejects zero.""" + with pytest.raises(ValueError, match="must be positive"): + OllamaChatAgent( + model="llama3.2", + base_url="http://localhost:11434/v1", + timeout=0.0, + ) + + def test_timeout_converter_string_to_float(self): + """Test that timeout converter coerces strings to float.""" + agent = OllamaChatAgent( + model="llama3.2", + base_url="http://localhost:11434/v1", + timeout="30", # Pass as string + ) + assert isinstance(agent.timeout, float) + assert agent.timeout == 30.0 + + def test_factory_initialization_of_client(self): + """Test that _client is created via factory function.""" + agent = OllamaChatAgent(model="llama3.2", base_url="http://localhost:11434/v1") + assert hasattr(agent, "_client") + assert isinstance(agent._client, httpx.AsyncClient) + # httpx normalizes URLs by adding a trailing slash + assert str(agent._client.base_url) == "http://localhost:11434/v1/" + assert agent._client.timeout == httpx.Timeout(60.0) + + def test_slots_enabled_no_dict(self): + """Test that slots=True prevents __dict__ attribute.""" + agent = OllamaChatAgent(model="llama3.2", base_url="http://localhost:11434/v1") + # With slots=True, instances shouldn't have __dict__ + # (unless also inherited from a class with __dict__) + assert not hasattr(agent, "__dict__") + + def test_equality_disabled(self): + """Test that eq=False means instances are not equal even with same values.""" + agent1 = 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 + assert agent1 != agent2 + assert agent1 == agent1 + + def test_repr_hides_client(self): + """Test that repr=False on _client hides it from string representation.""" + agent = OllamaChatAgent(model="llama3.2", base_url="http://localhost:11434/v1") + agent_repr = repr(agent) + # _client should not appear in repr + assert "_client" not in agent_repr + # But model and base_url should + assert "llama3.2" in agent_repr + assert "localhost" in agent_repr + + def test_build_chat_agent_factory_still_works(self): + """Test that the build_chat_agent factory function works unchanged.""" + config = ChatConfig(backend="stub") + local_agent = build_chat_agent(config) + assert isinstance(local_agent, LocalEchoAgent) + + def test_build_chat_agent_ollama_backend(self): + """Test that build_chat_agent can create OllamaChatAgent.""" + config = ChatConfig( + backend="ollama", + base_url="http://localhost:11434/v1", + model="llama3.2", + ) + agent = build_chat_agent(config) + assert isinstance(agent, OllamaChatAgent) + assert agent.model == "llama3.2" + assert agent.base_url == "http://localhost:11434/v1" + + def test_https_urls_accepted(self): + """Test that HTTPS URLs are properly accepted.""" + agent = OllamaChatAgent( + model="llama3.2", base_url="https://api.example.com/v1" + ) + assert agent.base_url == "https://api.example.com/v1" + + def test_metadata_annotations_present(self): + """Test that metadata is properly attached to fields.""" + import attrs + + fields = attrs.fields(OllamaChatAgent) + model_field = fields.model + assert "description" in model_field.metadata + assert "LLM model identifier" in model_field.metadata["description"] + + base_url_field = fields.base_url + assert "description" in base_url_field.metadata + assert "OpenAI-compatible" in base_url_field.metadata["description"] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) + From cb4a63b6c0829aba7aaa253ab32ab39c700fcb3e Mon Sep 17 00:00:00 2001 From: grillazz Date: Mon, 20 Jul 2026 10:27:12 +0200 Subject: [PATCH 09/11] refactor: update parameter names in validation functions for clarity --- app/main.py | 4 +--- app/services/chat_agent.py | 4 ++-- tests/test_ollama_chat_agent_attrs.py | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/app/main.py b/app/main.py index 6ef3d91..e3ab516 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,6 @@ from contextlib import asynccontextmanager from pathlib import Path -import asyncpg from fastapi import Depends, FastAPI, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates @@ -23,7 +22,6 @@ from app.redis import get_redis from app.services.auth import AuthBearer from app.services.chat_agent import build_chat_agent -from app.services.chat_session import ChatSessionManager templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates") @@ -32,7 +30,7 @@ async def lifespan(app: FastAPI): app.logger = get_logger() app.redis = await get_redis() - postgres_dsn = global_settings.postgres_url.unicode_string() + global_settings.postgres_url.unicode_string() # Chat service: initialize the pluggable model-client adapter. The session # manager is now a singleton and will be auto-instantiated on first access. # See app/services/chat_agent.py to swap the local stub for a real model diff --git a/app/services/chat_agent.py b/app/services/chat_agent.py index 994af9d..7ef5725 100644 --- a/app/services/chat_agent.py +++ b/app/services/chat_agent.py @@ -72,7 +72,7 @@ async def aclose(self) -> None: return None -def _validate_base_url(instance: OllamaChatAgent, attribute: attrs.Attribute, value: str) -> None: +def _validate_base_url(_instance: OllamaChatAgent, _attribute: attrs.Attribute, value: str) -> None: """Validate that base_url is a valid HTTP(S) URL. Args: @@ -92,7 +92,7 @@ def _validate_base_url(instance: OllamaChatAgent, attribute: attrs.Attribute, va raise ValueError(msg) -def _validate_timeout(instance: OllamaChatAgent, attribute: attrs.Attribute, value: float) -> None: +def _validate_timeout(_instance: OllamaChatAgent, _attribute: attrs.Attribute, value: float) -> None: """Validate that timeout is positive. Args: diff --git a/tests/test_ollama_chat_agent_attrs.py b/tests/test_ollama_chat_agent_attrs.py index 4fac694..19250f2 100644 --- a/tests/test_ollama_chat_agent_attrs.py +++ b/tests/test_ollama_chat_agent_attrs.py @@ -2,8 +2,8 @@ import httpx import pytest -from app.services.chat_agent import OllamaChatAgent, LocalEchoAgent, build_chat_agent from app.config import ChatConfig +from app.services.chat_agent import LocalEchoAgent, OllamaChatAgent, build_chat_agent class TestOllamaChatAgentAttrsFeatures: From 284a828faef4250faf0269f1c19a37eb49807986 Mon Sep 17 00:00:00 2001 From: grillazz Date: Mon, 20 Jul 2026 10:38:28 +0200 Subject: [PATCH 10/11] feat: enhance CI workflow by installing project dependencies and freezing pytest --- .github/workflows/build-and-test.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 9c2609b..fdb3795 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -52,8 +52,13 @@ jobs: with: python-version: ${{ matrix.python-version }} + - name: Install project dependecies + run: uv sync --group dev --group test --locked --no-install-project + with: + python-version: ${{ matrix.python-version }} + - name: Lint with ruff run: uv run --frozen ruff check . - name: Test with python ${{ matrix.python-version }} - run: uv run pytest + run: uv run --frozen pytest From b9d1f1a0d587422fbd31b46e49bf06e39bfd685b Mon Sep 17 00:00:00 2001 From: grillazz Date: Mon, 20 Jul 2026 10:39:16 +0200 Subject: [PATCH 11/11] feat: enhance CI workflow by installing project dependencies and freezing pytest --- .github/workflows/build-and-test.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index fdb3795..68ca285 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -54,8 +54,6 @@ jobs: - name: Install project dependecies run: uv sync --group dev --group test --locked --no-install-project - with: - python-version: ${{ matrix.python-version }} - name: Lint with ruff run: uv run --frozen ruff check .