diff --git a/api/alembic/versions/f7b8c9d0e1f2_add_strategy_threshold_override_table.py b/api/alembic/versions/f7b8c9d0e1f2_add_strategy_threshold_override_table.py new file mode 100644 index 000000000..bd692d873 --- /dev/null +++ b/api/alembic/versions/f7b8c9d0e1f2_add_strategy_threshold_override_table.py @@ -0,0 +1,78 @@ +"""Add strategy_threshold_override table + +Revision ID: f7b8c9d0e1f2 +Revises: e4a3a439c266 +Create Date: 2026-04-25 00:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "f7b8c9d0e1f2" +down_revision: Union[str, Sequence[str], None] = "e4a3a439c266" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "strategy_threshold_override", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("strategy_name", sa.String(), nullable=False), + sa.Column("symbol", sa.String(), nullable=False), + sa.Column("buy_trigger_pct", sa.Float(), nullable=False), + sa.Column("sell_trigger_pct", sa.Float(), nullable=False), + sa.Column("profile", sa.String(), nullable=False), + sa.Column("source_provider", sa.String(), nullable=False), + sa.Column("market_context_timestamp", sa.Float(), nullable=False), + sa.Column("expires_at", sa.Float(), nullable=False), + sa.Column("confidence", sa.Float(), nullable=False), + sa.Column("reason", sa.String(), nullable=False), + sa.Column("raw_model_response", sa.JSON(), nullable=False), + sa.Column("created_at", sa.Float(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_strategy_threshold_override_id", + "strategy_threshold_override", + ["id"], + unique=True, + ) + op.create_index( + "ix_strategy_threshold_override_strategy_name", + "strategy_threshold_override", + ["strategy_name"], + unique=False, + ) + op.create_index( + "ix_strategy_threshold_override_symbol", + "strategy_threshold_override", + ["symbol"], + unique=False, + ) + op.create_index( + "ix_strategy_threshold_override_source_provider", + "strategy_threshold_override", + ["source_provider"], + unique=False, + ) + op.create_index( + "ix_strategy_threshold_override_expires_at", + "strategy_threshold_override", + ["expires_at"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_strategy_threshold_override_expires_at", table_name="strategy_threshold_override") + op.drop_index("ix_strategy_threshold_override_source_provider", table_name="strategy_threshold_override") + op.drop_index("ix_strategy_threshold_override_symbol", table_name="strategy_threshold_override") + op.drop_index("ix_strategy_threshold_override_strategy_name", table_name="strategy_threshold_override") + op.drop_index("ix_strategy_threshold_override_id", table_name="strategy_threshold_override") + op.drop_table("strategy_threshold_override") diff --git a/api/databases/crud/strategy_threshold_override_crud.py b/api/databases/crud/strategy_threshold_override_crud.py new file mode 100644 index 000000000..72f8554b5 --- /dev/null +++ b/api/databases/crud/strategy_threshold_override_crud.py @@ -0,0 +1,54 @@ +from sqlmodel import Session, select, desc +from databases.tables.strategy_threshold_override_table import ( + StrategyThresholdOverrideTable, +) + + +class StrategyThresholdOverrideCrud: + def __init__(self, session: Session): + self.session = session + + def create( + self, + row: StrategyThresholdOverrideTable, + ) -> StrategyThresholdOverrideTable: + self.session.add(row) + self.session.commit() + self.session.refresh(row) + return row + + def get_active( + self, + strategy_name: str, + symbol: str, + now_ts: float, + ) -> StrategyThresholdOverrideTable | None: + statement = ( + select(StrategyThresholdOverrideTable) + .where( + StrategyThresholdOverrideTable.strategy_name == strategy_name, + StrategyThresholdOverrideTable.symbol == symbol, + StrategyThresholdOverrideTable.expires_at > now_ts, + ) + .order_by(desc(StrategyThresholdOverrideTable.created_at)) + ) + return self.session.exec(statement).first() + + def list_for_symbol( + self, + strategy_name: str, + symbol: str, + limit: int = 20, + offset: int = 0, + ) -> list[StrategyThresholdOverrideTable]: + statement = ( + select(StrategyThresholdOverrideTable) + .where( + StrategyThresholdOverrideTable.strategy_name == strategy_name, + StrategyThresholdOverrideTable.symbol == symbol, + ) + .order_by(desc(StrategyThresholdOverrideTable.created_at)) + .limit(limit) + .offset(offset) + ) + return list(self.session.exec(statement).all()) diff --git a/api/databases/tables/__init__.py b/api/databases/tables/__init__.py index 5c3e52159..705791752 100644 --- a/api/databases/tables/__init__.py +++ b/api/databases/tables/__init__.py @@ -8,3 +8,4 @@ from .symbol_table import * # noqa from .asset_index_table import * # noqa from .symbol_exchange_table import * # noqa +from .strategy_threshold_override_table import * # noqa diff --git a/api/databases/tables/strategy_threshold_override_table.py b/api/databases/tables/strategy_threshold_override_table.py new file mode 100644 index 000000000..bfa7cd5c3 --- /dev/null +++ b/api/databases/tables/strategy_threshold_override_table.py @@ -0,0 +1,29 @@ +from uuid import UUID, uuid4 +from typing import Optional +from sqlalchemy import Column, JSON +from sqlmodel import SQLModel, Field +from pybinbot import timestamp + + +class StrategyThresholdOverrideTable(SQLModel, table=True): + __tablename__ = "strategy_threshold_override" + + id: Optional[UUID] = Field( + default_factory=uuid4, + primary_key=True, + nullable=False, + unique=True, + index=True, + ) + strategy_name: str = Field(index=True) + symbol: str = Field(index=True) + buy_trigger_pct: float = Field(ge=0) + sell_trigger_pct: float = Field(ge=0) + profile: str = Field(default="") + source_provider: str = Field(default="manual", index=True) + market_context_timestamp: float = Field(default_factory=timestamp) + expires_at: float = Field(index=True) + confidence: float = Field(default=0, ge=0, le=1) + reason: str = Field(default="") + raw_model_response: dict = Field(default_factory=dict, sa_column=Column(JSON)) + created_at: float = Field(default_factory=timestamp) diff --git a/api/main.py b/api/main.py index 34bd34d31..9c312cc5b 100644 --- a/api/main.py +++ b/api/main.py @@ -18,6 +18,7 @@ from asset_index.routes import asset_index_blueprint from inquiries.routes import inquiries_router from portfolio.routes import portfolio_blueprint +from strategy_thresholds.routes import strategy_threshold_blueprint from pybinbot import configure_logging from databases.tables import * # noqa @@ -67,6 +68,7 @@ async def root(): app.include_router(asset_index_blueprint, prefix="/asset-index") app.include_router(inquiries_router) app.include_router(portfolio_blueprint) +app.include_router(strategy_threshold_blueprint) @app.exception_handler(RequestValidationError) diff --git a/api/openai_api/threshold_advisor.py b/api/openai_api/threshold_advisor.py new file mode 100644 index 000000000..4c80d6cde --- /dev/null +++ b/api/openai_api/threshold_advisor.py @@ -0,0 +1,61 @@ +import json +from openai import OpenAI + +from tools.config import Config +from strategy_thresholds.models import MarketContextPayload, ThresholdRecommendation + + +class OpenAiThresholdError(Exception): + pass + + +class OpenAiThresholdAdvisor: + def __init__(self, model: str | None = None): + self.config = Config() + self.model = model or self.config.grid_threshold_llm_model + self.client = OpenAI(api_key=self.config.openai_api_key) + if not self.client.api_key: + raise OpenAiThresholdError("OPENAI_API_KEY is not configured") + + def _build_prompt( + self, + strategy: str, + symbol: str, + context: MarketContextPayload, + ) -> str: + return ( + "Return ONLY valid JSON for a threshold recommendation. " + "Use keys: strategy, symbol, profile, buy_trigger_pct, sell_trigger_pct, ttl_minutes, confidence, reason. " + "Apply conservative thresholds for range markets and reject unstable trend states.\n" + f"Strategy: {strategy}\n" + f"Symbol: {symbol}\n" + f"Market context: {context.model_dump_json()}" + ) + + def suggest( + self, + strategy: str, + symbol: str, + context: MarketContextPayload, + ) -> tuple[ThresholdRecommendation, dict]: + prompt = self._build_prompt(strategy=strategy, symbol=symbol, context=context) + response = self.client.responses.create( + model=self.model, + input=prompt, + text={"format": {"type": "json_object"}}, + ) + + output_text = response.output_text + if not output_text: + raise OpenAiThresholdError("OpenAI response did not include output_text") + + try: + payload = json.loads(output_text) + except json.JSONDecodeError as exc: + raise OpenAiThresholdError("OpenAI response was not valid JSON") from exc + + payload.setdefault("strategy", strategy) + payload.setdefault("symbol", symbol) + + recommendation = ThresholdRecommendation.model_validate(payload) + return recommendation, response.model_dump() diff --git a/api/pyproject.toml b/api/pyproject.toml index b95a918e6..4f0534a74 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -22,7 +22,8 @@ dependencies = [ "pydantic-settings>=2.10.1", "confluent-kafka>=2.11.1", "aiokafka>=0.12.0", - "pybinbot>=1.3.2", + "pybinbot>=1.3.6", + "openai>=2.6.1", ] [project.urls] diff --git a/api/strategy_thresholds/models.py b/api/strategy_thresholds/models.py new file mode 100644 index 000000000..fbb6ad3c3 --- /dev/null +++ b/api/strategy_thresholds/models.py @@ -0,0 +1,121 @@ +from pydantic import BaseModel, Field, model_validator +from typing import Any +from tools.handle_error import IResponseBase + + +MIN_TRIGGER = 0.008 +MAX_TRIGGER = 0.05 +MAX_STEP_DELTA = 0.005 +MIN_TTL_MINUTES = 30 +MAX_TTL_MINUTES = 240 + + +class ThresholdRecommendation(BaseModel): + strategy: str + symbol: str + buy_trigger_pct: float = Field(ge=MIN_TRIGGER, le=MAX_TRIGGER) + sell_trigger_pct: float = Field(ge=MIN_TRIGGER, le=MAX_TRIGGER) + ttl_minutes: int = Field(ge=MIN_TTL_MINUTES, le=MAX_TTL_MINUTES) + confidence: float = Field(ge=0, le=1) + reason: str = Field(min_length=3) + profile: str = Field(default="") + + +class MarketContextPayload(BaseModel): + market_regime: str + coin_regime: str + market_stress_score: float = Field(ge=0) + atr_pct: float | None = None + bb_width: float | None = None + extras: dict[str, Any] = Field(default_factory=dict) + + +class SuggestThresholdRequest(BaseModel): + strategy: str + symbol: str + market_context: MarketContextPayload + model: str | None = None + apply: bool = True + + +class ApplyThresholdRequest(BaseModel): + recommendation: ThresholdRecommendation + source_provider: str = "manual" + market_context_timestamp: float + raw_model_response: dict[str, Any] = Field(default_factory=dict) + + +class ThresholdOverrideModel(BaseModel): + id: str + strategy_name: str + symbol: str + buy_trigger_pct: float + sell_trigger_pct: float + profile: str + source_provider: str + market_context_timestamp: float + expires_at: float + confidence: float + reason: str + raw_model_response: dict[str, Any] + created_at: float + + +class ThresholdOverrideResponse(IResponseBase): + data: ThresholdOverrideModel | None = None + + +class ThresholdOverrideListResponse(IResponseBase): + data: list[ThresholdOverrideModel] = Field(default_factory=list) + + +class SuggestThresholdResponse(IResponseBase): + data: ThresholdRecommendation | None = None + + +class GuardrailInput(BaseModel): + recommendation: ThresholdRecommendation + current_buy_trigger_pct: float | None = None + current_sell_trigger_pct: float | None = None + market_stress_score: float + market_regime: str + coin_regime: str + + @model_validator(mode="after") + def validate_step_delta(self): + if self.current_buy_trigger_pct is not None: + if ( + abs( + self.recommendation.buy_trigger_pct + - self.current_buy_trigger_pct + ) + > MAX_STEP_DELTA + ): + raise ValueError("buy_trigger_pct exceeds max step delta") + + if self.current_sell_trigger_pct is not None: + if ( + abs( + self.recommendation.sell_trigger_pct + - self.current_sell_trigger_pct + ) + > MAX_STEP_DELTA + ): + raise ValueError("sell_trigger_pct exceeds max step delta") + + if self.market_stress_score >= 0.35: + raise ValueError("market stress too high") + + blocked_coin_regimes = { + "VOLATILE", + "TREND_UP", + "TREND_DOWN", + "BREAKOUT_UP", + "BREAKDOWN", + } + if self.market_regime.upper() not in {"RANGE", "TRANSITIONAL"}: + raise ValueError("market regime does not support threshold updates") + if self.coin_regime.upper() in blocked_coin_regimes: + raise ValueError("coin regime does not support threshold updates") + + return self diff --git a/api/strategy_thresholds/routes.py b/api/strategy_thresholds/routes.py new file mode 100644 index 000000000..bae4b8739 --- /dev/null +++ b/api/strategy_thresholds/routes.py @@ -0,0 +1,169 @@ +from fastapi import APIRouter, Depends +from sqlmodel import Session +from pybinbot import timestamp +from user.services.auth import get_current_user + +from databases.utils import get_session +from databases.crud.strategy_threshold_override_crud import ( + StrategyThresholdOverrideCrud, +) +from databases.tables.strategy_threshold_override_table import ( + StrategyThresholdOverrideTable, +) +from strategy_thresholds.models import ( + ApplyThresholdRequest, + GuardrailInput, + SuggestThresholdRequest, + SuggestThresholdResponse, + ThresholdOverrideListResponse, + ThresholdOverrideModel, + ThresholdOverrideResponse, + ThresholdRecommendation, +) +from openai_api.threshold_advisor import OpenAiThresholdAdvisor, OpenAiThresholdError + + +strategy_threshold_blueprint = APIRouter(prefix="/strategy-threshold-overrides") + + +def _to_model(row: StrategyThresholdOverrideTable) -> ThresholdOverrideModel: + return ThresholdOverrideModel( + id=str(row.id), + strategy_name=row.strategy_name, + symbol=row.symbol, + buy_trigger_pct=row.buy_trigger_pct, + sell_trigger_pct=row.sell_trigger_pct, + profile=row.profile, + source_provider=row.source_provider, + market_context_timestamp=row.market_context_timestamp, + expires_at=row.expires_at, + confidence=row.confidence, + reason=row.reason, + raw_model_response=row.raw_model_response, + created_at=row.created_at, + ) + + +@strategy_threshold_blueprint.get("", response_model=ThresholdOverrideListResponse, tags=["strategy threshold overrides"]) +def list_overrides( + strategy_name: str, + symbol: str, + limit: int = 20, + offset: int = 0, + session: Session = Depends(get_session), + _: dict = Depends(get_current_user), +): + rows = StrategyThresholdOverrideCrud(session).list_for_symbol( + strategy_name=strategy_name, + symbol=symbol, + limit=limit, + offset=offset, + ) + return ThresholdOverrideListResponse( + message="Successfully found threshold overrides.", + data=[_to_model(r) for r in rows], + ) + + +@strategy_threshold_blueprint.get("/active", response_model=ThresholdOverrideResponse, tags=["strategy threshold overrides"]) +def get_active_override( + strategy_name: str, + symbol: str, + session: Session = Depends(get_session), + _: dict = Depends(get_current_user), +): + row = StrategyThresholdOverrideCrud(session).get_active( + strategy_name=strategy_name, + symbol=symbol, + now_ts=timestamp(), + ) + return ThresholdOverrideResponse( + message="Successfully found active override.", + data=_to_model(row) if row else None, + ) + + +@strategy_threshold_blueprint.post("/apply", response_model=ThresholdOverrideResponse, tags=["strategy threshold overrides"]) +def apply_override( + payload: ApplyThresholdRequest, + session: Session = Depends(get_session), + _: dict = Depends(get_current_user), +): + expires_at = payload.market_context_timestamp + payload.recommendation.ttl_minutes * 60 + row = StrategyThresholdOverrideTable( + strategy_name=payload.recommendation.strategy, + symbol=payload.recommendation.symbol, + buy_trigger_pct=payload.recommendation.buy_trigger_pct, + sell_trigger_pct=payload.recommendation.sell_trigger_pct, + profile=payload.recommendation.profile, + source_provider=payload.source_provider, + market_context_timestamp=payload.market_context_timestamp, + expires_at=expires_at, + confidence=payload.recommendation.confidence, + reason=payload.recommendation.reason, + raw_model_response=payload.raw_model_response, + ) + created = StrategyThresholdOverrideCrud(session).create(row) + return ThresholdOverrideResponse( + message="Successfully applied threshold override.", + data=_to_model(created), + ) + + +@strategy_threshold_blueprint.post("/suggest", response_model=SuggestThresholdResponse, tags=["strategy threshold overrides"]) +def suggest_override( + payload: SuggestThresholdRequest, + session: Session = Depends(get_session), + _: dict = Depends(get_current_user), +): + client = OpenAiThresholdAdvisor(model=payload.model) + + try: + recommendation, raw_model_response = client.suggest( + strategy=payload.strategy, + symbol=payload.symbol, + context=payload.market_context, + ) + recommendation = ThresholdRecommendation.model_validate(recommendation) + + current = StrategyThresholdOverrideCrud(session).get_active( + strategy_name=payload.strategy, + symbol=payload.symbol, + now_ts=timestamp(), + ) + + GuardrailInput( + recommendation=recommendation, + current_buy_trigger_pct=current.buy_trigger_pct if current else None, + current_sell_trigger_pct=current.sell_trigger_pct if current else None, + market_stress_score=payload.market_context.market_stress_score, + market_regime=payload.market_context.market_regime, + coin_regime=payload.market_context.coin_regime, + ) + + if payload.apply: + row = StrategyThresholdOverrideTable( + strategy_name=recommendation.strategy, + symbol=recommendation.symbol, + buy_trigger_pct=recommendation.buy_trigger_pct, + sell_trigger_pct=recommendation.sell_trigger_pct, + profile=recommendation.profile, + source_provider="openai", + market_context_timestamp=timestamp(), + expires_at=timestamp() + recommendation.ttl_minutes * 60, + confidence=recommendation.confidence, + reason=recommendation.reason, + raw_model_response=raw_model_response, + ) + StrategyThresholdOverrideCrud(session).create(row) + + return SuggestThresholdResponse( + message="Successfully generated threshold recommendation.", + data=recommendation, + ) + except (OpenAiThresholdError, ValueError) as error: + return SuggestThresholdResponse( + message=f"Rejected threshold recommendation: {error}", + error=1, + data=None, + ) diff --git a/api/tests/test_strategy_threshold_overrides.py b/api/tests/test_strategy_threshold_overrides.py new file mode 100644 index 000000000..c35587e4e --- /dev/null +++ b/api/tests/test_strategy_threshold_overrides.py @@ -0,0 +1,119 @@ +from unittest.mock import patch + + +def test_apply_and_get_active_threshold_override(client): + apply_payload = { + "recommendation": { + "strategy": "coinrule_grid_trading", + "symbol": "XBTUSDTM", + "buy_trigger_pct": 0.016, + "sell_trigger_pct": 0.02, + "ttl_minutes": 60, + "confidence": 0.72, + "reason": "Range regime with compressed volatility.", + "profile": "tight_range", + }, + "source_provider": "manual", + "market_context_timestamp": 1700000000, + "raw_model_response": {"type": "manual"}, + } + + apply_response = client.post( + "/strategy-threshold-overrides/apply", json=apply_payload + ) + assert apply_response.status_code == 200 + assert apply_response.json()["error"] == 0 + + active_response = client.get( + "/strategy-threshold-overrides/active", + params={"strategy_name": "coinrule_grid_trading", "symbol": "XBTUSDTM"}, + ) + assert active_response.status_code == 200 + assert active_response.json()["error"] == 0 + assert active_response.json()["data"]["buy_trigger_pct"] == 0.016 + + +def test_suggest_override_applies_when_guardrails_pass(client): + suggestion_payload = { + "strategy": "coinrule_grid_trading", + "symbol": "XBTUSDTM", + "market_context": { + "market_regime": "RANGE", + "coin_regime": "RANGE", + "market_stress_score": 0.2, + "atr_pct": 0.01, + "bb_width": 0.02, + }, + "model": "gpt-4.1-mini", + "apply": True, + } + + with patch( + "strategy_thresholds.routes.OpenAiThresholdAdvisor.suggest", + return_value=( + { + "strategy": "coinrule_grid_trading", + "symbol": "XBTUSDTM", + "buy_trigger_pct": 0.018, + "sell_trigger_pct": 0.021, + "ttl_minutes": 60, + "confidence": 0.8, + "reason": "RANGE market and stable volatility.", + "profile": "balanced_range", + }, + {"provider": "openai"}, + ), + ): + response = client.post( + "/strategy-threshold-overrides/suggest", json=suggestion_payload + ) + + assert response.status_code == 200 + body = response.json() + assert body["error"] == 0 + assert body["data"]["profile"] == "balanced_range" + + list_response = client.get( + "/strategy-threshold-overrides", + params={"strategy_name": "coinrule_grid_trading", "symbol": "XBTUSDTM"}, + ) + assert list_response.status_code == 200 + assert len(list_response.json()["data"]) >= 1 + + +def test_suggest_override_rejects_high_stress(client): + suggestion_payload = { + "strategy": "coinrule_grid_trading", + "symbol": "XBTUSDTM", + "market_context": { + "market_regime": "RANGE", + "coin_regime": "RANGE", + "market_stress_score": 0.45, + }, + "apply": False, + } + + with patch( + "strategy_thresholds.routes.OpenAiThresholdAdvisor.suggest", + return_value=( + { + "strategy": "coinrule_grid_trading", + "symbol": "XBTUSDTM", + "buy_trigger_pct": 0.018, + "sell_trigger_pct": 0.021, + "ttl_minutes": 60, + "confidence": 0.8, + "reason": "RANGE market and stable volatility.", + "profile": "balanced_range", + }, + {"provider": "openai"}, + ), + ): + response = client.post( + "/strategy-threshold-overrides/suggest", json=suggestion_payload + ) + + assert response.status_code == 200 + body = response.json() + assert body["error"] == 1 + assert "market stress too high" in body["message"] diff --git a/api/tools/config.py b/api/tools/config.py index 41195c0ac..ca6177a6d 100644 --- a/api/tools/config.py +++ b/api/tools/config.py @@ -213,6 +213,15 @@ def kucoin_secret(self) -> str: def kucoin_passphrase(self) -> str: return self._get_required("KUCOIN_PASSPHRASE") + + @property + def grid_threshold_llm_model(self) -> str: + return self._get_optional("GRID_THRESHOLD_LLM_MODEL", "gpt-4.1-mini") + + @property + def openai_api_key(self) -> str: + return self._get_optional("OPENAI_API_KEY") + @property def service_password(self) -> str: return self._get_optional("SERVICE_PASSWORD") diff --git a/api/uv.lock b/api/uv.lock index b8ada372e..314d00fe3 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -347,7 +347,7 @@ requires-dist = [ { name = "psycopg2", specifier = ">=2.9.11" }, { name = "py3cw", specifier = ">=0.0.39" }, { name = "py4j", specifier = ">=0.10.9" }, - { name = "pybinbot", specifier = ">=1.3.2" }, + { name = "pybinbot", specifier = ">=1.3.6" }, { name = "pydantic-settings", specifier = ">=2.10.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.2.0" }, @@ -393,11 +393,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.4.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] [[package]] @@ -491,14 +491,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.2" +version = "8.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, ] [[package]] @@ -691,7 +691,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.136.0" +version = "0.136.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -700,9 +700,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4e/d9/e66315807e41e69e7f6a1b42a162dada2f249c5f06ad3f1a95f84ab336ef/fastapi-0.136.0.tar.gz", hash = "sha256:cf08e067cc66e106e102d9ba659463abfac245200752f8a5b7b1e813de4ff73e", size = 396607, upload-time = "2026-04-16T11:47:13.623Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/a3/0bd5f0cdb0bbc92650e8dc457e9250358411ee5d1b65e42b6632387daf81/fastapi-0.136.0-py3-none-any.whl", hash = "sha256:8793d44ec7378e2be07f8a013cf7f7aa47d6327d0dfe9804862688ec4541a6b4", size = 117556, upload-time = "2026-04-16T11:47:11.922Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, ] [package.optional-dependencies] @@ -1095,11 +1095,11 @@ wheels = [ [[package]] name = "idna" -version = "3.12" +version = "3.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/12/2948fbe5513d062169bd91f7d7b1cd97bc8894f32946b71fa39f6e63ca0c/idna-3.12.tar.gz", hash = "sha256:724e9952cc9e2bd7550ea784adb098d837ab5267ef67a1ab9cf7846bdbdd8254", size = 194350, upload-time = "2026-04-21T13:32:48.916Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/b2/acc33950394b3becb2b664741a0c0889c7ef9f9ffbfa8d47eddb53a50abd/idna-3.12-py3-none-any.whl", hash = "sha256:60ffaa1858fac94c9c124728c24fcde8160f3fb4a7f79aa8cdd33a9d1af60a67", size = 68634, upload-time = "2026-04-21T13:32:47.403Z" }, + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, ] [[package]] @@ -1700,11 +1700,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.1" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] @@ -1815,11 +1815,11 @@ wheels = [ [[package]] name = "pathspec" -version = "1.0.4" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/17/9c3094b822982b9f1ea666d8580ce59000f61f87c1663556fb72031ad9ec/pathspec-1.1.0.tar.gz", hash = "sha256:f5d7c555da02fd8dde3e4a2354b6aba817a89112fa8f333f7917a2a4834dd080", size = 133918, upload-time = "2026-04-23T01:46:22.298Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/8eed0486f074e9f1ca7f8ce5ad663e65f12fdab344028d658fa1b03d35e0/pathspec-1.1.0-py3-none-any.whl", hash = "sha256:574b128f7456bd899045ccd142dd446af7e6cfd0072d63ad73fbc55fbb4aaa42", size = 56264, upload-time = "2026-04-23T01:46:20.606Z" }, ] [[package]] @@ -1984,7 +1984,7 @@ wheels = [ [[package]] name = "pybinbot" -version = "1.3.2" +version = "1.3.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -1999,9 +1999,9 @@ dependencies = [ { name = "python-dotenv" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/bc/f82dca90baafa109127195d322733f74eb658964ffbda0f5dbda16a51304/pybinbot-1.3.2.tar.gz", hash = "sha256:7b096925b8ec973ce21a0153f4900aa2dd12be9fd007e165fd1cd97d02fb1f37", size = 57158, upload-time = "2026-04-21T19:48:16.694Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/99/9b4b26c7740fad88424d1c7c1ae0e58cb4ff16a2ad2401520f79f9c4f391/pybinbot-1.3.6.tar.gz", hash = "sha256:e9833b25bc8891aebd36b4b78a4c0b96e2ca8f69ae1fd58ca29ca58870aae053", size = 57151, upload-time = "2026-04-25T16:04:58.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/85/ab782acfa174c1cda7a252ab4bca9107e2f261ce8f52a401c832b2f7c224/pybinbot-1.3.2-py3-none-any.whl", hash = "sha256:3ac01e4913bcfe9f50321f4b1e6d65eddf6da6120357fa467b7cd3ec02afd32c", size = 59812, upload-time = "2026-04-21T19:48:15.3Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0f/2d19c476a9d70002ebdcd01b1f529f9befa435e7d74b986087211adfbbbb/pybinbot-1.3.6-py3-none-any.whl", hash = "sha256:80c63d641030eb3eca672a4d3e57c791fdcbf988bd79fa149597320861842fb0", size = 59790, upload-time = "2026-04-25T16:04:57.008Z" }, ] [[package]] @@ -2593,27 +2593,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, - { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, - { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, - { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, - { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, - { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, - { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, - { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, - { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, - { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, - { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, - { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, - { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, - { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] [[package]] @@ -2771,7 +2771,7 @@ wheels = [ [[package]] name = "typer" -version = "0.24.1" +version = "0.24.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -2779,9 +2779,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/b8/9ebb531b6c2d377af08ac6746a5df3425b21853a5d2260876919b58a2a4a/typer-0.24.2.tar.gz", hash = "sha256:ec070dcfca1408e85ee203c6365001e818c3b7fffe686fd07ff2d68095ca0480", size = 119849, upload-time = "2026-04-22T17:45:34.413Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/9484b497e0a0410b901c12b8251c3e746e1e863f7d28419ffe06f7892fda/typer-0.24.2-py3-none-any.whl", hash = "sha256:b618bc3d721f9a8d30f3e05565be26416d06e9bcc29d49bc491dc26aba674fa8", size = 55977, upload-time = "2026-04-22T17:45:33.055Z" }, ] [[package]] @@ -2841,11 +2841,11 @@ wheels = [ [[package]] name = "tzdata" -version = "2026.1" +version = "2026.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] [[package]] @@ -2862,14 +2862,14 @@ wheels = [ [[package]] name = "url-normalize" -version = "2.2.1" +version = "3.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/31/febb777441e5fcdaacb4522316bf2a527c44551430a4873b052d545e3279/url_normalize-2.2.1.tar.gz", hash = "sha256:74a540a3b6eba1d95bdc610c24f2c0141639f3ba903501e61a52a8730247ff37", size = 18846, upload-time = "2025-04-26T20:37:58.553Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/cd/846d87d6d49d963b04ef4429b73d71d3c17468059956bab360866a9b0aec/url_normalize-3.0.0.tar.gz", hash = "sha256:0552cbf2831a32a28994a13d29bca58a60e10ff6c0380e343ec6d1c2a0d232d8", size = 21777, upload-time = "2026-04-25T00:31:59.514Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/d9/5ec15501b675f7bc07c5d16aa70d8d778b12375686b6efd47656efdc67cd/url_normalize-2.2.1-py3-none-any.whl", hash = "sha256:3deb687587dc91f7b25c9ae5162ffc0f057ae85d22b1e15cf5698311247f567b", size = 14728, upload-time = "2025-04-26T20:37:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/13/8a/f72344eab18674fd7b174f35abbce41ed88fea72927f111726732d0ca779/url_normalize-3.0.0-py3-none-any.whl", hash = "sha256:95234bd359f86831c1fd87c248877f2a6887db2f3b5087120083f2fffcba4889", size = 16854, upload-time = "2026-04-25T00:31:58.271Z" }, ] [[package]] @@ -2883,15 +2883,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.45.0" +version = "0.46.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/62b0d9a2cfc8b4de6771322dae30f2db76c66dae9ec32e94e176a44ad563/uvicorn-0.45.0.tar.gz", hash = "sha256:3fe650df136c5bd2b9b06efc5980636344a2fbb840e9ddd86437d53144fa335d", size = 87818, upload-time = "2026-04-21T10:43:46.815Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/88/d0f7512465b166a4e931ccf7e77792be60fb88466a43964c7566cbaff752/uvicorn-0.45.0-py3-none-any.whl", hash = "sha256:2db26f588131aeec7439de00f2dd52d5f210710c1f01e407a52c90b880d1fd4f", size = 69838, upload-time = "2026-04-21T10:43:45.029Z" }, + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, ] [package.optional-dependencies]