diff --git a/README.md b/README.md index beb4b229..8999f406 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,20 @@ alw --help - **Validators**: Monitor swaps, verify on-chain transactions, vote on outcomes - **Smart Contract**: Manages collateral, swap lifecycle, and validator voting - **CLI**: User interface for posting pairs, managing collateral, and executing swaps +- **swap-api**: Stateless HTTP wrapper around the CLI swap flow, for browser users + +## Running swap-api locally + +`allways.swap_api` is a thin FastAPI service that wraps the same Python modules the CLI uses (`contract_client`, `commitments`, `dendrite_lite`) and exposes them over HTTP for browser orchestration. It holds no keys, no funds, and no state — see [docs/swap-api/browser-swap-spec.md](../docs/swap-api/browser-swap-spec.md) for the contract. + +```bash +WS_ENDPOINT=ws://127.0.0.1:9944 \ +NETUID=2 \ +CONTRACT_ADDRESS=5DjJmTpcHZvF3aZZEafKBdo3ksmdUSZ8bBBUSFhW3Ce3xf1J \ +python -m allways.swap_api +# defaults: SWAP_API_HOST=0.0.0.0 SWAP_API_PORT=8000 +curl http://localhost:8000/healthz | jq +``` ## Validator Storage Layout diff --git a/allways/cli/dendrite_lite.py b/allways/cli/dendrite_lite.py index 075c8ebc..e6d00251 100644 --- a/allways/cli/dendrite_lite.py +++ b/allways/cli/dendrite_lite.py @@ -68,29 +68,39 @@ def discover_validators( return axons -def broadcast_synapse( +async def broadcast_synapse_async( wallet: bt.Wallet, axons: List[bt.AxonInfo], synapse: bt.Synapse, timeout: float = 30.0, ) -> list: - """Broadcast a synapse to all validator axons via dendrite. + """Async-native broadcast for callers already on an event loop (FastAPI). - Returns list of response synapses. + Uses Dendrite as an async context manager so the underlying aiohttp session + is closed on each call. Long-running services that skipped this would + accumulate sockets — Dendrite's `__del__` cleanup logs a warning and won't + close cleanly without a running loop. """ - import asyncio - - dendrite = bt.Dendrite(wallet=wallet) timeout = resolve_dendrite_timeout(timeout) + async with bt.Dendrite(wallet=wallet) as dendrite: + return await dendrite(axons=axons, synapse=synapse, deserialize=False, timeout=timeout) + + +def broadcast_synapse( + wallet: bt.Wallet, + axons: List[bt.AxonInfo], + synapse: bt.Synapse, + timeout: float = 30.0, +) -> list: + """Broadcast a synapse to all validator axons via dendrite (sync wrapper for CLI).""" + import asyncio loop = asyncio.new_event_loop() try: - responses = loop.run_until_complete(dendrite(axons=axons, synapse=synapse, deserialize=False, timeout=timeout)) + return loop.run_until_complete(broadcast_synapse_async(wallet, axons, synapse, timeout)) finally: loop.close() - return responses - def resolve_dendrite_timeout(default: float) -> float: """Honor ALW_DENDRITE_TIMEOUT as an override for slow chains (e.g. testnet).""" diff --git a/allways/swap_api/__init__.py b/allways/swap_api/__init__.py new file mode 100644 index 00000000..59e303dc --- /dev/null +++ b/allways/swap_api/__init__.py @@ -0,0 +1 @@ +"""HTTP wrapper around the CLI swap flow — see docs/swap-api/browser-swap-spec.md.""" diff --git a/allways/swap_api/__main__.py b/allways/swap_api/__main__.py new file mode 100644 index 00000000..81ccacf8 --- /dev/null +++ b/allways/swap_api/__main__.py @@ -0,0 +1,15 @@ +"""``python -m allways.swap_api`` — uvicorn entrypoint for local dev and prod.""" + +import os + +import uvicorn + + +def main() -> None: + port = int(os.environ.get('SWAP_API_PORT', '8000')) + host = os.environ.get('SWAP_API_HOST', '0.0.0.0') + uvicorn.run('allways.swap_api.app:app', host=host, port=port, log_level='info') + + +if __name__ == '__main__': + main() diff --git a/allways/swap_api/app.py b/allways/swap_api/app.py new file mode 100644 index 00000000..40dec8e4 --- /dev/null +++ b/allways/swap_api/app.py @@ -0,0 +1,37 @@ +"""FastAPI app factory for swap-api. Wires routers, CORS, and lifespan state.""" + +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from allways.swap_api.deps import build_app_state +from allways.swap_api.routes import chains, health, miners, proofs, swap + + +def create_app() -> FastAPI: + @asynccontextmanager + async def lifespan(app: FastAPI): + app.state.allways = build_app_state() + yield + + app = FastAPI( + title='Allways Swap API', + description='HTTP wrapper around the swap CLI — see docs/swap-api/browser-swap-spec.md', + lifespan=lifespan, + ) + app.add_middleware( + CORSMiddleware, + allow_origins=['*'], + allow_methods=['GET', 'POST', 'OPTIONS'], + allow_headers=['*'], + ) + app.include_router(health.router) + app.include_router(chains.router) + app.include_router(miners.router) + app.include_router(proofs.router) + app.include_router(swap.router) + return app + + +app = create_app() diff --git a/allways/swap_api/deps.py b/allways/swap_api/deps.py new file mode 100644 index 00000000..997c4a8b --- /dev/null +++ b/allways/swap_api/deps.py @@ -0,0 +1,83 @@ +"""Cached dependencies for swap-api routes. + +Reads config from env once at startup and holds onto the subtensor, contract +client, and ephemeral wallet for the process lifetime. Routes pull these +through ``get_state()`` rather than rebuilding per-request — creating a fresh +wallet or subtensor on every call thrashes both the keystore and the WS pool. +""" + +import os +from dataclasses import dataclass +from typing import Optional + +import bittensor as bt +from fastapi import Request + +from allways.cli.dendrite_lite import get_ephemeral_wallet +from allways.constants import CONTRACT_ADDRESS as DEFAULT_CONTRACT_ADDRESS +from allways.constants import NETUID_FINNEY +from allways.contract_client import AllwaysContractClient + + +@dataclass +class AppState: + subtensor: bt.Subtensor + contract_client: AllwaysContractClient + ephemeral_wallet: bt.Wallet + netuid: int + contract_address: str + quorum_timeout_s: float + quorum_poll_interval_s: float + + +def _env_int(name: str, default: int) -> int: + raw = os.environ.get(name) + if raw is None: + return default + try: + return int(raw) + except ValueError: + return default + + +def _env_float(name: str, default: float) -> float: + raw = os.environ.get(name) + if raw is None: + return default + try: + return float(raw) + except ValueError: + return default + + +def build_app_state(subtensor: Optional[bt.Subtensor] = None) -> AppState: + """Construct the singleton state pinned to the FastAPI app. + + Subtensor connection target is the standard bittensor ``network`` arg + (e.g. ``finney``, ``test``, or a ws URL); read from ``WS_ENDPOINT`` to + match the spec's env contract. + """ + network = os.environ.get('WS_ENDPOINT', 'finney') + netuid = _env_int('NETUID', NETUID_FINNEY) + contract_address = os.environ.get('CONTRACT_ADDRESS', DEFAULT_CONTRACT_ADDRESS) + quorum_timeout_s = _env_float('SWAP_API_QUORUM_TIMEOUT_S', 60.0) + quorum_poll_interval_s = _env_float('SWAP_API_QUORUM_POLL_S', 2.0) + + if subtensor is None: + subtensor = bt.Subtensor(network=network) + contract_client = AllwaysContractClient(contract_address=contract_address, subtensor=subtensor) + ephemeral_wallet = get_ephemeral_wallet() + + return AppState( + subtensor=subtensor, + contract_client=contract_client, + ephemeral_wallet=ephemeral_wallet, + netuid=netuid, + contract_address=contract_address, + quorum_timeout_s=quorum_timeout_s, + quorum_poll_interval_s=quorum_poll_interval_s, + ) + + +def get_state(request: Request) -> AppState: + return request.app.state.allways diff --git a/allways/swap_api/models.py b/allways/swap_api/models.py new file mode 100644 index 00000000..f71911c8 --- /dev/null +++ b/allways/swap_api/models.py @@ -0,0 +1,90 @@ +"""Pydantic request/response shapes for swap-api endpoints (spec §6).""" + +from typing import List, Optional, Tuple + +from pydantic import BaseModel, Field + + +class HealthResponse(BaseModel): + ok: bool + chainBlock: Optional[int] = Field(None, description='Current subtensor block, when reachable') + contractAddress: str + + +class ChainInfo(BaseModel): + id: str + name: str + decimals: int + native_unit: str + + +class ChainsResponse(BaseModel): + chains: List[ChainInfo] + pairs: List[Tuple[str, str]] = Field(..., description='Every supported (from, to) ordering') + + +class MinerSummary(BaseModel): + hotkey: str + rate: str = Field(..., description='Canonical rate string for the requested direction') + collateralRao: int + isActive: bool + hasActiveSwap: bool + + +class BestMinerResponse(BaseModel): + minerHotkey: str + rate: str + expectedOut: int = Field(..., description='Gross dest amount before fee, in smallest unit') + reservationCapacity: int = Field(..., description="Miner's collateral in rao — caps swap size") + sourceAddress: str = Field(..., description='Address users send source funds to') + freshAsOf: int = Field(..., description='Subtensor block when this quote was read') + + +class ProofMessage(BaseModel): + message: str + + +class ReserveRequest(BaseModel): + minerHotkey: str + fromChain: str + toChain: str + taoAmount: int + fromAmount: int + toAmount: int + fromAddress: str + fromAddressProof: str + blockAnchor: int + expectedRate: str + + +class ReserveResponse(BaseModel): + requestHash: str + reservedUntilBlock: int + minerSourceAddress: str + minerHotkey: str + + +class ConfirmRequest(BaseModel): + requestHash: str = Field( + ..., description='Correlation key from /reserve — informational; validators verify on-chain' + ) + minerHotkey: str = Field(..., description='Miner that holds the reservation (echoed from /reserve response)') + fromTxHash: str + fromTxProof: str + fromAddress: str + toAddress: str + fromChain: str + toChain: str + fromTxBlock: int = 0 + + +class ConfirmResponse(BaseModel): + accepted: bool + swapId: Optional[int] = None + rejection: Optional[str] = None + + +class RateChangedError(BaseModel): + code: str = 'RateChanged' + expected: str + actual: str diff --git a/allways/swap_api/routes/__init__.py b/allways/swap_api/routes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/allways/swap_api/routes/chains.py b/allways/swap_api/routes/chains.py new file mode 100644 index 00000000..d402f780 --- /dev/null +++ b/allways/swap_api/routes/chains.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter + +from allways.chains import SUPPORTED_CHAINS +from allways.swap_api.models import ChainInfo, ChainsResponse + +router = APIRouter() + + +@router.get('/chains', response_model=ChainsResponse) +async def list_chains() -> ChainsResponse: + chain_ids = list(SUPPORTED_CHAINS.keys()) + return ChainsResponse( + chains=[ + ChainInfo(id=c.id, name=c.name, decimals=c.decimals, native_unit=c.native_unit) + for c in SUPPORTED_CHAINS.values() + ], + pairs=[(src, dst) for src in chain_ids for dst in chain_ids if src != dst], + ) diff --git a/allways/swap_api/routes/health.py b/allways/swap_api/routes/health.py new file mode 100644 index 00000000..942e6aa3 --- /dev/null +++ b/allways/swap_api/routes/health.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter, Depends +from starlette.concurrency import run_in_threadpool + +from allways.swap_api.deps import AppState, get_state +from allways.swap_api.models import HealthResponse + +router = APIRouter() + + +@router.get('/healthz', response_model=HealthResponse) +async def healthz(state: AppState = Depends(get_state)) -> HealthResponse: + try: + block = await run_in_threadpool(state.subtensor.get_current_block) + except Exception: + block = None + return HealthResponse(ok=True, chainBlock=block, contractAddress=state.contract_address) diff --git a/allways/swap_api/routes/miners.py b/allways/swap_api/routes/miners.py new file mode 100644 index 00000000..a25f5947 --- /dev/null +++ b/allways/swap_api/routes/miners.py @@ -0,0 +1,98 @@ +"""Live miner reads for the swap form. Always hits chain, never DB.""" + +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, Query +from starlette.concurrency import run_in_threadpool + +from allways.chains import canonical_pair, get_chain +from allways.cli.swap_commands.helpers import find_matching_miners +from allways.commitments import read_miner_commitments +from allways.contract_client import ContractError +from allways.swap_api.deps import AppState, get_state +from allways.swap_api.models import BestMinerResponse, MinerSummary +from allways.utils.rate import calculate_to_amount +from allways.utils.rate_selection import filter_eligible, rank_pairs_by_rate + +router = APIRouter() + + +@router.get('/miners', response_model=List[MinerSummary]) +async def list_miners( + from_: str = Query(..., alias='from'), + to: str = Query(...), + state: AppState = Depends(get_state), +) -> List[MinerSummary]: + """Live miner list for a direction. Excludes commitments that don't quote this pair.""" + from_ = from_.lower() + to = to.lower() + pairs = await run_in_threadpool(read_miner_commitments, state.subtensor, state.netuid) + matching = find_matching_miners(pairs, from_, to) + + summaries: List[MinerSummary] = [] + for p in matching: + try: + is_active = await run_in_threadpool(state.contract_client.get_miner_active_flag, p.hotkey) + has_swap = await run_in_threadpool(state.contract_client.get_miner_has_active_swap, p.hotkey) + collateral = await run_in_threadpool(state.contract_client.get_miner_collateral, p.hotkey) + except ContractError: + continue + summaries.append( + MinerSummary( + hotkey=p.hotkey, + rate=p.rate_str, + collateralRao=collateral, + isActive=is_active, + hasActiveSwap=has_swap, + ) + ) + return summaries + + +@router.get('/miners/best', response_model=BestMinerResponse) +async def best_miner( + from_: str = Query(..., alias='from'), + to: str = Query(...), + amount: int = Query(..., gt=0), + state: AppState = Depends(get_state), +) -> BestMinerResponse: + """Cheapest quote for ``from_chain → to_chain`` at ``amount`` (smallest unit).""" + from_ = from_.lower() + to = to.lower() + if from_ == to: + raise HTTPException(status_code=400, detail='from and to chains must differ') + + pairs = await run_in_threadpool(read_miner_commitments, state.subtensor, state.netuid) + matching = find_matching_miners(pairs, from_, to) + if not matching: + raise HTTPException(status_code=404, detail=f'no miners quote {from_} → {to}') + + ranked = rank_pairs_by_rate(matching, from_, to) + eligible = await run_in_threadpool(filter_eligible, state.contract_client, ranked) + if not eligible: + raise HTTPException(status_code=404, detail='no eligible miner — all busy or uncollateralized') + + best = eligible[0] + canon_from, canon_to = canonical_pair(from_, to) + is_reverse = from_ != canon_from + expected_out = calculate_to_amount( + amount, + best.pair.rate_str, + is_reverse, + get_chain(canon_to).decimals, + get_chain(canon_from).decimals, + ) + + try: + fresh_block = await run_in_threadpool(state.subtensor.get_current_block) + except Exception: + fresh_block = 0 + + return BestMinerResponse( + minerHotkey=best.pair.hotkey, + rate=best.pair.rate_str, + expectedOut=expected_out, + reservationCapacity=best.collateral_rao, + sourceAddress=best.pair.from_address, + freshAsOf=fresh_block, + ) diff --git a/allways/swap_api/routes/proofs.py b/allways/swap_api/routes/proofs.py new file mode 100644 index 00000000..782687ec --- /dev/null +++ b/allways/swap_api/routes/proofs.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter + +from allways.swap_api.models import ProofMessage +from allways.utils.proofs import reserve_proof_message, swap_proof_message + +router = APIRouter() + + +@router.get('/proofs/reserve', response_model=ProofMessage) +async def reserve_proof(address: str, block: int) -> ProofMessage: + return ProofMessage(message=reserve_proof_message(address, block)) + + +@router.get('/proofs/confirm', response_model=ProofMessage) +async def confirm_proof(txHash: str) -> ProofMessage: + return ProofMessage(message=swap_proof_message(txHash)) diff --git a/allways/swap_api/routes/swap.py b/allways/swap_api/routes/swap.py new file mode 100644 index 00000000..3f04b023 --- /dev/null +++ b/allways/swap_api/routes/swap.py @@ -0,0 +1,184 @@ +"""POST /reserve and POST /confirm — the only mutating endpoints.""" + +import asyncio +import io +import time +from dataclasses import dataclass +from typing import Awaitable, Callable, List, Optional, TypeVar + +import bittensor as bt +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import JSONResponse +from rich.console import Console +from starlette.concurrency import run_in_threadpool + +from allways.cli.dendrite_lite import broadcast_synapse_async, discover_validators +from allways.cli.validator_rejections import RejectionInfo, render_and_aggregate +from allways.commitments import get_commitment, parse_commitment_data +from allways.contract_client import ContractError +from allways.swap_api.deps import AppState, get_state +from allways.swap_api.models import ( + ConfirmRequest, + ConfirmResponse, + RateChangedError, + ReserveRequest, + ReserveResponse, +) +from allways.synapses import SwapConfirmSynapse, SwapReserveSynapse + +router = APIRouter() + +T = TypeVar('T') + + +@dataclass +class _LiveQuote: + rate_str: str + miner_from_address: str + + +def _aggregate(responses: list) -> RejectionInfo: + """Compute the same RejectionInfo the CLI sees, but with no console output.""" + return render_and_aggregate(Console(file=io.StringIO(), record=False, no_color=True), responses, label='V') + + +async def _read_live_quote( + state: AppState, + miner_hotkey: str, + from_chain: str, + to_chain: str, +) -> Optional[_LiveQuote]: + """Re-read this miner's commitment once and project it onto the requested direction.""" + raw = await run_in_threadpool(get_commitment, state.subtensor, state.netuid, miner_hotkey) + if not raw: + return None + pair = parse_commitment_data(raw, uid=0, hotkey=miner_hotkey) + if pair is None: + return None + from_chain = from_chain.lower() + to_chain = to_chain.lower() + if {pair.from_chain, pair.to_chain} != {from_chain, to_chain}: + return None + _, rate_str = pair.get_rate_for_direction(from_chain) + miner_from = pair.from_address if pair.from_chain == from_chain else pair.to_address + return _LiveQuote(rate_str=rate_str, miner_from_address=miner_from) + + +async def _await_quorum( + state: AppState, + probe: Callable[[], Awaitable[T]], + is_done: Callable[[T], bool], +) -> Optional[T]: + """Generic poll-with-deadline. Returns the first probe result that satisfies is_done, or None.""" + deadline = time.monotonic() + state.quorum_timeout_s + while time.monotonic() < deadline: + try: + value = await probe() + if is_done(value): + return value + except ContractError: + pass + await asyncio.sleep(state.quorum_poll_interval_s) + return None + + +def _discover(state: AppState) -> List[bt.AxonInfo]: + return discover_validators(state.subtensor, state.netuid, contract_client=state.contract_client) + + +@router.post('/reserve') +async def reserve(req: ReserveRequest, state: AppState = Depends(get_state)): + quote = await _read_live_quote(state, req.minerHotkey, req.fromChain, req.toChain) + if quote is None: + raise HTTPException(status_code=404, detail='miner does not quote this pair') + if quote.rate_str != req.expectedRate: + # Zero-tolerance — see spec §4 rate-drift policy. + return JSONResponse( + status_code=409, + content=RateChangedError(expected=req.expectedRate, actual=quote.rate_str).model_dump(), + ) + + axons = await run_in_threadpool(_discover, state) + if not axons: + raise HTTPException(status_code=503, detail='no validators reachable on metagraph') + + synapse = SwapReserveSynapse( + miner_hotkey=req.minerHotkey, + tao_amount=req.taoAmount, + from_amount=req.fromAmount, + to_amount=req.toAmount, + from_address=req.fromAddress, + from_address_proof=req.fromAddressProof, + block_anchor=req.blockAnchor, + from_chain=req.fromChain, + to_chain=req.toChain, + ) + + responses = await broadcast_synapse_async(state.ephemeral_wallet, axons, synapse, timeout=state.quorum_timeout_s) + info = _aggregate(responses) + if info.accepted == 0: + raise HTTPException(status_code=502, detail=info.headline or 'validators rejected reservation') + + async def probe_reserved_until() -> int: + return await run_in_threadpool(state.contract_client.get_miner_reserved_until, req.minerHotkey) + + reserved_until = await _await_quorum( + state, + probe_reserved_until, + is_done=lambda block: block > req.blockAnchor, + ) + if reserved_until is None: + raise HTTPException(status_code=504, detail='quorum did not land on-chain within timeout') + + try: + reservation = await run_in_threadpool(state.contract_client.get_reservation, req.minerHotkey) + except ContractError as e: + raise HTTPException(status_code=502, detail=f'reservation read failed: {e}') from e + if reservation is None: + # Reserved_until advanced but the row vanished — almost certainly someone + # else's reservation already replaced ours. Fail loudly so the UI re-quotes. + raise HTTPException(status_code=504, detail='reservation lost mid-flow — re-quote and retry') + + return ReserveResponse( + requestHash=reservation.hash, + reservedUntilBlock=reserved_until, + minerSourceAddress=quote.miner_from_address, + minerHotkey=req.minerHotkey, + ) + + +@router.post('/confirm', response_model=ConfirmResponse) +async def confirm(req: ConfirmRequest, state: AppState = Depends(get_state)) -> ConfirmResponse: + axons = await run_in_threadpool(_discover, state) + if not axons: + raise HTTPException(status_code=503, detail='no validators reachable on metagraph') + + miner_hotkey = req.minerHotkey + + synapse = SwapConfirmSynapse( + reservation_id=miner_hotkey, + from_tx_hash=req.fromTxHash, + from_tx_proof=req.fromTxProof, + from_address=req.fromAddress, + from_tx_block=req.fromTxBlock, + to_address=req.toAddress, + from_chain=req.fromChain, + to_chain=req.toChain, + ) + + responses = await broadcast_synapse_async(state.ephemeral_wallet, axons, synapse, timeout=state.quorum_timeout_s) + info = _aggregate(responses) + if info.accepted == 0: + return ConfirmResponse(accepted=False, rejection=info.headline or 'validators rejected confirmation') + + async def probe_swap_id() -> Optional[int]: + if not await run_in_threadpool(state.contract_client.get_miner_has_active_swap, miner_hotkey): + return None + active = await run_in_threadpool(state.contract_client.get_miner_active_swaps, miner_hotkey) + # Contract guarantees at most one active swap per miner. + return active[0].id if active else None + + swap_id = await _await_quorum(state, probe_swap_id, is_done=lambda sid: sid is not None) + if swap_id is None: + return ConfirmResponse(accepted=True, rejection='swap not yet on-chain — validators still confirming') + return ConfirmResponse(accepted=True, swapId=swap_id) diff --git a/allways/utils/rate_selection.py b/allways/utils/rate_selection.py new file mode 100644 index 00000000..12217457 --- /dev/null +++ b/allways/utils/rate_selection.py @@ -0,0 +1,53 @@ +"""Pure best-rate miner selection — used by both CLI and swap-api. + +Filters miners that quote the requested direction (bilateral matching via +``find_matching_miners``), drops anyone ineligible (inactive, already busy, +no collateral), and ranks by direction-aware rate. The CLI and HTTP paths +must agree on the winner; this is the one place that decision lives. +""" + +from dataclasses import dataclass +from typing import List + +from allways.chains import canonical_pair +from allways.classes import MinerPair +from allways.contract_client import AllwaysContractClient, ContractError + + +@dataclass +class EligibleMiner: + pair: MinerPair + collateral_rao: int + + +def rank_pairs_by_rate(pairs: List[MinerPair], from_chain: str, to_chain: str) -> List[MinerPair]: + """Best-rate-first ordering for the requested swap direction. + + Rates are stored canonically (dest-per-source in alphabetical order). When + the requested direction is the reverse, higher rate is worse for the user, + so the sort reverses. Mirrors the CLI's inline sort in ``swap_now``. + """ + canon_from, _ = canonical_pair(from_chain, to_chain) + canon_is_reverse = from_chain != canon_from + return sorted(pairs, key=lambda p: p.rate, reverse=not canon_is_reverse) + + +def filter_eligible( + client: AllwaysContractClient, + pairs: List[MinerPair], +) -> List[EligibleMiner]: + """Drop miners that the contract would reject — active flag off, busy, or no collateral.""" + eligible: List[EligibleMiner] = [] + for pair in pairs: + try: + if not client.get_miner_active_flag(pair.hotkey): + continue + if client.get_miner_has_active_swap(pair.hotkey): + continue + collateral = client.get_miner_collateral(pair.hotkey) + except ContractError: + continue + if collateral <= 0: + continue + eligible.append(EligibleMiner(pair=pair, collateral_rao=collateral)) + return eligible diff --git a/pyproject.toml b/pyproject.toml index 2d76a99f..78fbe9cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,9 @@ dependencies = [ "bech32", "pycryptodome", "numpy", + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "pydantic>=2", ] [project.scripts] @@ -43,6 +46,7 @@ alw = "allways.cli.main:main" [dependency-groups] dev = [ + "httpx>=0.28.1", "pre-commit>=4.2.0", "pytest>=9.0.0", "ruff>=0.14.10", diff --git a/tests/swap_api/__init__.py b/tests/swap_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/swap_api/conftest.py b/tests/swap_api/conftest.py new file mode 100644 index 00000000..2f3b6a4f --- /dev/null +++ b/tests/swap_api/conftest.py @@ -0,0 +1,210 @@ +"""Test scaffolding for swap-api routes. + +Builds a fully-mocked AppState so route handlers can run end-to-end without a +live subtensor, contract, or wallet. Each test mutates the FakeContractClient +to script the responses it wants. Real ``build_app_state`` is not invoked. +""" + +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional + +import pytest +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.testclient import TestClient + +from allways.classes import MinerPair, Reservation, Swap, SwapStatus +from allways.swap_api.deps import AppState +from allways.swap_api.routes import chains, health, miners, proofs, swap + + +@dataclass +class FakeContractClient: + miners: Dict[str, Dict[str, Any]] = field(default_factory=dict) + reservations: Dict[str, Reservation] = field(default_factory=dict) + miner_swaps: Dict[str, List[Swap]] = field(default_factory=dict) + + def add_miner( + self, + hotkey: str, + *, + active: bool = True, + has_swap: bool = False, + collateral: int = 1_000_000_000_000, + reserved_until: int = 0, + ) -> None: + self.miners[hotkey] = { + 'active': active, + 'has_swap': has_swap, + 'collateral': collateral, + 'reserved_until': reserved_until, + } + + def get_miner_active_flag(self, hotkey: str) -> bool: + return self.miners.get(hotkey, {}).get('active', False) + + def get_miner_has_active_swap(self, hotkey: str) -> bool: + return self.miners.get(hotkey, {}).get('has_swap', False) + + def get_miner_collateral(self, hotkey: str) -> int: + return self.miners.get(hotkey, {}).get('collateral', 0) + + def get_miner_reserved_until(self, hotkey: str) -> int: + return self.miners.get(hotkey, {}).get('reserved_until', 0) + + def get_pending_reserve_vote_count(self, hotkey: str) -> int: + return 0 + + def get_reservation(self, hotkey: str) -> Optional[Reservation]: + return self.reservations.get(hotkey) + + def get_miner_active_swaps(self, hotkey: str) -> List[Swap]: + return self.miner_swaps.get(hotkey, []) + + def is_validator(self, hotkey: str) -> bool: + return True + + +@dataclass +class FakeSubtensor: + block: int = 100 + commitments: Dict[str, str] = field(default_factory=dict) + + def get_current_block(self) -> int: + return self.block + + # discover_validators calls subtensor.metagraph(netuid). + def metagraph(self, netuid: int): + return _empty_metagraph() + + +class _EmptyAxon: + is_serving = False + + +def _empty_metagraph(): + class _MG: + n = 0 + validator_permit: List[bool] = [] + axons: List[Any] = [] + hotkeys: List[str] = [] + + return _MG() + + +@dataclass +class FakeWallet: + name: str = 'ephemeral' + hotkey_str: str = 'default' + + +def make_app( + *, + contract_client: FakeContractClient, + subtensor: FakeSubtensor, + miner_pairs: Optional[List[MinerPair]] = None, + broadcast_factory: Optional[Callable[[List[Any]], List[Any]]] = None, + commitments: Optional[Dict[str, str]] = None, +) -> FastAPI: + """Build a FastAPI app whose lifespan installs a hand-built AppState.""" + + state = AppState( + subtensor=subtensor, # type: ignore[arg-type] + contract_client=contract_client, # type: ignore[arg-type] + ephemeral_wallet=FakeWallet(), # type: ignore[arg-type] + netuid=2, + contract_address='5DjJmTpcHZvF3aZZEafKBdo3ksmdUSZ8bBBUSFhW3Ce3xf1J', + quorum_timeout_s=0.05, + quorum_poll_interval_s=0.01, + ) + + app = FastAPI() + app.add_middleware(CORSMiddleware, allow_origins=['*'], allow_methods=['GET', 'POST'], allow_headers=['*']) + app.state.allways = state + app.include_router(health.router) + app.include_router(chains.router) + app.include_router(miners.router) + app.include_router(proofs.router) + app.include_router(swap.router) + + # The routes call module-level functions; monkeypatch the lookups here so + # tests stay self-contained without each one re-wiring imports. + import allways.swap_api.routes.miners as miners_mod + import allways.swap_api.routes.swap as swap_mod + + miners_mod.read_miner_commitments = lambda _s, _n: list(miner_pairs or []) + swap_mod.get_commitment = lambda _s, _n, hk: (commitments or {}).get(hk) + swap_mod._discover = lambda _state: [] # default: no validators + if broadcast_factory is not None: + + async def _bc(_wallet, _axons, synapse, timeout=60.0): + return broadcast_factory(synapse) + + swap_mod.broadcast_synapse_async = _bc + swap_mod._discover = lambda _state: [object()] # non-empty axon stub + + return app + + +@pytest.fixture +def client_factory(): + """Pytest fixture returning a TestClient builder for one-off app configs.""" + + def _build(**kwargs) -> TestClient: + return TestClient(make_app(**kwargs)) + + return _build + + +def make_pair( + hotkey: str, + *, + from_chain: str = 'btc', + from_address: str = 'bc1qsource', + to_chain: str = 'tao', + to_address: str = '5Cdest', + rate: float = 345.0, + counter_rate: float = 0.003, + uid: int = 1, +) -> MinerPair: + return MinerPair( + uid=uid, + hotkey=hotkey, + from_chain=from_chain, + from_address=from_address, + to_chain=to_chain, + to_address=to_address, + rate=rate, + rate_str=f'{rate:g}', + counter_rate=counter_rate, + counter_rate_str=f'{counter_rate:g}', + ) + + +def make_swap(swap_id: int, miner_hotkey: str) -> Swap: + return Swap( + id=swap_id, + user_hotkey='5Cuser', + miner_hotkey=miner_hotkey, + from_chain='btc', + to_chain='tao', + from_amount=10_000, + to_amount=3_450_000, + tao_amount=3_450_000, + user_from_address='bc1qsource', + user_to_address='5Cdest', + status=SwapStatus.ACTIVE, + ) + + +def make_reservation(miner_hotkey: str, *, request_hash: str = '0xabc', reserved_until: int = 200) -> Reservation: + return Reservation( + hash=request_hash, + from_addr='bc1qsource', + from_chain='btc', + to_chain='tao', + tao_amount=3_450_000, + from_amount=10_000, + to_amount=3_450_000, + reserved_until=reserved_until, + ) diff --git a/tests/swap_api/test_health.py b/tests/swap_api/test_health.py new file mode 100644 index 00000000..7d5f08d3 --- /dev/null +++ b/tests/swap_api/test_health.py @@ -0,0 +1,31 @@ +from tests.swap_api.conftest import FakeContractClient, FakeSubtensor + + +def test_healthz_returns_block_and_contract(client_factory): + client = client_factory(contract_client=FakeContractClient(), subtensor=FakeSubtensor(block=12345)) + resp = client.get('/healthz') + assert resp.status_code == 200 + body = resp.json() + assert body['ok'] is True + assert body['chainBlock'] == 12345 + assert body['contractAddress'].startswith('5') + + +def test_chains_lists_supported_chains(client_factory): + client = client_factory(contract_client=FakeContractClient(), subtensor=FakeSubtensor()) + resp = client.get('/chains') + assert resp.status_code == 200 + body = resp.json() + chain_ids = {c['id'] for c in body['chains']} + assert {'btc', 'tao'}.issubset(chain_ids) + # Pairs are every ordered (a, b) with a != b. + assert ['btc', 'tao'] in [list(p) for p in body['pairs']] + assert ['tao', 'btc'] in [list(p) for p in body['pairs']] + + +def test_proofs_match_canonical_format(client_factory): + client = client_factory(contract_client=FakeContractClient(), subtensor=FakeSubtensor()) + r1 = client.get('/proofs/reserve', params={'address': 'bc1qx', 'block': 42}) + assert r1.json() == {'message': 'allways-reserve:bc1qx:42'} + r2 = client.get('/proofs/confirm', params={'txHash': 'deadbeef'}) + assert r2.json() == {'message': 'allways-swap:deadbeef'} diff --git a/tests/swap_api/test_miners.py b/tests/swap_api/test_miners.py new file mode 100644 index 00000000..a30ffd12 --- /dev/null +++ b/tests/swap_api/test_miners.py @@ -0,0 +1,72 @@ +from tests.swap_api.conftest import FakeContractClient, FakeSubtensor, make_pair + + +def test_best_miner_picks_highest_rate_for_canonical_forward(client_factory): + cheap = make_pair('5Ccheap', rate=300.0, uid=1) + rich = make_pair('5Crich', rate=400.0, uid=2) + contract = FakeContractClient() + contract.add_miner('5Ccheap', collateral=1_000_000_000) + contract.add_miner('5Crich', collateral=1_000_000_000) + client = client_factory( + contract_client=contract, + subtensor=FakeSubtensor(block=500), + miner_pairs=[cheap, rich], + ) + + resp = client.get('/miners/best', params={'from': 'btc', 'to': 'tao', 'amount': 10_000}) + assert resp.status_code == 200 + body = resp.json() + assert body['minerHotkey'] == '5Crich' + assert body['rate'] == '400' + assert body['freshAsOf'] == 500 + + +def test_best_miner_skips_ineligible(client_factory): + busy = make_pair('5Cbusy', rate=400.0) + healthy = make_pair('5Chealthy', rate=300.0) + contract = FakeContractClient() + contract.add_miner('5Cbusy', has_swap=True, collateral=1_000_000_000) + contract.add_miner('5Chealthy', collateral=1_000_000_000) + client = client_factory( + contract_client=contract, + subtensor=FakeSubtensor(), + miner_pairs=[busy, healthy], + ) + + resp = client.get('/miners/best', params={'from': 'btc', 'to': 'tao', 'amount': 1}) + assert resp.status_code == 200 + assert resp.json()['minerHotkey'] == '5Chealthy' + + +def test_best_miner_404_when_no_eligible(client_factory): + contract = FakeContractClient() + contract.add_miner('5Csolo', active=False, collateral=1) + client = client_factory( + contract_client=contract, + subtensor=FakeSubtensor(), + miner_pairs=[make_pair('5Csolo')], + ) + resp = client.get('/miners/best', params={'from': 'btc', 'to': 'tao', 'amount': 1}) + assert resp.status_code == 404 + + +def test_miners_list_returns_summaries(client_factory): + contract = FakeContractClient() + contract.add_miner('5Ca', collateral=5_000) + client = client_factory( + contract_client=contract, + subtensor=FakeSubtensor(), + miner_pairs=[make_pair('5Ca', rate=345.0)], + ) + resp = client.get('/miners', params={'from': 'btc', 'to': 'tao'}) + assert resp.status_code == 200 + body = resp.json() + assert body[0]['hotkey'] == '5Ca' + assert body[0]['rate'] == '345' + assert body[0]['collateralRao'] == 5_000 + + +def test_best_miner_rejects_same_from_to(client_factory): + client = client_factory(contract_client=FakeContractClient(), subtensor=FakeSubtensor()) + resp = client.get('/miners/best', params={'from': 'btc', 'to': 'btc', 'amount': 1}) + assert resp.status_code == 400 diff --git a/tests/swap_api/test_swap.py b/tests/swap_api/test_swap.py new file mode 100644 index 00000000..f093904e --- /dev/null +++ b/tests/swap_api/test_swap.py @@ -0,0 +1,176 @@ +from types import SimpleNamespace + +from tests.swap_api.conftest import ( + FakeContractClient, + FakeSubtensor, + make_reservation, + make_swap, +) + +RESERVE_BODY = { + 'minerHotkey': '5Cminer', + 'fromChain': 'btc', + 'toChain': 'tao', + 'taoAmount': 3_450_000, + 'fromAmount': 10_000, + 'toAmount': 3_450_000, + 'fromAddress': 'bc1qsource', + 'fromAddressProof': '0xsig', + 'blockAnchor': 100, + 'expectedRate': '345', +} + + +def _accepted_response(): + return SimpleNamespace(accepted=True, rejection_reason='') + + +def _rejected_response(reason: str): + return SimpleNamespace(accepted=False, rejection_reason=reason) + + +def test_reserve_returns_409_on_rate_drift(client_factory): + contract = FakeContractClient() + contract.add_miner('5Cminer', reserved_until=200) + client = client_factory( + contract_client=contract, + subtensor=FakeSubtensor(), + commitments={'5Cminer': 'v1:btc:bc1qsource:tao:5Cdest:999:888'}, + broadcast_factory=lambda _s: [_accepted_response()], + ) + resp = client.post('/reserve', json=RESERVE_BODY) + assert resp.status_code == 409 + body = resp.json() + assert body['code'] == 'RateChanged' + assert body['expected'] == '345' + assert body['actual'] == '999' + + +def test_reserve_succeeds_when_rate_matches(client_factory): + contract = FakeContractClient() + contract.add_miner('5Cminer', reserved_until=250) + contract.reservations['5Cminer'] = make_reservation('5Cminer', request_hash='0xfeed', reserved_until=250) + client = client_factory( + contract_client=contract, + subtensor=FakeSubtensor(), + commitments={'5Cminer': 'v1:btc:bc1qmsrc:tao:5Cmdest:345:0.003'}, + broadcast_factory=lambda _s: [_accepted_response(), _accepted_response()], + ) + + resp = client.post('/reserve', json=RESERVE_BODY) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body['requestHash'] == '0xfeed' + assert body['reservedUntilBlock'] == 250 + assert body['minerHotkey'] == '5Cminer' + assert body['minerSourceAddress'] == 'bc1qmsrc' + + +def test_reserve_502_when_all_rejected(client_factory): + contract = FakeContractClient() + contract.add_miner('5Cminer', reserved_until=0) + client = client_factory( + contract_client=contract, + subtensor=FakeSubtensor(), + commitments={'5Cminer': 'v1:btc:bc1qsource:tao:5Cdest:345:0.003'}, + broadcast_factory=lambda _s: [_rejected_response('insufficient source balance')], + ) + resp = client.post('/reserve', json=RESERVE_BODY) + assert resp.status_code == 502 + + +def test_reserve_504_when_quorum_does_not_land(client_factory): + contract = FakeContractClient() + contract.add_miner('5Cminer', reserved_until=0) + client = client_factory( + contract_client=contract, + subtensor=FakeSubtensor(), + commitments={'5Cminer': 'v1:btc:bc1qsource:tao:5Cdest:345:0.003'}, + broadcast_factory=lambda _s: [_accepted_response()], + ) + resp = client.post('/reserve', json=RESERVE_BODY) + assert resp.status_code == 504 + + +def test_confirm_returns_swap_id_when_active_swap_exists(client_factory): + contract = FakeContractClient() + contract.add_miner('5Cminer', has_swap=True) + contract.miner_swaps['5Cminer'] = [make_swap(42, '5Cminer')] + client = client_factory( + contract_client=contract, + subtensor=FakeSubtensor(), + broadcast_factory=lambda _s: [_accepted_response()], + ) + body = { + 'requestHash': '0xfeed', + 'minerHotkey': '5Cminer', + 'fromTxHash': 'abc123', + 'fromTxProof': '0xsig', + 'fromAddress': 'bc1qsource', + 'toAddress': '5Cdest', + 'fromChain': 'btc', + 'toChain': 'tao', + 'fromTxBlock': 999, + } + resp = client.post('/confirm', json=body) + assert resp.status_code == 200, resp.text + payload = resp.json() + assert payload['accepted'] is True + assert payload['swapId'] == 42 + + +def test_reserve_passes_contract_client_to_validator_discovery(client_factory, monkeypatch): + """Broadcasts must be filtered to the contract whitelist (spec §4).""" + contract = FakeContractClient() + contract.add_miner('5Cminer', reserved_until=250) + contract.reservations['5Cminer'] = make_reservation('5Cminer', reserved_until=250) + + client = client_factory( + contract_client=contract, + subtensor=FakeSubtensor(), + commitments={'5Cminer': 'v1:btc:bc1qmsrc:tao:5Cmdest:345:0.003'}, + broadcast_factory=lambda _s: [_accepted_response()], + ) + + captured: dict = {} + + def fake_discover(subtensor, netuid, contract_client=None): + captured['contract_client'] = contract_client + return [object()] + + import allways.swap_api.routes.swap as swap_mod + + # Override the conftest stub so the real wiring through state.contract_client is exercised. + monkeypatch.setattr(swap_mod, 'discover_validators', fake_discover) + monkeypatch.setattr(swap_mod, '_discover', lambda s: fake_discover(s.subtensor, s.netuid, s.contract_client)) + + client.post('/reserve', json=RESERVE_BODY) + assert captured.get('contract_client') is contract + + +def test_confirm_returns_rejection_when_all_validators_reject(client_factory): + contract = FakeContractClient() + contract.add_miner('5Cminer') + client = client_factory( + contract_client=contract, + subtensor=FakeSubtensor(), + broadcast_factory=lambda _s: [_rejected_response('source tx not found')], + ) + resp = client.post( + '/confirm', + json={ + 'requestHash': '0xfeed', + 'minerHotkey': '5Cminer', + 'fromTxHash': 'abc123', + 'fromTxProof': '0xsig', + 'fromAddress': 'bc1qsource', + 'toAddress': '5Cdest', + 'fromChain': 'btc', + 'toChain': 'tao', + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body['accepted'] is False + assert body['swapId'] is None + assert body['rejection'] diff --git a/uv.lock b/uv.lock index 5345bd17..611a6752 100644 --- a/uv.lock +++ b/uv.lock @@ -176,17 +176,21 @@ dependencies = [ { name = "bittensor-wallet" }, { name = "click" }, { name = "embit" }, + { name = "fastapi" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pycryptodome" }, + { name = "pydantic" }, { name = "python-dotenv" }, { name = "requests" }, { name = "rich" }, + { name = "uvicorn", extra = ["standard"] }, { name = "wandb" }, ] [package.dev-dependencies] dev = [ + { name = "httpx" }, { name = "pre-commit" }, { name = "pytest" }, { name = "ruff" }, @@ -203,16 +207,20 @@ requires-dist = [ { name = "bittensor-wallet", specifier = "==4.0.1" }, { name = "click" }, { name = "embit" }, + { name = "fastapi", specifier = ">=0.115" }, { name = "numpy" }, { name = "pycryptodome" }, + { name = "pydantic", specifier = ">=2" }, { name = "python-dotenv", specifier = "==1.2.1" }, { name = "requests" }, { name = "rich" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, { name = "wandb", specifier = "==0.21.3" }, ] [package.metadata.requires-dev] dev = [ + { name = "httpx", specifier = ">=0.28.1" }, { name = "pre-commit", specifier = ">=4.2.0" }, { name = "pytest", specifier = ">=9.0.0" }, { name = "ruff", specifier = ">=0.14.10" }, @@ -903,6 +911,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/e5/c07e0bcf4ec8db8164e9f6738c048b2e66aabf30e7506f440c4cc6953f60/httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78", size = 204531, upload-time = "2025-10-10T03:54:20.887Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/35e3a63f863a659f92ffd92bef131f3e81cf849af26e6435b49bd9f6f751/httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4", size = 109408, upload-time = "2025-10-10T03:54:22.455Z" }, + { url = "https://files.pythonhosted.org/packages/f5/71/b0a9193641d9e2471ac541d3b1b869538a5fb6419d52fd2669fa9c79e4b8/httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05", size = 440889, upload-time = "2025-10-10T03:54:23.753Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d9/2e34811397b76718750fea44658cb0205b84566e895192115252e008b152/httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed", size = 440460, upload-time = "2025-10-10T03:54:25.313Z" }, + { url = "https://files.pythonhosted.org/packages/01/3f/a04626ebeacc489866bb4d82362c0657b2262bef381d68310134be7f40bb/httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a", size = 425267, upload-time = "2025-10-10T03:54:26.81Z" }, + { url = "https://files.pythonhosted.org/packages/a5/99/adcd4f66614db627b587627c8ad6f4c55f18881549bab10ecf180562e7b9/httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b", size = 424429, upload-time = "2025-10-10T03:54:28.174Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/ec8fc904a8fd30ba022dfa85f3bbc64c3c7cd75b669e24242c0658e22f3c/httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568", size = 86173, upload-time = "2025-10-10T03:54:29.5Z" }, + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "identify" version = "2.6.18" @@ -2177,6 +2256,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + [[package]] name = "virtualenv" version = "21.2.4" @@ -2222,6 +2356,109 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/2d/7ef56e25f78786e59fefd9b19867c325f9686317d9f7b93b5cb340360a3e/wandb-0.21.3-py3-none-win_amd64.whl", hash = "sha256:56d5a5697766f552a9933d8c6a564202194768eb0389bd5f9fe9a99cd4cee41e", size = 18709411, upload-time = "2025-08-30T18:21:52.874Z" }, ] +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" }, + { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" }, + { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" }, + { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" }, + { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + [[package]] name = "websockets" version = "16.0"