diff --git a/.env b/.env index 842a23b..846e673 100644 --- a/.env +++ b/.env @@ -22,4 +22,6 @@ EMAIL_HOST= EMAIL_HOST_USER= EMAIL_HOST_PASSWORD= +CHAT_BACKEND=stub + diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 9c2609b..68ca285 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -52,8 +52,11 @@ jobs: with: python-version: ${{ matrix.python-version }} + - name: Install project dependecies + run: uv sync --group dev --group test --locked --no-install-project + - 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 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/Dockerfile b/Dockerfile index a4cb75f..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 @@ -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 diff --git a/app/api/chat.py b/app/api/chat.py new file mode 100644 index 0000000..a33247e --- /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 = ChatSessionManager() + 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) 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 5a3f50a..e3ab516 100644 --- a/app/main.py +++ b/app/main.py @@ -1,14 +1,15 @@ 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 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 @@ -20,6 +21,7 @@ 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 templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates") @@ -28,22 +30,28 @@ 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 + # client (OpenAI, Ollama, etc.). + app.chat_agent = build_chat_agent(global_settings.chat) 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.redis.aclose() + await app.chat_agent.aclose() # await app.postgres_pool.close() @@ -65,6 +73,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"] ) @@ -79,7 +88,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/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/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/chat_agent.py b/app/services/chat_agent.py new file mode 100644 index 0000000..7ef5725 --- /dev/null +++ b/app/services/chat_agent.py @@ -0,0 +1,217 @@ +"""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 +from urllib.parse import urlparse + +import attrs +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 + + +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. + + 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. + + Attrs Configuration: + - slots=True: Memory-efficient attribute storage (~40-50% reduction) + - eq=False, hash=False: Instances are not comparable (contain async resources) + """ + + model: str = attrs.field( + 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], + "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: + """Release the internal HTTP client resources.""" + 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..4de6ab6 --- /dev/null +++ b/app/services/chat_session.py @@ -0,0 +1,58 @@ +"""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 uuid import UUID, uuid4 + +import attrs + +from app.schemas.chat import ChatMessage +from app.utils.singleton import SingletonMetaNoArgs + + +@attrs.define(slots=True) +class ChatSession: + id: UUID = attrs.field(factory=uuid4) + messages: list[ChatMessage] = attrs.field(factory=list) + + def add(self, message: ChatMessage) -> None: + self.messages.append(message) + + def history(self) -> list[ChatMessage]: + return list(self.messages) + + +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] = {} + 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/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/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/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..9ccfcc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,47 +1,55 @@ [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.136.3", - "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>=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" + [tool.mypy] strict = true @@ -83,3 +91,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" diff --git a/scripts/chit_chat_with_llm.py b/scripts/chit_chat_with_llm.py new file mode 100644 index 0000000..e5fa9a0 --- /dev/null +++ b/scripts/chit_chat_with_llm.py @@ -0,0 +1,454 @@ +#!/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 logging +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Literal, Protocol, TypedDict, cast + +import websockets +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 + +DEFAULT_WS_URL = "ws://localhost:8080/v1/chat/ws" +DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant." + +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), + ) + ) + + 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, + ) + 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__": + main() + 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..19250f2 --- /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.config import ChatConfig +from app.services.chat_agent import LocalEchoAgent, OllamaChatAgent, build_chat_agent + + +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"]) + diff --git a/uv.lock b/uv.lock index 6e1a25d..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.136.3" +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/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/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/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, -] - -[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" }, @@ -405,6 +349,7 @@ dependencies = [ { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, { name = "uvloop" }, + { name = "websockets" }, ] [package.dev-dependencies] @@ -415,45 +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.136.3" }, + { 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 = ">=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" @@ -480,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]] @@ -605,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" }, @@ -614,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'" }, @@ -631,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]] @@ -652,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" @@ -824,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]] @@ -875,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" @@ -954,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]] @@ -1025,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'" }, @@ -1034,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]] @@ -1111,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]] @@ -1131,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" @@ -1200,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]] @@ -1266,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] @@ -1371,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" @@ -1428,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" }, @@ -1526,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]]