diff --git a/.gitignore b/.gitignore index f288bb2..f924b50 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,6 @@ logs/ *.orig # uv -.python-version \ No newline at end of file +.python-version +.cache/ +tmp/ \ No newline at end of file diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..753601f --- /dev/null +++ b/AGENT.md @@ -0,0 +1,263 @@ +# AGENT.md + +This file records the current downstream rules that must not be forgotten when +working on `liveweb-arena`. + +## Scope + +`liveweb-arena` is responsible for: + +- episode execution +- browser / environment behavior +- task registry integration +- runtime-profile-gated behavior + +It is **not** the canonical source of benchmark scoring policy by itself. +Downstream benchmark code decides how a run is executed and how it is judged. + +## Runtime Profiles + +Downstream `liveweb-arena` supports two explicit runtime profiles: + +- `strict_eval` +- `fast_collect` + +Reference implementation: + +- [liveweb_arena/core/runtime_profiles.py](/home/xmyf/liveweb-arena/liveweb_arena/core/runtime_profiles.py) + +### `strict_eval` + +Use this for any path that claims upstream-compatible capability measurement. + +`strict_eval` should stay semantically aligned with upstream +`AffineFoundation/liveweb-arena`. + +Do **not** enable the following by default in `strict_eval`: + +- collect-only local recovery +- disallowed-domain correction +- invalid-generated-url correction +- taostats list recovery +- downstream-only prompt profiles +- extra downstream-only loopguard that changes episode semantics + +### `fast_collect` + +Use this for: + +- SFT sampling +- RL rollout collection +- candidate-trajectory generation +- debugging / diagnostics + +`fast_collect` may enable: + +- local recovery +- fail-fast / early-stop +- loop detection +- invalid URL correction +- collection-specific routing / timeout policy + +These tricks may improve runtime efficiency. +They must not redefine final benchmark scoring. + +## Recovery Guardrails + +Recent RL debugging established several constraints that must not be forgotten. + +### Format Recovery Must Be Bounded + +`strict_eval`/`fast_collect` recovery paths may be used, but recovery must never +be allowed to rebuild an effectively unbounded prompt. + +Current requirements: + +- recovery trimming must be token-aware, not only `len(content)//4` +- the final observation/user message may be truncated or summarized if it alone + exceeds budget +- recovery should prioritize: + - system prompt + - current task description + - the most recent 1-2 steps + - the recovery remediation message +- recovery must not blindly retain long accessibility trees or the full raw + browser history + +### Recovery Overflow Must Fail Fast + +If recovery still exceeds budget or hits model-context limits, it must fail as a +sample-level error, not continue retrying indefinitely. + +Expected classifications include: + +- `recoverable_context_overflow` +- `format_recovery_overflow` +- `llm_context_overflow` + +These should terminate the current recovery attempt quickly rather than sending +another oversized request. + +### Long-Tail Protection Matters More Than Saving One Sample + +The most expensive historical RL failures came from a single bad trajectory +triggering recovery, overflowing context, and then never being cleaned up +properly downstream. + +When in doubt: + +- prefer early termination of the bad sample +- do not preserve a pathological trajectory at the cost of blocking a whole + rollout round + +## Browser / Proxy Notes + +### Browser Proxy Is Allowed for External Sites + +Current downstream RL experiments intentionally allow the browser to use the +system proxy for external websites, because direct access was measured to be +materially slower for several important domains. + +Practical rule: + +- browser-side external traffic may use the system proxy +- local control-plane traffic and local LLM traffic must not be forced through + that proxy + +Observed high-value domains where system proxy was measured faster than direct +access on the current machine: + +- `taostats` +- `coingecko` +- `stooq` +- `hackernews` + +### Proxy Logic Must Stay Explicit + +If browser proxy behavior changes, keep the decision path explicit and simple. +Do not assume "inherit whatever the shell has" is always safe. + +In particular: + +- local service traffic should continue to honor `NO_PROXY` +- proxy-specific code paths in browser setup must stay import-safe and + regression-tested + +## Integration Contract + +Downstream benchmark code is expected to use: + +- strict evaluation: + - run with `strict_eval` + - judge with `strict_eval` +- accelerated collection: + - run with `fast_collect` + - judge with `strict_eval` + +The arena runtime profile controls execution only. +Final benchmark semantics come from the downstream strict judge. + +## Testing Rule + +When running Python tests, smoke checks, or validation scripts, prefer `uv` +entry points over bare `python` / `pytest`. + +Practical rule: + +- prefer `uv run ...` for Python-based tests and validation commands +- if a project-specific interpreter is required for browser/runtime + dependencies, still invoke it through `uv` when possible +- if `uv` cannot be used for a specific command, document the exception and the + reason in the task notes or run overview + +## Online-Aligned Reference + +The current online-aligned `LIVEWEB` definition is derived from: + +- `affine-cortex` + - `/tmp/affine-cortex/affine/database/system_config.json` + - `/tmp/affine-cortex/affine/core/environments.py` +- upstream `liveweb-arena` + - `/tmp/liveweb-arena-origin-main/liveweb_arena/core/task_registry.py` + +### Current online sampling config + +From `affine-cortex` `LIVEWEB`: + +- `dataset_range = [0, 78060000]` +- `sampling_count = 300` +- `rotation_count = 4` +- `min_completeness = 0.8` + +### Current online eval params + +From `affine-cortex` environment config: + +- `temperature = 0.0` +- `timeout = 7200` +- `max_concurrency = 10` +- `proxy_timeout = 7300` + +Notes: + +- `max_steps` is not a single fixed constant in upstream; upstream `env.py` + derives an effective value from task expectations unless explicitly + overridden. +- no explicit online `max_completion_tokens` constant has been confirmed in the + known affine/upstream config files. + +### Current online task-space assumptions + +At the time of writing: + +- `num_tasks` only takes `2/3/4` +- there is no online `task1` in the current upstream task-id space +- over the configured dataset range, the `2/3/4` ratio is exactly `1:1:1` + +### Current online site/plugin families + +For the current configured dataset range, active site families are: + +- `coingecko` +- `stooq` +- `taostats` +- `hybrid` +- `hackernews` + +The current online-aligned range does not include: + +- `openlibrary` +- `openmeteo` +- `arxiv` +- `weather` + +This can change if upstream registry ordering or affine dataset range changes. + +## Downstream Support Requirement + +Downstream-supported tasks must stay aligned with upstream task space in +`strict_eval`. + +Practical requirements: + +- keep downstream plugin coverage aligned with upstream plugin coverage +- keep strict task registry semantics aligned with upstream task registry +- do not silently fork strict parser / protocol semantics + +## Mandatory Maintenance Checks + +When changing runtime behavior or task support, always re-check: + +1. `strict_eval` still matches upstream semantics +2. `fast_collect` changes are gated behind runtime profile checks +3. downstream task registry still matches upstream for strict-eval paths +4. real downstream vs upstream parity still passes on fixture tasks +5. current online `LIVEWEB` config has not changed upstream + +## Source Documents + +Read these before changing policy-sensitive behavior: + +- [docs/runtime-profiles.md](/home/xmyf/liveweb-arena/docs/runtime-profiles.md) +- [docs/downstream-alignment.md](/home/xmyf/liveweb-arena/docs/downstream-alignment.md) +- [sampling-eval-rl-policy.md](/home/xmyf/liveweb-capability-bench/docs/sampling-eval-rl-policy.md) diff --git a/README.md b/README.md index e650c13..e770346 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,24 @@ cp .env.example .env python eval.py --seed 42 --verbose ``` +## Runtime Profiles + +Downstream integrations may now choose between two explicit episode runtime +profiles: + +- `strict_eval` +- `fast_collect` + +`strict_eval` is intended for upstream-compatible capability measurement. +`fast_collect` is intended for accelerated candidate-trajectory generation. The +runtime profile only changes episode execution behavior; final benchmark scoring +should still come from a strict judge. + +See: + +- [docs/runtime-profiles.md](/home/xmyf/liveweb-arena/docs/runtime-profiles.md) +- [docs/downstream-alignment.md](/home/xmyf/liveweb-arena/docs/downstream-alignment.md) + ## Usage ```bash diff --git a/docs/downstream-alignment.md b/docs/downstream-alignment.md new file mode 100644 index 0000000..4829e84 --- /dev/null +++ b/docs/downstream-alignment.md @@ -0,0 +1,80 @@ +# Downstream Alignment Rules + +This document records how downstream `liveweb-arena` is expected to integrate +with downstream benchmark / collection code. + +## Runtime Profiles + +`liveweb-arena` exposes runtime behavior through two profiles: + +- `strict_eval` +- `fast_collect` + +The runtime profile controls episode execution only. +It does not define the final benchmark score by itself. + +## Integration Contract + +Downstream benchmark code is responsible for choosing: + +- the runtime profile used to execute an episode +- the judge profile used to score the episode + +The intended combinations are: + +- strict evaluation: + - run with `strict_eval` + - judge with `strict_eval` +- accelerated collection: + - run with `fast_collect` + - judge with `strict_eval` + +## What Must Stay Out Of Strict Eval + +The strict path should remain compatible with upstream semantics. + +Do not enable the following by default in `strict_eval`: + +- collect-only local recovery +- disallowed-domain correction +- invalid-generated-url correction +- taostats list recovery +- downstream-only prompt profiles +- extra loopguard that changes single-episode semantics + +## What Is Allowed In Fast Collect + +`fast_collect` may enable runtime-only acceleration such as: + +- local recovery +- fail-fast / early-stop +- loop detection +- invalid URL correction +- collection-specific routing and timeout policy + +These features are allowed because they help find candidate trajectories faster. +They are not allowed to replace strict judging. + +## Source Of Truth + +The downstream benchmark repo contains the normative policy for: + +- strict evaluation +- collection +- SFT filtering +- RL reward / filtering + +See: + +- [sampling-eval-rl-policy.md](/home/xmyf/liveweb-capability-bench/docs/sampling-eval-rl-policy.md) + +## Current Online-Aligned Reference + +The current online-aligned LIVEWEB definition comes from: + +- `affine-cortex` `system_config.json` +- `affine-cortex` environment config +- upstream `liveweb-arena` task registry + +Downstream runtime behavior should be compatible with that contract when running +in `strict_eval`. diff --git a/docs/runtime-profiles.md b/docs/runtime-profiles.md new file mode 100644 index 0000000..d02d779 --- /dev/null +++ b/docs/runtime-profiles.md @@ -0,0 +1,64 @@ +# Runtime Profiles In LiveWeb Arena + +Downstream `liveweb-arena` now supports two explicit runtime profiles: + +- `strict_eval` +- `fast_collect` + +These profiles control episode execution behavior. They are intentionally +separate from final benchmark judging. + +## `strict_eval` + +Use for any evaluation path that needs to remain as close as possible to the +upstream `AffineFoundation/liveweb-arena` semantics. + +The strict path should avoid downstream-only acceleration features such as: + +- disallowed-domain recovery +- invalid-generated-url recovery +- taostats list recovery +- collect-only prompt profiles +- aggressive loop guards + +## `fast_collect` + +Use for sampling, SFT data generation, RL feedback collection, and diagnostics. + +The collect path may enable: + +- local recoveries +- early-stop / fail-fast +- loop detection +- invalid URL correction +- model-specific runtime prompt profiles + +These features are allowed to improve runtime efficiency, but they are not the +canonical scoring semantics. + +## Integration Rule + +`liveweb-arena` exposes runtime behavior. Downstream benchmark code is +responsible for deciding: + +- which runtime profile is used to execute an episode +- which judge profile is used to score the final result + +For downstream collection, the intended pattern is: + +- run with `fast_collect` +- judge with `strict_eval` + +For evaluation and upstream parity checks, use: + +- run with `strict_eval` +- judge with `strict_eval` + +## Code Entry Points + +- runtime profile definitions: + - [liveweb_arena/core/runtime_profiles.py](/home/xmyf/liveweb-arena/liveweb_arena/core/runtime_profiles.py) +- actor runtime selection: + - [env.py](/home/xmyf/liveweb-arena/env.py) +- agent loop runtime gates: + - [liveweb_arena/core/agent_loop.py](/home/xmyf/liveweb-arena/liveweb_arena/core/agent_loop.py) diff --git a/env.py b/env.py index 23a22a4..8e102e3 100644 --- a/env.py +++ b/env.py @@ -1,6 +1,7 @@ """LiveWeb Arena - Main evaluation entry point""" import asyncio +import json import os import random import time @@ -9,7 +10,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Set -from liveweb_arena.core.browser import BrowserEngine, BrowserSession +from liveweb_arena.core.browser import BrowserEngine, BrowserSession, is_browser_transport_error from liveweb_arena.core.task_manager import TaskManager from liveweb_arena.core.agent_protocol import FunctionCallingProtocol from liveweb_arena.core.agent_loop import AgentLoop, BrowserFatalError @@ -19,10 +20,19 @@ from liveweb_arena.core.interceptor import CacheInterceptor from liveweb_arena.core.models import BrowserObservation, CompositeTask, TrajectoryStep from liveweb_arena.core.reward import StepwiseRewardCalculator, RewardConfig, RewardBreakdown +from liveweb_arena.core.reachability_audit import audit_reachability_failure +from liveweb_arena.core.runtime_profiles import ( + FAST_COLLECT_PROFILE, + STRICT_EVAL_PROFILE, + is_fast_collect_profile, + normalize_runtime_profile, + runtime_profile_to_behavior_mode, +) +from liveweb_arena.core.task_registry_loader import parse_task_id as parse_task_id_for_runtime from liveweb_arena.plugins.base import BasePlugin from liveweb_arena.plugins import get_all_plugins from liveweb_arena.core.validators.llm_validator import validate_answers_with_llm -from liveweb_arena.utils.llm_client import LLMClient, LLMFatalError +from liveweb_arena.utils.llm_client import LLMClient, LLMFatalError, MultiServerLLMRouter from liveweb_arena.utils.logger import log from urllib.parse import urlparse @@ -68,6 +78,52 @@ def _find_plugin_for_url(plugins_used: Dict[str, "BasePlugin"], url: str) -> Opt return None +def _maybe_promote_disallowed_domain_failure( + *, + interceptor, + trajectory: list, + allowed_domains: Set[str], + failure_reason: str | None, + reachability_audit, + mode: str = "eval", +) -> tuple[str | None, Any]: + if mode != "collect": + return failure_reason, reachability_audit + if interceptor is None: + return failure_reason, reachability_audit + blocked = {} + if hasattr(interceptor, "get_last_blocked_document_metadata"): + blocked = interceptor.get_last_blocked_document_metadata() + blocked_url = blocked.get("blocked_url") + if not blocked_url: + return failure_reason, reachability_audit + if failure_reason in {"agent_timeout", "llm_error", "cache_error", "site_unreachable"}: + return failure_reason, reachability_audit + + last_url = None + last_observation = None + if trajectory: + observation = getattr(trajectory[-1], "observation", None) + last_url = getattr(observation, "url", None) + last_observation = getattr(observation, "accessibility_tree", None) + + blocked_marker = "Domain not allowed" + same_last_url = bool(last_url and normalize_url(last_url) == normalize_url(blocked_url)) + blocked_page_visible = blocked_marker in str(last_observation or "") + if not same_last_url and not blocked_page_visible: + return failure_reason, reachability_audit + + audit = audit_reachability_failure( + url=blocked_url, + plugin_name=None, + plugin=None, + reason="Domain not allowed", + allowed_domains=allowed_domains, + evidence={"interceptor": blocked}, + ) + return "disallowed_domain", audit + + async def _handle_navigation_event(interceptor, cached_pages, plugins_used, url, use_cache): """Navigation handler: error propagation + external URL extraction.""" if not use_cache: @@ -170,6 +226,14 @@ def __init__( api_key: str = None, cache_dir: Optional[Path] = None, use_cache: bool = True, + llm_router: Optional[MultiServerLLMRouter] = None, + llm_enable_thinking: Optional[bool] = None, + llm_separate_reasoning: Optional[bool] = None, + llm_reasoning_effort: Optional[str] = None, + llm_strip_reasoning_output: bool = False, + llm_prompt_profile: Optional[str] = None, + runtime_profile: Optional[str] = None, + behavior_mode: str = "eval", ): """ Initialize Actor. @@ -185,6 +249,14 @@ def __init__( self._semaphore: Optional[asyncio.Semaphore] = None self._lock = asyncio.Lock() self.use_cache = use_cache + self.llm_router = llm_router + self.llm_enable_thinking = llm_enable_thinking + self.llm_separate_reasoning = llm_separate_reasoning + self.llm_reasoning_effort = llm_reasoning_effort + self.llm_strip_reasoning_output = llm_strip_reasoning_output + self.llm_prompt_profile = llm_prompt_profile + self.runtime_profile = normalize_runtime_profile(runtime_profile or behavior_mode) + self.behavior_mode = runtime_profile_to_behavior_mode(self.runtime_profile) # Episode storage for OpenEnv interface self._episodes: Dict[str, EpisodeState] = {} @@ -196,7 +268,7 @@ def __init__( if env_cache_dir: cache_dir = Path(env_cache_dir) else: - cache_dir = Path("/var/lib/liveweb-arena/cache") + cache_dir = Path(__file__).resolve().parent / ".cache" / "liveweb" self.cache_manager = CacheManager(cache_dir) def _collect_plugin_info(self, task: CompositeTask): @@ -216,7 +288,10 @@ def _collect_plugin_info(self, task: CompositeTask): return plugins_used, allowed_domains, list(set(blocked_patterns)) async def _setup_interceptor(self, session, cached_pages, allowed_domains, blocked_patterns, plugins_used): - """Create and install CacheInterceptor. Returns interceptor.""" + """Create and install CacheInterceptor. Returns (session, interceptor).""" + if hasattr(session, "set_allowed_domains"): + session.set_allowed_domains(allowed_domains) + def url_validator(url): for p in plugins_used.values(): if hasattr(p, 'is_url_allowed') and p.is_url_allowed(url): @@ -233,16 +308,29 @@ def url_validator(url): plugin_resolver=plugin_resolver, offline=self.use_cache, ) - if self.use_cache: - await session.set_cache_interceptor(interceptor) - if not self.use_cache and blocked_patterns: - await session.block_urls(blocked_patterns) - return interceptor + for attempt in range(2): + try: + if self.use_cache: + await session.set_cache_interceptor(interceptor) + if not self.use_cache and blocked_patterns: + await session.block_urls(blocked_patterns) + return session, interceptor + except Exception as exc: + if attempt == 0 and is_browser_transport_error(exc): + log("Actor", f"Session setup hit browser transport error, rebuilding session: {exc}", force=True) + try: + await session.close() + except Exception: + pass + await self._ensure_browser() + session = await self.browser.new_session() + continue + raise async def evaluate( self, model: str, - base_url: str, + base_url: Optional[str] = None, api_key: Optional[str] = None, seed: Optional[int] = None, num_subtasks: Optional[int] = None, @@ -252,6 +340,9 @@ async def evaluate( temperature: float = 0.7, max_concurrency: int = 2, task_id: Optional[int] = None, + mode: str = "eval", + runtime_profile: Optional[str] = None, + route_key: Optional[str] = None, ) -> dict: """ Run a single evaluation. @@ -268,6 +359,8 @@ async def evaluate( temperature: LLM temperature max_concurrency: Container-local concurrency limit task_id: Optional task ID for deterministic question type + mode: "eval" for scored evaluation, "collect" for lightweight trajectory collection + route_key: Optional key for sticky LLM routing Returns: Evaluation result dict with scores and metadata @@ -276,8 +369,7 @@ async def evaluate( # Parse task_id to get templates and other config if not explicitly provided if task_id is not None and templates is None: - from liveweb_arena.core.task_registry import parse_task_id - task_config = parse_task_id(task_id) + task_config = parse_task_id_for_runtime(task_id, runtime_profile=runtime_profile or mode or self.runtime_profile) templates = task_config["templates"] # Use task_id's num_tasks if not explicitly provided if num_subtasks is None: @@ -296,24 +388,48 @@ async def evaluate( # Allow per-call API key override current_api_key = api_key or self.api_key + if self.llm_router is None and not base_url: + raise ValueError("base_url is required when Actor is not configured with llm_router") + # Initialize semaphore for concurrency control if self._semaphore is None: self._semaphore = asyncio.Semaphore(max_concurrency) async with self._semaphore: try: - result = await self._run_evaluation( - model=model, - base_url=base_url, - api_key=current_api_key, - seed=seed, - num_subtasks=num_subtasks, - templates=templates, - max_steps=max_steps, - timeout=timeout, - temperature=temperature, - task_id=task_id, - ) + effective_runtime_profile = normalize_runtime_profile(runtime_profile or mode or self.runtime_profile) + effective_mode = runtime_profile_to_behavior_mode(effective_runtime_profile) + effective_route_key = route_key or f"{effective_mode}:task:{task_id or 'none'}:seed:{seed}" + if is_fast_collect_profile(effective_runtime_profile): + result = await self._run_collection( + model=model, + base_url=base_url, + api_key=current_api_key, + seed=seed, + num_subtasks=num_subtasks, + templates=templates, + max_steps=max_steps, + timeout=timeout, + temperature=temperature, + task_id=task_id, + runtime_profile=effective_runtime_profile, + route_key=effective_route_key, + ) + else: + result = await self._run_evaluation( + model=model, + base_url=base_url, + api_key=current_api_key, + seed=seed, + num_subtasks=num_subtasks, + templates=templates, + max_steps=max_steps, + timeout=timeout, + temperature=temperature, + task_id=task_id, + runtime_profile=effective_runtime_profile, + route_key=effective_route_key, + ) except Exception as e: import traceback result = { @@ -333,10 +449,202 @@ async def evaluate( result["time_taken"] = time.time() - start_time return result + def _build_llm_client( + self, + base_url: Optional[str], + api_key: str, + route_key: str, + *, + max_retries: Optional[int] = None, + strict_serial: bool = False, + ) -> LLMClient: + if self.llm_router is not None: + return LLMClient( + api_key=api_key, + router=self.llm_router, + route_key=route_key, + max_retries=max_retries, + strict_serial=strict_serial, + enable_thinking=self.llm_enable_thinking, + separate_reasoning=self.llm_separate_reasoning, + reasoning_effort=self.llm_reasoning_effort, + strip_reasoning_output=self.llm_strip_reasoning_output, + ) + return LLMClient( + base_url=base_url, + api_key=api_key, + max_retries=max_retries, + strict_serial=strict_serial, + enable_thinking=self.llm_enable_thinking, + separate_reasoning=self.llm_separate_reasoning, + reasoning_effort=self.llm_reasoning_effort, + strip_reasoning_output=self.llm_strip_reasoning_output, + ) + + def _build_protocol( + self, + mode: Optional[str] = None, + runtime_profile: Optional[str] = None, + ) -> FunctionCallingProtocol: + effective_runtime_profile = normalize_runtime_profile(runtime_profile or mode or self.runtime_profile) + prompt_profile = self.llm_prompt_profile if is_fast_collect_profile(effective_runtime_profile) else None + return FunctionCallingProtocol( + prompt_profile=prompt_profile, + strict_compat=(effective_runtime_profile == STRICT_EVAL_PROFILE), + ) + + async def _run_agent_loop( + self, + task: CompositeTask, + session, + llm_client: LLMClient, + protocol, + model: str, + max_steps: int, + timeout: int, + temperature: float, + seed: int, + allowed_domains: Set[str], + runtime_profile: Optional[str] = None, + behavior_mode: Optional[str] = None, + plugins_used: Optional[Dict[str, BasePlugin]] = None, + enable_format_recovery: Optional[bool] = None, + on_navigation=None, + on_observation=None, + ): + plugins_used = plugins_used or {} + agent_loop = AgentLoop( + session=session, + llm_client=llm_client, + protocol=protocol, + max_steps=max_steps, + runtime_profile=runtime_profile or behavior_mode or self.runtime_profile, + behavior_mode=behavior_mode or self.behavior_mode, + enable_format_recovery=enable_format_recovery, + on_navigation=on_navigation, + on_observation=on_observation, + ) + + failure_reason = None + error_message = None + fatal_error_map = { + LLMFatalError: "llm_error", + CacheFatalError: "cache_error", + } + + try: + trajectory, final_answer, usage = await asyncio.wait_for( + agent_loop.run(task=task, model=model, temperature=temperature, seed=seed), + timeout=timeout, + ) + if agent_loop.is_invalid_generated_url(): + failure_reason = "invalid_generated_url" + log("Actor", "Invalid generated URL - marking as failed", force=True) + elif agent_loop.is_invalid_stop_payload(): + failure_reason = "invalid_stop_payload" + log("Actor", "Invalid stop payload - marking as failed", force=True) + elif agent_loop.is_parse_failed(): + failure_reason = "parse_failed" + log("Actor", "Parse failed - model output not valid JSON", force=True) + elif agent_loop.is_action_loop_detected(): + failure_reason = "action_loop_detected" + log("Actor", "Detected repetitive action loop - marking as failed", force=True) + elif agent_loop.is_max_steps_reached(): + failure_reason = "max_steps_reached" + log("Actor", "Max steps reached without completion - marking as failed", force=True) + except asyncio.TimeoutError: + failure_reason = "agent_timeout" + error_message = f"Agent timeout after {timeout}s" + log("Actor", error_message, force=True) + except BrowserFatalError as e: + is_required_domain = False + plugin_name = None + plugin = None + navigation_metadata = session.get_last_navigation_metadata() if hasattr(session, "get_last_navigation_metadata") else None + if e.url: + for domain in allowed_domains: + if _url_matches_domain(e.url, domain): + is_required_domain = True + break + plugin = _find_plugin_for_url(plugins_used, e.url) + plugin_name = getattr(plugin, "name", None) if plugin is not None else None + + if is_required_domain: + failure_reason = "site_unreachable" + error_message = f"Required site unreachable: {e.url} (after {e.attempts} attempts)" + log("Actor", f"Infrastructure error: {error_message}", force=True) + reachability_audit = audit_reachability_failure( + url=e.url or "", + plugin_name=plugin_name, + plugin=plugin, + exception=e, + reason=error_message, + allowed_domains=allowed_domains, + evidence={"attempts": e.attempts, "navigation_metadata": navigation_metadata or {}}, + ) + else: + failure_reason = "browser_error" + log("Actor", f"Browser error (agent issue): {e}", force=True) + reachability_audit = audit_reachability_failure( + url=e.url or "", + plugin_name=plugin_name, + plugin=plugin, + exception=e, + reason=str(e), + allowed_domains=allowed_domains, + evidence={"attempts": e.attempts, "navigation_metadata": navigation_metadata or {}}, + ) + if reachability_audit and reachability_audit.classification == "model_invalid_selector": + failure_reason = "invalid_selector" + elif reachability_audit and reachability_audit.classification == "model_invalid_ui_target": + failure_reason = "invalid_ui_target" + except (LLMFatalError, CacheFatalError) as e: + failure_reason = fatal_error_map[type(e)] + error_message = f"{failure_reason}: {e}" + log("Actor", f"Fatal error - {error_message}", force=True) + reachability_audit = None + if isinstance(e, CacheFatalError) and getattr(e, "url", None): + plugin = _find_plugin_for_url(plugins_used, e.url) + interceptor_metadata = {} + if "interceptor" in (getattr(e, "evidence", {}) or {}): + interceptor_metadata = (e.evidence or {}).get("interceptor") or {} + reachability_audit = audit_reachability_failure( + url=e.url, + plugin_name=getattr(plugin, "name", None) if plugin is not None else None, + plugin=plugin, + exception=e, + reason=error_message, + allowed_domains=allowed_domains, + http_status=getattr(e, "status_code", None), + evidence={ + **(getattr(e, "evidence", {}) or {}), + "interceptor": interceptor_metadata, + }, + ) + if reachability_audit and reachability_audit.classification == "model_invalid_selector": + failure_reason = "invalid_selector" + error_message = None + elif reachability_audit and reachability_audit.classification == "model_invalid_ui_target": + failure_reason = "invalid_ui_target" + error_message = None + else: + reachability_audit = None + + setattr(agent_loop, "_reachability_audit", reachability_audit) + if reachability_audit: + log("ReachabilityAudit", json.dumps(reachability_audit.to_dict(), ensure_ascii=False), force=True) + + if failure_reason and failure_reason not in ("max_steps_reached", "parse_failed"): + trajectory = agent_loop.get_trajectory() + final_answer = agent_loop.get_final_answer() + usage = agent_loop.get_usage() + + return trajectory, final_answer, usage, failure_reason, error_message, agent_loop + async def _run_evaluation( self, model: str, - base_url: str, + base_url: Optional[str], api_key: str, seed: int, num_subtasks: int, @@ -345,6 +653,8 @@ async def _run_evaluation( timeout: int, temperature: float, task_id: Optional[int] = None, + runtime_profile: Optional[str] = None, + route_key: Optional[str] = None, ) -> dict: """Internal evaluation logic.""" await self._ensure_browser() @@ -389,11 +699,18 @@ async def _run_evaluation( session = await self.browser.new_session() # Set up interceptor - interceptor = await self._setup_interceptor( + session, interceptor = await self._setup_interceptor( session, cached_pages, allowed_domains, blocked_patterns, plugins_used, ) - llm_client = LLMClient(base_url=base_url, api_key=api_key) + base_route_key = route_key or f"eval:task:{task_id or 'none'}:seed:{seed}" + agent_llm_client = self._build_llm_client( + base_url=base_url, + api_key=api_key, + route_key=f"{base_route_key}:agent", + max_retries=1, + strict_serial=True, + ) # Initialize unified GT collector gt_collector = GTCollector( @@ -414,73 +731,25 @@ async def on_observation(obs): interceptor, cached_pages, plugins_used, gt_collector, obs, self.use_cache, ) - active_protocol = FunctionCallingProtocol() - agent_loop = AgentLoop( + active_protocol = self._build_protocol(runtime_profile=STRICT_EVAL_PROFILE) + trajectory, final_answer, usage, failure_reason, error_message, agent_loop = await self._run_agent_loop( + task=task, session=session, - llm_client=llm_client, + plugins_used=plugins_used, + llm_client=agent_llm_client, protocol=active_protocol, + model=model, max_steps=effective_max_steps, + timeout=timeout, + temperature=temperature, + seed=seed, + allowed_domains=allowed_domains, + runtime_profile=STRICT_EVAL_PROFILE, + behavior_mode="eval", on_navigation=on_navigation, on_observation=on_observation, ) - - # Failure tracking: - # failure_reason: what happened (always set on failure, goes into extra) - # error_message: set = evaluation is INVALID (mechanism issue, not agent capability) - # Valid failures (no error_message): max_steps_reached - # Invalid failures (error_message set): llm_error, browser_error, cache_error, agent_timeout, gt_failure - failure_reason = None - error_message = None - - # Fatal errors that invalidate evaluation (system issues, not agent capability) - _FATAL_ERROR_MAP = { - LLMFatalError: "llm_error", - CacheFatalError: "cache_error", - } - - try: - trajectory, final_answer, usage = await asyncio.wait_for( - agent_loop.run(task=task, model=model, temperature=temperature, seed=seed), - timeout=timeout, - ) - if agent_loop.is_parse_failed(): - failure_reason = "parse_failed" - log("Actor", "Parse failed - model output not valid JSON", force=True) - elif agent_loop.is_max_steps_reached(): - failure_reason = "max_steps_reached" - log("Actor", "Max steps reached without completion - marking as failed", force=True) - except asyncio.TimeoutError: - failure_reason = "agent_timeout" - error_message = f"Agent timeout after {timeout}s" - log("Actor", error_message, force=True) - except BrowserFatalError as e: - # Check if the failed URL belongs to a required domain - # If so, it's an infrastructure issue (site unreachable), not agent error - is_required_domain = False - if e.url: - for domain in allowed_domains: - if _url_matches_domain(e.url, domain): - is_required_domain = True - break - - if is_required_domain: - failure_reason = "site_unreachable" - error_message = f"Required site unreachable: {e.url} (after {e.attempts} attempts)" - log("Actor", f"Infrastructure error: {error_message}", force=True) - else: - failure_reason = "browser_error" - log("Actor", f"Browser error (agent issue): {e}", force=True) - except (LLMFatalError, CacheFatalError) as e: - failure_reason = _FATAL_ERROR_MAP[type(e)] - error_message = f"{failure_reason}: {e}" - log("Actor", f"Fatal error - {error_message}", force=True) - - # Exception path: recover partial state from agent loop - # (parse_failed and max_steps_reached are normal exits, not exceptions) - if failure_reason and failure_reason not in ("max_steps_reached", "parse_failed"): - trajectory = agent_loop.get_trajectory() - final_answer = agent_loop.get_final_answer() - usage = agent_loop.get_usage() + reachability_audit = getattr(agent_loop, "_reachability_audit", None) # GT is collected in real-time via on_observation callback # For API_ONLY and HYBRID templates, fetch remaining API GT @@ -546,8 +815,13 @@ async def on_observation(obs): answer_validations = pre_failed_validations.copy() if subtasks_to_validate: + validator_llm_client = self._build_llm_client( + base_url=base_url, + api_key=api_key, + route_key=f"{base_route_key}:validator", + ) llm_validations = await validate_answers_with_llm( - llm_client=llm_client, + llm_client=validator_llm_client, subtasks=subtasks_to_validate, answers=parsed_answers, ground_truths=ground_truths, @@ -629,8 +903,16 @@ async def on_observation(obs): "usage": usage, "answer_details": answer_validations, "conversation": conversation, + "runtime_profile": STRICT_EVAL_PROFILE, "failure_reason": failure_reason, "cache_stats": interceptor_stats, + "reachability_audit": reachability_audit.to_dict() if reachability_audit else None, + "reachability_classification": reachability_audit.classification if reachability_audit else None, + "reachability_layer": reachability_audit.layer if reachability_audit else None, + "is_environment_failure": reachability_audit.is_environment_failure if reachability_audit else False, + "is_model_hallucination": reachability_audit.is_model_hallucination if reachability_audit else False, + **agent_loop.get_format_recovery_stats(), + **agent_loop.get_local_recovery_stats(), }, "rewards": { "step_rewards": step_rewards, @@ -670,12 +952,131 @@ async def on_observation(obs): if session is not None: await session.close() + async def _run_collection( + self, + model: str, + base_url: Optional[str], + api_key: str, + seed: int, + num_subtasks: int, + templates: Optional[List[tuple]], + max_steps: Optional[int], + timeout: int, + temperature: float, + task_id: Optional[int] = None, + runtime_profile: Optional[str] = None, + route_key: Optional[str] = None, + ) -> dict: + await self._ensure_browser() + + task = await self.task_manager.generate_composite_task( + seed=seed, + num_subtasks=num_subtasks, + templates=templates, + ) + log("Actor", f"[collect] Generated {len(task.subtasks)} subtasks, seed={seed}") + + total_expected_steps = sum(st.expected_steps for st in task.subtasks) + effective_max_steps = max(max_steps or 0, total_expected_steps) if max_steps is not None else total_expected_steps + plugins_used, allowed_domains, blocked_patterns = self._collect_plugin_info(task) + cached_pages: Dict[str, CachedPage] = {} + + session = None + interceptor = None + try: + session = await self.browser.new_session() + session, interceptor = await self._setup_interceptor( + session, cached_pages, allowed_domains, blocked_patterns, plugins_used, + ) + active_protocol = self._build_protocol(runtime_profile=FAST_COLLECT_PROFILE) + base_route_key = route_key or f"collect:task:{task_id or 'none'}:seed:{seed}" + llm_client = self._build_llm_client( + base_url=base_url, + api_key=api_key, + route_key=f"{base_route_key}:agent", + max_retries=1, + strict_serial=True, + ) + + async def on_navigation(url: str): + await _handle_navigation_event( + interceptor, cached_pages, plugins_used, url, self.use_cache, + ) + + trajectory, final_answer, usage, failure_reason, error_message, agent_loop = await self._run_agent_loop( + task=task, + session=session, + plugins_used=plugins_used, + llm_client=llm_client, + protocol=active_protocol, + model=model, + max_steps=effective_max_steps, + timeout=timeout, + temperature=temperature, + seed=seed, + allowed_domains=allowed_domains, + runtime_profile=FAST_COLLECT_PROFILE, + behavior_mode="collect", + on_navigation=on_navigation, + on_observation=None, + ) + reachability_audit = getattr(agent_loop, "_reachability_audit", None) + failure_reason, reachability_audit = _maybe_promote_disallowed_domain_failure( + interceptor=interceptor, + trajectory=trajectory, + allowed_domains=allowed_domains, + failure_reason=failure_reason, + reachability_audit=reachability_audit, + mode="collect", + ) + + interceptor_stats = interceptor.get_stats() + final_url = trajectory[-1].observation.url if trajectory else None + conversation = self._build_conversation(task, trajectory, active_protocol) + + result = { + "task_name": f"liveweb_arena_collect:{num_subtasks}tasks", + "score": 0.0, + "success": final_answer is not None and failure_reason is None, + "time_taken": 0.0, + "extra": { + "mode": "collect", + "task_id": task_id, + "seed": seed, + "num_subtasks": num_subtasks, + "runtime_profile": FAST_COLLECT_PROFILE, + "final_url": final_url, + "usage": usage, + "final_answer": final_answer, + "conversation": conversation, + "failure_reason": failure_reason, + "cache_stats": interceptor_stats, + "trajectory_steps": len(trajectory), + "reachability_audit": reachability_audit.to_dict() if reachability_audit else None, + "reachability_classification": reachability_audit.classification if reachability_audit else None, + "reachability_layer": reachability_audit.layer if reachability_audit else None, + "is_environment_failure": reachability_audit.is_environment_failure if reachability_audit else False, + "is_model_hallucination": reachability_audit.is_model_hallucination if reachability_audit else False, + **agent_loop.get_format_recovery_stats(), + **agent_loop.get_local_recovery_stats(), + }, + } + if error_message: + result["error"] = error_message + return result + finally: + if interceptor is not None: + interceptor.cleanup() + cached_pages.clear() + if session is not None: + await session.close() + async def _ensure_browser(self): - """Ensure browser is started (lazy initialization).""" + """Ensure browser is started and healthy.""" async with self._lock: if self.browser is None: self.browser = BrowserEngine(headless=True) - await self.browser.start() + await self.browser.ensure_healthy() async def shutdown(self): """Shutdown browser, cache manager, and cleanup resources.""" @@ -733,8 +1134,7 @@ async def reset( templates = None num_subtasks = 2 if task_id is not None: - from liveweb_arena.core.task_registry import parse_task_id - task_config = parse_task_id(task_id) + task_config = parse_task_id_for_runtime(task_id, runtime_profile=self.runtime_profile) templates = task_config["templates"] num_subtasks = task_config["num_tasks"] # Use variation_seed from task_id if seed was auto-generated @@ -777,7 +1177,7 @@ async def reset( session = await self.browser.new_session() # Set up interceptor - interceptor = await self._setup_interceptor( + session, interceptor = await self._setup_interceptor( session, cached_pages, allowed_domains, blocked_patterns, plugins_used, ) @@ -817,7 +1217,7 @@ async def reset( ) # Build agent protocol and system prompt - policy = FunctionCallingProtocol() + policy = self._build_protocol(mode="eval") system_prompt = policy.build_system_prompt(task) # Navigate to about:blank and get initial observation diff --git a/experiments/__init__.py b/experiments/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/experiments/__init__.py @@ -0,0 +1 @@ + diff --git a/experiments/think_ablation/README.md b/experiments/think_ablation/README.md new file mode 100644 index 0000000..67840fa --- /dev/null +++ b/experiments/think_ablation/README.md @@ -0,0 +1,65 @@ +# Think Ablation + +这个目录用于在 `liveweb-arena` 已收集的 teacher 轨迹上做 `Qwen3-32B base` 的 step-level think 消融实验。 + +## 目录结构 + +- `prepare_step_eval_set.py` + - 从 teacher 轨迹构造 step-level evaluation set +- `run_ablation.py` + - 对 step 样本运行 `no-think / with-think / sampled-think / intervention-think` 等模式 +- `common.py` + - 共享的数据结构、指标、OpenAI-compatible 推理辅助函数 + +## 数据来源 + +默认输入: + +`/data/liveweb_teacher_runs/teacher_dataset_runtimepool_formal_replay_20260324_1555/teacher_trajectories.jsonl` + +## 最小使用方式 + +先构造 step 数据集: + +```bash +uv run python experiments/think_ablation/prepare_step_eval_set.py \ + --output-dir /home/xmyf/liveweb-arena/tmp/think_ablation_smoke \ + --max-samples 24 +``` + +再运行小规模消融: + +```bash +uv run python experiments/think_ablation/run_ablation.py \ + --dataset /home/xmyf/liveweb-arena/tmp/think_ablation_smoke/step_eval_set.jsonl \ + --output-dir /home/xmyf/liveweb-arena/tmp/think_ablation_run_small \ + --base-url http://127.0.0.1:31003/v1 \ + --api-key local-liveweb-bench \ + --model /home/xmyf/Qwen3-32B \ + --max-samples 6 \ + --sample-count 3 +``` + +## 输出 + +`run_ablation.py` 会输出: + +- `raw_results.jsonl` +- `summary.json` +- `REPORT.md` + +## 当前已完成的真实小规模实验 + +真实实验输出目录: + +- `/home/xmyf/liveweb-arena/tmp/think_ablation_run_small` + +其中: + +- `summary.json`:结构化指标汇总 +- `REPORT.md`:简短 markdown 报告 + +## 备注 + +- 本实验默认优先使用离线 step 数据,不需要真实 browser rollout。 +- 对本地 `127.0.0.1` OpenAI-compatible 服务,代码会禁用系统代理,避免本地请求绕行代理。 diff --git a/experiments/think_ablation/__init__.py b/experiments/think_ablation/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/experiments/think_ablation/__init__.py @@ -0,0 +1 @@ + diff --git a/experiments/think_ablation/common.py b/experiments/think_ablation/common.py new file mode 100644 index 0000000..885cae6 --- /dev/null +++ b/experiments/think_ablation/common.py @@ -0,0 +1,644 @@ +from __future__ import annotations + +import asyncio +import json +import math +import os +import random +import re +from ipaddress import ip_address +from collections import Counter, defaultdict +from dataclasses import dataclass +from pathlib import Path +from statistics import mean +from typing import Any, Iterable, Optional +from urllib.parse import urlparse + +import httpx +import openai + +from liveweb_arena.core.agent_protocol import BROWSER_ACTIONS, FunctionCallingProtocol +from liveweb_arena.core.models import BrowserObservation, BrowserAction, CompositeTask, TrajectoryStep +from liveweb_arena.core.task_manager import TaskManager +from liveweb_arena.plugins import get_all_plugins + + +THINK_SCAFFOLD = """Before deciding the next browser action, reason explicitly in this order: +1. Restate the immediate sub-goal. +2. Summarize the most relevant current page evidence. +3. Compare 1-3 candidate next actions. +4. Commit to the single best next action.""" + +SYSTEM_PROMPT_VARIANTS: dict[str, str] = { + "base": "", + "think_brief": ( + "\n## Additional Decision Rules\n" + "- Before every action, briefly reason about the immediate sub-goal and the most relevant page evidence.\n" + "- Use that reasoning to choose the next browser action.\n" + ), + "think_structured": ( + "\n## Additional Decision Rules\n" + "- Before every action, explicitly reason in this order: sub-goal, page evidence, candidate actions, final choice.\n" + "- Prefer actions that are directly grounded in the current page and task goal.\n" + "- If the current page already contains the needed evidence, act on it instead of exploring broadly.\n" + ), + "action_only_strict": ( + "\n## Additional Decision Rules\n" + "- Be extremely terse and action-oriented.\n" + "- Avoid unnecessary exploration and prefer the shortest valid next action.\n" + "- If the current page is enough, act immediately.\n" + ), +} + +GENERIC_THINK_PATTERNS = [ + re.compile(r"\b(i need to|let me|i should|first,|next,|then,|carefully)\b", re.I), + re.compile(r"\bthe task is asking\b", re.I), + re.compile(r"\bI will browse\b", re.I), +] + + +@dataclass +class StepSample: + sample_id: str + task_id: int + seed: int + num_subtasks: int + templates: list[list[str]] + step_id: int + step_num: int + task_goal: str + system_prompt: str + user_prompt: str + current_observation: dict[str, Any] + reference_action: dict[str, Any] + history: list[dict[str, Any]] + metadata: dict[str, Any] + + +def read_jsonl(path: Path) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + with path.open() as f: + for line in f: + line = line.strip() + if not line: + continue + rows.append(json.loads(line)) + return rows + + +def write_jsonl(path: Path, rows: Iterable[dict[str, Any]]) -> None: + with path.open("w") as f: + for row in rows: + f.write(json.dumps(row, ensure_ascii=False) + "\n") + + +def normalize_whitespace(text: str) -> str: + return re.sub(r"\s+", " ", (text or "").strip()) + + +def tokenize(text: str) -> list[str]: + return re.findall(r"[a-z0-9]+", (text or "").lower()) + + +def jaccard_similarity(a: str, b: str) -> float: + ta = set(tokenize(a)) + tb = set(tokenize(b)) + if not ta and not tb: + return 1.0 + if not ta or not tb: + return 0.0 + return len(ta & tb) / len(ta | tb) + + +def normalize_url_for_compare(url: str) -> str: + text = normalize_whitespace(url).lower() + text = re.sub(r"^https?://", "", text) + return text.rstrip("/") + + +def normalize_action(action: dict[str, Any] | None) -> Optional[dict[str, Any]]: + if not action: + return None + action_type = action.get("action_type") + params = dict(action.get("params") or {}) + if action_type == "stop": + final = params.get("final") + if isinstance(final, dict): + params = {"answers": dict(final.get("answers") or {})} + return {"action_type": action_type, "params": params} + + +def action_signature(action: dict[str, Any] | None) -> str: + norm = normalize_action(action) + if not norm: + return "NONE" + return json.dumps(norm, sort_keys=True, ensure_ascii=False) + + +def is_executable_action(action: dict[str, Any] | None) -> bool: + norm = normalize_action(action) + if not norm: + return False + action_type = norm["action_type"] + spec = BROWSER_ACTIONS.get(action_type) + if not spec: + return False + params = norm.get("params") or {} + required = spec["parameters"].get("required", []) + return all(key in params and params[key] not in (None, "", {}) for key in required) + + +def exact_action_match(reference_action: dict[str, Any], predicted_action: dict[str, Any] | None) -> bool: + return action_signature(reference_action) == action_signature(predicted_action) + + +def normalized_action_match(reference_action: dict[str, Any], predicted_action: dict[str, Any] | None) -> bool: + ref = normalize_action(reference_action) + pred = normalize_action(predicted_action) + if ref is None or pred is None: + return False + if ref["action_type"] != pred["action_type"]: + return False + + r_params = ref["params"] + p_params = pred["params"] + if ref["action_type"] == "goto": + return normalize_url_for_compare(r_params.get("url", "")) == normalize_url_for_compare(p_params.get("url", "")) + if ref["action_type"] == "stop": + return json.dumps(r_params, sort_keys=True, ensure_ascii=False) == json.dumps( + p_params, sort_keys=True, ensure_ascii=False + ) + return json.dumps(r_params, sort_keys=True, ensure_ascii=False) == json.dumps( + p_params, sort_keys=True, ensure_ascii=False + ) + + +def grounding_score(action: dict[str, Any] | None, observation: dict[str, Any]) -> float: + norm = normalize_action(action) + if not norm: + return 0.0 + obs_text = " ".join( + [ + observation.get("url", ""), + observation.get("title", ""), + observation.get("accessibility_tree", ""), + ] + ).lower() + if norm["action_type"] == "goto": + url = normalize_url_for_compare((norm.get("params") or {}).get("url", "")) + if not url: + return 0.0 + return 1.0 if any(part and part in obs_text for part in url.split("/")) else 0.25 + joined = json.dumps(norm.get("params") or {}, ensure_ascii=False).lower() + tokens = [tok for tok in tokenize(joined) if len(tok) > 2] + if not tokens: + return 0.0 + overlap = sum(1 for tok in tokens if tok in obs_text) + return overlap / len(tokens) + + +def observation_overlap_ratio(think_text: str, observation: dict[str, Any]) -> float: + think_tokens = set(tokenize(think_text)) + if not think_tokens: + return 0.0 + obs_tokens = set( + tokenize( + " ".join( + [ + observation.get("url", ""), + observation.get("title", ""), + observation.get("accessibility_tree", ""), + ] + ) + ) + ) + if not obs_tokens: + return 0.0 + return len(think_tokens & obs_tokens) / len(think_tokens) + + +def prompt_generic_ratio(think_text: str) -> float: + tokens = tokenize(think_text) + if not tokens: + return 0.0 + generic_hits = 0 + for pattern in GENERIC_THINK_PATTERNS: + if pattern.search(think_text): + generic_hits += 1 + return min(1.0, generic_hits / max(1, len(GENERIC_THINK_PATTERNS))) + + +def label_think_category(think_text: str, observation: dict[str, Any]) -> str: + text = think_text.lower() + obs_text = (observation.get("accessibility_tree") or "").lower() + if any(k in text for k in ["compare", "option", "candidate", "best action"]): + return "candidate_action_comparison" + if any(k in text for k in ["summary", "current page", "page shows", "visible", "on this page"]): + return "page_state_summary" + if any(k in text for k in ["goal", "sub-goal", "need to answer", "need to find"]): + return "subgoal_planning" + if any(k in text for k in ["button", "link", "textbox", "selector", "element"]) or any( + tok in obs_text for tok in tokenize(text) + ): + return "element_grounding" + if any(k in text for k in ["maybe", "however", "double-check", "verify", "not sure"]): + return "self_correction" + if prompt_generic_ratio(think_text) > 0.5: + return "generic_reasoning" + return "task_restatement" + + +def crop_observation_prompt(user_prompt: str, max_accessibility_chars: int) -> str: + pattern = re.compile(r"(### Accessibility Tree\s+```)(.*?)(```)", re.DOTALL) + match = pattern.search(user_prompt) + if not match: + return user_prompt + tree = match.group(2) + cropped_tree = tree[:max_accessibility_chars] + return user_prompt[: match.start(2)] + cropped_tree + user_prompt[match.end(2) :] + + +def build_action_user_prompt(user_prompt: str, think_text: str | None, scaffold: bool = False) -> str: + if not think_text: + return user_prompt + scaffold_text = ( + "\nUse the reasoning trace below as a candidate plan. If it conflicts with the page, trust the page." + if scaffold + else "\nCandidate reasoning trace:" + ) + return ( + f"{user_prompt}\n" + f"{scaffold_text}\n" + f"\n{think_text.strip()}\n\n" + "Now output exactly one valid tool call for the next action." + ) + + +def build_think_user_prompt(user_prompt: str, scaffold: bool = False) -> str: + extra = ( + "\n" + THINK_SCAFFOLD + "\nReturn only the reasoning trace. Do not output a tool call." + if scaffold + else "\nThink through the next browser action. Return only a short reasoning trace. Do not output a tool call." + ) + return f"{user_prompt}{extra}" + + +def apply_system_prompt_variant(system_prompt: str, variant: str) -> str: + suffix = SYSTEM_PROMPT_VARIANTS.get(variant) + if suffix is None: + raise ValueError(f"Unknown system prompt variant: {variant}") + return f"{system_prompt}{suffix}" if suffix else system_prompt + + +def extract_reasoning_text(message: Any, visible_content: str = "") -> str: + reasoning = getattr(message, "reasoning_content", None) + if reasoning: + return normalize_whitespace(str(reasoning)) + content = getattr(message, "content", None) + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, dict): + if str(item.get("type")).lower() in {"reasoning", "reasoning_content", "thinking"}: + text = item.get("text") or item.get("content") + if text: + parts.append(str(text)) + elif isinstance(item, str): + parts.append(item) + if parts: + return normalize_whitespace(" ".join(parts)) + content_text = visible_content or (content if isinstance(content, str) else "") + think_match = re.search(r"\s*(.*?)\s*", content_text or "", re.DOTALL | re.IGNORECASE) + if think_match: + return normalize_whitespace(think_match.group(1)) + return normalize_whitespace(content_text or "") + + +def difficulty_bucket(num_subtasks: int, step_index: int, total_steps: int) -> str: + if num_subtasks <= 1 and step_index <= 1: + return "easy" + if num_subtasks == 2 and step_index <= max(1, total_steps // 2): + return "medium" + return "hard" + + +async def reconstruct_task_metadata( + task_id: int, + seed: int, + num_subtasks: int, + templates: list[list[str]], +) -> tuple[str, str]: + manager = TaskManager(get_all_plugins()) + tuple_templates = [tuple(item) for item in templates] + task = await manager.generate_composite_task( + seed=seed, + num_subtasks=num_subtasks, + templates=tuple_templates, + ) + protocol = FunctionCallingProtocol() + system_prompt = protocol.build_system_prompt(task) + return task.combined_intent, system_prompt + + +def serialize_history(trajectory: list[dict[str, Any]], upto_step_index: int) -> list[dict[str, Any]]: + history: list[dict[str, Any]] = [] + for step in trajectory[:upto_step_index]: + history.append( + { + "step_num": step["step_num"], + "action": step.get("action"), + "action_result": step.get("action_result", ""), + "observation": step.get("observation", {}), + "prompt": step.get("prompt"), + "raw_response": step.get("raw_response"), + } + ) + return history + + +def summarize_results(rows: list[dict[str, Any]]) -> dict[str, Any]: + by_mode: dict[str, list[dict[str, Any]]] = defaultdict(list) + by_variant_mode: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in rows: + by_mode[row["mode"]].append(row) + by_variant_mode[f"{row.get('system_prompt_variant', 'base')}::{row['mode']}"].append(row) + + def _summarize_bucket(mode_rows: list[dict[str, Any]]) -> dict[str, Any]: + parse_success = [1.0 if row["metrics"]["parse_success"] else 0.0 for row in mode_rows] + executable = [1.0 if row["metrics"]["executable_action"] else 0.0 for row in mode_rows] + exact = [1.0 if row["metrics"]["exact_match"] else 0.0 for row in mode_rows] + normalized = [1.0 if row["metrics"]["normalized_match"] else 0.0 for row in mode_rows] + action_type_acc = [1.0 if row["metrics"]["action_type_match"] else 0.0 for row in mode_rows] + grounding = [row["metrics"]["grounding_score"] for row in mode_rows] + think_lengths = [row["think"]["length_chars"] for row in mode_rows if row.get("think")] + obs_overlap = [row["think"]["observation_overlap_ratio"] for row in mode_rows if row.get("think")] + generic_ratio = [row["think"]["generic_ratio"] for row in mode_rows if row.get("think")] + think_categories = Counter(row["think"]["category"] for row in mode_rows if row.get("think")) + think_texts = [row["think"]["text"] for row in mode_rows if row.get("think", {}).get("text")] + unique_ratio = len(set(think_texts)) / len(think_texts) if think_texts else 0.0 + + return { + "count": len(mode_rows), + "parse_success_rate": mean(parse_success) if parse_success else 0.0, + "executable_action_rate": mean(executable) if executable else 0.0, + "exact_match_rate": mean(exact) if exact else 0.0, + "normalized_match_rate": mean(normalized) if normalized else 0.0, + "action_type_accuracy": mean(action_type_acc) if action_type_acc else 0.0, + "mean_grounding_score": mean(grounding) if grounding else 0.0, + "average_think_length": mean(think_lengths) if think_lengths else 0.0, + "unique_think_ratio": unique_ratio, + "mean_observation_overlap_ratio": mean(obs_overlap) if obs_overlap else 0.0, + "mean_prompt_generic_ratio": mean(generic_ratio) if generic_ratio else 0.0, + "think_category_distribution": dict(think_categories), + } + + mode_summary: dict[str, Any] = {mode: _summarize_bucket(mode_rows) for mode, mode_rows in by_mode.items()} + variant_mode_summary: dict[str, Any] = { + key: _summarize_bucket(mode_rows) for key, mode_rows in by_variant_mode.items() + } + + paired = defaultdict(dict) + for row in rows: + paired[row["sample_id"]][row["mode"]] = row + + coupling: dict[str, Any] = { + "real_vs_empty_exact_delta": 0.0, + "real_vs_shuffled_exact_delta": 0.0, + "same_history_different_think_different_action_rate": 0.0, + } + real_empty = [] + real_shuffle = [] + different_action = [] + for mode_map in paired.values(): + if "with_think_raw" in mode_map and "empty_think" in mode_map: + real_empty.append( + float(mode_map["with_think_raw"]["metrics"]["exact_match"]) + - float(mode_map["empty_think"]["metrics"]["exact_match"]) + ) + if "with_think_raw" in mode_map and "shuffled_think" in mode_map: + real_shuffle.append( + float(mode_map["with_think_raw"]["metrics"]["exact_match"]) + - float(mode_map["shuffled_think"]["metrics"]["exact_match"]) + ) + sampled = mode_map.get("sampled_think") + if sampled and sampled.get("sampled_actions"): + signatures = {action_signature(item.get("predicted_action")) for item in sampled["sampled_actions"]} + different_action.append(1.0 if len(signatures) > 1 else 0.0) + if real_empty: + coupling["real_vs_empty_exact_delta"] = mean(real_empty) + if real_shuffle: + coupling["real_vs_shuffled_exact_delta"] = mean(real_shuffle) + if different_action: + coupling["same_history_different_think_different_action_rate"] = mean(different_action) + + by_variant: dict[str, dict[str, float]] = defaultdict(dict) + for key, bucket in variant_mode_summary.items(): + variant, mode = key.split("::", 1) + by_variant[variant][mode] = bucket["exact_match_rate"] + + return { + "modes": mode_summary, + "variant_modes": variant_mode_summary, + "coupling": coupling, + "system_prompt_variants": by_variant, + "count": len(rows), + } + + +def render_markdown_report( + *, + title: str, + config: dict[str, Any], + summary: dict[str, Any], + sample_examples: list[dict[str, Any]], +) -> str: + lines = [f"# {title}", "", "## 实验设置", ""] + lines.append(f"- 样本数: `{config.get('num_samples')}`") + lines.append(f"- 模型: `{config.get('model')}`") + lines.append(f"- 服务: `{config.get('base_url')}`") + lines.append(f"- 模式: `{', '.join(config.get('modes', []))}`") + lines.append("") + lines.append("## 主要结果") + lines.append("") + lines.append("| mode | parse | exec | exact | normalized | type_acc | grounding | think_len | overlap | generic |") + lines.append("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |") + for mode, mode_summary in sorted(summary.get("modes", {}).items()): + lines.append( + "| {mode} | {parse:.3f} | {exec:.3f} | {exact:.3f} | {norm:.3f} | {type_acc:.3f} | {ground:.3f} | {think_len:.1f} | {overlap:.3f} | {generic:.3f} |".format( + mode=mode, + parse=mode_summary["parse_success_rate"], + exec=mode_summary["executable_action_rate"], + exact=mode_summary["exact_match_rate"], + norm=mode_summary["normalized_match_rate"], + type_acc=mode_summary["action_type_accuracy"], + ground=mode_summary["mean_grounding_score"], + think_len=mode_summary["average_think_length"], + overlap=mode_summary["mean_observation_overlap_ratio"], + generic=mode_summary["mean_prompt_generic_ratio"], + ) + ) + lines.append("") + variant_summary = summary.get("variant_modes", {}) + if variant_summary: + lines.append("## System Prompt Ablation") + lines.append("") + lines.append("| variant::mode | exact | normalized | type_acc | grounding |") + lines.append("| --- | ---: | ---: | ---: | ---: |") + for key, bucket in sorted(variant_summary.items()): + lines.append( + "| {key} | {exact:.3f} | {norm:.3f} | {type_acc:.3f} | {ground:.3f} |".format( + key=key, + exact=bucket["exact_match_rate"], + norm=bucket["normalized_match_rate"], + type_acc=bucket["action_type_accuracy"], + ground=bucket["mean_grounding_score"], + ) + ) + lines.append("") + coupling = summary.get("coupling", {}) + lines.append("## Coupling 指标") + lines.append("") + lines.append(f"- 真实 think vs 空 think exact-match 差值: `{coupling.get('real_vs_empty_exact_delta', 0.0):.3f}`") + lines.append(f"- 真实 think vs 打乱 think exact-match 差值: `{coupling.get('real_vs_shuffled_exact_delta', 0.0):.3f}`") + lines.append( + f"- 同一 history 下不同 think 导致不同 action 的比例: `{coupling.get('same_history_different_think_different_action_rate', 0.0):.3f}`" + ) + lines.append("") + lines.append("## 典型样例") + lines.append("") + for example in sample_examples[:5]: + lines.append(f"### {example['sample_id']} / {example['mode']}") + lines.append("") + lines.append(f"- 任务: `{example['metadata'].get('templates')}`") + lines.append(f"- reference action: `{json.dumps(example['reference_action'], ensure_ascii=False)}`") + lines.append(f"- predicted action: `{json.dumps(example.get('predicted_action'), ensure_ascii=False)}`") + if example.get("think", {}).get("text"): + lines.append(f"- think: `{example['think']['text'][:400]}`") + lines.append(f"- metrics: `{json.dumps(example['metrics'], ensure_ascii=False)}`") + lines.append("") + return "\n".join(lines) + "\n" + + +def create_openai_client(base_url: str, api_key: str, timeout_s: int = 120) -> openai.AsyncOpenAI: + timeout_config = httpx.Timeout(connect=30.0, read=timeout_s, write=30.0, pool=30.0) + hostname = urlparse(base_url).hostname or "" + trust_env = True + try: + if hostname in {"localhost", "127.0.0.1"} or ip_address(hostname).is_loopback: + trust_env = False + except ValueError: + trust_env = hostname not in {"localhost"} + http_client = httpx.AsyncClient(timeout=timeout_config, trust_env=trust_env) + return openai.AsyncOpenAI(base_url=base_url, api_key=api_key, timeout=timeout_config, max_retries=0, http_client=http_client) + + +def build_reasoning_extra_body(enable_thinking: bool, separate_reasoning: bool, reasoning_effort: str | None) -> dict[str, Any]: + extra_body: dict[str, Any] = {"request_id": f"think-ablation-{random.randint(0, 2**31-1):x}"} + extra_body["chat_template_kwargs"] = {"enable_thinking": enable_thinking} + extra_body["separate_reasoning"] = separate_reasoning + if enable_thinking: + if reasoning_effort: + extra_body["reasoning"] = {"effort": reasoning_effort} + else: + extra_body["reasoning"] = {"enabled": False} + return extra_body + + +async def fetch_reasoning_trace( + *, + base_url: str, + api_key: str, + model: str, + system_prompt: str, + user_prompt: str, + temperature: float, + top_p: float, + seed: int | None, + max_tokens: int, + reasoning_effort: str | None = "low", + enable_thinking: bool = True, +) -> dict[str, Any]: + client = create_openai_client(base_url=base_url, api_key=api_key, timeout_s=180) + try: + response = await client.chat.completions.create( + model=model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + temperature=temperature, + top_p=top_p, + seed=seed, + max_tokens=max_tokens, + max_completion_tokens=max_tokens, + extra_body=build_reasoning_extra_body( + enable_thinking=enable_thinking, + separate_reasoning=True, + reasoning_effort=reasoning_effort, + ), + ) + choice = response.choices[0] + message = choice.message + visible_content = message.content if isinstance(message.content, str) else "" + reasoning_text = extract_reasoning_text(message, visible_content) + usage = response.usage.model_dump() if response.usage else None + return { + "text": reasoning_text, + "raw_content": visible_content, + "request_id": getattr(response, "id", None), + "usage": usage, + } + finally: + await client.close() + + +async def fetch_action_response( + *, + base_url: str, + api_key: str, + model: str, + system_prompt: str, + user_prompt: str, + temperature: float, + seed: int | None, + max_tokens: int, + enable_thinking: bool = False, +) -> dict[str, Any]: + client = create_openai_client(base_url=base_url, api_key=api_key, timeout_s=180) + protocol = FunctionCallingProtocol() + try: + response = await client.chat.completions.create( + model=model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + tools=protocol.get_tools(), + temperature=temperature, + seed=seed, + max_tokens=max_tokens, + max_completion_tokens=max_tokens, + extra_body=build_reasoning_extra_body( + enable_thinking=enable_thinking, + separate_reasoning=True, + reasoning_effort=None, + ), + ) + choice = response.choices[0] + tool_calls = choice.message.tool_calls or [] + content = choice.message.content or "" + parsed_tool_calls = [ + {"id": tc.id, "function": {"name": tc.function.name, "arguments": tc.function.arguments}} + for tc in tool_calls + ] + action = protocol.parse_response(content, parsed_tool_calls) + usage = response.usage.model_dump() if response.usage else None + return { + "parsed_action": {"action_type": action.action_type, "params": action.params} if action else None, + "tool_calls": parsed_tool_calls, + "raw_content": content, + "usage": usage, + "request_id": getattr(response, "id", None), + } + finally: + await client.close() diff --git a/experiments/think_ablation/prepare_step_eval_set.py b/experiments/think_ablation/prepare_step_eval_set.py new file mode 100644 index 0000000..b559a3b --- /dev/null +++ b/experiments/think_ablation/prepare_step_eval_set.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import asyncio +import json +import random +import sys +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from liveweb_arena.core.agent_protocol import FunctionCallingProtocol +from liveweb_arena.core.models import BrowserObservation + +from experiments.think_ablation.common import ( + StepSample, + difficulty_bucket, + read_jsonl, + reconstruct_task_metadata, + serialize_history, + write_jsonl, +) + + +def _iter_step_candidates(row: dict[str, Any]) -> list[dict[str, Any]]: + candidates: list[dict[str, Any]] = [] + trajectory = row.get("trajectory") or [] + total_steps = len(trajectory) + for step_index, step in enumerate(trajectory): + action = step.get("action") + prompt = step.get("prompt") + if not action or not prompt: + continue + candidates.append( + { + "task_id": row["task_id"], + "seed": row["seed"], + "num_subtasks": row["num_subtasks"], + "templates": row["templates"], + "step_id": step_index, + "step_num": step["step_num"], + "current_observation": step["observation"], + "reference_action": action, + "history": serialize_history(trajectory, step_index), + "user_prompt": prompt, + "metadata": { + "action_type": action.get("action_type"), + "difficulty": difficulty_bucket(row["num_subtasks"], step_index + 1, total_steps + 1), + "step_position": "early" + if step_index <= 1 + else ("late" if step_index >= max(2, total_steps - 1) else "mid"), + "templates": row["templates"], + "score": row.get("score"), + "trajectory_steps": row.get("trajectory_steps"), + }, + } + ) + + final_answer = row.get("final_answer") + if trajectory and final_answer: + last_obs = trajectory[-1]["observation"] + last_history = serialize_history(trajectory, len(trajectory)) + protocol = FunctionCallingProtocol() + prompt = protocol.build_step_prompt( + BrowserObservation( + url=last_obs.get("url", ""), + title=last_obs.get("title", ""), + accessibility_tree=last_obs.get("accessibility_tree", ""), + ), + [], + current_step=len(trajectory) + 1, + max_steps=30, + ) + candidates.append( + { + "task_id": row["task_id"], + "seed": row["seed"], + "num_subtasks": row["num_subtasks"], + "templates": row["templates"], + "step_id": len(trajectory), + "step_num": len(trajectory) + 1, + "current_observation": last_obs, + "reference_action": {"action_type": "stop", "params": {"final": {"answers": final_answer}}}, + "history": last_history, + "user_prompt": prompt, + "metadata": { + "action_type": "stop", + "difficulty": difficulty_bucket(row["num_subtasks"], len(trajectory) + 1, len(trajectory) + 1), + "step_position": "late", + "templates": row["templates"], + "score": row.get("score"), + "trajectory_steps": row.get("trajectory_steps"), + }, + } + ) + return candidates + + +def _select_candidates(candidates: list[dict[str, Any]], max_samples: int, seed: int) -> list[dict[str, Any]]: + rng = random.Random(seed) + grouped: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list) + for item in candidates: + grouped[(item["metadata"]["action_type"], item["metadata"]["difficulty"])].append(item) + + for bucket in grouped.values(): + rng.shuffle(bucket) + + keys = sorted(grouped.keys()) + selected: list[dict[str, Any]] = [] + seen_keys: set[tuple[int, int]] = set() + while len(selected) < max_samples: + progressed = False + for key in keys: + bucket = grouped[key] + while bucket: + item = bucket.pop() + dedupe_key = (item["task_id"], item["step_id"]) + if dedupe_key in seen_keys: + continue + selected.append(item) + seen_keys.add(dedupe_key) + progressed = True + break + if len(selected) >= max_samples: + break + if not progressed: + break + return selected + + +async def _attach_task_metadata(samples: list[dict[str, Any]]) -> list[StepSample]: + task_cache: dict[tuple[int, int], tuple[str, str]] = {} + keys = {(sample["task_id"], sample["seed"]) for sample in samples} + for task_id, seed in keys: + exemplar = next(sample for sample in samples if sample["task_id"] == task_id and sample["seed"] == seed) + task_cache[(task_id, seed)] = await reconstruct_task_metadata( + task_id=task_id, + seed=seed, + num_subtasks=exemplar["num_subtasks"], + templates=exemplar["templates"], + ) + + result: list[StepSample] = [] + for sample in samples: + task_goal, system_prompt = task_cache[(sample["task_id"], sample["seed"])] + result.append( + StepSample( + sample_id=f"task{sample['task_id']}_step{sample['step_id']}", + task_id=sample["task_id"], + seed=sample["seed"], + num_subtasks=sample["num_subtasks"], + templates=sample["templates"], + step_id=sample["step_id"], + step_num=sample["step_num"], + task_goal=task_goal, + system_prompt=system_prompt, + user_prompt=sample["user_prompt"], + current_observation=sample["current_observation"], + reference_action=sample["reference_action"], + history=sample["history"], + metadata=sample["metadata"], + ) + ) + return result + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build a step-level think ablation eval set from teacher trajectories.") + parser.add_argument( + "--teacher-trajectories", + type=Path, + default=Path("/data/liveweb_teacher_runs/teacher_dataset_runtimepool_formal_replay_20260324_1555/teacher_trajectories.jsonl"), + ) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--max-samples", type=int, default=200) + parser.add_argument("--seed", type=int, default=42) + args = parser.parse_args() + + rows = read_jsonl(args.teacher_trajectories) + all_candidates: list[dict[str, Any]] = [] + for row in rows: + all_candidates.extend(_iter_step_candidates(row)) + + selected = _select_candidates(all_candidates, args.max_samples, args.seed) + samples = asyncio.run(_attach_task_metadata(selected)) + + args.output_dir.mkdir(parents=True, exist_ok=True) + out_path = args.output_dir / "step_eval_set.jsonl" + write_jsonl(out_path, [sample.__dict__ for sample in samples]) + + action_counts = Counter(sample.metadata["action_type"] for sample in samples) + difficulty_counts = Counter(sample.metadata["difficulty"] for sample in samples) + summary = { + "teacher_trajectories": str(args.teacher_trajectories), + "num_rows": len(rows), + "num_candidates": len(all_candidates), + "num_selected": len(samples), + "action_type_counts": dict(action_counts), + "difficulty_counts": dict(difficulty_counts), + "seed": args.seed, + "max_samples": args.max_samples, + } + (args.output_dir / "step_eval_manifest.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2)) + print(json.dumps(summary, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/experiments/think_ablation/run_ablation.py b/experiments/think_ablation/run_ablation.py new file mode 100644 index 0000000..a008692 --- /dev/null +++ b/experiments/think_ablation/run_ablation.py @@ -0,0 +1,517 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import asyncio +import json +import random +import sys +from pathlib import Path +from statistics import mean +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from experiments.think_ablation.common import ( + SYSTEM_PROMPT_VARIANTS, + action_signature, + apply_system_prompt_variant, + build_action_user_prompt, + build_think_user_prompt, + crop_observation_prompt, + exact_action_match, + fetch_action_response, + fetch_reasoning_trace, + grounding_score, + is_executable_action, + jaccard_similarity, + label_think_category, + normalized_action_match, + observation_overlap_ratio, + prompt_generic_ratio, + read_jsonl, + render_markdown_report, + summarize_results, + write_jsonl, +) + + +SUPPORTED_MODES = [ + "no_think_raw", + "with_think_raw", + "with_think_scaffold", + "sampled_think", + "empty_think", + "shuffled_think", + "with_think_cropped", +] + + +def _metrics_for_prediction(sample: dict[str, Any], predicted_action: dict[str, Any] | None) -> dict[str, Any]: + reference_action = sample["reference_action"] + return { + "parse_success": predicted_action is not None, + "executable_action": is_executable_action(predicted_action), + "exact_match": exact_action_match(reference_action, predicted_action), + "normalized_match": normalized_action_match(reference_action, predicted_action), + "action_type_match": ( + predicted_action is not None + and predicted_action.get("action_type") == reference_action.get("action_type") + ), + "grounding_score": grounding_score(predicted_action, sample["current_observation"]), + } + + +async def _run_no_think( + sample: dict[str, Any], + *, + base_url: str, + api_key: str, + model: str, + temperature: float, + max_action_tokens: int, + seed: int | None, + crop_chars: int | None = None, + system_prompt_variant: str = "base", + repeat_index: int = 0, +) -> dict[str, Any]: + user_prompt = sample["user_prompt"] + if crop_chars: + user_prompt = crop_observation_prompt(user_prompt, crop_chars) + system_prompt = apply_system_prompt_variant(sample["system_prompt"], system_prompt_variant) + response = await fetch_action_response( + base_url=base_url, + api_key=api_key, + model=model, + system_prompt=system_prompt, + user_prompt=user_prompt, + temperature=temperature, + seed=None if seed is None else seed + repeat_index, + max_tokens=max_action_tokens, + enable_thinking=False, + ) + predicted_action = response["parsed_action"] + return { + "mode": "no_think_raw", + "sample_id": sample["sample_id"], + "task_id": sample["task_id"], + "step_id": sample["step_id"], + "system_prompt_variant": system_prompt_variant, + "repeat_index": repeat_index, + "metadata": sample["metadata"], + "reference_action": sample["reference_action"], + "predicted_action": predicted_action, + "metrics": _metrics_for_prediction(sample, predicted_action), + "raw": response, + "think": { + "text": "", + "length_chars": 0, + "observation_overlap_ratio": 0.0, + "generic_ratio": 0.0, + "category": "no_think", + }, + } + + +async def _run_with_think( + sample: dict[str, Any], + *, + mode: str, + base_url: str, + api_key: str, + model: str, + think_temperature: float, + action_temperature: float, + max_think_tokens: int, + max_action_tokens: int, + seed: int | None, + scaffold: bool = False, + injected_think: str | None = None, + crop_chars: int | None = None, + system_prompt_variant: str = "base", + repeat_index: int = 0, +) -> dict[str, Any]: + user_prompt = sample["user_prompt"] + if crop_chars: + user_prompt = crop_observation_prompt(user_prompt, crop_chars) + system_prompt = apply_system_prompt_variant(sample["system_prompt"], system_prompt_variant) + if injected_think is None: + think_prompt = build_think_user_prompt(user_prompt, scaffold=scaffold) + think_result = await fetch_reasoning_trace( + base_url=base_url, + api_key=api_key, + model=model, + system_prompt=system_prompt, + user_prompt=think_prompt, + temperature=think_temperature, + top_p=0.95, + seed=None if seed is None else seed + repeat_index, + max_tokens=max_think_tokens, + reasoning_effort="low", + enable_thinking=True, + ) + think_text = think_result["text"] + else: + think_result = {"text": injected_think, "raw_content": injected_think, "usage": None, "request_id": None} + think_text = injected_think + + action_prompt = build_action_user_prompt(user_prompt, think_text, scaffold=scaffold) + action_result = await fetch_action_response( + base_url=base_url, + api_key=api_key, + model=model, + system_prompt=system_prompt, + user_prompt=action_prompt, + temperature=action_temperature, + seed=None if seed is None else seed + repeat_index, + max_tokens=max_action_tokens, + enable_thinking=False, + ) + predicted_action = action_result["parsed_action"] + return { + "mode": mode, + "sample_id": sample["sample_id"], + "task_id": sample["task_id"], + "step_id": sample["step_id"], + "system_prompt_variant": system_prompt_variant, + "repeat_index": repeat_index, + "metadata": sample["metadata"], + "reference_action": sample["reference_action"], + "predicted_action": predicted_action, + "metrics": _metrics_for_prediction(sample, predicted_action), + "raw": {"think": think_result, "action": action_result}, + "think": { + "text": think_text, + "length_chars": len(think_text or ""), + "observation_overlap_ratio": observation_overlap_ratio(think_text or "", sample["current_observation"]), + "generic_ratio": prompt_generic_ratio(think_text or ""), + "category": label_think_category(think_text or "", sample["current_observation"]), + }, + } + + +async def _run_sampled_think( + sample: dict[str, Any], + *, + base_url: str, + api_key: str, + model: str, + think_temperature: float, + action_temperature: float, + max_think_tokens: int, + max_action_tokens: int, + seed: int | None, + sample_count: int, + system_prompt_variant: str = "base", + repeat_index: int = 0, +) -> dict[str, Any]: + sampled_actions: list[dict[str, Any]] = [] + think_texts: list[str] = [] + for i in range(sample_count): + result = await _run_with_think( + sample, + mode="sampled_think_member", + base_url=base_url, + api_key=api_key, + model=model, + think_temperature=think_temperature, + action_temperature=action_temperature, + max_think_tokens=max_think_tokens, + max_action_tokens=max_action_tokens, + seed=None if seed is None else seed + repeat_index * 1000 + i + 1, + scaffold=False, + system_prompt_variant=system_prompt_variant, + repeat_index=repeat_index, + ) + sampled_actions.append( + { + "think_text": result["think"]["text"], + "predicted_action": result["predicted_action"], + "metrics": result["metrics"], + } + ) + think_texts.append(result["think"]["text"]) + + majority_action = None + if sampled_actions: + signatures: dict[str, tuple[int, dict[str, Any] | None]] = {} + for item in sampled_actions: + sig = action_signature(item["predicted_action"]) + count, _ = signatures.get(sig, (0, item["predicted_action"])) + signatures[sig] = (count + 1, item["predicted_action"]) + majority_action = max(signatures.values(), key=lambda item: item[0])[1] + + pairwise_similarity: list[float] = [] + for i in range(len(think_texts)): + for j in range(i + 1, len(think_texts)): + pairwise_similarity.append(jaccard_similarity(think_texts[i], think_texts[j])) + + result = { + "mode": "sampled_think", + "sample_id": sample["sample_id"], + "task_id": sample["task_id"], + "step_id": sample["step_id"], + "system_prompt_variant": system_prompt_variant, + "repeat_index": repeat_index, + "metadata": sample["metadata"], + "reference_action": sample["reference_action"], + "predicted_action": majority_action, + "metrics": _metrics_for_prediction(sample, majority_action), + "raw": {}, + "think": { + "text": think_texts[0] if think_texts else "", + "length_chars": sum(len(text) for text in think_texts) / max(1, len(think_texts)), + "observation_overlap_ratio": mean( + observation_overlap_ratio(text, sample["current_observation"]) for text in think_texts + ) + if think_texts + else 0.0, + "generic_ratio": mean(prompt_generic_ratio(text) for text in think_texts) if think_texts else 0.0, + "category": "sampled_think", + "self_consistency": sum(pairwise_similarity) / max(1, len(pairwise_similarity)) if pairwise_similarity else 1.0, + }, + "sampled_actions": sampled_actions, + } + return result + + +async def main_async(args: argparse.Namespace) -> None: + samples = read_jsonl(args.dataset) + if args.max_samples: + samples = samples[: args.max_samples] + + rng = random.Random(args.seed) + modes = [mode.strip() for mode in args.modes.split(",") if mode.strip()] + system_prompt_variants = [variant.strip() for variant in args.system_prompt_variants.split(",") if variant.strip()] + for mode in modes: + if mode not in SUPPORTED_MODES: + raise ValueError(f"Unsupported mode: {mode}") + for variant in system_prompt_variants: + if variant not in SYSTEM_PROMPT_VARIANTS: + raise ValueError(f"Unsupported system prompt variant: {variant}") + + args.output_dir.mkdir(parents=True, exist_ok=True) + raw_path = args.output_dir / "raw_results.jsonl" + if raw_path.exists(): + raw_path.unlink() + + results: list[dict[str, Any]] = [] + think_pool_by_action: dict[str, list[str]] = {} + with_think_cache: dict[str, dict[str, Any]] = {} + + for sample in samples: + action_type = sample["reference_action"]["action_type"] + think_pool_by_action.setdefault(action_type, []) + new_rows: list[dict[str, Any]] = [] + for system_prompt_variant in system_prompt_variants: + for repeat_index in range(args.repeats): + if "no_think_raw" in modes: + new_rows.append( + await _run_no_think( + sample, + base_url=args.base_url, + api_key=args.api_key, + model=args.model, + temperature=args.action_temperature, + max_action_tokens=args.max_action_tokens, + seed=args.seed, + system_prompt_variant=system_prompt_variant, + repeat_index=repeat_index, + ) + ) + + if "with_think_raw" in modes: + res = await _run_with_think( + sample, + mode="with_think_raw", + base_url=args.base_url, + api_key=args.api_key, + model=args.model, + think_temperature=args.think_temperature, + action_temperature=args.action_temperature, + max_think_tokens=args.max_think_tokens, + max_action_tokens=args.max_action_tokens, + seed=args.seed, + scaffold=False, + system_prompt_variant=system_prompt_variant, + repeat_index=repeat_index, + ) + new_rows.append(res) + with_think_cache[f"{sample['sample_id']}::{system_prompt_variant}::{repeat_index}"] = res + if res["think"]["text"]: + think_pool_by_action[action_type].append(res["think"]["text"]) + + if "with_think_scaffold" in modes: + new_rows.append( + await _run_with_think( + sample, + mode="with_think_scaffold", + base_url=args.base_url, + api_key=args.api_key, + model=args.model, + think_temperature=args.think_temperature, + action_temperature=args.action_temperature, + max_think_tokens=args.max_think_tokens, + max_action_tokens=args.max_action_tokens, + seed=args.seed, + scaffold=True, + system_prompt_variant=system_prompt_variant, + repeat_index=repeat_index, + ) + ) + + if "sampled_think" in modes: + new_rows.append( + await _run_sampled_think( + sample, + base_url=args.base_url, + api_key=args.api_key, + model=args.model, + think_temperature=args.think_temperature, + action_temperature=args.action_temperature, + max_think_tokens=args.max_think_tokens, + max_action_tokens=args.max_action_tokens, + seed=args.seed, + sample_count=args.sample_count, + system_prompt_variant=system_prompt_variant, + repeat_index=repeat_index, + ) + ) + + if "empty_think" in modes: + new_rows.append( + await _run_with_think( + sample, + mode="empty_think", + base_url=args.base_url, + api_key=args.api_key, + model=args.model, + think_temperature=args.think_temperature, + action_temperature=args.action_temperature, + max_think_tokens=args.max_think_tokens, + max_action_tokens=args.max_action_tokens, + seed=args.seed, + injected_think="", + system_prompt_variant=system_prompt_variant, + repeat_index=repeat_index, + ) + ) + + if "shuffled_think" in modes: + pool = think_pool_by_action.get(action_type) or [] + shuffled_think = None + if pool: + shuffled_think = rng.choice(pool) + else: + cached = with_think_cache.get( + f"{sample['sample_id']}::{system_prompt_variant}::{repeat_index}" + ) + shuffled_think = cached["think"]["text"] if cached else "" + new_rows.append( + await _run_with_think( + sample, + mode="shuffled_think", + base_url=args.base_url, + api_key=args.api_key, + model=args.model, + think_temperature=args.think_temperature, + action_temperature=args.action_temperature, + max_think_tokens=args.max_think_tokens, + max_action_tokens=args.max_action_tokens, + seed=args.seed, + injected_think=shuffled_think, + system_prompt_variant=system_prompt_variant, + repeat_index=repeat_index, + ) + ) + + if "with_think_cropped" in modes: + new_rows.append( + await _run_with_think( + sample, + mode="with_think_cropped", + base_url=args.base_url, + api_key=args.api_key, + model=args.model, + think_temperature=args.think_temperature, + action_temperature=args.action_temperature, + max_think_tokens=args.max_think_tokens, + max_action_tokens=args.max_action_tokens, + seed=args.seed, + scaffold=False, + crop_chars=args.crop_observation_chars, + system_prompt_variant=system_prompt_variant, + repeat_index=repeat_index, + ) + ) + + results.extend(new_rows) + with raw_path.open("a") as f: + for row in new_rows: + f.write(json.dumps(row, ensure_ascii=False) + "\n") + + summary = summarize_results(results) + config = { + "dataset": str(args.dataset), + "num_samples": len(samples), + "base_url": args.base_url, + "model": args.model, + "modes": modes, + "system_prompt_variants": system_prompt_variants, + "seed": args.seed, + "sample_count": args.sample_count, + "repeats": args.repeats, + "think_temperature": args.think_temperature, + "action_temperature": args.action_temperature, + "max_think_tokens": args.max_think_tokens, + "max_action_tokens": args.max_action_tokens, + } + (args.output_dir / "summary.json").write_text(json.dumps({"config": config, "summary": summary}, ensure_ascii=False, indent=2)) + report = render_markdown_report( + title="Qwen3-32B Think Ablation", + config=config, + summary=summary, + sample_examples=results, + ) + (args.output_dir / "REPORT.md").write_text(report) + print(json.dumps({"config": config, "summary": summary}, ensure_ascii=False, indent=2)) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run step-level think ablations on liveweb-arena teacher states.") + parser.add_argument("--dataset", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--base-url", type=str, required=True) + parser.add_argument("--api-key", type=str, required=True) + parser.add_argument("--model", type=str, required=True) + parser.add_argument( + "--modes", + type=str, + default="no_think_raw,with_think_raw,with_think_scaffold,sampled_think,empty_think,shuffled_think,with_think_cropped", + ) + parser.add_argument( + "--system-prompt-variants", + type=str, + default="base,think_brief,think_structured,action_only_strict", + ) + parser.add_argument("--max-samples", type=int, default=48) + parser.add_argument("--seed", type=int, default=1234) + parser.add_argument("--sample-count", type=int, default=3) + parser.add_argument("--repeats", type=int, default=10) + parser.add_argument("--think-temperature", type=float, default=0.6) + parser.add_argument("--action-temperature", type=float, default=0.0) + parser.add_argument("--max-think-tokens", type=int, default=256) + parser.add_argument("--max-action-tokens", type=int, default=128) + parser.add_argument("--crop-observation-chars", type=int, default=1200) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + asyncio.run(main_async(args)) + + +if __name__ == "__main__": + main() diff --git a/liveweb_arena/core/agent_loop.py b/liveweb_arena/core/agent_loop.py index 28cc104..14d8cb6 100644 --- a/liveweb_arena/core/agent_loop.py +++ b/liveweb_arena/core/agent_loop.py @@ -1,12 +1,16 @@ """Agent loop for browser-based task execution""" import asyncio +import os +from collections import Counter from typing import Any, Callable, List, Optional, Tuple +from urllib.parse import urlparse from .browser import BrowserSession from .cache import CacheFatalError -from .models import BrowserAction, CompositeTask, TrajectoryStep +from .models import BrowserAction, BrowserObservation, CompositeTask, TrajectoryStep from .agent_protocol import AgentProtocol +from .runtime_profiles import is_fast_collect_profile, normalize_runtime_profile from ..utils.llm_client import LLMClient, LLMFatalError from ..utils.logger import log @@ -39,6 +43,8 @@ def __init__(self, message: str, url: str = None, attempts: int = 0): "about:neterror", ] +LOCAL_RECOVERY_PREVIEW_LIMIT = 20 + def is_error_page(url: str) -> bool: """Check if URL indicates a browser error (not AI's fault). @@ -65,6 +71,9 @@ def __init__( llm_client: LLMClient, protocol: AgentProtocol, max_steps: int = 30, + runtime_profile: str | None = None, + behavior_mode: str = "eval", + enable_format_recovery: Optional[bool] = None, on_navigation: Optional[NavigationCallback] = None, on_step_complete: Optional[StepCompleteCallback] = None, on_observation: Optional[ObservationCallback] = None, @@ -73,14 +82,92 @@ def __init__( self._llm_client = llm_client self._protocol = protocol self._max_steps = max_steps + self._runtime_profile = normalize_runtime_profile(runtime_profile or behavior_mode) + self._behavior_mode = "collect" if is_fast_collect_profile(self._runtime_profile) else "eval" + self._collect_mode = is_fast_collect_profile(self._runtime_profile) self._on_navigation = on_navigation self._on_step_complete = on_step_complete self._on_observation = on_observation + self._failfast_action_failures = int(os.getenv("LIVEWEB_FAILFAST_ACTION_FAILURES", "5")) + self._failfast_error_pages = int(os.getenv("LIVEWEB_FAILFAST_ERROR_PAGES", "10")) + self._failfast_blank_observations = int(os.getenv("LIVEWEB_FAILFAST_BLANK_OBSERVATIONS", "4")) + self._enable_format_recovery = ( + enable_format_recovery + if enable_format_recovery is not None + else (os.getenv("LIVEWEB_ENABLE_FORMAT_RECOVERY", "1") == "1") + ) + self._format_recovery_max_retries = int(os.getenv("LIVEWEB_FORMAT_RECOVERY_MAX_RETRIES", "4")) + self._format_recovery_max_new_tokens = int(os.getenv("LIVEWEB_FORMAT_RECOVERY_MAX_NEW_TOKENS", "96")) + self._format_recovery_empty_max_retries = int( + os.getenv("LIVEWEB_FORMAT_RECOVERY_EMPTY_MAX_RETRIES", "2") + ) + self._format_recovery_context_length = int( + os.getenv("LIVEWEB_FORMAT_RECOVERY_CONTEXT_LENGTH", os.getenv("LIVEWEB_LLM_CONTEXT_LENGTH", "32768")) + ) + self._format_recovery_token_margin = int(os.getenv("LIVEWEB_FORMAT_RECOVERY_TOKEN_MARGIN", "256")) + self._enable_disallowed_domain_recovery = os.getenv("LIVEWEB_ENABLE_DISALLOWED_DOMAIN_RECOVERY", "1") == "1" + self._disallowed_domain_recovery_max_retries = int( + os.getenv("LIVEWEB_DISALLOWED_DOMAIN_RECOVERY_MAX_RETRIES", "4") + ) + self._disallowed_domain_recovery_max_new_tokens = int( + os.getenv("LIVEWEB_DISALLOWED_DOMAIN_RECOVERY_MAX_NEW_TOKENS", "96") + ) + self._failfast_disallowed_domain_consecutive = int( + os.getenv("LIVEWEB_FAILFAST_DISALLOWED_DOMAIN_CONSECUTIVE", "2") + ) + self._failfast_disallowed_domain_total = int( + os.getenv("LIVEWEB_FAILFAST_DISALLOWED_DOMAIN_TOTAL", "3") + ) + self._empty_stop_recovery_max_retries = int( + os.getenv("LIVEWEB_EMPTY_STOP_RECOVERY_MAX_RETRIES", "1") + ) + self._invalid_ui_target_recovery_max_retries = int( + os.getenv("LIVEWEB_INVALID_UI_TARGET_RECOVERY_MAX_RETRIES", "1") + ) + self._taostats_list_action_recovery_max_retries = int( + os.getenv("LIVEWEB_TAOSTATS_LIST_ACTION_RECOVERY_MAX_RETRIES", "1") + ) + self._local_recovery_max_new_tokens = int( + os.getenv("LIVEWEB_LOCAL_RECOVERY_MAX_NEW_TOKENS", "96") + ) + self._natural_language_parse_recovery_max_retries = int( + os.getenv("LIVEWEB_NATURAL_LANGUAGE_PARSE_RECOVERY_MAX_RETRIES", "1") + ) + self._invalid_generated_url_recovery_max_retries = int( + os.getenv("LIVEWEB_INVALID_GENERATED_URL_RECOVERY_MAX_RETRIES", "1") + ) # Internal state for partial recovery self._trajectory: List[TrajectoryStep] = [] self._total_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} self._final_answer = None + self._format_recovery_attempts = 0 + self._format_recovery_successes = 0 + self._format_recovery_exhausted = 0 + self._format_failure_class_counts: Counter[str] = Counter() + self._last_parse_failure_metadata: dict[str, Any] = {} + self._last_llm_failure_metadata: dict[str, Any] = {} + self._invalid_stop_payload = False + self._last_stop_failure_class: str | None = None + self._local_recovery_attempt_counts: Counter[str] = Counter() + self._local_recovery_success_counts: Counter[str] = Counter() + self._local_recovery_events_preview: list[dict[str, Any]] = [] + self._gemini_loop_same_action_threshold = int( + os.getenv("LIVEWEB_GEMINI_LOOP_SAME_ACTION_THRESHOLD", "6") + ) + self._gemini_loop_goto_oscillation_threshold = int( + os.getenv("LIVEWEB_GEMINI_LOOP_GOTO_OSCILLATION_THRESHOLD", "4") + ) + self._action_loop_detected = False + self._last_action_loop_detail: dict[str, Any] = {} + self._invalid_generated_url = False + self._last_invalid_generated_url_detail: dict[str, Any] = {} + self._gemini_loop_same_type_threshold = int( + os.getenv("LIVEWEB_GEMINI_LOOP_SAME_TYPE_THRESHOLD", "4") + ) + self._gemini_loop_search_bounce_threshold = int( + os.getenv("LIVEWEB_GEMINI_LOOP_SEARCH_BOUNCE_THRESHOLD", "4") + ) def get_trajectory(self) -> List[TrajectoryStep]: """Get current trajectory (for partial recovery on timeout)""" @@ -90,14 +177,83 @@ def get_usage(self) -> Optional[dict]: """Get current usage stats""" return self._total_usage.copy() if any(self._total_usage.values()) else None + def get_runtime_profile(self) -> str: + return self._runtime_profile + def get_final_answer(self) -> Any: """Get final answer if available""" return self._final_answer + def get_format_recovery_stats(self) -> dict: + attempts = self._format_recovery_attempts + successes = self._format_recovery_successes + exhausted = self._format_recovery_exhausted + return { + "format_recovery_attempts": attempts, + "format_recovery_successes": successes, + "format_recovery_exhausted": exhausted, + "format_recovery_success_rate": (successes / attempts) if attempts else 0.0, + "format_failure_class_counts": dict(self._format_failure_class_counts), + "format_failure_recoverable_rate": ( + sum(count for name, count in self._format_failure_class_counts.items() if name.startswith("recoverable_")) / total + if (total := sum(self._format_failure_class_counts.values())) + else 0.0 + ), + "format_failure_terminal_rate": ( + sum(count for name, count in self._format_failure_class_counts.items() if name.startswith("terminal_")) / total + if (total := sum(self._format_failure_class_counts.values())) + else 0.0 + ), + } + + def get_last_parse_failure_metadata(self) -> dict[str, Any]: + return dict(self._last_parse_failure_metadata) + + def get_last_llm_failure_metadata(self) -> dict[str, Any]: + return dict(self._last_llm_failure_metadata) + + def get_local_recovery_stats(self) -> dict[str, Any]: + attempts = dict(self._local_recovery_attempt_counts) + successes = dict(self._local_recovery_success_counts) + total_attempts = sum(attempts.values()) + total_successes = sum(successes.values()) + if "empty_stop_recovery" in attempts and "invalid_stop_recovery" not in attempts: + attempts["invalid_stop_recovery"] = attempts["empty_stop_recovery"] + if "empty_stop_recovery" in successes and "invalid_stop_recovery" not in successes: + successes["invalid_stop_recovery"] = successes["empty_stop_recovery"] + stats: dict[str, Any] = { + "local_recovery_attempts_total": total_attempts, + "local_recovery_successes_total": total_successes, + "local_recovery_success_rate": (total_successes / total_attempts) if total_attempts else 0.0, + "local_recovery_events_preview": list(self._local_recovery_events_preview), + } + for key in sorted(set(attempts) | set(successes)): + stats[f"{key}_attempts"] = attempts.get(key, 0) + stats[f"{key}_successes"] = successes.get(key, 0) + return stats + + def is_action_loop_detected(self) -> bool: + return self._action_loop_detected + + def get_last_action_loop_detail(self) -> dict[str, Any]: + return dict(self._last_action_loop_detail) + + def is_invalid_stop_payload(self) -> bool: + return self._invalid_stop_payload + + def get_last_stop_failure_class(self) -> str | None: + return self._last_stop_failure_class + + def is_invalid_generated_url(self) -> bool: + return self._invalid_generated_url + + def get_last_invalid_generated_url_detail(self) -> dict[str, Any]: + return dict(self._last_invalid_generated_url_detail) + async def _call_llm( self, system_prompt: str, user_prompt: str, model: str, temperature: float, seed: Optional[int], - ) -> Tuple[str, Optional[BrowserAction], Optional[dict]]: + ) -> Tuple[str, Optional[BrowserAction], Any]: """ Call LLM with function calling protocol. @@ -118,7 +274,800 @@ async def _call_llm( tc = response.tool_calls[0] raw_response = raw_response or f"[tool_call: {tc.function['name']}({tc.function['arguments']})]" action = self._protocol.parse_response(raw_response, response.tool_calls) - return raw_response, action, response.usage + return raw_response, action, response + + def _build_recovery_messages( + self, + *, + system_prompt: str, + user_prompt: str, + failure_class: str, + ) -> list[dict]: + messages: list[dict] = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + for step in self._trajectory: + messages.extend(self._protocol.serialize_step(step)) + messages.append({"role": "user", "content": user_prompt}) + remediation = ( + "The previous assistant output had invalid tool-call formatting. " + "Emit exactly one valid tool call now. No explanation, no prose, no markdown, no XML." + ) + if failure_class == "recoverable_empty": + remediation = ( + "Your previous assistant message was empty. Emit exactly one valid tool call now. " + "No explanation, no prose, no markdown, no XML." + ) + messages.append( + { + "role": "user", + "content": remediation, + } + ) + return messages + + def _build_disallowed_domain_recovery_messages( + self, + *, + system_prompt: str, + user_prompt: str, + blocked_url: str, + allowed_domains: list[str], + ) -> list[dict]: + messages: list[dict] = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + for step in self._trajectory: + messages.extend(self._protocol.serialize_step(step)) + messages.append({"role": "user", "content": user_prompt}) + allowed_display = ", ".join(allowed_domains) if allowed_domains else "(unknown)" + messages.append( + { + "role": "user", + "content": ( + "Your previous action navigated to a disallowed domain.\n" + f"Blocked URL: {blocked_url}\n" + f"Allowed domains: {allowed_display}\n\n" + "Emit exactly one new valid NON-STOP tool call that stays on an allowed domain. " + "Do not reuse the blocked domain. No explanation, no prose, no markdown, no XML." + ), + } + ) + return messages + + def _build_local_recovery_messages( + self, + *, + system_prompt: str, + user_prompt: str, + remediation: str, + ) -> list[dict]: + messages: list[dict] = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + for step in self._trajectory: + messages.extend(self._protocol.serialize_step(step)) + messages.append({"role": "user", "content": user_prompt}) + messages.append({"role": "user", "content": remediation}) + return messages + + @staticmethod + def _get_navigation_metadata(session: Any) -> dict[str, Any] | None: + getter = getattr(session, "get_last_navigation_metadata", None) + if callable(getter): + return getter() + return None + + @staticmethod + def _clear_navigation_metadata(session: Any) -> None: + clearer = getattr(session, "clear_last_navigation_metadata", None) + if callable(clearer): + clearer() + + @staticmethod + def _is_disallowed_domain_metadata(metadata: dict[str, Any] | None) -> bool: + if not metadata: + return False + return str(metadata.get("classification_hint") or "") == "model_disallowed_domain" + + @staticmethod + def _url_matches_allowed_domain(url: str, allowed_domain: str) -> bool: + try: + hostname = (urlparse(url).hostname or "").lower() + except Exception: + return False + allowed_domain = (allowed_domain or "").lower() + return bool(hostname) and (hostname == allowed_domain or hostname.endswith("." + allowed_domain)) + + def _action_targets_allowed_domain(self, action: BrowserAction, allowed_domains: list[str]) -> bool: + if action.action_type != "goto": + return True + url = str(action.params.get("url", "") or "") + if not url or not allowed_domains: + return True + return any(self._url_matches_allowed_domain(url, domain) for domain in allowed_domains) + + @staticmethod + def _action_is_same_page_recovery_safe(action: BrowserAction, *, allow_stop: bool = True) -> bool: + if action.action_type in {"click", "click_role", "scroll", "view_more", "wait"}: + return True + if allow_stop and action.action_type == "stop": + return True + return False + + @staticmethod + def _is_blank_answer_value(value: Any) -> bool: + if value is None: + return True + if isinstance(value, str): + normalized = value.strip().lower() + if not normalized: + return True + if normalized in { + "n/a", + "na", + "unknown", + "none", + "null", + "need to check", + "unable to determine", + "not enough information", + }: + return True + return False + + def _classify_stop_payload( + self, + *, + task: CompositeTask, + action: BrowserAction, + ) -> tuple[str | None, list[str]]: + if action.action_type != "stop": + return None, [] + answers = action.params.get("answers") + if answers is None and isinstance(action.params.get("final"), dict): + answers = action.params.get("final", {}).get("answers") + if not isinstance(answers, dict) or not answers: + return "empty_answers", [] + required_tags = [str(getattr(st, "answer_tag", "") or "").strip() for st in task.subtasks] + required_tags = [tag for tag in required_tags if tag] + missing_tags = [tag for tag in required_tags if self._is_blank_answer_value(answers.get(tag))] + if missing_tags: + return "missing_answers", missing_tags + return None, [] + + def _record_local_recovery_event( + self, + *, + kind: str, + status: str, + url: str | None, + detail: dict[str, Any] | None = None, + ) -> None: + event = { + "kind": kind, + "status": status, + "url": url, + } + if detail: + event.update(detail) + if len(self._local_recovery_events_preview) < LOCAL_RECOVERY_PREVIEW_LIMIT: + self._local_recovery_events_preview.append(event) + + @staticmethod + def _is_gemini_model(model: str) -> bool: + return "gemini" in str(model or "").lower() + + @staticmethod + def _is_kimi_model(model: str) -> bool: + model_lower = str(model or "").lower() + return "kimi" in model_lower or "moonshotai/" in model_lower + + @staticmethod + def _normalize_action_signature_value(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + def _action_signature(self, action: BrowserAction) -> tuple[str, str]: + action_type = str(action.action_type or "") + params = action.params or {} + if action_type == "click": + target = self._normalize_action_signature_value(params.get("selector")) + elif action_type == "click_role": + target = "|".join( + [ + self._normalize_action_signature_value(params.get("role")), + self._normalize_action_signature_value(params.get("name")), + ] + ) + elif action_type in {"type", "type_role"}: + target = "|".join( + [ + self._normalize_action_signature_value(params.get("selector")), + self._normalize_action_signature_value(params.get("role")), + self._normalize_action_signature_value(params.get("name")), + self._normalize_action_signature_value(params.get("text")), + ] + ) + elif action_type == "goto": + target = self._normalize_action_signature_value(params.get("url")) + else: + parts = [f"{k}={self._normalize_action_signature_value(v)}" for k, v in sorted(params.items())] + target = "|".join(parts) + return action_type, target + + @staticmethod + def _normalize_action_url(url: Any) -> str: + return str(url or "").strip() + + def _classify_invalid_generated_url(self, action: BrowserAction) -> dict[str, Any] | None: + if action.action_type != "goto": + return None + raw_url = self._normalize_action_url(action.params.get("url")) + if not raw_url: + return { + "kind": "missing_url", + "url": raw_url, + "reason": "missing URL in goto action", + } + try: + parsed = urlparse(raw_url) + except Exception as exc: + return { + "kind": "parse_error", + "url": raw_url, + "reason": f"URL parse failed: {exc}", + } + scheme = (parsed.scheme or "").lower() + hostname = (parsed.hostname or "").strip() + if scheme not in {"http", "https"}: + return { + "kind": "invalid_scheme", + "url": raw_url, + "reason": f"unsupported URL scheme: {scheme or '(missing)'}", + } + if not hostname: + return { + "kind": "missing_host", + "url": raw_url, + "reason": "URL is missing a valid host", + } + if any(char.isspace() for char in raw_url): + return { + "kind": "whitespace", + "url": raw_url, + "reason": "URL contains whitespace", + } + return None + + def _detect_action_loop( + self, + *, + model: str, + current_obs: BrowserObservation, + action: BrowserAction, + ) -> dict[str, Any] | None: + if not self._collect_mode or not self._is_gemini_model(model): + return None + + current_url = str(getattr(current_obs, "url", "") or "") + signature = self._action_signature(action) + tail_matches = 0 + for step in reversed(self._trajectory): + if step.action is None: + break + if str(getattr(step.observation, "url", "") or "") != current_url: + break + if self._action_signature(step.action) != signature: + break + tail_matches += 1 + if tail_matches >= self._gemini_loop_same_action_threshold - 1: + return { + "kind": "same_action_repeat", + "url": current_url, + "action_type": signature[0], + "action_target": signature[1], + "repeat_count": tail_matches + 1, + } + + if action.action_type in {"type", "type_role"}: + same_type_matches = 0 + for step in reversed(self._trajectory): + if step.action is None: + break + if step.action.action_type not in {"type", "type_role"}: + break + if str(getattr(step.observation, "url", "") or "") != current_url: + break + if self._action_signature(step.action) != signature: + break + same_type_matches += 1 + if same_type_matches >= self._gemini_loop_same_type_threshold - 1: + return { + "kind": "same_type_repeat", + "url": current_url, + "action_type": signature[0], + "action_target": signature[1], + "repeat_count": same_type_matches + 1, + } + + if action.action_type == "goto": + goto_targets = [ + str(step.action.params.get("url", "") or "") + for step in self._trajectory + if step.action is not None and step.action.action_type == "goto" + ] + goto_targets.append(str(action.params.get("url", "") or "")) + window = self._gemini_loop_goto_oscillation_threshold * 2 + recent_targets = goto_targets[-window:] + unique_targets = [] + for target in recent_targets: + if target and target not in unique_targets: + unique_targets.append(target) + if len(recent_targets) == window and len(unique_targets) == 2: + expected = [unique_targets[i % 2] for i in range(window)] + if recent_targets == expected or recent_targets == list(reversed(expected)): + return { + "kind": "goto_oscillation", + "url": current_url, + "action_type": "goto", + "action_target": str(action.params.get("url", "") or ""), + "oscillation_urls": unique_targets, + "repeat_count": window, + } + search_finance_markers = ( + "google.com/search", + "google.com/finance", + "finance.yahoo.com", + "coinmarketcap.com", + "marketwatch.com", + "cnbc.com", + "wsj.com", + ) + search_finance_targets = [target for target in goto_targets if any(marker in target.lower() for marker in search_finance_markers)] + if len(search_finance_targets) >= self._gemini_loop_search_bounce_threshold: + recent_search_targets = search_finance_targets[-self._gemini_loop_search_bounce_threshold :] + unique_recent = [] + for target in recent_search_targets: + if target and target not in unique_recent: + unique_recent.append(target) + if len(unique_recent) <= 3 and len(recent_search_targets) > len(unique_recent): + return { + "kind": "search_finance_bounce", + "url": current_url, + "action_type": "goto", + "action_target": str(action.params.get("url", "") or ""), + "oscillation_urls": unique_recent, + "repeat_count": len(recent_search_targets), + } + return None + + @staticmethod + def _looks_like_missing_ui_target(raw_exception_message: str) -> bool: + lower = (raw_exception_message or "").lower() + return ( + "no element found with role" in lower + or "strict mode violation" in lower + or "waiting for locator" in lower and "resolved to 0 elements" in lower + ) + + def _classify_collect_recovery_kind( + self, + *, + current_obs: BrowserObservation, + navigation_metadata: dict[str, Any] | None, + ) -> tuple[str | None, dict[str, Any]]: + if not self._collect_mode or not navigation_metadata: + return None, {} + raw_exception_message = str(navigation_metadata.get("raw_exception_message") or "") + raw_exception_type = str(navigation_metadata.get("raw_exception_type") or "") + evidence = dict(navigation_metadata.get("evidence") or {}) + current_url = str(navigation_metadata.get("url") or current_obs.url or "") + try: + parsed = urlparse(current_url) + hostname = (parsed.hostname or "").lower() + path = parsed.path or "/" + except Exception: + hostname = "" + path = "/" + + if evidence.get("ui_target_missing") or self._looks_like_missing_ui_target(raw_exception_message): + return ( + "invalid_ui_target_recovery", + { + "page_kind": evidence.get("page_kind"), + "interaction_kind": evidence.get("interaction_kind"), + "target_locator": evidence.get("target_locator") or evidence.get("selector"), + "raw_exception_type": raw_exception_type, + "raw_exception_message": raw_exception_message, + }, + ) + + lower = f"{raw_exception_type} {raw_exception_message}".lower() + if ( + hostname.endswith("taostats.io") + and path.startswith("/subnets") + and str(navigation_metadata.get("navigation_stage") or "").startswith("action_") + and ("timeout" in lower or "too many consecutive action failures" in lower) + ): + return ( + "taostats_list_action_recovery", + { + "page_kind": "taostats_list", + "interaction_kind": evidence.get("interaction_kind") or "unknown", + "target_locator": evidence.get("target_locator") or evidence.get("selector"), + "raw_exception_type": raw_exception_type, + "raw_exception_message": raw_exception_message, + }, + ) + return None, {} + + @staticmethod + def _estimate_message_tokens(messages: list[dict]) -> int: + total = 0 + for message in messages: + content = message.get("content", "") + if isinstance(content, str): + total += max(1, len(content) // 4) + 8 + else: + total += 16 + return total + + @staticmethod + def _truncate_recovery_content( + content: str, + *, + max_chars: int, + keep_head_ratio: float = 0.7, + ) -> str: + if len(content) <= max_chars: + return content + if max_chars <= 64: + return content[:max_chars] + head_chars = max(32, int(max_chars * keep_head_ratio)) + tail_chars = max(16, max_chars - head_chars - 32) + if head_chars + tail_chars + 32 > max_chars: + tail_chars = max(16, max_chars - head_chars - 32) + marker = "\n...[truncated for format recovery]...\n" + return f"{content[:head_chars]}{marker}{content[-tail_chars:]}" + + def _slim_recovery_message( + self, + message: dict[str, Any], + *, + index: int, + total: int, + ) -> dict[str, Any]: + slimmed = dict(message) + content = slimmed.get("content") + if not isinstance(content, str): + return slimmed + + role = str(slimmed.get("role") or "") + max_chars = 1200 + if index == 0 and role == "system": + max_chars = 4000 + elif index >= total - 1: + max_chars = 800 + elif index >= total - 2: + max_chars = 2500 + elif role == "assistant": + max_chars = 900 + slimmed["content"] = self._truncate_recovery_content(content, max_chars=max_chars) + return slimmed + + def _shrink_recovery_messages_to_budget( + self, + *, + messages: list[dict[str, Any]], + budget: int, + ) -> list[dict[str, Any]] | None: + current = [dict(message) for message in messages] + if not current: + return current + + protected_indices: set[int] = set() + if current and current[0].get("role") == "system": + protected_indices.add(0) + protected_indices.add(len(current) - 1) + if len(current) >= 2: + protected_indices.add(len(current) - 2) + + while self._estimate_message_tokens(current) > budget: + largest_index = None + largest_size = 0 + for idx, message in enumerate(current): + content = message.get("content") + if not isinstance(content, str): + continue + if len(content) > largest_size: + largest_index = idx + largest_size = len(content) + if largest_index is None: + break + + message = dict(current[largest_index]) + content = message.get("content", "") + if not isinstance(content, str): + break + + if len(content) > 256: + reduced = max(192, int(len(content) * 0.65)) + message["content"] = self._truncate_recovery_content(content, max_chars=reduced) + current[largest_index] = message + continue + + removable_indices = [ + idx + for idx in range(len(current)) + if idx not in protected_indices + ] + if not removable_indices: + return None + del current[removable_indices[0]] + + protected_indices = { + idx if idx < removable_indices[0] else idx - 1 + for idx in protected_indices + if idx != removable_indices[0] + } + + if self._estimate_message_tokens(current) > budget: + return None + return current + + def _trim_recovery_messages( + self, + *, + messages: list[dict], + max_new_tokens: int, + ) -> tuple[list[dict] | None, bool]: + budget = self._format_recovery_context_length - max_new_tokens - self._format_recovery_token_margin + if budget <= 0: + return None, True + overflowed = self._estimate_message_tokens(messages) > budget + if not messages: + return [], overflowed + + system_message = messages[0] if messages[0].get("role") == "system" else None + tail_keep = 4 if len(messages) >= 5 else len(messages) + trimmed_candidates: list[dict[str, Any]] = [] + if system_message is not None: + trimmed_candidates.append(system_message) + trimmed_candidates.extend(messages[-tail_keep:]) + + deduped: list[dict[str, Any]] = [] + seen_ids: set[int] = set() + for message in trimmed_candidates: + marker = id(message) + if marker in seen_ids: + continue + seen_ids.add(marker) + deduped.append(message) + + slimmed = [ + self._slim_recovery_message(message, index=index, total=len(deduped)) + for index, message in enumerate(deduped) + ] + shrunk = self._shrink_recovery_messages_to_budget(messages=slimmed, budget=budget) + if shrunk is None: + return None, True + return shrunk, overflowed + + @staticmethod + def _classify_recovery_exception(exc: Exception) -> str | None: + message = str(exc or "").lower() + if "recoverable_context_overflow" in message: + return "recoverable_context_overflow" + if ( + "format recovery error" in message and "context length" in message + ) or "strict-serial format recovery error" in message: + return "format_recovery_overflow" + if "requested token count exceeds the model's maximum context length" in message: + return "llm_context_overflow" + if "longer than the model's context length" in message: + return "llm_context_overflow" + return None + + async def _attempt_format_recovery( + self, + *, + model: str, + seed: Optional[int], + raw_response: str, + messages: list[dict], + failure_class: str, + ) -> Tuple[str, Optional[BrowserAction], Optional[dict], Optional[str]]: + if not self._enable_format_recovery: + return raw_response, None, None, None + + tools = self._protocol.get_tools() + base_messages, overflowed = self._trim_recovery_messages( + messages=list(messages), + max_new_tokens=self._format_recovery_max_new_tokens, + ) + if base_messages is None: + log("Agent", "Skipping format recovery because trimmed recovery context still exceeds context budget", force=True) + return raw_response, None, None, "recoverable_context_overflow" + + self._format_recovery_attempts += 1 + recovery_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} + max_retries = self._format_recovery_max_retries + if failure_class == "recoverable_empty": + max_retries = min(max_retries, self._format_recovery_empty_max_retries) + + for retry_idx in range(max_retries): + try: + response = await self._llm_client.chat_with_tools_recovery( + messages=base_messages, + model=model, + tools=tools, + seed=seed, + max_new_tokens=self._format_recovery_max_new_tokens, + ) + except Exception as exc: + classified = self._classify_recovery_exception(exc) + if classified is not None: + log( + "Agent", + f"Format recovery failed fast due to classified error={classified}: {exc}", + force=True, + ) + return raw_response, None, recovery_usage, classified + raise + if response.usage: + for key in recovery_usage: + recovery_usage[key] += int(response.usage.get(key, 0) or 0) + recovered_raw = response.content + if response.has_tool_calls: + tc = response.tool_calls[0] + recovered_raw = recovered_raw or f"[tool_call: {tc.function['name']}({tc.function['arguments']})]" + action = self._protocol.parse_response(recovered_raw, response.tool_calls) + if action is not None: + self._format_recovery_successes += 1 + log( + "Agent", + f"Format recovery succeeded on retry {retry_idx + 1} with action={action.action_type}", + force=True, + ) + return recovered_raw, action, recovery_usage, None + + self._format_recovery_exhausted += 1 + return raw_response, None, recovery_usage, None + + async def _attempt_disallowed_domain_recovery( + self, + *, + model: str, + seed: Optional[int], + system_prompt: str, + user_prompt: str, + blocked_url: str, + allowed_domains: list[str], + ) -> Tuple[Optional[str], Optional[BrowserAction], Optional[dict]]: + if not self._enable_disallowed_domain_recovery: + return None, None, None + + tools = self._protocol.get_tools() + base_messages, _overflowed = self._trim_recovery_messages( + messages=self._build_disallowed_domain_recovery_messages( + system_prompt=system_prompt, + user_prompt=user_prompt, + blocked_url=blocked_url, + allowed_domains=allowed_domains, + ), + max_new_tokens=self._disallowed_domain_recovery_max_new_tokens, + ) + if base_messages is None: + log("Agent", "Skipping disallowed-domain recovery because context budget is exceeded", force=True) + return None, None, None + + recovery_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} + for retry_idx in range(self._disallowed_domain_recovery_max_retries): + response = await self._llm_client.chat_with_tools_recovery( + messages=base_messages, + model=model, + tools=tools, + seed=seed, + max_new_tokens=self._disallowed_domain_recovery_max_new_tokens, + ) + if response.usage: + for key in recovery_usage: + recovery_usage[key] += int(response.usage.get(key, 0) or 0) + recovered_raw = response.content + if response.has_tool_calls: + tc = response.tool_calls[0] + recovered_raw = recovered_raw or f"[tool_call: {tc.function['name']}({tc.function['arguments']})]" + action = self._protocol.parse_response(recovered_raw, response.tool_calls) + if action is None: + continue + if action.action_type == "stop": + log("Agent", f"Disallowed-domain recovery retry {retry_idx + 1} returned stop; retrying", force=True) + continue + if not self._action_targets_allowed_domain(action, allowed_domains): + log( + "Agent", + f"Disallowed-domain recovery retry {retry_idx + 1} still targeted blocked domain; retrying", + force=True, + ) + continue + log( + "Agent", + f"Disallowed-domain recovery succeeded on retry {retry_idx + 1} with action={action.action_type}", + force=True, + ) + return recovered_raw, action, recovery_usage + return None, None, recovery_usage + + async def _attempt_local_recovery( + self, + *, + kind: str, + model: str, + seed: Optional[int], + system_prompt: str, + user_prompt: str, + remediation: str, + validator, + max_retries: int, + url: str, + detail: dict[str, Any] | None = None, + ) -> Tuple[Optional[str], Optional[BrowserAction], Optional[dict]]: + tools = self._protocol.get_tools() + base_messages, _overflowed = self._trim_recovery_messages( + messages=self._build_local_recovery_messages( + system_prompt=system_prompt, + user_prompt=user_prompt, + remediation=remediation, + ), + max_new_tokens=self._local_recovery_max_new_tokens, + ) + if base_messages is None: + self._record_local_recovery_event( + kind=kind, + status="skipped_context_overflow", + url=url, + detail=detail, + ) + return None, None, None + + recovery_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} + for retry_idx in range(max_retries): + self._local_recovery_attempt_counts[kind] += 1 + response = await self._llm_client.chat_with_tools_recovery( + messages=base_messages, + model=model, + tools=tools, + seed=seed, + max_new_tokens=self._local_recovery_max_new_tokens, + ) + if response.usage: + for key in recovery_usage: + recovery_usage[key] += int(response.usage.get(key, 0) or 0) + recovered_raw = response.content + if response.has_tool_calls: + tc = response.tool_calls[0] + recovered_raw = recovered_raw or f"[tool_call: {tc.function['name']}({tc.function['arguments']})]" + action = self._protocol.parse_response(recovered_raw, response.tool_calls) + if action is None: + continue + if validator(action): + self._local_recovery_success_counts[kind] += 1 + self._record_local_recovery_event( + kind=kind, + status="success", + url=url, + detail={"retry_index": retry_idx + 1, **(detail or {})}, + ) + return recovered_raw, action, recovery_usage + self._record_local_recovery_event( + kind=kind, + status="exhausted", + url=url, + detail=detail, + ) + return None, None, recovery_usage async def run( self, @@ -148,6 +1097,21 @@ async def run( self._final_answer = None self._max_steps_reached = False self._parse_failed = False + self._format_recovery_attempts = 0 + self._format_recovery_successes = 0 + self._format_recovery_exhausted = 0 + self._format_failure_class_counts = Counter() + self._last_parse_failure_metadata = {} + self._last_llm_failure_metadata = {} + self._invalid_stop_payload = False + self._last_stop_failure_class = None + self._local_recovery_attempt_counts = Counter() + self._local_recovery_success_counts = Counter() + self._local_recovery_events_preview = [] + self._action_loop_detected = False + self._last_action_loop_detail = {} + self._invalid_generated_url = False + self._last_invalid_generated_url_detail = {} system_prompt = self._protocol.build_system_prompt(task) log("Agent", f"Starting loop, max_steps={self._max_steps}, protocol=function_calling") @@ -155,7 +1119,11 @@ async def run( obs = await self._session.goto("about:blank") consecutive_errors = 0 consecutive_error_pages = 0 - max_error_page_retries = 10 # Prevent infinite loops on persistent network issues + consecutive_action_failures = 0 + consecutive_blank_observations = 0 + max_error_page_retries = self._failfast_error_pages + consecutive_disallowed_domain_hits = 0 + total_disallowed_domain_hits = 0 effective_step = 0 # Count all steps including error pages (AI sees them) iteration = 0 # Total iterations (safety limit) @@ -186,6 +1154,19 @@ async def run( # Reset error page counter on valid page consecutive_error_pages = 0 + obs_text = getattr(obs, "accessibility_tree", "") or "" + if obs.url == "about:blank" or len(obs_text.strip()) < 50: + consecutive_blank_observations += 1 + else: + consecutive_blank_observations = 0 + + if consecutive_blank_observations >= self._failfast_blank_observations: + raise BrowserFatalError( + f"Too many blank observations ({consecutive_blank_observations})", + url=last_goto_url or obs.url, + attempts=consecutive_blank_observations, + ) + effective_step += 1 log("") # Blank line between steps log("Agent", f"Step {effective_step}/{self._max_steps}, url={obs.url[:50]}") @@ -215,13 +1196,14 @@ async def run( raw_response, action, usage = await self._call_llm( system_prompt, user_prompt, model, temperature, seed, ) - if usage: + if usage and usage.usage: for key in self._total_usage: - self._total_usage[key] += usage.get(key, 0) + self._total_usage[key] += usage.usage.get(key, 0) consecutive_errors = 0 except Exception as e: consecutive_errors += 1 + self._last_llm_failure_metadata = self._llm_client.get_last_failure_metadata() max_consecutive = 3 log("Agent", f"LLM error ({consecutive_errors}/{max_consecutive}): {type(e).__name__}: {e}", force=True) @@ -238,21 +1220,130 @@ async def run( # Parse failed - terminate immediately if action is None: - log("Agent", f"PARSE FAILED: {raw_response[:200]!r}", force=True) + failure_class = self._protocol.classify_format_failure(raw_response, usage.tool_calls if usage else None) + parse_debug = self._protocol.debug_parse_metadata(raw_response, usage.tool_calls if usage else None) + self._format_failure_class_counts[failure_class] += 1 + self._last_parse_failure_metadata = { + "format_failure_class": failure_class, + "raw_response_preview": (raw_response or "")[:400], + **parse_debug, + } + if failure_class.startswith("recoverable_"): + if self._enable_format_recovery: + log("Agent", f"Parse failed, attempting format recovery: {failure_class}", force=True) + raw_response, action, usage, failure_override = await self._attempt_format_recovery( + model=model, + seed=seed, + raw_response=raw_response, + messages=self._build_recovery_messages( + system_prompt=system_prompt, + user_prompt=user_prompt, + failure_class=failure_class, + ), + failure_class=failure_class, + ) + if failure_override is not None: + if self._format_failure_class_counts[failure_class] > 0: + self._format_failure_class_counts[failure_class] -= 1 + self._format_failure_class_counts[failure_override] += 1 + if usage: + for key in self._total_usage: + self._total_usage[key] += usage.get(key, 0) + else: + log("Agent", f"Parse failed with format recovery disabled: {failure_class}", force=True) + elif ( + self._collect_mode + and self._is_kimi_model(model) + and failure_class == "terminal_natural_language" + and str(parse_debug.get("protocol_parser_branch") or "") == "natural_language" + ): + remediation = ( + "Your previous assistant message used prose instead of a tool call.\n" + "Emit exactly one valid tool call or one valid stop call now. " + "No prose, no explanation, no markdown." + ) + recovered_raw, recovered_action, recovered_usage = await self._attempt_local_recovery( + kind="natural_language_parse_recovery", + model=model, + seed=seed, + system_prompt=system_prompt, + user_prompt=user_prompt, + remediation=remediation, + validator=lambda candidate: candidate is not None, + max_retries=self._natural_language_parse_recovery_max_retries, + url=current_obs.url, + detail={"failure_class": failure_class, "parser_branch": parse_debug.get("protocol_parser_branch")}, + ) + if recovered_usage: + for key in self._total_usage: + self._total_usage[key] += recovered_usage.get(key, 0) + if recovered_action is not None: + raw_response = recovered_raw or raw_response + action = recovered_action - step = TrajectoryStep( - step_num=step_num, - observation=current_obs, - action=None, - action_result="Parse failed - model output not valid JSON", - prompt=user_prompt, - raw_response=raw_response, - ) - self._trajectory.append(step) - self._parse_failed = True - break + if action is None: + log("Agent", f"PARSE FAILED: {raw_response[:200]!r}", force=True) + + step = TrajectoryStep( + step_num=step_num, + observation=current_obs, + action=None, + action_result="Parse failed - model output not valid JSON", + prompt=user_prompt, + raw_response=raw_response, + ) + self._trajectory.append(step) + self._parse_failed = True + break if action.action_type == "stop": + stop_failure_class = None + stop_missing_tags: list[str] = [] + if self._collect_mode: + stop_failure_class, stop_missing_tags = self._classify_stop_payload(task=task, action=action) + if stop_failure_class is not None: + self._last_stop_failure_class = stop_failure_class + remediation = ( + "Your previous stop action had empty or incomplete answers.\n" + f"Required answer tags: {', '.join(stop_missing_tags) if stop_missing_tags else 'all required tags'}\n\n" + "Emit exactly one valid stop tool call now. " + "You must provide a short final string for each required answer key. " + "Do not explore further. Do not emit any non-stop action. " + "Do not include explanations." + ) + recovered_raw, recovered_action, recovered_usage = await self._attempt_local_recovery( + kind="empty_stop_recovery", + model=model, + seed=seed, + system_prompt=system_prompt, + user_prompt=user_prompt, + remediation=remediation, + validator=lambda candidate: ( + candidate.action_type == "stop" + and self._classify_stop_payload(task=task, action=candidate)[0] is None + ), + max_retries=self._empty_stop_recovery_max_retries, + url=current_obs.url, + detail={"stop_failure_class": stop_failure_class, "missing_tags": stop_missing_tags}, + ) + if recovered_usage: + for key in self._total_usage: + self._total_usage[key] += recovered_usage.get(key, 0) + if recovered_action is None: + log("Agent", f"Invalid stop payload after recovery: {stop_failure_class}", force=True) + step = TrajectoryStep( + step_num=step_num, + observation=current_obs, + action=action, + action_result=f"Invalid stop payload: {stop_failure_class}", + prompt=user_prompt, + raw_response=raw_response, + ) + self._trajectory.append(step) + self._invalid_stop_payload = True + break + raw_response = recovered_raw or raw_response + action = recovered_action final_params = action.params.get("final", {}) self._final_answer = final_params if final_params else action.params log("Agent", f"Completed: {self._final_answer}") @@ -277,28 +1368,230 @@ async def run( else: log("Agent", f"Action: {action.action_type}") old_url = obs.url if obs else None + recovered_before_execute_kind = None + invalid_generated_url_detail = self._classify_invalid_generated_url(action) + if self._collect_mode and invalid_generated_url_detail is not None: + remediation = ( + "Your previous goto action used an invalid URL.\n" + f"Invalid URL: {invalid_generated_url_detail.get('url') or '(missing)'}\n" + f"Reason: {invalid_generated_url_detail.get('reason') or 'invalid generated URL'}\n\n" + "Emit exactly one new valid NON-STOP tool call now. " + "If you use goto, it must be a fully qualified http(s) URL with a valid host. " + "No explanation." + ) + recovered_raw, recovered_action, recovered_usage = await self._attempt_local_recovery( + kind="invalid_generated_url_recovery", + model=model, + seed=seed, + system_prompt=system_prompt, + user_prompt=user_prompt, + remediation=remediation, + validator=lambda candidate: ( + candidate.action_type != "stop" + and self._classify_invalid_generated_url(candidate) is None + ), + max_retries=self._invalid_generated_url_recovery_max_retries, + url=current_obs.url, + detail=invalid_generated_url_detail, + ) + if recovered_usage: + for key in self._total_usage: + self._total_usage[key] += recovered_usage.get(key, 0) + if recovered_action is not None: + raw_response = recovered_raw or raw_response + action = recovered_action + recovered_before_execute_kind = "invalid_generated_url_recovery" + else: + self._invalid_generated_url = True + self._last_invalid_generated_url_detail = dict(invalid_generated_url_detail) + step = TrajectoryStep( + step_num=step_num, + observation=current_obs, + action=action, + action_result=f"Invalid generated URL: {invalid_generated_url_detail.get('reason')}", + prompt=user_prompt, + raw_response=raw_response, + ) + self._trajectory.append(step) + break + loop_detail = self._detect_action_loop(model=model, current_obs=current_obs, action=action) + if loop_detail is not None: + self._action_loop_detected = True + self._last_action_loop_detail = loop_detail + detail_text = ( + f"{loop_detail.get('kind')} type={loop_detail.get('action_type')} " + f"target={loop_detail.get('action_target')} repeat={loop_detail.get('repeat_count')}" + ) + log("Agent", f"Detected repetitive action loop: {detail_text}", force=True) + step = TrajectoryStep( + step_num=step_num, + observation=current_obs, + action=action, + action_result=f"Aborted: repetitive_action_loop ({detail_text})", + prompt=user_prompt, + raw_response=raw_response, + ) + self._trajectory.append(step) + break # Execute action - browser handles navigation errors internally # and returns error pages as valid observations try: - obs = await self._session.execute_action(action) - action_result = "Success" - - # Track goto URL for error context - if action.action_type == "goto": - last_goto_url = action.params.get("url", "") - - # Fire navigation callback if URL changed - if self._on_navigation and obs.url != old_url: + recovered_after_disallowed = False + recovered_after_local_error = None + disallowed_recovery_attempts = 0 + while True: try: - await self._on_navigation(obs.url) - except CacheFatalError: - raise # Cache failure = browser can't load = terminate immediately + obs = await self._session.execute_action(action) + action_result = "Success" + if recovered_before_execute_kind: + action_result = f"Success (recovered after {recovered_before_execute_kind})" + consecutive_action_failures = 0 + except BrowserFatalError: + raise except Exception as e: - log("Agent", f"Navigation callback error: {e}") - except Exception as e: - # Non-navigation action failed - action_result = f"Failed: {e}" + navigation_metadata = self._get_navigation_metadata(self._session) + recovery_kind, recovery_detail = self._classify_collect_recovery_kind( + current_obs=current_obs, + navigation_metadata=navigation_metadata, + ) + remediation = None + validator = None + max_retries = 0 + if recovery_kind == "invalid_ui_target_recovery": + remediation = ( + "Your previous UI target was missing on the current page.\n" + f"Current URL: {current_obs.url}\n" + f"Failed target: {recovery_detail.get('target_locator') or '(unknown)'}\n\n" + "Emit exactly one safer SAME-PAGE action now. " + "Do not use goto. Do not repeat the missing target. " + "Allowed actions: click, click_role, scroll, view_more, wait, stop. " + "No explanation." + ) + validator = lambda candidate: self._action_is_same_page_recovery_safe(candidate, allow_stop=True) + max_retries = self._invalid_ui_target_recovery_max_retries + elif recovery_kind == "taostats_list_action_recovery": + remediation = ( + "You are already on the taostats subnet list page.\n" + f"Current URL: {current_obs.url}\n" + f"Failed interaction kind: {recovery_detail.get('interaction_kind') or 'unknown'}\n" + f"Failed target: {recovery_detail.get('target_locator') or '(unknown)'}\n\n" + "Emit exactly one more conservative SAME-PAGE action now. " + "Do not use goto. Do not repeat the same failed selector or control. " + "Allowed actions: click, click_role, scroll, view_more, wait, stop. " + "If the page already contains enough data, you may stop." + ) + validator = lambda candidate: self._action_is_same_page_recovery_safe(candidate, allow_stop=True) + max_retries = self._taostats_list_action_recovery_max_retries + + if remediation and validator and max_retries > 0: + recovered_raw, recovered_action, recovered_usage = await self._attempt_local_recovery( + kind=recovery_kind, + model=model, + seed=seed, + system_prompt=system_prompt, + user_prompt=user_prompt, + remediation=remediation, + validator=validator, + max_retries=max_retries, + url=current_obs.url, + detail=recovery_detail, + ) + if recovered_usage: + for key in self._total_usage: + self._total_usage[key] += recovered_usage.get(key, 0) + self._clear_navigation_metadata(self._session) + if recovered_action is not None: + raw_response = recovered_raw or raw_response + action = recovered_action + recovered_after_local_error = recovery_kind + continue + + action_result = f"Failed: {e}" + consecutive_action_failures += 1 + if consecutive_action_failures >= self._failfast_action_failures: + raise BrowserFatalError( + f"Too many consecutive action failures ({consecutive_action_failures})", + url=last_goto_url or current_obs.url, + attempts=consecutive_action_failures, + ) + break + + if action.action_type == "goto": + last_goto_url = action.params.get("url", "") + + navigation_metadata = self._get_navigation_metadata(self._session) + if self._is_disallowed_domain_metadata(navigation_metadata): + blocked_url = ( + str((navigation_metadata.get("evidence") or {}).get("blocked_url") or "") + or str(navigation_metadata.get("url") or "") + or str(action.params.get("url", "") or "") + ) + allowed_domains = list((navigation_metadata.get("evidence") or {}).get("allowed_domains") or []) + consecutive_disallowed_domain_hits += 1 + total_disallowed_domain_hits += 1 + disallowed_recovery_attempts += 1 + log( + "Agent", + ( + "Disallowed domain hit; attempting local recovery " + f"(consecutive={consecutive_disallowed_domain_hits}, total={total_disallowed_domain_hits}, " + f"local_retry={disallowed_recovery_attempts}/{self._disallowed_domain_recovery_max_retries})" + ), + force=True, + ) + recovered_raw, recovered_action, recovered_usage = await self._attempt_disallowed_domain_recovery( + model=model, + seed=seed, + system_prompt=system_prompt, + user_prompt=user_prompt, + blocked_url=blocked_url, + allowed_domains=allowed_domains, + ) + if recovered_usage: + for key in self._total_usage: + self._total_usage[key] += recovered_usage.get(key, 0) + self._clear_navigation_metadata(self._session) + if recovered_action is not None: + raw_response = recovered_raw or raw_response + action = recovered_action + recovered_after_disallowed = True + continue + if ( + consecutive_disallowed_domain_hits >= self._failfast_disallowed_domain_consecutive + or total_disallowed_domain_hits >= self._failfast_disallowed_domain_total + ): + raise BrowserFatalError( + ( + "Too many disallowed-domain navigations " + f"(consecutive={consecutive_disallowed_domain_hits}, total={total_disallowed_domain_hits})" + ), + url=blocked_url or last_goto_url or current_obs.url, + attempts=consecutive_disallowed_domain_hits, + ) + raise BrowserFatalError( + "disallowed-domain recovery exhausted before reaching an allowed page", + url=blocked_url or last_goto_url or current_obs.url, + attempts=disallowed_recovery_attempts, + ) + + consecutive_disallowed_domain_hits = 0 + if recovered_after_disallowed: + action_result = "Success (recovered after disallowed-domain retry)" + elif recovered_after_local_error: + action_result = f"Success (recovered after {recovered_after_local_error})" + + # Fire navigation callback if URL changed + if self._on_navigation and obs.url != old_url: + try: + await self._on_navigation(obs.url) + except CacheFatalError: + raise # Cache failure = browser can't load = terminate immediately + except Exception as e: + log("Agent", f"Navigation callback error: {e}") + break + except BrowserFatalError: + raise step = TrajectoryStep( step_num=step_num, diff --git a/liveweb_arena/core/agent_protocol.py b/liveweb_arena/core/agent_protocol.py index 0e38de3..ab513ed 100644 --- a/liveweb_arena/core/agent_protocol.py +++ b/liveweb_arena/core/agent_protocol.py @@ -14,6 +14,7 @@ """ import json +import re from abc import ABC, abstractmethod from typing import Any, Dict, List, Optional, Tuple @@ -170,6 +171,10 @@ def parse_response(self, raw: str, tool_calls: Optional[List[Any]] = None) -> Op def serialize_step(self, step: TrajectoryStep) -> List[dict]: """Serialize a trajectory step as conversation messages for training export.""" + def classify_format_failure(self, raw: str, tool_calls: Optional[List[Any]] = None) -> str: + """Classify whether a parse failure is worth a local recovery attempt.""" + return "terminal" + # Shared step prompt (observation format is protocol-independent) _STEP_PROMPT_TEMPLATE = """## Current Page State @@ -190,7 +195,7 @@ def serialize_step(self, step: TrajectoryStep) -> List[dict]: _LAST_STEP_WARNING = """ -**THIS IS YOUR LAST STEP!** You MUST use the "stop" action now and provide your best answers based on the information you have gathered. Do not attempt any other action.""" +**THIS IS YOUR LAST STEP!** You MUST use the "stop" action now and provide your best answers based on the information you have gathered. Do not attempt any other action. Do not explore. Each answer must be a short final string only.""" def _build_step_prompt_common( @@ -241,10 +246,41 @@ class FunctionCallingProtocol(AgentProtocol): Models trained on this data can deploy with any tool-calling framework. """ - def __init__(self, max_recent_steps: int = 5): + def __init__( + self, + max_recent_steps: int = 5, + prompt_profile: str | None = None, + strict_compat: bool = False, + ): self._max_recent_steps = max_recent_steps + self._prompt_profile = (prompt_profile or "").strip().lower() or None + self._strict_compat = strict_compat self._tools = self._build_tools() + def _extra_system_rules(self) -> str: + if self._prompt_profile != "gpt54_strict_domains": + return "" + return ( + "## Model-Specific Rules\n\n" + "- Do not use Google Search, Google Finance, Yahoo Finance, CoinMarketCap, XE, TradingView, DuckDuckGo, MarketWatch, WSJ, CNBC, or Bloomberg.\n" + "- Go directly to the allowed domains for this task. If a page says 'Domain not allowed', immediately switch to an allowed domain.\n" + "- Never call stop with an empty answers object.\n" + "- When you call stop, every required answer tag must be present and mapped to a non-empty short string.\n" + "- If the current page already shows the requested value, extract it from the page and call stop. Do not browse elsewhere.\n" + "- For task2, solve the first subtask completely, then solve the second subtask. Do not replace allowed sites with general search.\n\n" + ) + + def _extra_step_rules(self) -> str: + if self._prompt_profile != "gpt54_strict_domains": + return "" + return ( + "\nImportant reminders for this model:" + "\n- Never call stop with {\"answers\": {}}." + "\n- If you use stop, fill every answer tag with a non-empty short string." + "\n- Do not use Google, Yahoo, CoinMarketCap, XE, TradingView, or search engines." + "\n- If the answer is visible on the current allowed page, extract it now instead of browsing away." + ) + def _build_tools(self) -> List[dict]: """Build OpenAI-format tool definitions from BROWSER_ACTIONS.""" tools = [] @@ -265,13 +301,13 @@ def build_system_prompt(self, task: CompositeTask) -> str: hints = "## Available Information Sources\n\n" for _, usage_hint in task.plugin_hints.items(): hints += usage_hint + "\n\n" - return ( "You are a web automation agent that interacts with real websites to complete tasks.\n\n" "You have access to a browser and can navigate to any website to gather information.\n" "Use the provided tools to interact with the browser.\n\n" f"{hints}" f"{task.combined_intent}\n\n" + f"{self._extra_system_rules()}" "## Tips\n\n" "- First analyze the task and decide which website to visit\n" "- Use the goto tool to navigate to the appropriate URL\n" @@ -298,45 +334,266 @@ def format_step(step: TrajectoryStep) -> str: obs, trajectory, current_step, max_steps, self._max_recent_steps, format_step, ) - return prompt + "\nWhat is your next action? Use one of the available tools." + if self._strict_compat: + return prompt + "\nWhat is your next action? Use one of the available tools." + return ( + prompt + + "\nWhat is your next action? Use one of the available tools." + + "\nReturn only a tool call. Do not output any text explanation, block, XML tag, markdown, or raw JSON." + + self._extra_step_rules() + ) def get_tools(self) -> List[dict]: return self._tools def parse_response(self, raw: str, tool_calls: Optional[List[Any]] = None) -> Optional[BrowserAction]: - """Parse tool_calls from LLM response.""" + """Parse tool_calls from LLM response. + + Fallback support covers common text-encoded tool-call styles: + - {...} + - raw JSON object {"name": "...", "arguments": {...}} + - [tool_call: stop({...})] + """ + parsed = self._parse_primary_tool_call(tool_calls) + if parsed is None and raw and not self._strict_compat: + parsed = self._parse_qwen_fallback(raw) + if parsed is None: + return None + + fn_name, params = parsed + + # Normalize stop action format for compatibility with existing agent_loop + if fn_name == "stop": + answers = params.get("answers", {}) + params = {"final": {"answers": answers}} + + return BrowserAction(action_type=fn_name, params=params) + + def classify_format_failure(self, raw: str, tool_calls: Optional[List[Any]] = None) -> str: + if self._strict_compat: + return "none" if self._parse_primary_tool_call(tool_calls) is not None else "terminal" + if self._parse_primary_tool_call(tool_calls) is not None: + return "none" + if raw and self._parse_qwen_fallback(raw) is not None: + return "none" + + stripped = (raw or "").strip() + if tool_calls: + return "recoverable_truncated_tool_json" + if not stripped: + return "recoverable_empty" + + stripped = re.sub(r"^\s*.*?\s*", "", stripped, flags=re.DOTALL).strip() + if not stripped: + return "recoverable_empty" + + if "" in stripped or "" in stripped: + return "recoverable_truncated_tool_json" + if re.match(r"^\s*\{", stripped): + return "recoverable_truncated_tool_json" + if re.match(r"^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*\(", stripped): + return "recoverable_qwen_tool_text" + if stripped.startswith("[tool_call:"): + return "recoverable_truncated_tool_json" + return "terminal_natural_language" + + def debug_parse_metadata(self, raw: str, tool_calls: Optional[List[Any]] = None) -> Dict[str, Any]: + branch = "none" + preview_calls: list[dict[str, Any]] = [] + if tool_calls: + for call in tool_calls[:2]: + fn_name = None + fn_args = None + if hasattr(call, "function") and hasattr(call.function, "name"): + fn_name = call.function.name + fn_args = call.function.arguments + elif hasattr(call, "function") and isinstance(call.function, dict): + fn_name = call.function.get("name") + fn_args = call.function.get("arguments") + elif isinstance(call, dict): + function = call.get("function", {}) + fn_name = function.get("name") + fn_args = function.get("arguments") + preview_calls.append( + { + "name": fn_name, + "arguments_preview": str(fn_args)[:200] if fn_args is not None else None, + } + ) + if self._parse_primary_tool_call(tool_calls) is not None: + branch = "tool_calls" + elif self._strict_compat: + branch = "strict_unparsed" + else: + stripped = (raw or "").strip() + if stripped: + stripped = re.sub(r"^\s*.*?\s*", "", stripped, flags=re.DOTALL).strip() + tag_match = re.search(r"\s*(.*?)\s*", stripped, re.DOTALL) + bracket_tool_match = re.fullmatch(r"\[\s*tool_call:\s*(.+?)\s*\]", stripped, flags=re.DOTALL) + if tag_match and self._parse_qwen_fallback(raw) is not None: + branch = "tool_call_tag" + elif bracket_tool_match and self._parse_qwen_fallback(raw) is not None: + branch = "bracket_tool_call" + elif re.match(r"^\s*\{", stripped) and self._parse_qwen_fallback(raw) is not None: + branch = "raw_json_object" + elif re.match(r"^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*\(", stripped) and self._parse_qwen_fallback(raw) is not None: + branch = "qwen_function_text" + elif re.match(r"^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*\(", stripped): + branch = "qwen_function_text_unparsed" + elif stripped.startswith("[tool_call:"): + branch = "bracket_tool_call_unparsed" + elif "" in stripped or "" in stripped: + branch = "tool_call_tag_unparsed" + elif re.match(r"^\s*\{", stripped): + branch = "raw_json_object_unparsed" + else: + branch = "natural_language" + return { + "protocol_parser_branch": branch, + "tool_calls_preview": preview_calls, + } + + def _parse_primary_tool_call(self, tool_calls: Optional[List[Any]]) -> Optional[Tuple[str, Dict[str, Any]]]: if not tool_calls: return None - # Use the first tool call — handle both OpenAI SDK objects and dicts call = tool_calls[0] - if hasattr(call, 'function') and hasattr(call.function, 'name'): - # OpenAI SDK object (from streaming) + if hasattr(call, "function") and hasattr(call.function, "name"): fn_name = call.function.name fn_args = call.function.arguments - elif hasattr(call, 'function') and isinstance(call.function, dict): - # ToolCall dataclass (from chat_with_tools) + elif hasattr(call, "function") and isinstance(call.function, dict): fn_name = call.function.get("name") fn_args = call.function.get("arguments", "{}") else: - # Plain dict fn_name = call.get("function", {}).get("name") fn_args = call.get("function", {}).get("arguments", "{}") - if not fn_name or fn_name not in VALID_ACTION_TYPES: + return self._normalize_tool_call(fn_name, fn_args) + + def _parse_qwen_fallback(self, raw: str) -> Optional[Tuple[str, Dict[str, Any]]]: + stripped = raw.strip() + if not stripped: return None + stripped = re.sub(r"^\s*.*?\s*", "", stripped, flags=re.DOTALL) + if not stripped: + return None + + tag_match = re.search(r"\s*(.*?)\s*", stripped, re.DOTALL) + if tag_match: + stripped = tag_match.group(1).strip() + + for candidate in self._qwen_payload_candidates(stripped): + payload = self._parse_qwen_payload(candidate) + if not isinstance(payload, dict): + repaired_candidate = self._repair_function_text_payload(candidate) + if repaired_candidate and repaired_candidate != candidate: + payload = self._parse_qwen_payload(repaired_candidate) + if not isinstance(payload, dict): + continue + fn_name = payload.get("name") + fn_args = payload.get("arguments", {}) + normalized = self._normalize_tool_call(fn_name, fn_args) + if normalized is not None: + return normalized + return None + + def _qwen_payload_candidates(self, payload_text: str) -> List[str]: + candidates: List[str] = [] + seen: set[str] = set() + + def add(text: str) -> None: + text = text.strip() + if text and text not in seen: + seen.add(text) + candidates.append(text) + + add(payload_text) + stripped = payload_text.strip() + add(re.sub(r"^```(?:json)?\s*|\s*```$", "", stripped, flags=re.DOTALL).strip()) + add(re.sub(r"^<[^>]+>\s*|\s*]+>$", "", stripped, flags=re.DOTALL).strip()) + + bracket_tool_match = re.fullmatch(r"\[\s*tool_call:\s*(.+?)\s*\]", stripped, flags=re.DOTALL) + if bracket_tool_match: + add(bracket_tool_match.group(1)) + + wrapper_match = re.fullmatch(r"([`_]+)\s*(.+?)\s*\1", stripped, flags=re.DOTALL) + if wrapper_match: + add(wrapper_match.group(2)) + add(stripped.strip("`_ \n\t")) + return candidates + + def _parse_qwen_payload(self, payload_text: str) -> Optional[Dict[str, Any]]: + stripped = payload_text.strip() try: - params = json.loads(fn_args) if isinstance(fn_args, str) else fn_args + payload = json.loads(stripped) except json.JSONDecodeError: + payload = None + if isinstance(payload, dict): + return payload + + sanitized = stripped.strip("` \n\t") + sanitized = re.sub(r"^_+", "", sanitized) + sanitized = re.sub(r"_+\s*\)$", ")", sanitized) + match = re.fullmatch( + r"([a-zA-Z_][a-zA-Z0-9_]*)\s*\(\s*(\{.*\})\s*\)\s*", + sanitized, + flags=re.DOTALL, + ) + if not match: + repaired = self._repair_function_text_payload(sanitized) + if repaired is not None: + match = re.fullmatch( + r"([a-zA-Z_][a-zA-Z0-9_]*)\s*\(\s*(\{.*\})\s*\)\s*", + repaired, + flags=re.DOTALL, + ) + if not match: return None - # Normalize stop action format for compatibility with existing agent_loop - if fn_name == "stop": - answers = params.get("answers", {}) - params = {"final": {"answers": answers}} + fn_name = match.group(1) + if fn_name not in VALID_ACTION_TYPES: + return None - return BrowserAction(action_type=fn_name, params=params) + try: + fn_args = json.loads(match.group(2)) + except json.JSONDecodeError: + return None + if not isinstance(fn_args, dict): + return None + return {"name": fn_name, "arguments": fn_args} + + def _repair_function_text_payload(self, payload_text: str) -> Optional[str]: + stripped = payload_text.strip() + prefix_match = re.match(r"^([a-zA-Z_][a-zA-Z0-9_]*)\s*\(", stripped) + if not prefix_match or not stripped.endswith(")"): + return None + + fn_name = prefix_match.group(1) + body = stripped[prefix_match.end():-1].strip() + if not body.startswith("{"): + return None + + opens = body.count("{") + closes = body.count("}") + if opens <= closes: + return None + + repaired_body = body + ("}" * (opens - closes)) + return f"{fn_name}({repaired_body})" + + def _normalize_tool_call(self, fn_name: Any, fn_args: Any) -> Optional[Tuple[str, Dict[str, Any]]]: + if not isinstance(fn_name, str) or fn_name not in VALID_ACTION_TYPES: + return None + + try: + params = json.loads(fn_args) if isinstance(fn_args, str) else fn_args + except json.JSONDecodeError: + return None + + if not isinstance(params, dict): + return None + return fn_name, params def serialize_step(self, step: TrajectoryStep) -> List[dict]: """Serialize as tool_call + tool response messages (standard OpenAI format).""" diff --git a/liveweb_arena/core/browser.py b/liveweb_arena/core/browser.py index adc8c93..b7f3cfe 100644 --- a/liveweb_arena/core/browser.py +++ b/liveweb_arena/core/browser.py @@ -1,7 +1,10 @@ """Browser engine with session isolation for concurrent evaluations""" import asyncio -from typing import Optional, TYPE_CHECKING +import os +from dataclasses import asdict, dataclass, field +from urllib.parse import parse_qs, urlencode, urljoin, urlparse, urlunparse +from typing import Any, Optional, TYPE_CHECKING from playwright.async_api import async_playwright, Browser, BrowserContext, Page, Playwright from .block_patterns import STEALTH_BROWSER_ARGS, STEALTH_USER_AGENT @@ -16,6 +19,226 @@ VIEW_MORE_OVERLAP = 2000 # Overlap between views for context continuity PAGE_TIMEOUT_MS = 30000 NAVIGATION_TIMEOUT_MS = 30000 +_STOOQ_ONLY_DOMAINS = {"stooq.com", "www.stooq.com"} +_NON_HTML_FILE_EXTENSIONS = ( + ".csv", + ".tsv", + ".json", + ".xml", + ".txt", + ".pdf", + ".zip", + ".gz", + ".bz2", + ".xz", + ".xlsx", + ".xls", + ".doc", + ".docx", + ".ppt", + ".pptx", +) +_NON_HTML_QUERY_VALUE_HINTS = { + "attachment", + "csv", + "download", + "file", + "json", + "pdf", + "rss", + "text", + "tsv", + "txt", + "xhtml+xml", + "xls", + "xlsx", + "xml", + "zip", +} +_NON_HTML_QUERY_KEYS = { + "attachment", + "download", + "export", + "filename", + "format", + "output", + "raw", + "response-content-disposition", + "type", +} + +_BROWSER_TRANSPORT_ERROR_PATTERNS = ( + "handler is closed", + "transport closed", + "browser has been closed", + "target page, context or browser has been closed", + "connection closed", + "browser.new_context", +) + +_TAOSTATS_LIST_PAGINATE_SELECTORS = ( + ".ant-pagination-next", + ".paginate_button.next", + ".next.paginate_button", + "#subnets-table_paginate .paginate_button.next", + ".pagination-wrap .next-page", + "li.page-item:nth-child(5) a.page-link", +) + +_TAOSTATS_LIST_SHOW_ALL_SELECTORS = ( + '[data-testid="rows-select"]', + ".ant-select-selector", + "select", +) + +_TAOSTATS_LIST_SORT_SELECTORS = ( + '.rt-th:nth-child(6)', + 'div.rt-th:has-text("1M")', + 'th:nth-child(7)', +) + + +def _browser_proxy_mode() -> str: + return os.getenv("LIVEWEB_BROWSER_PROXY_MODE", "system").strip().lower() + + +def _browser_should_force_direct() -> bool: + return _browser_proxy_mode() in {"direct", "no_proxy", "off"} + + +def _browser_should_direct_stooq() -> bool: + return os.getenv("LIVEWEB_BROWSER_STOOQ_DIRECT", "0") == "1" + + +@dataclass +class BrowserNavigationMetadata: + url: str + normalized_url: str + navigation_stage: str + wait_until: str | None = None + timeout_ms: int | None = None + raw_exception_type: str | None = None + raw_exception_message: str | None = None + attempt_index: int = 1 + max_attempts: int = 1 + browser_reused: bool = True + context_reused: bool = True + page_recreated_before_retry: bool = False + used_url_normalization: bool = False + resource_type: str = "document" + classification_hint: str | None = None + evidence: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +class BrowserNavigationError(Exception): + def __init__(self, message: str, metadata: BrowserNavigationMetadata): + super().__init__(message) + self.metadata = metadata + + +def is_browser_transport_error(exc: BaseException) -> bool: + """Return True when an exception indicates a dead Playwright/browser transport.""" + text = f"{type(exc).__name__}: {exc}".lower() + return any(pattern in text for pattern in _BROWSER_TRANSPORT_ERROR_PATTERNS) + + +def _is_stooq_url(url: str) -> bool: + hostname = (urlparse(url).hostname or "").lower() + return "stooq.com" in hostname + + +def _normalize_stooq_url(url: str) -> str: + parsed = urlparse(url) + hostname = (parsed.hostname or "").lower() + if "stooq.com" not in hostname: + return url + + query = parse_qs(parsed.query, keep_blank_values=True) + if "q/currency" in parsed.path.lower(): + return urlunparse(parsed._replace(scheme="https", netloc="stooq.com")) + + if "q" in query: + query["q"] = [query["q"][0].lower()] + if "s" in query: + query["s"] = [query["s"][0].lower()] + if "e" in query: + query["e"] = [query["e"][0].lower()] + + normalized_query = urlencode([(key, value) for key, values in query.items() for value in values], doseq=True) + return urlunparse(parsed._replace(scheme="https", netloc="stooq.com", query=normalized_query)) + + +def _looks_like_non_html_navigation_target(url: str) -> bool: + parsed = urlparse(url) + if parsed.scheme and parsed.scheme not in {"http", "https"}: + return False + + path = parsed.path.lower() + if path.endswith(_NON_HTML_FILE_EXTENSIONS): + return True + if any(marker in path for marker in ("/download", "/export", "/attachment")): + return True + + query = parse_qs(parsed.query, keep_blank_values=True) + for key, values in query.items(): + key_lower = key.lower() + normalized_values = [value.strip().lower() for value in values if value is not None] + if key_lower in _NON_HTML_QUERY_KEYS and any( + not value or value in _NON_HTML_QUERY_VALUE_HINTS or "." in value + for value in normalized_values + ): + return True + if key_lower in {"e", "format", "output", "type"} and any( + value in _NON_HTML_QUERY_VALUE_HINTS for value in normalized_values + ): + return True + + return False + + +def _classify_browser_exception(exc: BaseException) -> str | None: + text = f"{type(exc).__name__}: {exc}".lower() + if "err_aborted" in text or "frame was detached" in text: + return "env_nav_aborted" + if "target page, context or browser has been closed" in text or "targetclosederror" in text: + return "env_target_closed" + if "timeout" in text: + return "env_nav_timeout" + if is_browser_transport_error(exc): + return "env_browser_context_invalidated" + return None + + +def _is_taostats_list_page_url(url: str) -> bool: + parsed = urlparse(url) + host = (parsed.hostname or "").lower() + path = parsed.path.lower().rstrip("/") + return "taostats.io" in host and path in {"", "/subnets"} + + +def _infer_taostats_list_target_kind(*, selector: str | None = None, role: str | None = None, name: str | None = None) -> str | None: + text = " ".join(part for part in [selector or "", role or "", name or ""] if part).lower() + if not text: + return None + if any(marker in text for marker in ("view all", "rows:", "rows-select", "ant-select-selector", "all", "customise table")): + return "show_all" + if any(marker in text for marker in ("pagination", "paginate", "page-link", "next-page", "next")) or (name or "").strip().isdigit(): + return "paginate" + if any(marker in text for marker in ("1m", "1h", "24h", "1w", "sort", "rt-th", "th:nth-child")): + return "sort" + return None + + +def _should_fallback_to_direct_navigation(exc: BaseException) -> bool: + text = f"{type(exc).__name__}: {exc}".lower() + return ( + "intercepts pointer events" in text + or "waiting for element to be visible" in text + or "timeout" in text + ) class BrowserSession: @@ -34,10 +257,17 @@ def __init__( context: BrowserContext, page: Page, browser: Browser = None, + *, + context_options: dict[str, Any] | None = None, + browser_launch_options: dict[str, Any] | None = None, ): self._context = context self._page = page self._browser = browser # Only set in strict isolation mode + self._context_options = dict(context_options or {}) + self._browser_launch_options = dict(browser_launch_options or {}) + self._fallback_playwright: Playwright | None = None + self._stooq_transport_mode = "default" # Virtual scroll state for handling truncated content self._view_offset = 0 self._last_full_content = "" @@ -45,6 +275,142 @@ def __init__( self._blocked_patterns = [] self._allowed_domains = None # None means allow all self._cache_interceptor: Optional["CacheInterceptor"] = None + self._last_navigation_metadata: BrowserNavigationMetadata | None = None + self._pending_navigation_override: BrowserObservation | None = None + + def get_last_navigation_metadata(self) -> dict[str, Any] | None: + return self._last_navigation_metadata.to_dict() if self._last_navigation_metadata else None + + def clear_last_navigation_metadata(self) -> None: + self._last_navigation_metadata = None + self._pending_navigation_override = None + + def set_allowed_domains(self, domains: set[str] | list[str] | tuple[str, ...] | None) -> None: + if not domains: + self._allowed_domains = None + return + self._allowed_domains = {(domain or "").lower() for domain in domains if domain} + + def _record_action_failure_metadata( + self, + *, + url: str, + action_stage: str, + exc: BaseException, + evidence: dict[str, Any] | None = None, + ) -> None: + if isinstance(exc, BrowserNavigationError): + self._last_navigation_metadata = exc.metadata + return + self._last_navigation_metadata = BrowserNavigationMetadata( + url=url, + normalized_url=_normalize_stooq_url(url), + navigation_stage=action_stage, + timeout_ms=NAVIGATION_TIMEOUT_MS, + raw_exception_type=type(exc).__name__, + raw_exception_message=str(exc), + attempt_index=1, + max_attempts=1, + browser_reused=self._browser is None, + context_reused=True, + page_recreated_before_retry=False, + classification_hint=_classify_browser_exception(exc) or "ambiguous_navigation_failure", + evidence=dict(evidence or {}), + ) + + async def _direct_nav_fallback_from_selector(self, selector: str) -> bool: + try: + element = await self._page.query_selector(selector) + if not element: + return False + href = await element.get_attribute("href") + if not href: + return False + target = urljoin(self._page.url, href) + await self._goto_with_recovery(target) + return True + except Exception: + return False + + async def _force_click_selector_fallback(self, selector: str, timeout_ms: int = 2000) -> bool: + try: + element = await self._page.query_selector(selector) + if not element: + return False + try: + await element.click(force=True, timeout=timeout_ms) + return True + except Exception: + await element.evaluate("(el) => el.click()") + return True + except Exception: + return False + + async def _direct_nav_fallback_from_locator(self, locator) -> bool: + try: + href = await locator.get_attribute("href") + if not href: + return False + target = urljoin(self._page.url, href) + await self._goto_with_recovery(target) + return True + except Exception: + return False + + async def _force_click_locator_fallback(self, locator, timeout_ms: int = 2000) -> bool: + try: + first = locator.first if hasattr(locator, "first") else locator + try: + await first.click(force=True, timeout=timeout_ms) + return True + except Exception: + await first.evaluate("(el) => el.click()") + return True + except Exception: + return False + + async def _click_any_selector(self, selectors: list[str], timeout_ms: int = 2500) -> bool: + seen: set[str] = set() + for selector in selectors: + if not selector or selector in seen: + continue + seen.add(selector) + try: + await self._page.click(selector, timeout=timeout_ms) + return True + except Exception: + if await self._force_click_selector_fallback(selector, timeout_ms=min(timeout_ms, 1500)): + return True + return False + + async def _taostats_list_selector_fallback(self, selector: str, timeout_ms: int) -> bool: + if not _is_taostats_list_page_url(self._page.url): + return False + kind = _infer_taostats_list_target_kind(selector=selector) + if kind == "show_all": + return await self._click_any_selector(list(_TAOSTATS_LIST_SHOW_ALL_SELECTORS), timeout_ms=min(timeout_ms, 3000)) + if kind == "paginate": + return await self._click_any_selector(list(_TAOSTATS_LIST_PAGINATE_SELECTORS), timeout_ms=min(timeout_ms, 3000)) + if kind == "sort": + candidates = [selector, *_TAOSTATS_LIST_SORT_SELECTORS] + return await self._click_any_selector(candidates, timeout_ms=min(timeout_ms, 3000)) + return False + + async def _taostats_list_role_fallback(self, role: str, name: str, timeout_ms: int) -> bool: + if not _is_taostats_list_page_url(self._page.url): + return False + kind = _infer_taostats_list_target_kind(role=role, name=name) + page_url = self._page.url + if kind == "show_all": + if "view all" in (name or "").lower() and page_url.rstrip("/") == "https://taostats.io": + await self._goto_with_recovery("https://taostats.io/subnets") + return True + return await self._click_any_selector(list(_TAOSTATS_LIST_SHOW_ALL_SELECTORS), timeout_ms=min(timeout_ms, 3000)) + if kind == "paginate": + return await self._click_any_selector(list(_TAOSTATS_LIST_PAGINATE_SELECTORS), timeout_ms=min(timeout_ms, 3000)) + if kind == "sort": + return await self._click_any_selector(list(_TAOSTATS_LIST_SORT_SELECTORS), timeout_ms=min(timeout_ms, 3000)) + return False async def block_urls(self, patterns: list): """ @@ -58,8 +424,11 @@ async def block_urls(self, patterns: list): patterns: List of URL patterns (glob-style with * wildcard) Example: ["*api.example.com*", "*?format=*"] """ - import re self._blocked_patterns.extend(patterns) + await self._apply_block_patterns_to_context(self._context, patterns) + + async def _apply_block_patterns_to_context(self, context: BrowserContext, patterns: list[str]) -> None: + import re # Build a combined regex for all patterns (more efficient than multiple routes) regex_patterns = [] @@ -96,6 +465,181 @@ async def set_cache_interceptor(self, interceptor: "CacheInterceptor"): # Route all requests through the interceptor await self._context.route("**/*", interceptor.handle_route) + def _stooq_prefers_direct_transport(self, normalized_url: str) -> bool: + if not _is_stooq_url(normalized_url): + return False + if self._allowed_domains is None: + return True + return bool(self._allowed_domains) and self._allowed_domains.issubset(_STOOQ_ONLY_DOMAINS) + + async def _switch_to_direct_stooq_browser(self) -> None: + if not _browser_should_direct_stooq(): + return + if self._stooq_transport_mode == "direct": + return + + self._fallback_playwright = await async_playwright().start() + launch_options = dict(self._browser_launch_options or {}) + launch_options.setdefault("headless", True) + args = list(launch_options.get("args", [])) + if "--no-proxy-server" not in args: + args.append("--no-proxy-server") + launch_options["args"] = args + browser = await self._fallback_playwright.chromium.launch(**launch_options) + + context = await browser.new_context(**self._context_options) + context.set_default_timeout(PAGE_TIMEOUT_MS) + if self._cache_interceptor is not None: + await context.route("**/*", self._cache_interceptor.handle_route) + elif self._blocked_patterns: + await self._apply_block_patterns_to_context(context, self._blocked_patterns) + page = await context.new_page() + + old_page = self._page + old_context = self._context + old_browser = self._browser + + self._page = page + self._context = context + self._browser = browser + self._stooq_transport_mode = "direct" + + try: + await old_page.close() + except Exception: + pass + try: + await old_context.close() + except Exception: + pass + if old_browser is not None and old_browser is not browser: + try: + await old_browser.close() + except Exception: + pass + + async def _stooq_page_has_meaningful_content(self) -> bool: + current_url = self._page.url or "" + if current_url.startswith(("about:blank", "chrome-error://", "about:neterror")): + return False + if not _is_stooq_url(current_url): + return False + try: + title = (await self._page.title()).strip() + body = await self._page.evaluate(""" + () => { + const body = document.body; + if (!body) return ''; + return body.innerText || body.textContent || ''; + } + """) + except Exception: + return False + body = (body or "").strip() + return bool(title) and len(body) >= 120 + + def _should_preflight_navigation(self, url: str) -> bool: + return _looks_like_non_html_navigation_target(url) + + async def _preflight_navigation_request(self, url: str) -> dict[str, Any] | None: + if not url.startswith(("http://", "https://")): + return None + + last_error: BaseException | None = None + for attempt_index in range(1, 3): + try: + response = await self._context.request.get( + url, + fail_on_status_code=False, + max_redirects=5, + timeout=12000, + ) + headers = {key.lower(): value for key, value in response.headers.items()} + content_type = headers.get("content-type", "").lower() + content_disposition = headers.get("content-disposition", "").lower() + looks_like_html = ( + "text/html" in content_type + or "application/xhtml+xml" in content_type + or not content_type + ) + if not looks_like_html: + try: + preview = (await response.text())[:200] + except Exception: + preview = "" + preview_lower = preview.lstrip().lower() + looks_like_html = preview_lower.startswith(" None: + self._last_navigation_metadata = BrowserNavigationMetadata( + url=url, + normalized_url=_normalize_stooq_url(url), + navigation_stage="goto_preflight", + timeout_ms=NAVIGATION_TIMEOUT_MS, + raw_exception_type=None, + raw_exception_message=None, + attempt_index=1, + max_attempts=1, + browser_reused=self._browser is None, + context_reused=True, + page_recreated_before_retry=False, + classification_hint=classification_hint, + evidence=evidence, + ) + self._pending_navigation_override = BrowserObservation(url=url, title=title, accessibility_tree=body) + + def _set_non_html_navigation_override(self, *, url: str, preflight: dict[str, Any]) -> None: + evidence = { + key: value + for key, value in preflight.items() + if value not in (None, "") + } + content_type = str(preflight.get("content_type", "") or "").lower() + content_disposition = str(preflight.get("content_disposition", "") or "").lower() + is_download = "attachment" in content_disposition or any( + marker in content_type + for marker in ("application/octet-stream", "text/csv", "application/pdf", "application/zip") + ) + title = "Download" if is_download else "Non-HTML Response" + summary = "file download" if is_download else "non-HTML response" + self._set_navigation_override( + url=url, + title=title, + body=( + f"[This URL resolved to a {summary}, so the browser did not call page.goto().]\n\n" + f"Original URL: {url}\n" + f"Final URL: {preflight.get('final_url', url)}\n" + f"HTTP status: {preflight.get('status', 'unknown')}\n" + f"Content-Type: {preflight.get('content_type', '(unknown)')}\n" + f"Content-Disposition: {preflight.get('content_disposition', '(none)')}" + ), + classification_hint="env_navigation_download" if is_download else "env_non_html_response", + evidence=evidence, + ) + async def goto(self, url: str) -> BrowserObservation: """Navigate to URL and return observation. @@ -105,25 +649,25 @@ async def goto(self, url: str) -> BrowserObservation: # Reset view offset when navigating to a new page self._view_offset = 0 self._last_full_content = "" + self.clear_last_navigation_metadata() # Ensure URL has protocol prefix if url and not url.startswith(("http://", "https://", "about:")): url = "https://" + url try: - await self._page.goto(url, wait_until="domcontentloaded", timeout=NAVIGATION_TIMEOUT_MS) - # Wait a bit for dynamic content - try: - await self._page.wait_for_load_state("networkidle", timeout=10000) - except Exception: - # Network idle timeout is acceptable, page may still be usable - pass + await self._goto_with_recovery(url) except Exception as e: # Navigation failed — browser may show error page (chrome-error://). # Log but don't raise: _get_observation() detects error pages and # returns them as visible observations so the AI can react. log("Browser", f"Navigation failed for {url[:80]}: {type(e).__name__}: {e}") + if self._pending_navigation_override is not None: + obs = self._pending_navigation_override + self._pending_navigation_override = None + return obs + # Return observation regardless of whether it's an error page # AI can see the error and decide what to do return await self._get_observation() @@ -145,13 +689,13 @@ async def execute_action(self, action: BrowserAction) -> BrowserObservation: url = "https://" + url # Navigate and return observation (including error pages) try: - await self._page.goto(url, wait_until="domcontentloaded", timeout=NAVIGATION_TIMEOUT_MS) - try: - await self._page.wait_for_load_state("networkidle", timeout=10000) - except Exception: - pass + await self._goto_with_recovery(url) except Exception as e: log("Browser", f"Navigation failed for {url[:80]}: {type(e).__name__}: {e}") + if self._pending_navigation_override is not None: + obs = self._pending_navigation_override + self._pending_navigation_override = None + return obs elif action_type == "click": selector = params.get("selector", "") @@ -163,8 +707,15 @@ async def execute_action(self, action: BrowserAction) -> BrowserObservation: await self._page.click(selector, timeout=timeout_ms) clicked = True except Exception as click_err: + if await self._force_click_selector_fallback(selector, timeout_ms=min(timeout_ms, 2000)): + clicked = True + elif await self._taostats_list_selector_fallback(selector, timeout_ms): + clicked = True + elif _should_fallback_to_direct_navigation(click_err): + clicked = await self._direct_nav_fallback_from_selector(selector) + # If selector contains case-sensitive attribute match, try case-insensitive - if '[href*=' in selector or '[src*=' in selector: + if not clicked and ('[href*=' in selector or '[src*=' in selector): import re # Extract attribute and value: a[href*='GOOGL.US'] -> (href, GOOGL.US) match = re.search(r"\[(\w+)\*=['\"]([^'\"]+)['\"]\]", selector, re.IGNORECASE) @@ -297,6 +848,7 @@ async def execute_action(self, action: BrowserAction) -> BrowserObservation: role = params.get("role", "button") name = params.get("name", "") exact = params.get("exact", False) + timeout_ms = params.get("timeout_ms", 5000) locator = self._page.get_by_role(role, name=name, exact=exact) count = await locator.count() @@ -317,11 +869,24 @@ async def execute_action(self, action: BrowserAction) -> BrowserObservation: break if count > 0: - await locator.click(timeout=5000) + try: + await locator.click(timeout=timeout_ms) + except Exception as click_err: + if await self._force_click_locator_fallback(locator): + pass + elif await self._taostats_list_role_fallback(role, name, timeout_ms): + pass + elif _should_fallback_to_direct_navigation(click_err) and await self._direct_nav_fallback_from_locator(locator): + pass + else: + raise # Wait briefly for potential navigation await asyncio.sleep(0.3) else: - raise Exception(f"No element found with role='{role}' name='{name}'") + if await self._taostats_list_role_fallback(role, name, timeout_ms): + await asyncio.sleep(0.3) + else: + raise Exception(f"No element found with role='{role}' name='{name}'") elif action_type == "type_role": role = params.get("role", "textbox") @@ -454,10 +1019,137 @@ async def execute_action(self, action: BrowserAction) -> BrowserObservation: except Exception as e: # Re-raise action execution errors so agent_loop can report failure + action_evidence = {} + if action_type == "click": + action_evidence = {"selector": params.get("selector", "")} + elif action_type == "click_role": + action_evidence = { + "role": params.get("role", "button"), + "name": params.get("name", ""), + "exact": params.get("exact", False), + } + self._record_action_failure_metadata( + url=self._page.url or params.get("url", "") or self._last_url or "about:blank", + action_stage=f"action_{action_type}", + exc=e, + evidence=action_evidence, + ) raise return await self._get_observation() + async def _goto_with_recovery(self, url: str) -> None: + normalized_url = _normalize_stooq_url(url) + if self._stooq_prefers_direct_transport(normalized_url): + await self._switch_to_direct_stooq_browser() + + preflight = None + if self._should_preflight_navigation(url): + preflight = await self._preflight_navigation_request(url) + if preflight and not preflight.get("is_html", True): + self._set_non_html_navigation_override(url=url, preflight=preflight) + return + + max_attempts = 2 if _is_stooq_url(normalized_url) else 1 + for attempt_index in range(1, max_attempts + 1): + wait_until = "domcontentloaded" if attempt_index == 1 else "commit" + page_recreated = False + if attempt_index > 1: + log("Browser", f"Retrying unstable navigation for {normalized_url[:80]}") + try: + await self._page.close() + except Exception: + pass + self._page = await self._context.new_page() + page_recreated = True + await asyncio.sleep(0.2) + document_failures: list[dict[str, Any]] = [] + download_events: list[dict[str, Any]] = [] + + def _on_request_failed(req): + if req.resource_type != "document": + return + failure = req.failure + if isinstance(failure, dict): + error_text = failure.get("errorText") + else: + error_text = str(failure) if failure is not None else None + document_failures.append( + { + "url": req.url, + "error_text": error_text, + } + ) + + def _on_download(download): + download_events.append( + { + "url": download.url, + "suggested_filename": download.suggested_filename, + } + ) + + self._page.on("requestfailed", _on_request_failed) + self._page.on("download", _on_download) + try: + await self._page.goto(normalized_url, wait_until=wait_until, timeout=NAVIGATION_TIMEOUT_MS) + try: + await self._page.wait_for_load_state("networkidle", timeout=10000) + except Exception: + pass + self.clear_last_navigation_metadata() + return + except Exception as exc: + text = f"{type(exc).__name__}: {exc}".lower() + classification_hint = None + if "err_aborted" in text or "frame was detached" in text: + classification_hint = "env_nav_aborted" + elif "target page, context or browser has been closed" in text or "targetclosederror" in text: + classification_hint = "env_target_closed" + elif "timeout" in text: + classification_hint = "env_nav_timeout" + if _is_stooq_url(normalized_url) and await self._stooq_page_has_meaningful_content(): + self.clear_last_navigation_metadata() + return + elif is_browser_transport_error(exc): + classification_hint = "env_browser_context_invalidated" + if download_events or "download is starting" in text: + classification_hint = "env_navigation_download" + if preflight is None and ( + classification_hint == "env_navigation_download" + or download_events + ): + preflight = await self._preflight_navigation_request(url) + if preflight and not preflight.get("is_html", True): + self._set_non_html_navigation_override(url=url, preflight=preflight) + return + self._last_navigation_metadata = BrowserNavigationMetadata( + url=url, + normalized_url=normalized_url, + navigation_stage=f"goto_{wait_until}", + wait_until=wait_until, + timeout_ms=NAVIGATION_TIMEOUT_MS, + raw_exception_type=type(exc).__name__, + raw_exception_message=str(exc), + attempt_index=attempt_index, + max_attempts=max_attempts, + browser_reused=self._browser is None, + context_reused=True, + page_recreated_before_retry=page_recreated, + used_url_normalization=(normalized_url != url), + classification_hint=classification_hint, + evidence={ + "preflight": preflight or {}, + "document_request_failures": document_failures[:3], + "download_events": download_events[:3], + }, + ) + if attempt_index == max_attempts or not _is_stooq_url(normalized_url) or classification_hint not in {"env_nav_aborted", "env_target_closed"}: + raise BrowserNavigationError(str(exc), self._last_navigation_metadata) from exc + finally: + self._page.remove_listener("requestfailed", _on_request_failed) + self._page.remove_listener("download", _on_download) + async def get_observation(self, max_retries: int = 3) -> BrowserObservation: """Get current browser observation with retry logic for navigation timing""" return await self._get_observation(max_retries) @@ -629,7 +1321,22 @@ async def _get_observation(self, max_retries: int = 5) -> BrowserObservation: else: # Final attempt failed - raise error instead of returning empty observation # Empty observation would affect agent decisions and GT collection - raise RuntimeError(f"Failed to get browser observation after {max_retries} retries: {e}") from e + metadata = BrowserNavigationMetadata( + url=self._page.url, + normalized_url=_normalize_stooq_url(self._page.url), + navigation_stage="observation_fetch", + timeout_ms=15000, + raw_exception_type=type(e).__name__, + raw_exception_message=str(e), + attempt_index=attempt + 1, + max_attempts=max_retries, + browser_reused=self._browser is None, + context_reused=True, + page_recreated_before_retry=False, + classification_hint="env_nav_timeout" if "timeout" in f"{type(e).__name__}: {e}".lower() else "ambiguous_navigation_failure", + ) + self._last_navigation_metadata = metadata + raise BrowserNavigationError(f"Failed to get browser observation after {max_retries} retries: {e}", metadata) from e def _format_accessibility_tree(self, node: dict, indent: int = 0) -> str: """Format accessibility tree node recursively""" @@ -680,6 +1387,12 @@ async def close(self): await self._browser.close() except Exception: pass + if self._fallback_playwright is not None: + try: + await self._fallback_playwright.stop() + except Exception: + pass + self._fallback_playwright = None class BrowserEngine: @@ -711,6 +1424,59 @@ def __init__(self, headless: bool = True, isolation_mode: str = "shared"): "--disable-dev-shm-usage", "--disable-gpu", ] + if _browser_should_force_direct() and "--no-proxy-server" not in self._browser_args: + self._browser_args.append("--no-proxy-server") + self._dirty = False + + def _launch_options(self) -> dict[str, Any]: + options: dict[str, Any] = { + "headless": self._headless, + "args": list(self._browser_args), + } + if self._headless: + # Use Chromium's "new headless" instead of the legacy headless shell. + options["channel"] = "chromium" + return options + + def _context_options(self) -> dict[str, Any]: + return { + "viewport": {"width": 1280, "height": 720}, + "user_agent": STEALTH_USER_AGENT, + "ignore_https_errors": False, + "java_script_enabled": True, + "bypass_csp": False, + "accept_downloads": False, + } + + def mark_dirty(self): + """Mark the shared browser state as unhealthy so the next session rebuilds it.""" + self._dirty = True + + def is_alive(self) -> bool: + """Best-effort health check for the shared browser transport.""" + if self._playwright is None: + return False + if self._isolation_mode == "strict": + return True + if self._browser is None or self._dirty: + return False + try: + return bool(self._browser.is_connected()) + except Exception: + return False + + async def ensure_healthy(self): + """Ensure the underlying browser transport is healthy, rebuilding if needed.""" + if self._isolation_mode == "strict": + if self._playwright is None: + await self.start() + return + + if self.is_alive(): + return + + await self.stop() + await self.start() async def start(self): """Start Playwright and launch browser (for shared mode)""" @@ -719,10 +1485,8 @@ async def start(self): self._playwright = await async_playwright().start() if self._isolation_mode == "shared" and self._browser is None: - self._browser = await self._playwright.chromium.launch( - headless=self._headless, - args=self._browser_args, - ) + self._browser = await self._playwright.chromium.launch(**self._launch_options()) + self._dirty = False async def new_session(self) -> BrowserSession: """ @@ -731,35 +1495,46 @@ async def new_session(self) -> BrowserSession: Returns: BrowserSession instance """ - if self._playwright is None: - await self.start() + await self.ensure_healthy() # Prepare context options - context_options = { - "viewport": {"width": 1280, "height": 720}, - "user_agent": STEALTH_USER_AGENT, - "ignore_https_errors": False, - "java_script_enabled": True, - "bypass_csp": False, - } + context_options = self._context_options() + launch_options = self._launch_options() if self._isolation_mode == "strict": - browser = await self._playwright.chromium.launch( - headless=self._headless, - args=self._browser_args, - ) + browser = await self._playwright.chromium.launch(**launch_options) context = await browser.new_context(**context_options) context.set_default_timeout(PAGE_TIMEOUT_MS) page = await context.new_page() - return BrowserSession(context, page, browser=browser) + return BrowserSession( + context, + page, + browser=browser, + context_options=context_options, + browser_launch_options=launch_options, + ) else: if self._browser is None: await self.start() - context = await self._browser.new_context(**context_options) - context.set_default_timeout(PAGE_TIMEOUT_MS) - page = await context.new_page() - return BrowserSession(context, page) + for attempt in range(2): + try: + context = await self._browser.new_context(**context_options) + context.set_default_timeout(PAGE_TIMEOUT_MS) + page = await context.new_page() + return BrowserSession( + context, + page, + context_options=context_options, + browser_launch_options=launch_options, + ) + except Exception as exc: + if attempt == 0 and is_browser_transport_error(exc): + log("Browser", f"Shared browser unhealthy during new_session(), rebuilding: {exc}", force=True) + self.mark_dirty() + await self.ensure_healthy() + continue + raise async def stop(self): """Stop browser and Playwright with timeout""" @@ -780,7 +1555,9 @@ async def stop(self): except Exception: pass self._playwright = None + self._dirty = False except asyncio.TimeoutError: # 超时则强制清理引用 self._browser = None self._playwright = None + self._dirty = False diff --git a/liveweb_arena/core/cache.py b/liveweb_arena/core/cache.py index 4c8bc89..f5d5e17 100644 --- a/liveweb_arena/core/cache.py +++ b/liveweb_arena/core/cache.py @@ -18,13 +18,16 @@ """ import asyncio +import contextlib import fcntl +import html import json import logging import os import re import time from dataclasses import dataclass +from html.parser import HTMLParser from pathlib import Path from typing import Any, Dict, List, Optional, TYPE_CHECKING from urllib.parse import unquote, urlparse @@ -36,6 +39,165 @@ # Default TTL: 48 hours DEFAULT_TTL = 48 * 3600 +_TEXT_CONTENT_SEPARATOR = "\n\n--- Page Text Content ---\n" + + +def _browser_proxy_mode() -> str: + return os.environ.get("LIVEWEB_BROWSER_PROXY_MODE", "system").strip().lower() + + +def _browser_should_force_direct() -> bool: + return _browser_proxy_mode() in {"direct", "no_proxy", "off"} + + +def _browser_should_direct_stooq() -> bool: + return os.environ.get("LIVEWEB_BROWSER_STOOQ_DIRECT", "0") == "1" + + +def _is_stooq_url(url: str) -> bool: + return "stooq.com" in (urlparse(url).hostname or "").lower() + + +def _is_retryable_stooq_prefetch_error(exc: BaseException) -> bool: + text = f"{type(exc).__name__}: {exc}".lower() + return any( + marker in text + for marker in ( + "timeout", + "err_aborted", + "frame was detached", + "target page, context or browser has been closed", + "transport closed", + "handler is closed", + "browser has been closed", + "connection closed", + ) + ) + + +def _is_taostats_url(url: str) -> bool: + return "taostats.io" in (urlparse(url).hostname or "").lower() + + +def _is_taostats_detail_url(url: str) -> bool: + parsed = urlparse(url) + host = (parsed.hostname or "").lower() + path = parsed.path.lower() + if "taostats.io" not in host: + return False + return path.startswith("/subnet/") or re.match(r"^/subnets/\d+(?:/chart)?/?$", path) is not None + + +def _is_retryable_taostats_detail_prefetch_error(exc: BaseException) -> bool: + text = f"{type(exc).__name__}: {exc}".lower() + return any( + marker in text + for marker in ( + "target page, context or browser has been closed", + "targetclosederror", + "err_aborted", + "frame was detached", + "handler is closed", + "transport closed", + "connection closed", + "browser has been closed", + ) + ) + + +def _build_taostats_prefetch_evidence( + *, + url: str, + exc: BaseException, + prefetch_phase: str, + wait_target: str | None = None, + background_refresh: bool = False, +) -> dict[str, Any] | None: + if not _is_taostats_detail_url(url) or not _is_retryable_taostats_detail_prefetch_error(exc): + return None + evidence = { + "classification": "env_taostats_detail_prefetch_invalidated", + "page_kind": "taostats_detail", + "prefetch_phase": prefetch_phase, + "wait_target": wait_target, + "background_refresh": background_refresh, + "raw_exception_type": type(exc).__name__, + "raw_exception_message": str(exc), + } + extra_evidence = dict(getattr(exc, "evidence", {}) or {}) + if extra_evidence: + evidence.update(extra_evidence) + return evidence + + +class _VisibleTextExtractor(HTMLParser): + """Best-effort visible text extractor for cached HTML fallback.""" + + def __init__(self) -> None: + super().__init__() + self._skip_depth = 0 + self._parts: list[str] = [] + + def handle_starttag(self, tag: str, attrs) -> None: + if tag in {"script", "style", "noscript"}: + self._skip_depth += 1 + + def handle_endtag(self, tag: str) -> None: + if tag in {"script", "style", "noscript"} and self._skip_depth > 0: + self._skip_depth -= 1 + + def handle_data(self, data: str) -> None: + if self._skip_depth: + return + text = _compact_text(data) + if text: + self._parts.append(text) + + def text(self) -> str: + lines: list[str] = [] + seen: set[str] = set() + for part in self._parts: + if part in seen: + continue + seen.add(part) + lines.append(part) + return "\n".join(lines) + + +def _compact_text(text: str) -> str: + return re.sub(r"\s+", " ", html.unescape(text or "")).strip() + + +def _extract_visible_text_from_html(html_text: str) -> str: + if not html_text: + return "" + parser = _VisibleTextExtractor() + parser.feed(html_text) + return parser.text() + + +def _merge_accessibility_and_page_text( + accessibility_tree: Optional[str], + page_text: Optional[str], +) -> str: + a11y = (accessibility_tree or "").strip() + text = (page_text or "").strip() + if not text: + return a11y + compact_text = _compact_text(text) + if not compact_text: + return a11y + if not a11y: + return text if len(compact_text) >= 20 else a11y + + compact_a11y = _compact_text(a11y) + if compact_text == compact_a11y or compact_text in compact_a11y: + return a11y + if len(compact_a11y) < 64 and len(compact_text) >= len(compact_a11y) + 16: + return a11y + _TEXT_CONTENT_SEPARATOR + text + if len(compact_text) <= max(256, int(len(compact_a11y) * 0.75)): + return a11y + return a11y + _TEXT_CONTENT_SEPARATOR + text class CacheFatalError(Exception): @@ -46,9 +208,27 @@ class CacheFatalError(Exception): Evaluation should be terminated immediately. """ - def __init__(self, message: str, url: str = None): + def __init__( + self, + message: str, + url: str = None, + kind: str = "fatal", + fatal: bool = True, + status_code: int | None = None, + evidence: dict | None = None, + soft_fail_applied: bool = False, + stale_fallback_used: bool = False, + plugin_name: str | None = None, + ): super().__init__(message) self.url = url + self.kind = kind + self.fatal = fatal + self.status_code = status_code + self.evidence = evidence or {} + self.soft_fail_applied = soft_fail_applied + self.stale_fallback_used = stale_fallback_used + self.plugin_name = plugin_name def log(tag: str, message: str): @@ -89,12 +269,17 @@ def to_dict(self) -> dict: @classmethod def from_dict(cls, data: dict) -> "CachedPage": + html_text = data["html"] + accessibility_tree = _merge_accessibility_and_page_text( + data.get("accessibility_tree"), + _extract_visible_text_from_html(html_text), + ) or None return cls( url=data["url"], - html=data["html"], + html=html_text, api_data=data.get("api_data"), fetched_at=data["fetched_at"], - accessibility_tree=data.get("accessibility_tree"), + accessibility_tree=accessibility_tree, need_api=data.get("need_api", True), # Default True for old caches ) @@ -116,6 +301,18 @@ def data(url: str) -> "PageRequirement": return PageRequirement(url, need_api=True) +@dataclass +class CacheFetchResult: + """Result of ensuring one cached page.""" + + page: CachedPage + source: str # hit|hit_after_lock|stale|fresh + normalized_url: str + domain_key: str + latency_s: float = 0.0 + stale: bool = False + + async def async_file_lock_acquire(lock_path: Path, timeout: float = 60.0) -> int: """ Acquire file lock asynchronously (non-blocking with retry). @@ -270,16 +467,62 @@ class CacheManager: # Minimum interval between consecutive cache-miss fetches (seconds) _PREFETCH_INTERVAL = 1.0 + _TAOSTATS_DETAIL_COOLDOWN_S = 600 def __init__(self, cache_dir: Path, ttl: int = None): self.cache_dir = Path(cache_dir) if ttl is None: ttl = int(os.environ.get("LIVEWEB_CACHE_TTL", str(DEFAULT_TTL))) self.ttl = ttl + self._prefetch_interval = float( + os.environ.get("LIVEWEB_CACHE_PREFETCH_INTERVAL", str(self._PREFETCH_INTERVAL)) + ) + self.allow_stale = os.environ.get("LIVEWEB_CACHE_ALLOW_STALE", "1") == "1" + self.stale_max_age = int(os.environ.get("LIVEWEB_CACHE_STALE_MAX_AGE", str(24 * 3600))) + self.max_cache_fetches = int(os.environ.get("LIVEWEB_MAX_CACHE_FETCHES", "6")) + self.enable_shared_cache = os.environ.get("LIVEWEB_ENABLE_SHARED_CACHE", "1") == "1" + self.shared_cache_dir = Path( + os.environ.get( + "LIVEWEB_SHARED_CACHE_DIR", + str(Path(__file__).resolve().parents[2] / ".cache" / "persistent" / "shared"), + ) + ) + if self.shared_cache_dir == self.cache_dir: + self.enable_shared_cache = False self._playwright = None self._browser = None self._browser_lock = asyncio.Lock() self._last_fetch_time: float = 0 + self._global_fetch_semaphore = asyncio.Semaphore(max(1, self.max_cache_fetches)) + self._domain_semaphores: Dict[str, asyncio.Semaphore] = {} + self._refresh_tasks: Dict[str, asyncio.Task] = {} + self._taostats_prefetch_cooldowns: Dict[str, tuple[float, dict[str, Any]]] = {} + + def _browser_launch_options(self, *, direct: bool = False) -> dict[str, Any]: + from liveweb_arena.core.block_patterns import STEALTH_BROWSER_ARGS + + args = [ + *STEALTH_BROWSER_ARGS, + "--disable-dev-shm-usage", + "--disable-gpu", + ] + if direct or _browser_should_force_direct(): + args.append("--no-proxy-server") + return { + "headless": True, + "channel": "chromium", + "args": args, + } + + def _context_options(self) -> dict[str, Any]: + from liveweb_arena.core.block_patterns import STEALTH_USER_AGENT + + return { + "viewport": {"width": 1280, "height": 720}, + "user_agent": STEALTH_USER_AGENT, + "ignore_https_errors": True, + "accept_downloads": False, + } async def _ensure_browser(self): """Ensure shared Playwright browser is running (lazy singleton).""" @@ -289,11 +532,8 @@ async def _ensure_browser(self): if self._browser is not None: return from playwright.async_api import async_playwright - from liveweb_arena.core.block_patterns import STEALTH_BROWSER_ARGS self._playwright = await async_playwright().start() - self._browser = await self._playwright.chromium.launch( - headless=True, args=STEALTH_BROWSER_ARGS, - ) + self._browser = await self._playwright.chromium.launch(**self._browser_launch_options()) async def shutdown(self): """Shutdown shared browser and Playwright.""" @@ -315,7 +555,7 @@ async def ensure_cached( self, pages: List[PageRequirement], plugin: "BasePlugin", - ) -> Dict[str, CachedPage]: + ) -> Dict[str, CacheFetchResult]: """ Ensure specified pages are cached. @@ -326,7 +566,7 @@ async def ensure_cached( Returns: {normalized_url: CachedPage} mapping """ - result = {} + result: Dict[str, CacheFetchResult] = {} for page_req in pages: normalized = normalize_url(page_req.url) @@ -340,92 +580,105 @@ async def _ensure_single( url: str, plugin: "BasePlugin", need_api: bool, - ) -> CachedPage: + allow_stale_lookup: bool = True, + ) -> CacheFetchResult: """Ensure single URL is cached.""" normalized = normalize_url(url) cache_dir = url_to_cache_dir(self.cache_dir, normalized) cache_file = cache_dir / "page.json" lock_file = cache_dir / ".lock" - + domain_key = self._domain_key_for_url(url) page_type = "data" if need_api else "nav" # 1. Quick check (no lock) - cached = self._load_if_valid(cache_file, need_api) - if cached: + status, cached = self._load_with_status(normalized, cache_file, need_api) + if status == "valid" and cached: log("Cache", f"HIT {page_type} - {url_display(normalized)}") - return cached + return CacheFetchResult( + page=cached, + source="hit", + normalized_url=normalized, + domain_key=domain_key, + ) + if allow_stale_lookup and status == "stale" and cached: + log("Cache", f"STALE {page_type} - serving {url_display(normalized)}") + self._schedule_refresh(url, plugin, need_api) + return CacheFetchResult( + page=cached, + source="stale", + normalized_url=normalized, + domain_key=domain_key, + stale=True, + ) + + cooldown_evidence = self._get_taostats_prefetch_cooldown(url) + if cooldown_evidence is not None: + raise CacheFatalError( + "Taostats prefetch cooldown active", + url=url, + kind="taostats_prefetch_cooldown", + fatal=False, + evidence=dict(cooldown_evidence), + plugin_name=getattr(plugin, "name", None), + ) # 2. Need update, acquire async lock (non-blocking to avoid deadlock) lock_fd = await async_file_lock_acquire(lock_file) try: # 3. Double check (another process may have updated) - cached = self._load_if_valid(cache_file, need_api) - if cached: + status, cached = self._load_with_status(normalized, cache_file, need_api) + if status == "valid" and cached: log("Cache", f"HIT {page_type} (after lock) - {url_display(normalized)}") - return cached + return CacheFetchResult( + page=cached, + source="hit_after_lock", + normalized_url=normalized, + domain_key=domain_key, + ) + if allow_stale_lookup and status == "stale" and cached: + log("Cache", f"STALE {page_type} (after lock) - serving {url_display(normalized)}") + self._schedule_refresh(url, plugin, need_api) + return CacheFetchResult( + page=cached, + source="stale", + normalized_url=normalized, + domain_key=domain_key, + stale=True, + ) + + cooldown_evidence = self._get_taostats_prefetch_cooldown(url) + if cooldown_evidence is not None: + raise CacheFatalError( + "Taostats prefetch cooldown active", + url=url, + kind="taostats_prefetch_cooldown", + fatal=False, + evidence=dict(cooldown_evidence), + plugin_name=getattr(plugin, "name", None), + ) # 4. Actually fetch - page and API in parallel when possible log("Cache", f"MISS {page_type} - fetching {url_display(normalized)}") # Rate limit consecutive fetches to avoid triggering anti-bot now = time.time() - wait = self._PREFETCH_INTERVAL - (now - self._last_fetch_time) + wait = self._prefetch_interval - (now - self._last_fetch_time) if wait > 0: await asyncio.sleep(wait) self._last_fetch_time = time.time() start = time.time() - - if need_api: - # Fetch HTML and API data concurrently - page_task = asyncio.ensure_future(self._fetch_page(url, plugin)) - api_task = asyncio.ensure_future(plugin.fetch_api_data(url)) - - # Wait for both, collecting errors - page_result = None - page_error = None - api_data = None - api_error = None - - try: - page_result = await page_task - except Exception as e: - page_error = e - # Cancel API task if page fails — no point caching without HTML - api_task.cancel() - - if page_error is None: - try: - api_data = await api_task - except Exception as e: - api_error = e - - if page_error is not None: - raise CacheFatalError( - f"Page fetch failed (browser cannot load): {page_error}", - url=url, - ) - html, accessibility_tree = page_result - - if api_error is not None: - raise CacheFatalError( - f"API data fetch failed (GT will be invalid): {api_error}", - url=url, - ) - if not api_data: - raise CacheFatalError( - f"API data is empty (GT will be invalid)", - url=url, - ) - else: - try: - html, accessibility_tree = await self._fetch_page(url, plugin) - except Exception as e: - raise CacheFatalError( - f"Page fetch failed (browser cannot load): {e}", - url=url, - ) - api_data = None + try: + async with self._global_fetch_semaphore: + async with self._domain_semaphore(domain_key): + html, accessibility_tree, api_data = await self._fetch_and_build_cache( + url=url, + plugin=plugin, + need_api=need_api, + ) + except Exception as exc: + self._maybe_activate_taostats_prefetch_cooldown(url, exc) + raise cached = CachedPage( url=url, @@ -439,36 +692,255 @@ async def _ensure_single( self._save(cache_file, cached) elapsed = time.time() - start log("Cache", f"SAVED {page_type} - {url_display(normalized)} ({elapsed:.1f}s)") - return cached + return CacheFetchResult( + page=cached, + source="fresh", + normalized_url=normalized, + domain_key=domain_key, + latency_s=elapsed, + ) finally: async_file_lock_release(lock_fd) - def _load_if_valid(self, cache_file: Path, need_api: bool) -> Optional[CachedPage]: - """Load cache if valid.""" + def _load_with_status( + self, + normalized_url: str, + cache_file: Path, + need_api: bool, + ) -> tuple[str, Optional[CachedPage]]: + status, cached = self._load_with_status_from_file(cache_file, need_api, delete_invalid=True) + if status in {"valid", "stale"} and cached: + return status, cached + + shared_file = self._shared_cache_file(normalized_url) + if shared_file is None: + return status, cached + + shared_status, shared_cached = self._load_with_status_from_file( + shared_file, + need_api, + delete_invalid=False, + ) + if shared_status in {"valid", "stale"} and shared_cached: + with contextlib.suppress(Exception): + self._save_to_path(cache_file, shared_cached) + return shared_status, shared_cached + return status, cached + + def _load_with_status_from_file( + self, + cache_file: Path, + need_api: bool, + *, + delete_invalid: bool, + ) -> tuple[str, Optional[CachedPage]]: + """Load cache and classify it as valid, stale, invalid, or missing.""" if not cache_file.exists(): - return None + return "missing", None try: cached = self._load(cache_file) except Exception as e: logger.warning(f"Failed to load cache {cache_file}: {e}") - # Corrupted cache - delete it - self._delete_cache(cache_file) - return None + if delete_invalid: + self._delete_cache(cache_file) + return "invalid", None - if cached.is_expired(self.ttl): - # Expired cache - delete it - self._delete_cache(cache_file) - return None - - # Check if cache is complete based on its own need_api flag - # Also handle case where current request needs API but old cache doesn't have it if not cached.is_complete() or (need_api and not cached.api_data): log("Cache", f"Incomplete (missing API) - deleting {url_display(cached.url)}") + if delete_invalid: + self._delete_cache(cache_file) + return "invalid", None + + age = time.time() - cached.fetched_at + if age <= self.ttl: + return "valid", cached + + if self.allow_stale and age <= self.ttl + self.stale_max_age: + return "stale", cached + + if delete_invalid: self._delete_cache(cache_file) + return "expired", None + + def _load_if_valid(self, cache_file: Path, need_api: bool) -> Optional[CachedPage]: + """Backward-compatible wrapper for tests and older callers. + + Returns the cached page only when it is currently valid under the + configured TTL and completeness rules. Stale, invalid, expired, and + missing entries all map to ``None``. + """ + status, cached = self._load_with_status_from_file(cache_file, need_api, delete_invalid=True) + return cached if status == "valid" else None + + async def _fetch_and_build_cache( + self, + url: str, + plugin: "BasePlugin", + need_api: bool, + ) -> tuple[str, str, Optional[Dict[str, Any]]]: + if need_api: + page_task = asyncio.create_task(self._fetch_page(url, plugin)) + api_task = asyncio.create_task(plugin.fetch_api_data(url)) + + page_result = None + page_error = None + api_data = None + api_error = None + + try: + page_result = await page_task + except Exception as e: + page_error = e + api_task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await api_task + + if page_error is None: + try: + api_data = await api_task + except Exception as e: + api_error = e + + if page_error is not None: + if isinstance(page_error, CacheFatalError): + raise page_error + raise CacheFatalError( + f"Page fetch failed (browser cannot load): {page_error}", + url=url, + ) + + html, accessibility_tree = page_result + + if api_error is not None: + plugin_failure_metadata = {} + try: + from liveweb_arena.plugins.base_client import APIFetchError + + if isinstance(api_error, APIFetchError): + plugin_failure_metadata = dict(api_error.metadata or {}) + except Exception: + plugin_failure_metadata = {} + raise CacheFatalError( + f"API data fetch failed (GT will be invalid): {api_error}", + url=url, + kind="api_error", + fatal=False, + evidence={"plugin_failure_metadata": plugin_failure_metadata}, + plugin_name=getattr(plugin, "name", None), + ) + if not api_data: + raise CacheFatalError( + "API data is empty (GT will be invalid)", + url=url, + kind="api_empty", + fatal=False, + ) + return html, accessibility_tree, api_data + + try: + html, accessibility_tree = await self._fetch_page(url, plugin) + except Exception as e: + if isinstance(e, CacheFatalError): + raise e + raise CacheFatalError( + f"Page fetch failed (browser cannot load): {e}", + url=url, + ) + return html, accessibility_tree, None + + def _schedule_refresh(self, url: str, plugin: "BasePlugin", need_api: bool): + normalized = normalize_url(url) + existing = self._refresh_tasks.get(normalized) + if existing and not existing.done(): + return + task = asyncio.create_task(self._background_refresh(url, plugin, need_api, normalized)) + self._refresh_tasks[normalized] = task + + async def _background_refresh(self, url: str, plugin: "BasePlugin", need_api: bool, normalized: str): + try: + await self._ensure_single(url, plugin, need_api, allow_stale_lookup=False) + except Exception as e: + evidence = None + if isinstance(e, CacheFatalError): + evidence = dict(getattr(e, "evidence", {}) or {}) + taostats_evidence = _build_taostats_prefetch_evidence( + url=url, + exc=e, + prefetch_phase="background_refresh", + background_refresh=True, + ) + if taostats_evidence: + evidence.update(taostats_evidence) + e.evidence = evidence + log("Cache", f"Background refresh skipped for {url_display(normalized)}: {e}") + finally: + self._refresh_tasks.pop(normalized, None) + + def _domain_key_for_url(self, url: str) -> str: + hostname = urlparse(url).netloc.lower() + if "coingecko" in hostname: + return "coingecko" + if "stooq" in hostname: + return "stooq" + if "news.ycombinator" in hostname: + return "news_ycombinator" + if "taostats" in hostname: + return "taostats" + return "default" + + def _domain_limit_for_key(self, domain_key: str) -> int: + env_name = f"LIVEWEB_CACHE_DOMAIN_LIMIT_{domain_key.upper()}" + default_limit = int(os.environ.get("LIVEWEB_CACHE_DOMAIN_LIMIT_DEFAULT", "2")) + if domain_key == "stooq": + default_limit = int(os.environ.get("LIVEWEB_CACHE_DOMAIN_LIMIT_STOOQ", "1")) + return max(1, int(os.environ.get(env_name, str(default_limit)))) + + def _get_taostats_prefetch_cooldown(self, url: str) -> Optional[dict[str, Any]]: + if not _is_taostats_detail_url(url): + return None + key = normalize_url(url) + entry = self._taostats_prefetch_cooldowns.get(key) + if entry is None: return None + expires_at, evidence = entry + if expires_at > time.monotonic(): + return evidence + self._taostats_prefetch_cooldowns.pop(key, None) + return None + + def _maybe_activate_taostats_prefetch_cooldown(self, url: str, exc: BaseException) -> None: + if not _is_taostats_detail_url(url): + return + evidence: dict[str, Any] | None = None + if isinstance(exc, CacheFatalError): + evidence = dict(exc.evidence or {}) + classification = evidence.get("classification") + if classification != "env_taostats_detail_prefetch_invalidated": + return + else: + evidence = _build_taostats_prefetch_evidence( + url=url, + exc=exc, + prefetch_phase=getattr(exc, "prefetch_phase", "setup_page_for_cache"), + wait_target=getattr(exc, "wait_target", None), + ) + if not evidence: + return + evidence = dict(evidence or {}) + evidence.setdefault("cooldown_seconds", self._TAOSTATS_DETAIL_COOLDOWN_S) + evidence.setdefault("cooldown_applied", True) + self._taostats_prefetch_cooldowns[normalize_url(url)] = ( + time.monotonic() + self._TAOSTATS_DETAIL_COOLDOWN_S, + evidence, + ) - return cached + def _domain_semaphore(self, domain_key: str) -> asyncio.Semaphore: + sem = self._domain_semaphores.get(domain_key) + if sem is None: + sem = asyncio.Semaphore(self._domain_limit_for_key(domain_key)) + self._domain_semaphores[domain_key] = sem + return sem def _delete_cache(self, cache_file: Path): """Delete cache file.""" @@ -486,46 +958,81 @@ def _load(self, cache_file: Path) -> CachedPage: def _save(self, cache_file: Path, cached: CachedPage): """Save cache to file.""" + self._save_to_path(cache_file, cached) + shared_file = self._shared_cache_file(normalize_url(cached.url)) + if shared_file is not None: + with contextlib.suppress(Exception): + self._save_to_path(shared_file, cached) + + @staticmethod + def _save_to_path(cache_file: Path, cached: CachedPage): cache_file.parent.mkdir(parents=True, exist_ok=True) with open(cache_file, 'w', encoding='utf-8') as f: json.dump(cached.to_dict(), f, ensure_ascii=False) + def _shared_cache_file(self, normalized_url: str) -> Optional[Path]: + if not self.enable_shared_cache: + return None + return url_to_cache_dir(self.shared_cache_dir, normalized_url) / "page.json" + async def _fetch_page(self, url: str, plugin=None) -> tuple: """ - Fetch page HTML and accessibility tree using shared Playwright browser. - - Args: - url: Page URL to fetch - plugin: Optional plugin for page setup (e.g., click "Show All") + Fetch page HTML and accessibility tree. - Returns: - (html, accessibility_tree) tuple + Stooq gets one extra retry through a temporary direct browser because the + cache prefetch path is especially proxy-sensitive in this environment. """ + await self._ensure_browser() + try: + return await self._fetch_page_once(self._browser, url, plugin) + except Exception as exc: + if ( + not _browser_should_direct_stooq() + or not _is_stooq_url(url) + or not _is_retryable_stooq_prefetch_error(exc) + ): + raise + log("Cache", f"Retrying Stooq prefetch via direct browser: {type(exc).__name__}: {exc}") + direct_browser = await self._playwright.chromium.launch(**self._browser_launch_options(direct=True)) + try: + return await self._fetch_page_once(direct_browser, url, plugin) + finally: + await direct_browser.close() + + async def _fetch_page_once(self, browser, url: str, plugin=None) -> tuple: from liveweb_arena.core.block_patterns import ( - STEALTH_INIT_SCRIPT, STEALTH_USER_AGENT, - is_captcha_page, should_block_url, + STEALTH_INIT_SCRIPT, + is_captcha_page, + should_block_url, ) - await self._ensure_browser() + prefetch_phase = "new_context" + wait_target = None try: - context = await self._browser.new_context( - viewport={"width": 1280, "height": 720}, - user_agent=STEALTH_USER_AGENT, - ) + context = await browser.new_context(**self._context_options()) except Exception: - # Browser process may have crashed — clean up and retry once + if browser is not self._browser: + raise await self.shutdown() await self._ensure_browser() - context = await self._browser.new_context( - viewport={"width": 1280, "height": 720}, - user_agent=STEALTH_USER_AGENT, - ) + browser = self._browser + context = await browser.new_context(**self._context_options()) try: + prefetch_phase = "new_page" page = await context.new_page() await page.add_init_script(STEALTH_INIT_SCRIPT) + prefetch_phase = "goto" + wait_target = None + + classification = plugin.classify_url(url) if plugin and hasattr(plugin, "classify_url") else None + if classification == "model_invalid_url_shape": + return await self._render_browser_error_page( + page, + title="Invalid page", + message="This URL shape is not a stable Stooq page. Try a quote page like /q/?s=symbol instead.", + url=url, + ) - # Block tracking/ads to avoid networkidle delays - # Merge global + plugin-specific block patterns plugin_block_res = [] if plugin and hasattr(plugin, 'get_blocked_patterns'): for pat in plugin.get_blocked_patterns(): @@ -544,33 +1051,62 @@ async def _block_tracking(route): await route.continue_() await page.route("**/*", _block_tracking) - response = await page.goto(url, timeout=60000, wait_until="domcontentloaded") - # Layer 1: HTTP status check if response and response.status >= 400: + fatal = response.status not in (404, 410) raise CacheFatalError( f"HTTP {response.status} for {url}", url=url, + kind=f"http_{response.status}", + fatal=fatal, + status_code=response.status, + evidence={"page_url": url}, ) - # Wait for network idle (short timeout: ads are blocked, so - # legitimate content loads in ~3-4s; streaming endpoints like - # aq*.stooq.com keep connections open indefinitely) try: + prefetch_phase = "post_wait" await page.wait_for_load_state("networkidle", timeout=5000) except Exception: pass - # Plugin-specific page setup (e.g., click "ALL" to show all rows) + setup_metadata: dict[str, Any] = {} if plugin and hasattr(plugin, 'setup_page_for_cache'): try: - await plugin.setup_page_for_cache(page, url) + prefetch_phase = "setup_page_for_cache" + setup_metadata = dict(await plugin.setup_page_for_cache(page, url) or {}) except Exception as e: + taostats_evidence = _build_taostats_prefetch_evidence( + url=url, + exc=e, + prefetch_phase=getattr(e, "prefetch_phase", "setup_page_for_cache"), + wait_target=getattr(e, "wait_target", None), + ) + if taostats_evidence: + raise CacheFatalError( + f"Taostats detail prefetch setup failed: {e}", + url=url, + kind="taostats_prefetch_invalidated", + fatal=False, + evidence=taostats_evidence, + plugin_name=getattr(plugin, "name", None), + ) from e log("Cache", f"Page setup failed (continuing): {e}") + if setup_metadata.get("page_kind") == "taostats_detail": + log( + "Cache", + "Taostats detail setup metadata: " + + json.dumps(setup_metadata, ensure_ascii=False, sort_keys=True), + ) + elif setup_metadata.get("page_kind") == "taostats_list" and setup_metadata.get("list_setup_soft_failed"): + log( + "Cache", + "Taostats list setup soft failure: " + + json.dumps(setup_metadata, ensure_ascii=False, sort_keys=True), + ) - # Scroll to trigger lazy loading for pos in [0, 500, 1000, 2000]: + prefetch_phase = "post_wait" await page.evaluate(f"window.scrollTo(0, {pos})") await page.wait_for_timeout(300) @@ -578,23 +1114,20 @@ async def _block_tracking(route): await page.wait_for_timeout(500) html = await page.content() - - # Layer 2: CAPTCHA/challenge detection page_title = await page.title() if is_captcha_page(html, page_title): raise CacheFatalError( f"CAPTCHA/challenge page detected (title: {page_title!r})", url=url, + evidence={"page_title": page_title}, ) - - # Layer 3: Minimum content length (real pages are >5KB) if len(html) < 1000: raise CacheFatalError( f"Page too short ({len(html)} bytes, title: {page_title!r})", url=url, + evidence={"page_title": page_title, "html_length": len(html)}, ) - # Extract accessibility tree for deterministic caching a11y_tree = "" try: a11y_snapshot = await page.accessibility.snapshot() @@ -603,30 +1136,80 @@ async def _block_tracking(route): except Exception: pass - # If accessibility tree is empty, get page text content - if len(a11y_tree.strip()) < 100: - try: - page_text = await page.evaluate(""" - () => { - const preElements = document.querySelectorAll('pre'); - if (preElements.length > 0) { - return Array.from(preElements).map(el => el.innerText).join('\\n'); - } - return document.body.innerText || ''; + page_text = "" + try: + page_text = await page.evaluate(""" + () => { + const preElements = document.querySelectorAll('pre'); + if (preElements.length > 0) { + return Array.from(preElements).map(el => el.innerText).join('\\n'); } - """) - if page_text.strip(): - if a11y_tree.strip(): - a11y_tree += "\n\n--- Page Text Content ---\n" + page_text - else: - a11y_tree = page_text - except Exception: - pass + return document.body.innerText || ''; + } + """) + except Exception: + page_text = "" + + a11y_tree = _merge_accessibility_and_page_text(a11y_tree, page_text) return html, a11y_tree + except Exception as exc: + taostats_evidence = _build_taostats_prefetch_evidence( + url=url, + exc=exc, + prefetch_phase=getattr(exc, "prefetch_phase", prefetch_phase), + wait_target=getattr(exc, "wait_target", wait_target), + ) + if taostats_evidence: + if isinstance(exc, CacheFatalError): + exc.evidence.update(taostats_evidence) + exc.plugin_name = getattr(plugin, "name", None) + raise + raise CacheFatalError( + f"Taostats detail prefetch invalidated: {exc}", + url=url, + kind="taostats_prefetch_invalidated", + fatal=False, + evidence=taostats_evidence, + plugin_name=getattr(plugin, "name", None), + ) from exc + raise finally: await context.close() + async def _render_browser_error_page(self, page, *, title: str, message: str, url: str) -> tuple[str, str]: + safe_title = title.replace("&", "&").replace("<", "<").replace(">", ">") + safe_message = message.replace("&", "&").replace("<", "<").replace(">", ">") + safe_url = url.replace("&", "&").replace("<", "<").replace(">", ">") + html = ( + "" + f"{safe_title}" + "" + f"

{safe_title}

" + f"

{safe_message}

" + f"

URL: {safe_url}

" + "" + ) + await page.set_content(html, wait_until="domcontentloaded") + + a11y_tree = "" + try: + a11y_snapshot = await page.accessibility.snapshot() + if a11y_snapshot: + a11y_tree = self._format_accessibility_tree(a11y_snapshot) + except Exception: + pass + + if len(a11y_tree.strip()) < 20: + try: + page_text = await page.evaluate("() => document.body.innerText || ''") + if page_text.strip(): + a11y_tree = page_text + except Exception: + pass + + return await page.content(), a11y_tree + def _format_accessibility_tree(self, node: dict, indent: int = 0) -> str: """Format accessibility tree node recursively.""" if not node: @@ -666,4 +1249,3 @@ def get_cached(self, url: str) -> Optional[CachedPage]: return self._load(cache_file) except Exception: return None - diff --git a/liveweb_arena/core/interceptor.py b/liveweb_arena/core/interceptor.py index 1b4ad6b..42e0199 100644 --- a/liveweb_arena/core/interceptor.py +++ b/liveweb_arena/core/interceptor.py @@ -10,21 +10,59 @@ import asyncio import logging +import os import re from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Set +from typing import Any, Dict, List, Optional, Set, TYPE_CHECKING from urllib.parse import urlparse -from playwright.async_api import Route +if TYPE_CHECKING: + from playwright.async_api import Route +else: + Route = Any from liveweb_arena.core.block_patterns import TRACKING_BLOCK_PATTERNS -from liveweb_arena.core.cache import CachedPage, CacheFatalError, CacheManager, PageRequirement, normalize_url +from liveweb_arena.core.cache import ( + CachedPage, + CacheFatalError, + CacheFetchResult, + CacheManager, + PageRequirement, + normalize_url, +) logger = logging.getLogger(__name__) -# Pre-fetch timeout must be less than the main browser's NAVIGATION_TIMEOUT_MS (30s) -# so that route.abort() reaches the browser BEFORE page.goto() times out. -PREFETCH_TIMEOUT = 25 +DEFAULT_PREFETCH_TIMEOUT_NAV = int( + os.environ.get("LIVEWEB_PREFETCH_TIMEOUT_NAV", "12") +) +DEFAULT_PREFETCH_TIMEOUT_DATA = int( + os.environ.get("LIVEWEB_PREFETCH_TIMEOUT_DATA", "25") +) +DEFAULT_SOFT_FAIL_DOMAINS = "news.ycombinator.com,channelsurfer.tv,taostats.io,coingecko.com" +DEFAULT_SOFT_FAIL_URL_PATTERNS = ( + "news.ycombinator.com/ask," + "news.ycombinator.com/show," + "channelsurfer.tv," + "runcaptain.com," + "aether.saphal.me," + "stooq.com/q/currency/," + "stooq.com/q/c/," + "coingecko.app.link," + "coingecko.com/en/highlights/" +) +DEFAULT_REQUIRED_SOFT_URL_REGEXES = ( + r"^news\.ycombinator\.com/?$," + r"^news\.ycombinator\.com/(ask|show)(?:[/?].*)?$" +) +DEFAULT_PREFETCH_SOFT_URL_REGEXES = ( + r"^channelsurfer\.tv(?:/.*)?$," + r"^runcaptain\.com(?:/.*)?$," + r"^aether\.saphal\.me(?:/.*)?$," + r"^(?:www\.)?taostats\.io(?:/subnets(?:/\d+(?:/chart)?)?)?(?:[/?].*)?$," + r"^(?:www\.)?coingecko\.com/en/(?:coins/[^/?#]+(?:/historical_data)?|highlights/.*)(?:[/?].*)?$," + r"^(?:www\.)?stooq\.com/q/(?:currency|c)/.*$" +) # 1x1 transparent GIF (43 bytes) _TRANSPARENT_GIF = ( @@ -57,9 +95,16 @@ class InterceptorStats: blocked: int = 0 passed: int = 0 errors: int = 0 + stale_hits: int = 0 + prefetch_timeouts: int = 0 + soft_failures: int = 0 miss_urls: List[str] = field(default_factory=list) blocked_urls: Set[str] = field(default_factory=set) passed_urls: Set[str] = field(default_factory=set) + per_domain_prefetch_timeouts: Dict[str, int] = field(default_factory=dict) + per_domain_soft_failures: Dict[str, int] = field(default_factory=dict) + per_domain_miss_count: Dict[str, int] = field(default_factory=dict) + per_domain_miss_latency_s: Dict[str, float] = field(default_factory=dict) def to_dict(self) -> dict: total = self.hits + self.misses + self.blocked + self.passed @@ -69,11 +114,18 @@ def to_dict(self) -> dict: "blocked": self.blocked, "passed": self.passed, "errors": self.errors, + "stale_hits": self.stale_hits, + "prefetch_timeouts": self.prefetch_timeouts, + "soft_failures": self.soft_failures, "total": total, "hit_rate": self.hits / max(1, self.hits + self.misses), "miss_urls": self.miss_urls[:10], "blocked_urls": sorted(self.blocked_urls), "passed_urls": sorted(self.passed_urls), + "per_domain_prefetch_timeouts": self.per_domain_prefetch_timeouts, + "per_domain_soft_failures": self.per_domain_soft_failures, + "per_domain_miss_count": self.per_domain_miss_count, + "per_domain_miss_latency_s": self.per_domain_miss_latency_s, } @@ -140,6 +192,28 @@ def __init__( self.offline = offline self.stats = InterceptorStats() self._pending_error: Optional[Exception] = None + self._last_error_metadata: Dict[str, Any] = {} + self._last_blocked_document_metadata: Dict[str, Any] = {} + self._soft_fail_domains = { + item.strip().lower() + for item in os.environ.get("LIVEWEB_SOFT_FAIL_DOMAINS", DEFAULT_SOFT_FAIL_DOMAINS).split(",") + if item.strip() + } + self._soft_fail_url_patterns = [ + item.strip().lower() + for item in os.environ.get("LIVEWEB_SOFT_FAIL_URL_PATTERNS", DEFAULT_SOFT_FAIL_URL_PATTERNS).split(",") + if item.strip() + ] + self._required_soft_url_regexes = [ + re.compile(item.strip(), re.IGNORECASE) + for item in os.environ.get("LIVEWEB_REQUIRED_SOFT_URL_REGEXES", DEFAULT_REQUIRED_SOFT_URL_REGEXES).split(",") + if item.strip() + ] + self._prefetch_soft_url_regexes = [ + re.compile(item.strip(), re.IGNORECASE) + for item in os.environ.get("LIVEWEB_PREFETCH_SOFT_URL_REGEXES", DEFAULT_PREFETCH_SOFT_URL_REGEXES).split(",") + if item.strip() + ] # Per-evaluation storage for cached accessibility trees self._accessibility_trees: Dict[str, str] = {} @@ -247,6 +321,15 @@ async def _handle_document(self, route: Route, url: str): log("Intercept", f"MISS document - {self._url_display(url)}") if not self._is_domain_allowed(url): + blocked_domain = (urlparse(url).hostname or "").lower() + self._last_blocked_document_metadata = { + "classification": "model_disallowed_domain", + "blocked_url": url, + "blocked_domain": blocked_domain, + "allowed_domains": sorted(self.allowed_domains), + "blocked_resource_type": "document", + "blocked_by": "interceptor", + } await route.fulfill( status=403, headers={"content-type": "text/html"}, @@ -279,35 +362,164 @@ async def _handle_document(self, route: Route, url: str): try: need_api = plugin.needs_api_data(url) page_req = PageRequirement.data(url) if need_api else PageRequirement.nav(url) + timeout_s = self._prefetch_timeout_for_url(url, need_api) pages = await asyncio.wait_for( self.cache_manager.ensure_cached([page_req], plugin), - timeout=PREFETCH_TIMEOUT, + timeout=timeout_s, ) - self.cached_pages.update(pages) - - cached = pages.get(normalize_url(url)) - if cached and cached.html: - if cached.accessibility_tree: - self._accessibility_trees[normalized] = cached.accessibility_tree + cached_result = pages.get(normalize_url(url)) + if cached_result: + self._register_cache_fetch(cached_result) + self.cached_pages[cached_result.normalized_url] = cached_result.page + + if cached_result and cached_result.page.html: + if cached_result.page.accessibility_tree: + self._accessibility_trees[normalized] = cached_result.page.accessibility_tree await route.fulfill( status=200, headers={"content-type": "text/html; charset=utf-8"}, - body=cached.html, + body=cached_result.page.html, ) return except asyncio.TimeoutError: - self._pending_error = CacheFatalError( - f"Pre-fetch timeout ({PREFETCH_TIMEOUT}s)", url=url, + self._register_prefetch_timeout(url) + cached_fallback = self._find_any_cached_page(url) + if cached_fallback and cached_fallback.html: + if cached_fallback.accessibility_tree: + self._accessibility_trees[normalized] = cached_fallback.accessibility_tree + self.cached_pages[normalized] = cached_fallback + await route.fulfill( + status=200, + headers={"content-type": "text/html; charset=utf-8"}, + body=cached_fallback.html, + ) + return + self._last_error_metadata = { + "classification": "env_prefetch_timeout", + "layer": "cache", + "prefetch_attempted": True, + "prefetch_timeout_kind": "asyncio_timeout", + "prefetch_elapsed_s": timeout_s, + "soft_fail_triggered": True, + "soft_fail_reason": "prefetch_timeout", + "stale_fallback_used": False, + } + await route.fulfill( + status=200, + headers={"content-type": "text/html; charset=utf-8"}, + body=self._build_soft_error_page( + url=url, + title="Pre-fetch timeout", + message=f"The page could not be prefetched within {timeout_s}s. Try another page or retry later.", + ), ) - await route.abort("failed") return except CacheFatalError as e: - self._pending_error = e - await route.abort("failed") + if e.fatal and not self._should_soft_fail_domain(url): + e.evidence.setdefault("interceptor", {}) + e.evidence["interceptor"].update( + { + "prefetch_attempted": True, + "soft_fail_triggered": False, + "stale_fallback_used": False, + } + ) + e.plugin_name = getattr(plugin, "name", None) if plugin is not None else e.plugin_name + self._pending_error = e + await route.abort("failed") + else: + self._register_soft_failure(url) + cached_fallback = self._find_any_cached_page(url) + if cached_fallback and cached_fallback.html: + self._last_error_metadata = { + "classification": "env_cache_fetch_failed", + "layer": "cache", + "prefetch_attempted": True, + "soft_fail_triggered": True, + "soft_fail_reason": str(e), + "stale_fallback_used": True, + } + if cached_fallback.accessibility_tree: + self._accessibility_trees[normalized] = cached_fallback.accessibility_tree + self.cached_pages[normalized] = cached_fallback + await route.fulfill( + status=200, + headers={"content-type": "text/html; charset=utf-8"}, + body=cached_fallback.html, + ) + return + self._last_error_metadata = { + "classification": "env_cache_fetch_failed", + "layer": "cache", + "prefetch_attempted": True, + "soft_fail_triggered": True, + "soft_fail_reason": str(e), + "stale_fallback_used": False, + } + await route.fulfill( + status=200, + headers={"content-type": "text/html; charset=utf-8"}, + body=self._build_soft_error_page( + url=url, + title="Page unavailable", + message=str(e), + ), + ) return except Exception as e: - self._pending_error = CacheFatalError(str(e), url=url) - await route.abort("failed") + if self._should_soft_fail_domain(url): + self._register_soft_failure(url) + cached_fallback = self._find_any_cached_page(url) + if cached_fallback and cached_fallback.html: + self._last_error_metadata = { + "classification": "env_cache_fetch_failed", + "layer": "cache", + "prefetch_attempted": True, + "soft_fail_triggered": True, + "soft_fail_reason": str(e), + "stale_fallback_used": True, + } + if cached_fallback.accessibility_tree: + self._accessibility_trees[normalized] = cached_fallback.accessibility_tree + self.cached_pages[normalized] = cached_fallback + await route.fulfill( + status=200, + headers={"content-type": "text/html; charset=utf-8"}, + body=cached_fallback.html, + ) + return + self._last_error_metadata = { + "classification": "env_cache_fetch_failed", + "layer": "cache", + "prefetch_attempted": True, + "soft_fail_triggered": True, + "soft_fail_reason": str(e), + "stale_fallback_used": False, + } + await route.fulfill( + status=200, + headers={"content-type": "text/html; charset=utf-8"}, + body=self._build_soft_error_page( + url=url, + title="Temporary page unavailable", + message=str(e), + ), + ) + else: + self._pending_error = CacheFatalError( + str(e), + url=url, + kind="prefetch_failed", + evidence={ + "interceptor": { + "prefetch_attempted": True, + "soft_fail_triggered": False, + "stale_fallback_used": False, + } + }, + plugin_name=getattr(plugin, "name", None) if plugin is not None else None, + ) + await route.abort("failed") return # Fallback: LIVE mode or URL without plugin → pass through to network @@ -415,6 +627,112 @@ def _find_cached_page(self, url: str) -> Optional[CachedPage]: return None + def _find_any_cached_page(self, url: str) -> Optional[CachedPage]: + normalized = normalize_url(url) + parsed = urlparse(normalized) + + candidates = [normalized] + if parsed.netloc.startswith("www."): + candidates.append(normalized.replace("www.", "", 1)) + else: + candidates.append(normalized.replace("://", "://www.", 1)) + + for candidate in candidates: + page = self.cached_pages.get(candidate) or self._url_map.get(candidate) + if page and page.html: + return page + + if self.cache_manager: + for candidate in self._url_variants(url, parsed): + page = self.cache_manager.get_cached(candidate) + if page and page.html: + return page + return None + + def _prefetch_timeout_for_page(self, need_api: bool) -> int: + return DEFAULT_PREFETCH_TIMEOUT_DATA if need_api else DEFAULT_PREFETCH_TIMEOUT_NAV + + def _domain_key(self, url: str) -> str: + hostname = urlparse(url).netloc.lower() + if "coingecko" in hostname: + return "coingecko" + if "stooq" in hostname: + return "stooq" + if "news.ycombinator" in hostname: + return "news_ycombinator" + if "taostats" in hostname: + return "taostats" + return "default" + + def _prefetch_timeout_for_url(self, url: str, need_api: bool) -> int: + timeout = self._prefetch_timeout_for_page(need_api) + domain_key = self._domain_key(url) + if domain_key == "coingecko": + return max(timeout, 35 if need_api else 18) + if domain_key == "stooq": + return max(timeout, 45 if need_api else 20) + if domain_key == "taostats": + return max(timeout, 35 if need_api else 18) + return timeout + + def _register_prefetch_timeout(self, url: str): + self.stats.prefetch_timeouts += 1 + domain_key = self._domain_key(url) + self.stats.per_domain_prefetch_timeouts[domain_key] = ( + self.stats.per_domain_prefetch_timeouts.get(domain_key, 0) + 1 + ) + self._register_soft_failure(url) + + def _register_soft_failure(self, url: str): + self.stats.soft_failures += 1 + domain_key = self._domain_key(url) + self.stats.per_domain_soft_failures[domain_key] = ( + self.stats.per_domain_soft_failures.get(domain_key, 0) + 1 + ) + + def _should_soft_fail_domain(self, url: str) -> bool: + return self._soft_fail_policy(url) is not None + + def _soft_fail_policy(self, url: str) -> str | None: + parsed = urlparse(url) + hostname = parsed.netloc.lower() + path = parsed.path.lower() + combined = f"{hostname}{path}" + if any(pattern.match(combined) for pattern in self._required_soft_url_regexes): + return "required_soft" + if any(pattern.match(combined) for pattern in self._prefetch_soft_url_regexes): + return "prefetch_soft" + if any(domain in hostname for domain in self._soft_fail_domains): + return "domain_soft" + combined = f"{hostname}{path}" + if any(pattern in combined for pattern in self._soft_fail_url_patterns): + return "domain_soft" + return None + + def _register_cache_fetch(self, result: CacheFetchResult): + domain_key = result.domain_key + if result.source == "stale": + self.stats.stale_hits += 1 + if result.source in ("fresh", "stale"): + self.stats.per_domain_miss_count[domain_key] = ( + self.stats.per_domain_miss_count.get(domain_key, 0) + 1 + ) + self.stats.per_domain_miss_latency_s[domain_key] = ( + self.stats.per_domain_miss_latency_s.get(domain_key, 0.0) + result.latency_s + ) + + def _build_soft_error_page(self, url: str, title: str, message: str) -> str: + safe_url = url.replace("&", "&").replace("<", "<").replace(">", ">") + safe_message = message.replace("&", "&").replace("<", "<").replace(">", ">") + return ( + "" + f"

{title}

" + f"

{safe_message}

" + f"

URL: {safe_url}

" + "

This page could not be prefetched. You may retry or navigate elsewhere.

" + "" + ) + @staticmethod def _url_variants(url: str, parsed) -> List[str]: """Generate URL variants for cache lookup (original, without www, with www).""" @@ -484,6 +802,14 @@ def get_and_clear_error(self) -> Optional[Exception]: self._pending_error = None return err + def get_and_clear_error_metadata(self) -> Dict[str, Any]: + metadata = dict(self._last_error_metadata) + self._last_error_metadata = {} + return metadata + + def get_last_blocked_document_metadata(self) -> Dict[str, Any]: + return dict(self._last_blocked_document_metadata) + def raise_if_error(self, url: str = None) -> None: """Check for pending error and raise as CacheFatalError if present.""" err = self._pending_error @@ -491,7 +817,11 @@ def raise_if_error(self, url: str = None) -> None: if err is not None: if isinstance(err, CacheFatalError): raise err - raise CacheFatalError(str(err), url=url) + raise CacheFatalError( + str(err), + url=url, + evidence={"interceptor": self.get_and_clear_error_metadata()}, + ) def get_stats(self) -> dict: """Get interception statistics.""" @@ -509,3 +839,5 @@ def cleanup(self): self.cached_pages.clear() self.stats = InterceptorStats() self._pending_error = None + self._last_error_metadata = {} + self._last_blocked_document_metadata = {} diff --git a/liveweb_arena/core/reachability_audit.py b/liveweb_arena/core/reachability_audit.py new file mode 100644 index 0000000..a82f31f --- /dev/null +++ b/liveweb_arena/core/reachability_audit.py @@ -0,0 +1,589 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, Optional +from urllib.parse import urlparse + +from liveweb_arena.core.cache import normalize_url +from liveweb_arena.core.site_probe import probe_site + + +@dataclass +class ReachabilityAuditResult: + status: str + classification: str + layer: str + url: str + normalized_url: str + domain: str + plugin_name: Optional[str] = None + reason: Optional[str] = None + http_status: Optional[int] = None + exception_type: Optional[str] = None + raw_exception_type: Optional[str] = None + raw_exception_message: Optional[str] = None + navigation_stage: Optional[str] = None + resource_type: Optional[str] = None + attempt_index: Optional[int] = None + max_attempts: Optional[int] = None + browser_reused: Optional[bool] = None + context_reused: Optional[bool] = None + page_recreated_before_retry: Optional[bool] = None + is_environment_failure: bool = False + is_model_hallucination: bool = False + evidence: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def _domain(url: str) -> str: + try: + return (urlparse(url).hostname or "").lower() + except Exception: + return "" + + +def _matches_allowed_domain(domain: str, allowed_domain: str) -> bool: + return domain == allowed_domain or domain.endswith("." + allowed_domain) + + +def _is_taostats_list_url(url: str) -> bool: + parsed = urlparse(url) + host = (parsed.hostname or "").lower() + path = parsed.path.lower().rstrip("/") + return "taostats.io" in host and path in {"", "/subnets"} + + +def _is_taostats_detail_url(url: str) -> bool: + parsed = urlparse(url) + host = (parsed.hostname or "").lower() + path = parsed.path.lower() + return "taostats.io" in host and (path.startswith("/subnet/") or path.startswith("/subnets/")) + + +def _infer_taostats_interaction_kind(target_locator: str | None, raw_exception_message: str | None) -> str: + text = " ".join(part for part in [target_locator or "", raw_exception_message or ""] if part).lower() + if any(marker in text for marker in ("page-item", "next", "prev", "next page")): + return "paginate" + if any(marker in text for marker in ("rt-th", "dt-orderable", "1h", "24h", "1w", "1m", "sort")): + return "sort" + if any(marker in text for marker in ("all", "rows:", "dataTables_length", ".dataTables_length")): + return "show_all" + return "unknown" + + +def _is_invalid_selector_message(raw_exception_type: str | None, raw_exception_message: str | None) -> bool: + text = " ".join(part for part in [raw_exception_type or "", raw_exception_message or ""] if part).lower() + return any( + marker in text + for marker in ( + "not a valid selector", + "unexpected token", + "queryselectorall", + "selector engine", + "selector is malformed", + "unknown engine", + ) + ) + + +def _is_missing_ui_target_message(raw_exception_type: str | None, raw_exception_message: str | None) -> bool: + text = " ".join(part for part in [raw_exception_type or "", raw_exception_message or ""] if part).lower() + return "no element found with role" in text or "no element found for selector" in text + + +def _build_disallowed_domain_audit( + *, + url: str, + normalized: str, + domain: str, + plugin_name: str | None, + reason: str | None, + http_status: int | None, + exception: BaseException | None, + raw_exception_type: str | None, + raw_exception_message: str | None, + navigation_stage: str | None, + resource_type: str | None, + attempt_index: int | None, + max_attempts: int | None, + browser_reused: bool | None, + context_reused: bool | None, + page_recreated_before_retry: bool | None, + evidence: dict[str, Any], +) -> "ReachabilityAuditResult": + return ReachabilityAuditResult( + status="unreachable", + classification="model_disallowed_domain", + layer="model", + url=url, + normalized_url=normalized, + domain=domain, + plugin_name=plugin_name, + reason=reason or "Domain not allowed", + http_status=http_status, + exception_type=type(exception).__name__ if exception is not None else None, + raw_exception_type=raw_exception_type, + raw_exception_message=raw_exception_message, + navigation_stage=navigation_stage, + resource_type=resource_type, + attempt_index=attempt_index, + max_attempts=max_attempts, + browser_reused=browser_reused, + context_reused=context_reused, + page_recreated_before_retry=page_recreated_before_retry, + is_environment_failure=False, + is_model_hallucination=True, + evidence=evidence, + ) + + +def classify_stooq_url(url: str) -> str | None: + parsed = urlparse(url) + host = (parsed.hostname or "").lower() + if "stooq.com" not in host: + return None + path = parsed.path.lower() + query = parsed.query.lower() + + if host.startswith("www."): + return "env_tls_error" + if "/q/conv/" in path or "/s/mst/" in path or "quote.php" in path: + return "model_invalid_url_shape" + if "q=" in query and "s=" not in query: + return "model_invalid_url_shape" + return None + + +def classify_coingecko_url(url: str) -> str | None: + parsed = urlparse(url) + host = (parsed.hostname or "").lower() + if "coingecko.com" not in host: + return None + path = parsed.path.lower() + if "/coins/" in path: + slug = path.split("/coins/", 1)[1].split("/", 1)[0] + # Very loose first-pass hallucination heuristic for stock/company names in crypto namespace. + if slug in { + "microsoft", + "google", + "exxon-mobil", + "jpmorgan-chase", + "tesla", + "walmart", + "apple", + "amazon", + }: + return "model_invalid_asset_id" + return None + + +def classify_taostats_url(url: str) -> str | None: + parsed = urlparse(url) + host = (parsed.hostname or "").lower() + if "taostats.io" not in host: + return None + path = parsed.path.lower() + if path in {"", "/", "/subnets"}: + return None + if path.startswith("/subnets/"): + return None + return "model_invalid_url_shape" + + +def classify_model_hallucination(url: str) -> str | None: + return classify_stooq_url(url) or classify_coingecko_url(url) or classify_taostats_url(url) + + +def audit_reachability_failure( + *, + url: str, + plugin_name: str | None, + plugin: Any | None = None, + exception: BaseException | None = None, + reason: str | None = None, + allowed_domains: set[str] | None = None, + http_status: int | None = None, + evidence: dict[str, Any] | None = None, +) -> ReachabilityAuditResult: + normalized = normalize_url(url) if url else "" + domain = _domain(url) + evidence = dict(evidence or {}) + exception_text = f"{type(exception).__name__}: {exception}" if exception is not None else "" + exception_lower = exception_text.lower() + plugin_classification = plugin.classify_url(url) if plugin is not None and hasattr(plugin, "classify_url") else None + hallucination_class = plugin_classification or classify_model_hallucination(url) + + navigation_metadata = evidence.get("navigation_metadata") or {} + raw_exception_type = navigation_metadata.get("raw_exception_type") or (type(exception).__name__ if exception is not None else None) + raw_exception_message = navigation_metadata.get("raw_exception_message") or (str(exception) if exception is not None else None) + raw_exception_lower = (raw_exception_message or "").lower() + navigation_stage = navigation_metadata.get("navigation_stage") + resource_type = navigation_metadata.get("resource_type") + attempt_index = navigation_metadata.get("attempt_index") + max_attempts = navigation_metadata.get("max_attempts") + browser_reused = navigation_metadata.get("browser_reused") + context_reused = navigation_metadata.get("context_reused") + page_recreated_before_retry = navigation_metadata.get("page_recreated_before_retry") + navigation_evidence = dict(navigation_metadata.get("evidence") or {}) + interceptor_metadata = dict(evidence.get("interceptor") or {}) + if not resource_type: + resource_type = interceptor_metadata.get("blocked_resource_type") or resource_type + + if allowed_domains and domain: + normalized_allowed = {(item or "").lower() for item in allowed_domains if item} + if normalized_allowed and not any(_matches_allowed_domain(domain, allowed) for allowed in normalized_allowed): + evidence.setdefault("interceptor", interceptor_metadata) + evidence.setdefault("allowed_domains", sorted(normalized_allowed)) + return _build_disallowed_domain_audit( + url=url, + normalized=normalized, + domain=domain, + plugin_name=plugin_name, + reason=reason, + http_status=http_status, + exception=exception, + raw_exception_type=raw_exception_type, + raw_exception_message=raw_exception_message, + navigation_stage=navigation_stage, + resource_type=resource_type, + attempt_index=attempt_index, + max_attempts=max_attempts, + browser_reused=browser_reused, + context_reused=context_reused, + page_recreated_before_retry=page_recreated_before_retry, + evidence=evidence, + ) + + if hallucination_class is not None: + is_env = hallucination_class.startswith("env_") or hallucination_class.startswith("ambiguous_") + return ReachabilityAuditResult( + status="unreachable", + classification=hallucination_class, + layer="model" if not is_env else "tls", + url=url, + normalized_url=normalized, + domain=domain, + plugin_name=plugin_name, + reason=reason or exception_text, + http_status=http_status, + exception_type=type(exception).__name__ if exception is not None else None, + raw_exception_type=raw_exception_type, + raw_exception_message=raw_exception_message, + navigation_stage=navigation_stage, + resource_type=resource_type, + attempt_index=attempt_index, + max_attempts=max_attempts, + browser_reused=browser_reused, + context_reused=context_reused, + page_recreated_before_retry=page_recreated_before_retry, + is_environment_failure=is_env, + is_model_hallucination=not is_env, + evidence=evidence, + ) + + combined_lower = " ".join(part for part in [exception_lower, raw_exception_lower] if part) + + if "taostats.io" in domain and _is_taostats_list_url(url): + target_locator = ( + navigation_evidence.get("selector") + or navigation_evidence.get("target_locator") + or ( + f"role={navigation_evidence.get('role')} name={navigation_evidence.get('name')}" + if navigation_evidence.get("role") + else None + ) + ) + interaction_kind = _infer_taostats_interaction_kind(target_locator, raw_exception_message) + selector_syntax_invalid = _is_invalid_selector_message(raw_exception_type, raw_exception_message) + missing_ui_target = _is_missing_ui_target_message(raw_exception_type, raw_exception_message) + if selector_syntax_invalid: + evidence.update( + { + "page_kind": "taostats_list", + "interaction_kind": interaction_kind, + "target_locator": target_locator, + "selector_syntax_invalid": True, + } + ) + return ReachabilityAuditResult( + status="unreachable", + classification="model_invalid_selector", + layer="model", + url=url, + normalized_url=normalized, + domain=domain, + plugin_name=plugin_name, + reason=reason or exception_text, + http_status=http_status, + exception_type=type(exception).__name__ if exception is not None else None, + raw_exception_type=raw_exception_type, + raw_exception_message=raw_exception_message, + navigation_stage=navigation_stage, + resource_type=resource_type, + attempt_index=attempt_index, + max_attempts=max_attempts, + browser_reused=browser_reused, + context_reused=context_reused, + page_recreated_before_retry=page_recreated_before_retry, + is_environment_failure=False, + is_model_hallucination=True, + evidence=evidence, + ) + if missing_ui_target: + evidence.update( + { + "page_kind": "taostats_list", + "interaction_kind": interaction_kind, + "target_locator": target_locator, + "selector_syntax_invalid": False, + "ui_target_missing": True, + } + ) + return ReachabilityAuditResult( + status="unreachable", + classification="model_invalid_ui_target", + layer="model", + url=url, + normalized_url=normalized, + domain=domain, + plugin_name=plugin_name, + reason=reason or exception_text, + http_status=http_status, + exception_type=type(exception).__name__ if exception is not None else None, + raw_exception_type=raw_exception_type, + raw_exception_message=raw_exception_message, + navigation_stage=navigation_stage, + resource_type=resource_type, + attempt_index=attempt_index, + max_attempts=max_attempts, + browser_reused=browser_reused, + context_reused=context_reused, + page_recreated_before_retry=page_recreated_before_retry, + is_environment_failure=False, + is_model_hallucination=True, + evidence=evidence, + ) + if ( + (navigation_stage or "").startswith("action_") + and ( + "timeout" in combined_lower + or "too many consecutive action failures" in combined_lower + or "no element found with role" in combined_lower + ) + ): + evidence.update( + { + "page_kind": "taostats_list", + "interaction_kind": interaction_kind, + "target_locator": target_locator, + "selector_syntax_invalid": False, + } + ) + return ReachabilityAuditResult( + status="unreachable", + classification="env_taostats_list_action_timeout", + layer="browser", + url=url, + normalized_url=normalized, + domain=domain, + plugin_name=plugin_name, + reason=reason or exception_text, + http_status=http_status, + exception_type=type(exception).__name__ if exception is not None else None, + raw_exception_type=raw_exception_type, + raw_exception_message=raw_exception_message, + navigation_stage=navigation_stage, + resource_type=resource_type, + attempt_index=attempt_index, + max_attempts=max_attempts, + browser_reused=browser_reused, + context_reused=context_reused, + page_recreated_before_retry=page_recreated_before_retry, + is_environment_failure=True, + is_model_hallucination=False, + evidence=evidence, + ) + + taostats_prefetch = dict(evidence.get("taostats_prefetch") or {}) + if not taostats_prefetch: + taostats_prefetch = { + key: evidence.get(key) + for key in ("page_kind", "prefetch_phase", "wait_target", "background_refresh") + if evidence.get(key) is not None + } + if "taostats.io" in domain and _is_taostats_detail_url(url): + prefetch_phase = taostats_prefetch.get("prefetch_phase") + wait_target = taostats_prefetch.get("wait_target") + background_refresh = bool(taostats_prefetch.get("background_refresh", False)) + page_kind = taostats_prefetch.get("page_kind") + detail_setup_soft_failed = bool(taostats_prefetch.get("detail_setup_soft_failed", False)) + page_body_ready = taostats_prefetch.get("page_body_ready") + if prefetch_phase or page_kind == "taostats_detail": + if detail_setup_soft_failed and page_body_ready is True: + evidence.update( + { + "page_kind": "taostats_detail", + "prefetch_phase": prefetch_phase or "setup_page_for_cache", + "wait_target": wait_target, + "background_refresh": background_refresh, + "page_body_ready": True, + "detail_setup_soft_failed": True, + } + ) + else: + evidence.update( + { + "page_kind": "taostats_detail", + "prefetch_phase": prefetch_phase or "goto", + "wait_target": wait_target, + "background_refresh": background_refresh, + } + ) + return ReachabilityAuditResult( + status="unreachable", + classification="env_taostats_detail_prefetch_invalidated", + layer="browser", + url=url, + normalized_url=normalized, + domain=domain, + plugin_name=plugin_name, + reason=reason or exception_text, + http_status=http_status, + exception_type=type(exception).__name__ if exception is not None else None, + raw_exception_type=raw_exception_type, + raw_exception_message=raw_exception_message, + navigation_stage=navigation_stage, + resource_type=resource_type, + attempt_index=attempt_index, + max_attempts=max_attempts, + browser_reused=browser_reused, + context_reused=context_reused, + page_recreated_before_retry=page_recreated_before_retry, + is_environment_failure=True, + is_model_hallucination=False, + evidence=evidence, + ) + + if navigation_metadata.get("classification_hint") in { + "env_nav_aborted", + "env_target_closed", + "env_nav_timeout", + "env_browser_context_invalidated", + }: + return ReachabilityAuditResult( + status="unreachable", + classification=navigation_metadata["classification_hint"], + layer="browser", + url=url, + normalized_url=normalized, + domain=domain, + plugin_name=plugin_name, + reason=reason or exception_text, + http_status=http_status, + exception_type=type(exception).__name__ if exception is not None else None, + raw_exception_type=raw_exception_type, + raw_exception_message=raw_exception_message, + navigation_stage=navigation_stage, + resource_type=resource_type, + attempt_index=attempt_index, + max_attempts=max_attempts, + browser_reused=browser_reused, + context_reused=context_reused, + page_recreated_before_retry=page_recreated_before_retry, + is_environment_failure=True, + is_model_hallucination=False, + evidence=evidence, + ) + + probe = probe_site(url) if url else None + if probe is not None: + evidence.setdefault("site_probe", probe.to_dict()) + if http_status is None: + http_status = probe.http_status + + if http_status == 403 and "coingecko" in domain: + return ReachabilityAuditResult( + status="unreachable", + classification="env_cdn_blocked", + layer="cdn", + url=url, + normalized_url=normalized, + domain=domain, + plugin_name=plugin_name, + reason=reason or exception_text, + http_status=http_status, + exception_type=type(exception).__name__ if exception is not None else None, + raw_exception_type=raw_exception_type, + raw_exception_message=raw_exception_message, + navigation_stage=navigation_stage, + resource_type=resource_type, + attempt_index=attempt_index, + max_attempts=max_attempts, + browser_reused=browser_reused, + context_reused=context_reused, + page_recreated_before_retry=page_recreated_before_retry, + is_environment_failure=True, + is_model_hallucination=False, + evidence=evidence, + ) + + if probe and probe.exception_type == "SSLError": + classification = "env_tls_error" + layer = "tls" + elif "certificate_verify_failed" in combined_lower or "sslerror" in combined_lower: + classification = "env_tls_error" + layer = "tls" + elif "err_aborted" in combined_lower or "frame was detached" in combined_lower: + classification = "env_nav_aborted" + layer = "browser" + elif "target page, context or browser has been closed" in combined_lower or "targetclosederror" in combined_lower: + classification = "env_target_closed" + layer = "browser" + elif "timeout" in combined_lower: + classification = "env_nav_timeout" + layer = "browser" + elif "handler is closed" in combined_lower or "transport closed" in combined_lower or "connection closed" in combined_lower: + classification = "env_browser_context_invalidated" + layer = "browser" + elif "status=429" in combined_lower: + classification = "env_api_rate_limited" + layer = "api" + elif "empty response for coin_id" in combined_lower: + classification = "env_api_empty" + layer = "api" + elif http_status is not None and 400 <= http_status < 500: + classification = "env_http_4xx" + layer = "cdn" + elif http_status is not None and http_status >= 500: + classification = "env_http_5xx" + layer = "cdn" + else: + classification = "ambiguous_navigation_failure" + layer = "browser" + + return ReachabilityAuditResult( + status="unreachable", + classification=classification, + layer=layer, + url=url, + normalized_url=normalized, + domain=domain, + plugin_name=plugin_name, + reason=reason or exception_text, + http_status=http_status, + exception_type=type(exception).__name__ if exception is not None else None, + raw_exception_type=raw_exception_type, + raw_exception_message=raw_exception_message, + navigation_stage=navigation_stage, + resource_type=resource_type, + attempt_index=attempt_index, + max_attempts=max_attempts, + browser_reused=browser_reused, + context_reused=context_reused, + page_recreated_before_retry=page_recreated_before_retry, + is_environment_failure=classification.startswith("env_") or classification.startswith("ambiguous_"), + is_model_hallucination=False, + evidence=evidence, + ) diff --git a/liveweb_arena/core/runtime_profiles.py b/liveweb_arena/core/runtime_profiles.py new file mode 100644 index 0000000..82ba9b5 --- /dev/null +++ b/liveweb_arena/core/runtime_profiles.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +STRICT_EVAL_PROFILE = "strict_eval" +FAST_COLLECT_PROFILE = "fast_collect" + + +def normalize_runtime_profile(profile: str | None) -> str: + value = (profile or "").strip().lower() + if value in {"", "eval", STRICT_EVAL_PROFILE}: + return STRICT_EVAL_PROFILE + if value in {"collect", FAST_COLLECT_PROFILE}: + return FAST_COLLECT_PROFILE + raise ValueError(f"Unknown runtime profile: {profile}") + + +def runtime_profile_to_behavior_mode(profile: str | None) -> str: + normalized = normalize_runtime_profile(profile) + if normalized == FAST_COLLECT_PROFILE: + return "collect" + return "eval" + + +def is_fast_collect_profile(profile: str | None) -> bool: + return normalize_runtime_profile(profile) == FAST_COLLECT_PROFILE diff --git a/liveweb_arena/core/site_probe.py b/liveweb_arena/core/site_probe.py new file mode 100644 index 0000000..31a7681 --- /dev/null +++ b/liveweb_arena/core/site_probe.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any + +import requests +import time + + +@dataclass +class SiteProbeResult: + ok: bool + url: str + final_url: str | None = None + http_status: int | None = None + exception_type: str | None = None + reason: str | None = None + server: str | None = None + cf_ray: str | None = None + location: str | None = None + body_length: int | None = None + elapsed_ms: int | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def probe_site(url: str, timeout: float = 10.0) -> SiteProbeResult: + start = time.time() + try: + response = requests.get( + url, + timeout=timeout, + headers={"User-Agent": "Mozilla/5.0"}, + allow_redirects=True, + ) + return SiteProbeResult( + ok=response.ok, + url=url, + final_url=response.url, + http_status=response.status_code, + server=response.headers.get("server"), + cf_ray=response.headers.get("cf-ray"), + location=response.headers.get("location"), + body_length=len(response.text or ""), + elapsed_ms=int((time.time() - start) * 1000), + reason=f"http_{response.status_code}" if not response.ok else None, + ) + except Exception as exc: + return SiteProbeResult( + ok=False, + url=url, + exception_type=type(exc).__name__, + reason=str(exc), + elapsed_ms=int((time.time() - start) * 1000), + ) diff --git a/liveweb_arena/core/task_manager.py b/liveweb_arena/core/task_manager.py index 3ae57c8..b453dfd 100644 --- a/liveweb_arena/core/task_manager.py +++ b/liveweb_arena/core/task_manager.py @@ -36,52 +36,76 @@ def _get_plugin(self, name: str) -> BasePlugin: self._plugin_instances[name] = plugin_cls() return self._plugin_instances[name] - async def generate_composite_task( + @staticmethod + def derive_subtask_seed(seed: int, index: int) -> int: + """Derive a deterministic seed for one subtask inside a composite task.""" + hash_input = f"{seed}:{index}".encode() + return int(hashlib.sha256(hash_input).hexdigest()[:8], 16) + + def plan_subtasks( self, seed: int, num_subtasks: int = 2, templates: Optional[List[tuple]] = None, - ) -> CompositeTask: + ) -> List[Dict[str, object]]: """ - Generate a composite task with multiple sub-tasks. - - Args: - seed: Random seed for deterministic generation - num_subtasks: Number of sub-tasks (1-4) - templates: List of (plugin, template_name, variant) tuples; None = random. - variant can be None for random selection or int for specific variant. + Plan deterministic subtask generation without constructing the full CompositeTask. - Returns: - CompositeTask with subtasks and combined_intent + The returned plan is stable and matches the seeds/templates that + generate_composite_task() will use. """ - # Validate num_subtasks num_subtasks = max(1, min(4, num_subtasks)) - - # Initialize RNG with seed for deterministic generation rng = random.Random(seed) - # Build list of (plugin_name, template_name, variant) for each subtask if templates: - # Use specified templates (cycle if not enough) - # Normalize to 3-element tuples selected_templates = [] for i in range(num_subtasks): t = templates[i % len(templates)] if len(t) == 2: - # (plugin, template_name) -> (plugin, template_name, None) selected_templates.append((t[0], t[1], None)) else: - # Already (plugin, template_name, variant) selected_templates.append(t) else: - # Random selection from available plugins (no specific template or variant) available = list(self._plugin_classes.keys()) if len(available) == 0: raise ValueError("No plugins available") selected_templates = [(rng.choice(available), None, None) for _ in range(num_subtasks)] + plan = [] + for i, (plugin_name, template_name, variant) in enumerate(selected_templates): + plan.append( + { + "subtask_index": i, + "plugin_name": plugin_name, + "template_name": template_name, + "variant": variant, + "subtask_seed": self.derive_subtask_seed(seed, i), + } + ) + return plan + + async def generate_composite_task( + self, + seed: int, + num_subtasks: int = 2, + templates: Optional[List[tuple]] = None, + ) -> CompositeTask: + """ + Generate a composite task with multiple sub-tasks. + + Args: + seed: Random seed for deterministic generation + num_subtasks: Number of sub-tasks (1-4) + templates: List of (plugin, template_name, variant) tuples; None = random. + variant can be None for random selection or int for specific variant. + + Returns: + CompositeTask with subtasks and combined_intent + """ + plan = self.plan_subtasks(seed=seed, num_subtasks=num_subtasks, templates=templates) + # Initialize plugins that will be used (some need API data before question generation) - plugins_to_use = set(p for p, _, _ in selected_templates) + plugins_to_use = {item["plugin_name"] for item in plan} for plugin_name in plugins_to_use: plugin = self._get_plugin(plugin_name) if hasattr(plugin, 'initialize'): @@ -90,11 +114,12 @@ async def generate_composite_task( # Generate sub-tasks subtasks: List[SubTask] = [] - for i, (plugin_name, template_name, variant) in enumerate(selected_templates): + for i, item in enumerate(plan): + plugin_name = str(item["plugin_name"]) + template_name = item["template_name"] + variant = item["variant"] plugin = self._get_plugin(plugin_name) - # Derive seed for this sub-task (hash-based to avoid collisions) - hash_input = f"{seed}:{i}".encode() - subtask_seed = int(hashlib.sha256(hash_input).hexdigest()[:8], 16) + subtask_seed = int(item["subtask_seed"]) subtask = await plugin.generate_task( subtask_seed, template_name=template_name, @@ -104,20 +129,13 @@ async def generate_composite_task( subtask.answer_tag = f"answer{i + 1}" subtasks.append(subtask) - # Include hints only for plugins used in the task - # Avoids eagerly instantiating unused plugins (which may trigger API calls) - plugin_hints: Dict[str, str] = {} - for plugin_name in plugins_to_use: - plugin = self._get_plugin(plugin_name) - plugin_hints[plugin_name] = f"Use {', '.join(plugin.allowed_domains)} to find information." - # Build combined intent (without start_url - Agent decides navigation) combined_intent = self._build_combined_intent(subtasks) return CompositeTask( subtasks=subtasks, combined_intent=combined_intent, - plugin_hints=plugin_hints, + plugin_hints={}, seed=seed, ) diff --git a/liveweb_arena/core/task_registry.py b/liveweb_arena/core/task_registry.py index 8257cc2..d1cef6b 100644 --- a/liveweb_arena/core/task_registry.py +++ b/liveweb_arena/core/task_registry.py @@ -140,6 +140,19 @@ class TaskRegistry: 81: ("openlibrary", "openlibrary_subject_multi_condition"), 82: ("openlibrary", "openlibrary_book_comparison"), 84: ("openlibrary", "openlibrary_author_editions"), + + # Open Meteo templates + 85: ("openmeteo", "openmeteo_current"), + 86: ("openmeteo", "openmeteo_comparison"), + 87: ("openmeteo", "openmeteo_hourly_extrema"), + 88: ("openmeteo", "openmeteo_forecast_trend"), + + # ArXiv templates + 90: ("arxiv", "arxiv_paper_info"), + 91: ("arxiv", "arxiv_author_extrema"), + 92: ("arxiv", "arxiv_category_comparison"), + 94: ("arxiv", "arxiv_multi_author_filter"), + 95: ("arxiv", "arxiv_title_length_extrema"), } # Template versions - each version's combinations come AFTER all previous versions @@ -164,6 +177,10 @@ class TaskRegistry: [80, 81], # Version 4: Additional Open Library templates [82, 84], + # Version 5: Open Meteo templates + [85, 86, 87, 88], + # Version 6: ArXiv templates + [90, 91, 92, 94, 95], ] # Combination registry: list of template ID tuples @@ -251,7 +268,7 @@ def parse_task_id(cls, task_id: int) -> Dict[str, Any]: - template_ids: Tuple of template IDs in this combination - templates: List of (plugin, template_name) tuples - variation_seed: Seed for variation within this combination - - num_tasks: Number of sub-tasks (3-5) + - num_tasks: Number of sub-tasks (1-4) Raises: ValueError: If task_id is out of valid range @@ -273,7 +290,7 @@ def parse_task_id(cls, task_id: int) -> Dict[str, Any]: template_ids = cls._combinations[combo_index] templates = [cls.TEMPLATES[tid] for tid in template_ids] - num_tasks = (variation_seed % 3) + 2 + num_tasks = (variation_seed % 4) + 1 return { "task_id": task_id, diff --git a/liveweb_arena/core/task_registry_loader.py b/liveweb_arena/core/task_registry_loader.py new file mode 100644 index 0000000..45eae54 --- /dev/null +++ b/liveweb_arena/core/task_registry_loader.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import importlib.util +import os +from pathlib import Path +from typing import Any + +from .runtime_profiles import is_fast_collect_profile, normalize_runtime_profile + +STRICT_TASK_REGISTRY_DIR_ENV = "LIVEWEB_STRICT_TASK_REGISTRY_DIR" + + +def _load_task_registry_module_from_dir(source_dir: Path): + module_path = source_dir / "liveweb_arena" / "core" / "task_registry.py" + spec = importlib.util.spec_from_file_location("liveweb_runtime_task_registry", module_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Failed to load task registry from {module_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def resolve_task_registry_source_dir(runtime_profile: str | None = None) -> Path | None: + profile = normalize_runtime_profile(runtime_profile) + if is_fast_collect_profile(profile): + return None + raw = os.getenv(STRICT_TASK_REGISTRY_DIR_ENV, "").strip() + if not raw: + return None + return Path(raw) + + +def parse_task_id(task_id: int, *, runtime_profile: str | None = None) -> dict[str, Any]: + source_dir = resolve_task_registry_source_dir(runtime_profile) + if source_dir is None: + from .task_registry import parse_task_id as local_parse_task_id + + return local_parse_task_id(task_id) + module = _load_task_registry_module_from_dir(source_dir) + return module.parse_task_id(task_id) + + +def max_task_id(*, runtime_profile: str | None = None) -> int: + source_dir = resolve_task_registry_source_dir(runtime_profile) + if source_dir is None: + from .task_registry import max_task_id as local_max_task_id + + return local_max_task_id() + module = _load_task_registry_module_from_dir(source_dir) + return module.max_task_id() diff --git a/liveweb_arena/core/validators/llm_validator.py b/liveweb_arena/core/validators/llm_validator.py index 0c732bc..87636fc 100644 --- a/liveweb_arena/core/validators/llm_validator.py +++ b/liveweb_arena/core/validators/llm_validator.py @@ -50,6 +50,12 @@ class LLMValidationResult: "Qwen/Qwen3-32B", ] +OPENROUTER_VALIDATION_MODELS: List[str] = [ + "google/gemini-3-flash-preview", + "z-ai/glm-5", + "xiaomi/mimo-v2-pro", +] + OPENAI_VALIDATION_MODELS: List[str] = [ "gpt-4", "gpt-3.5-turbo", @@ -62,8 +68,10 @@ def _get_validation_models(llm_client) -> List[str]: Priority: 1. VALIDATION_MODELS env var (comma-separated) - 2. OpenAI-safe defaults when base_url points to api.openai.com - 3. Project default VALIDATION_MODELS + 2. Provider-specific env override (e.g. VALIDATION_OPENROUTER_MODELS) + 3. OpenAI-safe defaults when base_url points to api.openai.com + 4. OpenRouter-safe defaults when base_url points to openrouter.ai + 5. Project default VALIDATION_MODELS """ env_models = os.getenv("VALIDATION_MODELS", "") if env_models.strip(): @@ -72,8 +80,23 @@ def _get_validation_models(llm_client) -> List[str]: return models base_url = str(getattr(llm_client, "_base_url", "")).lower() + provider_env_key = "" + if "api.openai.com" in base_url: + provider_env_key = "VALIDATION_OPENAI_MODELS" + elif "openrouter.ai" in base_url: + provider_env_key = "VALIDATION_OPENROUTER_MODELS" + + if provider_env_key: + provider_models = os.getenv(provider_env_key, "") + if provider_models.strip(): + models = [m.strip() for m in provider_models.split(",") if m.strip()] + if models: + return models + if "api.openai.com" in base_url: return OPENAI_VALIDATION_MODELS + if "openrouter.ai" in base_url: + return OPENROUTER_VALIDATION_MODELS return VALIDATION_MODELS diff --git a/liveweb_arena/plugins/__init__.py b/liveweb_arena/plugins/__init__.py index 48990ba..8a95417 100644 --- a/liveweb_arena/plugins/__init__.py +++ b/liveweb_arena/plugins/__init__.py @@ -139,6 +139,11 @@ def get_plugin_names() -> List[str]: return list(_plugins.keys()) +def get_disabled_plugins() -> List[str]: + """Return disabled plugin names in a stable order.""" + return sorted(str(name) for name in DISABLED_PLUGINS) + + def reload_plugins(): """Reload all plugins (useful for development).""" _plugins.clear() diff --git a/liveweb_arena/plugins/arxiv/__init__.py b/liveweb_arena/plugins/arxiv/__init__.py new file mode 100644 index 0000000..73945a8 --- /dev/null +++ b/liveweb_arena/plugins/arxiv/__init__.py @@ -0,0 +1,8 @@ +"""ArXiv plugin for browsing and querying academic paper listings.""" + +from .arxiv import ArxivPlugin + +# Import templates to register them +from . import templates as templates # noqa: F401 — import registers templates + +__all__ = ["ArxivPlugin"] diff --git a/liveweb_arena/plugins/arxiv/api_client.py b/liveweb_arena/plugins/arxiv/api_client.py new file mode 100644 index 0000000..058156e --- /dev/null +++ b/liveweb_arena/plugins/arxiv/api_client.py @@ -0,0 +1,243 @@ +"""ArXiv API client with rate limiting. + +Fetches the HTML listing page (arxiv.org/list//new) and parses +paper metadata (title, authors, primary category) directly from the page +HTML. This guarantees the ground-truth data matches what the agent sees. + +Rate limit: ArXiv requests max 1 request per 3 seconds. +""" + +import asyncio +import logging +import re +from typing import Any, ClassVar, Dict, List, Optional + +import aiohttp + +from liveweb_arena.plugins.base_client import APIFetchError, BaseAPIClient, RateLimiter + +logger = logging.getLogger(__name__) + +CACHE_SOURCE = "arxiv" + +# Shared session for connection reuse +_session: Optional[aiohttp.ClientSession] = None + + +async def _get_session() -> aiohttp.ClientSession: + """Get or create the shared aiohttp session.""" + global _session + if _session is None or _session.closed: + _session = aiohttp.ClientSession( + headers={"User-Agent": "LiveWebArena/1.0"}, + ) + return _session + + +async def close_session(): + """Close the shared session. Call during shutdown.""" + global _session + if _session and not _session.closed: + await _session.close() + _session = None + + +# --------------------------------------------------------------------------- +# HTML listing page parsing +# --------------------------------------------------------------------------- + +# Matches arxiv IDs like "2603.17021" in
blocks +_DT_ID_RE = re.compile(r"arXiv:(\d{4}\.\d{4,5})") + +# Extracts title text after the "Title:" descriptor span +_TITLE_RE = re.compile( + r"" + r"\s*Title:\s*(.*?)\s*", + re.DOTALL, +) + +# Extracts author names from tags inside list-authors div +_AUTHORS_DIV_RE = re.compile( + r"(.*?)", re.DOTALL +) +_AUTHOR_NAME_RE = re.compile(r"]*>([^<]+)") + +# Extracts primary subject: "Artificial Intelligence (cs.AI)" → "cs.AI" +_PRIMARY_SUBJECT_RE = re.compile( + r'[^(]*\(([^)]+)\)' +) + + +def parse_listing_html(html_text: str) -> List[Dict[str, Any]]: + """Parse an ArXiv listing page into a list of new-submission paper dicts. + + Only papers in the "New submissions" section are included — cross-listings + and replacements are excluded. Paper order matches what the agent sees. + + Args: + html_text: Raw HTML of an arxiv.org/list//new page. + + Returns: + List of paper dicts in page order. + """ + # Isolate the "New submissions" section (before cross-lists/replacements) + cross_idx = html_text.find("

Cross submissions") + repl_idx = html_text.find("

Replacement submissions") + # Use the earliest section boundary that exists + boundaries = [b for b in (cross_idx, repl_idx) if b > 0] + if boundaries: + new_section = html_text[: min(boundaries)] + else: + new_section = html_text + + # Split into
/
pairs + dt_blocks = re.split(r"
", new_section)[1:] # skip before first
+ + papers: List[Dict[str, Any]] = [] + for block in dt_blocks: + # Each block contains the
content and the following
+ # Split at
to separate ID block from metadata block + parts = block.split("
", 1) + if len(parts) < 2: + continue + + dt_part, dd_part = parts + + # Extract arxiv ID from
+ id_match = _DT_ID_RE.search(dt_part) + if not id_match: + continue + arxiv_id = id_match.group(1) + + # Extract title + title_match = _TITLE_RE.search(dd_part) + title = "" + if title_match: + raw = title_match.group(1) + # Strip any remaining HTML tags and normalize whitespace + raw = re.sub(r"<[^>]+>", "", raw) + title = " ".join(raw.split()) + + # Extract authors + authors: List[str] = [] + authors_div_match = _AUTHORS_DIV_RE.search(dd_part) + if authors_div_match: + authors = _AUTHOR_NAME_RE.findall(authors_div_match.group(1)) + + # Extract primary category code from primary-subject span + primary_category = "" + primary_match = _PRIMARY_SUBJECT_RE.search(dd_part) + if primary_match: + primary_category = primary_match.group(1).strip() + + papers.append({ + "arxiv_id": arxiv_id, + "title": title, + "authors": authors, + "primary_category": primary_category, + "categories": [primary_category] if primary_category else [], + "published": "", + "summary": "", + }) + + return papers + + +class ArxivClient(BaseAPIClient): + """ + ArXiv API client with rate limiting. + + Fetches HTML listing pages and parses paper data directly from the + page structure, guaranteeing GT matches the agent's view. + + Rate limit: 1 request per 3 seconds (ArXiv policy). + """ + + _rate_limiter: ClassVar[RateLimiter] = RateLimiter(min_interval=3.0) + + MAX_RETRIES = 3 + + @classmethod + async def fetch_listing( + cls, + category: str, + timeout: float = 30.0, + ) -> List[Dict[str, Any]]: + """Fetch and parse the HTML listing page for a category. + + Returns new-submission papers in page order. + """ + url = f"https://arxiv.org/list/{category}/new" + session = await _get_session() + req_timeout = aiohttp.ClientTimeout(total=timeout) + + for attempt in range(cls.MAX_RETRIES): + await cls._rate_limit() + try: + async with session.get(url, timeout=req_timeout) as resp: + if resp.status == 200: + text = await resp.text() + return parse_listing_html(text) + if resp.status >= 500 and attempt < cls.MAX_RETRIES - 1: + wait = 2 ** attempt + logger.info(f"ArXiv listing {resp.status}, retry in {wait}s") + await asyncio.sleep(wait) + continue + logger.warning(f"ArXiv listing error: status={resp.status}") + return [] + except Exception as e: + if attempt < cls.MAX_RETRIES - 1: + wait = 2 ** attempt + logger.info(f"ArXiv listing failed: {e}, retry in {wait}s") + await asyncio.sleep(wait) + continue + logger.warning(f"ArXiv listing request failed: {e}") + return [] + return [] + + +async def fetch_listing_api_data(category: str) -> Dict[str, Any]: + """ + Fetch data for a category listing page (e.g., /list/cs.AI/new). + + Fetches and parses the same HTML page the agent sees, so the + ground-truth paper list, order, titles, and author counts are + guaranteed to match the page content. + + Returns: + { + "category": "cs.AI", + "paper_count": , + "papers": { + "": { + "rank": <1-based>, + "arxiv_id": "...", + "title": "...", + "authors": [...], + "primary_category": "...", + "categories": [...], + "published": "...", + "summary": "...", + }, + ... + } + } + """ + papers_list = await ArxivClient.fetch_listing(category) + + if not papers_list: + raise APIFetchError( + f"No new papers on listing page for category '{category}'", + source="arxiv", + ) + + papers = {} + for rank, paper in enumerate(papers_list, start=1): + arxiv_id = paper["arxiv_id"] + papers[arxiv_id] = {**paper, "rank": rank} + + return { + "category": category, + "paper_count": len(papers), + "papers": papers, + } diff --git a/liveweb_arena/plugins/arxiv/arxiv.py b/liveweb_arena/plugins/arxiv/arxiv.py new file mode 100644 index 0000000..1e17727 --- /dev/null +++ b/liveweb_arena/plugins/arxiv/arxiv.py @@ -0,0 +1,75 @@ +""" +ArXiv Plugin. + +Plugin for browsing and querying ArXiv academic paper listings. +Supports category listing pages (/list/cs.AI/new). +""" + +import re +from typing import Any, Dict, List +from urllib.parse import urlparse + +from liveweb_arena.plugins.base import BasePlugin +from .api_client import fetch_listing_api_data + + +class ArxivPlugin(BasePlugin): + """ + ArXiv plugin for academic paper queries. + + Handles pages like: + - https://arxiv.org/list/cs.AI/new (new submissions listing) + + Data source: HTML listing page — parsed directly so GT matches + the page content the agent sees. + """ + + name = "arxiv" + + allowed_domains = [ + "arxiv.org", + ] + + def get_blocked_patterns(self) -> List[str]: + return [ + "*export.arxiv.org/api/*", + "*rss.arxiv.org/*", + ] + + async def fetch_api_data(self, url: str) -> Dict[str, Any]: + parsed = urlparse(url) + path = parsed.path.strip("/") + + # Listing page: /list//new + category = self._extract_category(path) + if category: + return await fetch_listing_api_data(category) + + return {} + + def needs_api_data(self, url: str) -> bool: + parsed = urlparse(url) + path = parsed.path.strip("/") + + return bool(self._extract_category(path)) + + @staticmethod + def _extract_category(path: str) -> str: + """Extract category from listing path like 'list/cs.AI/new'. + + Matches /new and /recent listings — both trigger GT collection. + GT data is always fetched from /new regardless of which path the + agent visited, so the ground truth is consistent. /pastweek and + month-archive paths are excluded (different paper sets). + + Handles all ArXiv category formats: + - cs.AI, math.CO (group.SUBCAT) + - hep-th, quant-ph (hyphenated, no dot) + - cond-mat.str-el (hyphenated group with lowercase subcat) + - astro-ph.CO (hyphenated group with uppercase subcat) + """ + match = re.match(r"list/([a-z-]+(?:\.[A-Za-z-]+)?)/(new|recent)", path) + if match: + return match.group(1) + return "" + diff --git a/liveweb_arena/plugins/arxiv/templates/__init__.py b/liveweb_arena/plugins/arxiv/templates/__init__.py new file mode 100644 index 0000000..9bc49da --- /dev/null +++ b/liveweb_arena/plugins/arxiv/templates/__init__.py @@ -0,0 +1,16 @@ +"""ArXiv question templates.""" + +from .paper_info import ArxivPaperInfoTemplate + +from .author_extrema import ArxivAuthorExtremaTemplate +from .multi_author_filter import ArxivMultiAuthorFilterTemplate +from .title_length_extrema import ArxivTitleLengthExtremaTemplate +from .category_comparison import ArxivCategoryComparisonTemplate + +__all__ = [ + "ArxivPaperInfoTemplate", + "ArxivAuthorExtremaTemplate", + "ArxivMultiAuthorFilterTemplate", + "ArxivTitleLengthExtremaTemplate", + "ArxivCategoryComparisonTemplate", +] diff --git a/liveweb_arena/plugins/arxiv/templates/author_extrema.py b/liveweb_arena/plugins/arxiv/templates/author_extrema.py new file mode 100644 index 0000000..8ee5499 --- /dev/null +++ b/liveweb_arena/plugins/arxiv/templates/author_extrema.py @@ -0,0 +1,145 @@ +"""Author extrema template for ArXiv - MEDIUM DIFFICULTY. + +Asks which paper among the top-N newest submissions in a category has the +most (or fewest) authors. The agent must scan multiple paper entries on +the listing page and compare author counts. + +Dynamic data: paper pool rotates daily. +Computation required: agent must compare across papers, not read a single value. +41 categories × 3 top-N × 2 extrema × 5 patterns = 1230 variants. +""" + +import random +from typing import Any, Dict, Optional + +from liveweb_arena.core.validators.base import ( + QuestionTemplate, GeneratedQuestion, ValidationResult, register_template, +) +from liveweb_arena.core.ground_truth_trigger import ( + UrlPatternTrigger, TriggerConfig, GroundTruthResult, +) +from liveweb_arena.core.gt_collector import GTSourceType + +from .common import get_collected_listing_data, get_papers_from_listing +from .variables import CATEGORIES, TOP_N_CHOICES + +PATTERNS_MOST = [ + "Among the {n} most recent papers in today's new submissions for {category} on ArXiv, which one has the most authors? Give its title.", + "On ArXiv, look at today's new submissions in {category}. Among the top {n}, which paper has the largest number of authors? Report the title.", + "In today's new {category} submissions on ArXiv, find the paper with the most authors among the top {n}. What is its title?", + "Check today's new submissions for {category} on ArXiv. Of the first {n} papers, which has the most authors? Give the title.", + "Among the first {n} papers in today's new ArXiv submissions for {category}, which one lists the most authors? Report its title.", +] + +PATTERNS_FEWEST = [ + "Among the {n} most recent papers in today's new submissions for {category} on ArXiv, which one has the fewest authors? Give its title.", + "On ArXiv, look at today's new submissions in {category}. Among the top {n}, which paper has the smallest number of authors? Report the title.", + "In today's new {category} submissions on ArXiv, find the paper with the fewest authors among the top {n}. What is its title?", + "Check today's new submissions for {category} on ArXiv. Of the first {n} papers, which has the fewest authors? Give the title.", + "Among the first {n} papers in today's new ArXiv submissions for {category}, which one lists the fewest authors? Report its title.", +] + + +@register_template("arxiv_author_extrema") +class ArxivAuthorExtremaTemplate(QuestionTemplate): + """ + MEDIUM: Find the paper with the most or fewest authors among the top N. + + Requires scanning multiple paper entries and comparing author counts. + 41 categories × 3 top-N × 2 extrema × 5 patterns = 1230 question variants. + """ + + GT_SOURCE = GTSourceType.PAGE_ONLY + + def __init__(self): + super().__init__("arxiv_author_extrema") + + def generate(self, seed: int, variant: Optional[int] = None) -> GeneratedQuestion: + rng = random.Random(seed) + + is_most = (variant % 2 == 0) if variant is not None else rng.choice([True, False]) + top_n = rng.choice(TOP_N_CHOICES) + + category = rng.choice(CATEGORIES) + patterns = PATTERNS_MOST if is_most else PATTERNS_FEWEST + question_text = rng.choice(patterns).format(n=top_n, category=category.name) + + return GeneratedQuestion( + question_text=question_text, + start_url=category.listing_url, + variables={"category": category.code, "is_most": is_most, "top_n": top_n}, + validation_info={ + "category_code": category.code, + "category_name": category.name, + "is_most": is_most, + "top_n": top_n, + }, + template_name=self.name, + expected_steps=7, + ) + + def get_validation_rules(self, validation_info: Dict[str, Any]) -> str: + cat_name = validation_info["category_name"] + is_most = validation_info["is_most"] + top_n = validation_info["top_n"] + extrema = "most" if is_most else "fewest" + return f"""Task-Specific Rules (ArXiv Author Extrema): +- Category: {cat_name} +- Looking for: paper with the {extrema} authors among the top {top_n} newest +- Score 1.0: Title matches the correct paper (allow minor formatting differences) +- Score 0.5: Title partially matches or identifies correct paper with slight error +- Score 0.0: Wrong paper or no answer +- If there is a tie, any of the tied papers is acceptable +- Data source: ArXiv new submissions listing""" + + async def get_ground_truth(self, validation_info: Dict[str, Any]) -> GroundTruthResult: + category_code = validation_info["category_code"] + is_most = validation_info["is_most"] + top_n = validation_info["top_n"] + + data, failure = get_collected_listing_data(category_code) + if failure is not None: + return failure + + papers, paper_failure = get_papers_from_listing(data) + if paper_failure is not None: + return paper_failure + + # Take only the top N papers — system error if listing has fewer than + # requested (not the agent's fault; live data has insufficient volume) + if len(papers) < top_n: + return GroundTruthResult.system_error( + f"Category {category_code} has only {len(papers)} papers, " + f"need {top_n} for this question" + ) + subset = papers[:top_n] + + if is_most: + target = max(subset, key=lambda p: len(p["authors"])) + else: + target = min(subset, key=lambda p: len(p["authors"])) + + title = target["title"] + author_count = len(target["authors"]) + + return GroundTruthResult.ok(f"{title} ({author_count} authors)") + + async def validate_answer( + self, answer: str, validation_info: Dict[str, Any] + ) -> ValidationResult: + """Not used — the pipeline uses LLM-based validation via get_validation_rules().""" + return ValidationResult( + score=0.0, is_correct=False, expected=None, actual=answer, + details="Use LLM validation", + ) + + def get_ground_truth_trigger(self, validation_info: dict) -> TriggerConfig: + trigger = UrlPatternTrigger(domains=["arxiv.org"]) + return TriggerConfig(trigger=trigger) + + @classmethod + def get_cache_source(cls) -> str: + return "arxiv" + + def get_gt_source(self) -> GTSourceType: + return self.GT_SOURCE diff --git a/liveweb_arena/plugins/arxiv/templates/category_comparison.py b/liveweb_arena/plugins/arxiv/templates/category_comparison.py new file mode 100644 index 0000000..379810e --- /dev/null +++ b/liveweb_arena/plugins/arxiv/templates/category_comparison.py @@ -0,0 +1,157 @@ +"""Category comparison template for ArXiv - HARD DIFFICULTY. + +Compares the total number of authors across the N most recent submissions +in two categories from different subject groups. The agent must visit two +separate listing pages, count authors on each paper, sum them per category, +and compute the numeric difference. + +Dynamic data: paper pool rotates daily. +Multi-page + computation: agent visits two pages and sums + subtracts. +Answer is a numeric difference — random baseline ≈ 0%. +605 cross-group pairs × 6 patterns × 2 orderings = 7260 variants. +""" + +import random +from typing import Any, Dict, Optional + +from liveweb_arena.core.validators.base import ( + QuestionTemplate, GeneratedQuestion, ValidationResult, register_template, +) +from liveweb_arena.core.ground_truth_trigger import ( + UrlPatternTrigger, TriggerConfig, GroundTruthResult, +) +from liveweb_arena.core.gt_collector import GTSourceType + +from .common import get_collected_listing_data, get_papers_from_listing +from .variables import CATEGORY_PAIRS, TOP_N + +PATTERNS = [ + "On ArXiv, look at today's new submissions in {cat1} and {cat2}. Sum the author counts for the top {n} papers in each category. What is the difference ({cat1} total minus {cat2} total)?", + "Sum up the author counts for the {n} newest papers in today's new submissions for {cat1} and {cat2} on ArXiv. What is the difference ({cat1} total minus {cat2} total)?", + "Compare today's new submissions in {cat1} and {cat2} on ArXiv. Count all authors across the top {n} papers in each and report the difference ({cat1} minus {cat2}).", + "Using ArXiv, add up the number of authors across the top {n} papers in today's new {cat1} and {cat2} submissions. How many more total authors does {cat1} have? (Give a signed number.)", + "In today's new ArXiv submissions, sum the authors for the first {n} papers in {cat1} and the first {n} in {cat2}. Report the difference ({cat1} minus {cat2}).", + "Check today's new submissions for {cat1} and {cat2} on ArXiv. For each category, sum the author counts of the top {n} papers. What is {cat1}'s total minus {cat2}'s total?", +] + + +@register_template("arxiv_category_comparison") +class ArxivCategoryComparisonTemplate(QuestionTemplate): + """ + HARD: Compare total author counts across the top-N papers in two categories. + + Requires visiting two different listing pages, counting authors on each + paper, summing per category, and computing the numeric difference. + 605 cross-group pairs × 6 patterns × 2 orderings = 7260 question variants. + """ + + GT_SOURCE = GTSourceType.PAGE_ONLY + + def __init__(self): + super().__init__("arxiv_category_comparison") + + def generate(self, seed: int, variant: Optional[int] = None) -> GeneratedQuestion: + rng = random.Random(seed) + + pair = rng.choice(CATEGORY_PAIRS) + cat1, cat2 = pair + + # Randomly swap order so both orderings are represented + if rng.random() > 0.5: + cat1, cat2 = cat2, cat1 + + pattern = rng.choice(PATTERNS) + question_text = pattern.format( + n=TOP_N, + cat1=cat1.name, + cat2=cat2.name, + ) + + return GeneratedQuestion( + question_text=question_text, + start_url=cat1.listing_url, + variables={"cat1": cat1.code, "cat2": cat2.code}, + validation_info={ + "cat1_code": cat1.code, + "cat1_name": cat1.name, + "cat2_code": cat2.code, + "cat2_name": cat2.name, + "cat2_url": cat2.listing_url, + "top_n": TOP_N, + }, + template_name=self.name, + expected_steps=9, + ) + + def get_validation_rules(self, validation_info: Dict[str, Any]) -> str: + cat1 = validation_info["cat1_name"] + cat2 = validation_info["cat2_name"] + top_n = validation_info["top_n"] + return f"""Task-Specific Rules (ArXiv Category Comparison): +- Answer is the total-author-count difference across the {top_n} most recent papers: {cat1} minus {cat2} +- Positive means {cat1} has more total authors +- Score 1.0: Difference within ±3 of ground truth +- Score 0.5: Difference within ±8 of ground truth +- Score 0.0: Difference off by more than 8, or no numeric answer +- Accept formats: "12", "+12", "-3", "7 more" +- Do NOT accept answers that only name a category without the numeric difference""" + + async def get_ground_truth(self, validation_info: Dict[str, Any]) -> GroundTruthResult: + cat1_code = validation_info["cat1_code"] + cat2_code = validation_info["cat2_code"] + cat1_name = validation_info["cat1_name"] + cat2_name = validation_info["cat2_name"] + top_n = validation_info["top_n"] + + data1, fail1 = get_collected_listing_data(cat1_code) + if fail1 is not None: + return fail1 + data2, fail2 = get_collected_listing_data(cat2_code) + if fail2 is not None: + return fail2 + + papers1, pf1 = get_papers_from_listing(data1) + if pf1 is not None: + return pf1 + papers2, pf2 = get_papers_from_listing(data2) + if pf2 is not None: + return pf2 + + # System error if listing has fewer papers than requested — + # not the agent's fault; live data has insufficient volume. + if len(papers1) < top_n: + return GroundTruthResult.system_error( + f"{cat1_code} has only {len(papers1)} papers, need {top_n}" + ) + if len(papers2) < top_n: + return GroundTruthResult.system_error( + f"{cat2_code} has only {len(papers2)} papers, need {top_n}" + ) + + total1 = sum(len(p["authors"]) for p in papers1[:top_n]) + total2 = sum(len(p["authors"]) for p in papers2[:top_n]) + diff = total1 - total2 + + return GroundTruthResult.ok( + f"{diff} ({cat1_name}: {total1} authors, {cat2_name}: {total2} authors)" + ) + + async def validate_answer( + self, answer: str, validation_info: Dict[str, Any] + ) -> ValidationResult: + """Not used — the pipeline uses LLM-based validation via get_validation_rules().""" + return ValidationResult( + score=0.0, is_correct=False, expected=None, actual=answer, + details="Use LLM validation", + ) + + def get_ground_truth_trigger(self, validation_info: dict) -> TriggerConfig: + trigger = UrlPatternTrigger(domains=["arxiv.org"]) + return TriggerConfig(trigger=trigger) + + @classmethod + def get_cache_source(cls) -> str: + return "arxiv" + + def get_gt_source(self) -> GTSourceType: + return self.GT_SOURCE diff --git a/liveweb_arena/plugins/arxiv/templates/common.py b/liveweb_arena/plugins/arxiv/templates/common.py new file mode 100644 index 0000000..22c8f1c --- /dev/null +++ b/liveweb_arena/plugins/arxiv/templates/common.py @@ -0,0 +1,41 @@ +"""Shared helpers for ArXiv templates.""" + +from typing import Any, Dict, List, Optional, Tuple + +from liveweb_arena.core.ground_truth_trigger import GroundTruthResult +from liveweb_arena.core.gt_collector import get_current_gt_collector + + +def get_collected_listing_data( + category: str, +) -> Tuple[Optional[Dict[str, Any]], Optional[GroundTruthResult]]: + """Return collected API data for a category listing, or a GT failure.""" + gt_collector = get_current_gt_collector() + if gt_collector is None: + return None, GroundTruthResult.system_error("No GT collector") + + collected = gt_collector.get_collected_api_data() + data = collected.get(f"arxiv:{category}") + if data is None: + keys = [k for k in collected if k.startswith("arxiv:")][:5] + return None, GroundTruthResult.not_collected( + f"Agent did not visit ArXiv listing for '{category}'. " + f"Collected keys: {keys}" + ) + + return data, None + + +def get_papers_from_listing( + data: Dict[str, Any], +) -> Tuple[Optional[List[Dict[str, Any]]], Optional[GroundTruthResult]]: + """Extract a rank-sorted list of papers from collected listing data.""" + papers = data.get("papers") + if not papers or not isinstance(papers, dict): + return None, GroundTruthResult.fail("No papers in collected listing data") + + sorted_papers = sorted(papers.values(), key=lambda p: p["rank"]) + if not sorted_papers: + return None, GroundTruthResult.fail("Papers dict is empty after sort") + + return sorted_papers, None diff --git a/liveweb_arena/plugins/arxiv/templates/multi_author_filter.py b/liveweb_arena/plugins/arxiv/templates/multi_author_filter.py new file mode 100644 index 0000000..7c3fe24 --- /dev/null +++ b/liveweb_arena/plugins/arxiv/templates/multi_author_filter.py @@ -0,0 +1,131 @@ +"""Multi-author filter template for ArXiv - MEDIUM DIFFICULTY. + +Asks how many of the top-N newest papers in a category have more than K +authors. The agent must scan multiple entries, inspect each author list, +and count those exceeding the threshold. + +Dynamic data: paper pool rotates daily. +Computation required: filter + count, not just read a single value. +41 categories × 3 top-N × 3 thresholds × 5 patterns = 1845 question variants. +""" + +import random +from typing import Any, Dict, Optional + +from liveweb_arena.core.validators.base import ( + QuestionTemplate, GeneratedQuestion, ValidationResult, register_template, +) +from liveweb_arena.core.ground_truth_trigger import ( + UrlPatternTrigger, TriggerConfig, GroundTruthResult, +) +from liveweb_arena.core.gt_collector import GTSourceType + +from .common import get_collected_listing_data, get_papers_from_listing +from .variables import CATEGORIES, TOP_N_CHOICES + +# Author-count thresholds: "more than K authors" +AUTHOR_THRESHOLDS = [1, 2, 3] + +PATTERNS = [ + "Among the {n} most recent papers in today's new submissions for {category} on ArXiv, how many have more than {k} author(s)?", + "On ArXiv, look at today's new submissions in {category}. Among the top {n}, how many have more than {k} author(s)?", + "In today's new {category} submissions on ArXiv, check the first {n} papers. How many list more than {k} author(s)?", + "Check today's new submissions for {category} on ArXiv. Of the first {n} papers, how many have more than {k} author(s)?", + "Among the first {n} papers in today's new ArXiv submissions for {category}, how many have more than {k} author(s)?", +] + + +@register_template("arxiv_multi_author_filter") +class ArxivMultiAuthorFilterTemplate(QuestionTemplate): + """ + MEDIUM: Count papers exceeding an author-count threshold among the top N. + + Requires inspecting each paper's author list and filtering. + 41 categories × 3 top-N × 3 thresholds × 5 patterns = 1845 question variants. + """ + + GT_SOURCE = GTSourceType.PAGE_ONLY + + def __init__(self): + super().__init__("arxiv_multi_author_filter") + + def generate(self, seed: int, variant: Optional[int] = None) -> GeneratedQuestion: + rng = random.Random(seed) + + top_n = rng.choice(TOP_N_CHOICES) + threshold = rng.choice(AUTHOR_THRESHOLDS) + category = rng.choice(CATEGORIES) + pattern = rng.choice(PATTERNS) + question_text = pattern.format(n=top_n, category=category.name, k=threshold) + + return GeneratedQuestion( + question_text=question_text, + start_url=category.listing_url, + variables={"category": category.code, "top_n": top_n, "threshold": threshold}, + validation_info={ + "category_code": category.code, + "category_name": category.name, + "top_n": top_n, + "threshold": threshold, + }, + template_name=self.name, + expected_steps=7, + ) + + def get_validation_rules(self, validation_info: Dict[str, Any]) -> str: + cat_name = validation_info["category_name"] + top_n = validation_info["top_n"] + threshold = validation_info["threshold"] + return f"""Task-Specific Rules (ArXiv Multi-Author Filter): +- Category: {cat_name} +- Looking for: count of papers with MORE THAN {threshold} authors among the top {top_n} newest +- Score 1.0: Exact count matches +- Score 0.5: Off by 1 +- Score 0.0: Wrong count or no answer +- The answer must be a number +- "More than {threshold}" means strictly greater than {threshold} (not equal) +- Data source: ArXiv new submissions listing""" + + async def get_ground_truth(self, validation_info: Dict[str, Any]) -> GroundTruthResult: + category_code = validation_info["category_code"] + top_n = validation_info["top_n"] + threshold = validation_info["threshold"] + + data, failure = get_collected_listing_data(category_code) + if failure is not None: + return failure + + papers, paper_failure = get_papers_from_listing(data) + if paper_failure is not None: + return paper_failure + + # System error if listing has fewer papers than requested + if len(papers) < top_n: + return GroundTruthResult.system_error( + f"Category {category_code} has only {len(papers)} papers, " + f"need {top_n} for this question" + ) + subset = papers[:top_n] + + count = sum(1 for p in subset if len(p["authors"]) > threshold) + return GroundTruthResult.ok(str(count)) + + async def validate_answer( + self, answer: str, validation_info: Dict[str, Any] + ) -> ValidationResult: + """Not used — the pipeline uses LLM-based validation via get_validation_rules().""" + return ValidationResult( + score=0.0, is_correct=False, expected=None, actual=answer, + details="Use LLM validation", + ) + + def get_ground_truth_trigger(self, validation_info: dict) -> TriggerConfig: + trigger = UrlPatternTrigger(domains=["arxiv.org"]) + return TriggerConfig(trigger=trigger) + + @classmethod + def get_cache_source(cls) -> str: + return "arxiv" + + def get_gt_source(self) -> GTSourceType: + return self.GT_SOURCE diff --git a/liveweb_arena/plugins/arxiv/templates/paper_info.py b/liveweb_arena/plugins/arxiv/templates/paper_info.py new file mode 100644 index 0000000..50af6c5 --- /dev/null +++ b/liveweb_arena/plugins/arxiv/templates/paper_info.py @@ -0,0 +1,174 @@ +"""Paper info template for ArXiv - EASY DIFFICULTY. + +Asks for a single fact about the Nth newest paper in a category: +author count or title. The agent navigates to the new-submissions +listing and reads data from the specified paper entry. + +Dynamic data: new papers are posted daily, rotating the answer. +Large entity pool: 41 categories × 2 metrics × 5 patterns × 3 ranks = 1230 variants. +""" + +import random +from enum import Enum +from typing import Any, Dict, Optional + +from liveweb_arena.core.validators.base import ( + QuestionTemplate, GeneratedQuestion, ValidationResult, register_template, +) +from liveweb_arena.core.ground_truth_trigger import ( + UrlPatternTrigger, TriggerConfig, GroundTruthResult, +) +from liveweb_arena.core.gt_collector import GTSourceType + +from .common import get_collected_listing_data, get_papers_from_listing +from .variables import CATEGORIES, RANK_CHOICES, RANK_LABELS + + +class PaperMetric(Enum): + """Metrics extractable from a single paper entry.""" + AUTHOR_COUNT = ("author_count", "number of authors") + TITLE = ("title", "title") + + @property + def api_field(self) -> str: + return self.value[0] + + @property + def display_name(self) -> str: + return self.value[1] + + +PATTERNS = { + PaperMetric.AUTHOR_COUNT: [ + "How many authors does the {rank} paper in today's new submissions for {category} on ArXiv have?", + "On ArXiv, look at today's new submissions in {category}. How many authors does the {rank} paper have?", + "In today's new submissions for {category} on ArXiv, report the author count for the {rank} paper.", + "Find the {rank} paper in today's new {category} submissions on ArXiv. How many authors are listed?", + "What is the author count of the {rank} paper among today's new ArXiv submissions in {category}?", + ], + PaperMetric.TITLE: [ + "What is the title of the {rank} paper in today's new submissions for {category} on ArXiv?", + "On ArXiv, find today's new submissions in {category}. What is the title of the {rank} paper?", + "In today's new submissions for {category} on ArXiv, what is the {rank} paper's title?", + "Find the {rank} paper in today's new {category} submissions on ArXiv. What is its title?", + "Report the title of the {rank} paper among today's new ArXiv submissions in {category}.", + ], +} + + +@register_template("arxiv_paper_info") +class ArxivPaperInfoTemplate(QuestionTemplate): + """ + EASY: Navigate to a category listing and read one fact about the Nth paper. + + RL value: + - Category navigation: must find and open the correct listing page + - Dynamic data: paper pool rotates daily + - 41 categories × 2 metrics × 5 patterns × 3 ranks = 1230 question variants + """ + + GT_SOURCE = GTSourceType.PAGE_ONLY + + def __init__(self): + super().__init__("arxiv_paper_info") + + def generate(self, seed: int, variant: Optional[int] = None) -> GeneratedQuestion: + rng = random.Random(seed) + + metrics = list(PaperMetric) + metric = metrics[variant % len(metrics)] if variant is not None else rng.choice(metrics) + + rank = rng.choice(RANK_CHOICES) + category = rng.choice(CATEGORIES) + pattern = rng.choice(PATTERNS[metric]) + question_text = pattern.format( + category=category.name, + rank=RANK_LABELS[rank], + ) + + return GeneratedQuestion( + question_text=question_text, + start_url=category.listing_url, + variables={"category": category.code, "metric": metric.name, "rank": rank}, + validation_info={ + "category_code": category.code, + "category_name": category.name, + "metric_field": metric.api_field, + "metric_label": metric.display_name, + "rank": rank, + }, + template_name=self.name, + expected_steps=5, + ) + + def get_validation_rules(self, validation_info: Dict[str, Any]) -> str: + cat_name = validation_info["category_name"] + label = validation_info["metric_label"] + metric_field = validation_info["metric_field"] + rank = validation_info["rank"] + rank_label = RANK_LABELS[rank] + if metric_field == "author_count": + return f"""Task-Specific Rules (ArXiv Paper Info): +- Category: {cat_name} +- Metric: {label} of the {rank_label} paper +- Score 1.0: Exact author count matches +- Score 0.5: Off by 1 (co-authors may appear differently on page vs API) +- Score 0.0: Wrong count or no answer +- The answer must be a number +- Data source: ArXiv new submissions listing""" + return f"""Task-Specific Rules (ArXiv Paper Info): +- Category: {cat_name} +- Metric: {label} of the {rank_label} paper +- Score 1.0: Title matches exactly (minor whitespace/punctuation differences OK) +- Score 0.5: Title is substantially correct with minor omissions or additions +- Score 0.0: Wrong paper title or no answer +- Data source: ArXiv new submissions listing""" + + async def get_ground_truth(self, validation_info: Dict[str, Any]) -> GroundTruthResult: + category_code = validation_info["category_code"] + metric_field = validation_info["metric_field"] + rank = validation_info["rank"] + + data, failure = get_collected_listing_data(category_code) + if failure is not None: + return failure + + papers, paper_failure = get_papers_from_listing(data) + if paper_failure is not None: + return paper_failure + + if len(papers) < rank: + return GroundTruthResult.system_error( + f"Category {category_code} has only {len(papers)} papers, " + f"need at least {rank} for this question" + ) + + target_paper = papers[rank - 1] + + if metric_field == "author_count": + return GroundTruthResult.ok(str(len(target_paper["authors"]))) + + if metric_field == "title": + return GroundTruthResult.ok(target_paper["title"]) + + return GroundTruthResult.fail(f"Unknown metric: {metric_field}") + + async def validate_answer( + self, answer: str, validation_info: Dict[str, Any] + ) -> ValidationResult: + """Not used — the pipeline uses LLM-based validation via get_validation_rules().""" + return ValidationResult( + score=0.0, is_correct=False, expected=None, actual=answer, + details="Use LLM validation", + ) + + def get_ground_truth_trigger(self, validation_info: dict) -> TriggerConfig: + trigger = UrlPatternTrigger(domains=["arxiv.org"]) + return TriggerConfig(trigger=trigger) + + @classmethod + def get_cache_source(cls) -> str: + return "arxiv" + + def get_gt_source(self) -> GTSourceType: + return self.GT_SOURCE diff --git a/liveweb_arena/plugins/arxiv/templates/title_length_extrema.py b/liveweb_arena/plugins/arxiv/templates/title_length_extrema.py new file mode 100644 index 0000000..ce77b87 --- /dev/null +++ b/liveweb_arena/plugins/arxiv/templates/title_length_extrema.py @@ -0,0 +1,144 @@ +"""Title length extrema template for ArXiv - MEDIUM DIFFICULTY. + +Asks which paper among the top-N newest submissions in a category has the +longest (or shortest) title. The agent must scan multiple paper entries +and compare title lengths. + +Dynamic data: paper pool rotates daily. +Computation required: agent must compare across papers, not read a single value. +41 categories × 3 top-N × 2 extrema × 5 patterns = 1230 question variants. +""" + +import random +from typing import Any, Dict, Optional + +from liveweb_arena.core.validators.base import ( + QuestionTemplate, GeneratedQuestion, ValidationResult, register_template, +) +from liveweb_arena.core.ground_truth_trigger import ( + UrlPatternTrigger, TriggerConfig, GroundTruthResult, +) +from liveweb_arena.core.gt_collector import GTSourceType + +from .common import get_collected_listing_data, get_papers_from_listing +from .variables import CATEGORIES, TOP_N_CHOICES + +PATTERNS_LONGEST = [ + "Among the {n} most recent papers in today's new submissions for {category} on ArXiv, which one has the longest title? Give its title.", + "On ArXiv, look at today's new submissions in {category}. Among the top {n}, which paper has the longest title? Report the title.", + "In today's new {category} submissions on ArXiv, find the paper with the longest title among the top {n}. What is it?", + "Check today's new submissions for {category} on ArXiv. Of the first {n} papers, which has the longest title? Give the title.", + "Among the first {n} papers in today's new ArXiv submissions for {category}, which one has the longest title? Report it.", +] + +PATTERNS_SHORTEST = [ + "Among the {n} most recent papers in today's new submissions for {category} on ArXiv, which one has the shortest title? Give its title.", + "On ArXiv, look at today's new submissions in {category}. Among the top {n}, which paper has the shortest title? Report the title.", + "In today's new {category} submissions on ArXiv, find the paper with the shortest title among the top {n}. What is it?", + "Check today's new submissions for {category} on ArXiv. Of the first {n} papers, which has the shortest title? Give the title.", + "Among the first {n} papers in today's new ArXiv submissions for {category}, which one has the shortest title? Report it.", +] + + +@register_template("arxiv_title_length_extrema") +class ArxivTitleLengthExtremaTemplate(QuestionTemplate): + """ + MEDIUM: Find the paper with the longest or shortest title among the top N. + + Requires scanning multiple paper entries and comparing title lengths. + 41 categories × 3 top-N × 2 extrema × 5 patterns = 1230 question variants. + """ + + GT_SOURCE = GTSourceType.PAGE_ONLY + + def __init__(self): + super().__init__("arxiv_title_length_extrema") + + def generate(self, seed: int, variant: Optional[int] = None) -> GeneratedQuestion: + rng = random.Random(seed) + + is_longest = (variant % 2 == 0) if variant is not None else rng.choice([True, False]) + top_n = rng.choice(TOP_N_CHOICES) + + category = rng.choice(CATEGORIES) + patterns = PATTERNS_LONGEST if is_longest else PATTERNS_SHORTEST + question_text = rng.choice(patterns).format(n=top_n, category=category.name) + + return GeneratedQuestion( + question_text=question_text, + start_url=category.listing_url, + variables={"category": category.code, "is_longest": is_longest, "top_n": top_n}, + validation_info={ + "category_code": category.code, + "category_name": category.name, + "is_longest": is_longest, + "top_n": top_n, + }, + template_name=self.name, + expected_steps=7, + ) + + def get_validation_rules(self, validation_info: Dict[str, Any]) -> str: + cat_name = validation_info["category_name"] + is_longest = validation_info["is_longest"] + top_n = validation_info["top_n"] + extrema = "longest" if is_longest else "shortest" + return f"""Task-Specific Rules (ArXiv Title Length Extrema): +- Category: {cat_name} +- Looking for: paper with the {extrema} title among the top {top_n} newest +- Score 1.0: Title matches the correct paper (allow minor formatting differences) +- Score 0.5: Title partially matches or identifies correct paper with slight error +- Score 0.0: Wrong paper or no answer +- If there is a tie in title length, any of the tied papers is acceptable +- Data source: ArXiv new submissions listing""" + + async def get_ground_truth(self, validation_info: Dict[str, Any]) -> GroundTruthResult: + category_code = validation_info["category_code"] + is_longest = validation_info["is_longest"] + top_n = validation_info["top_n"] + + data, failure = get_collected_listing_data(category_code) + if failure is not None: + return failure + + papers, paper_failure = get_papers_from_listing(data) + if paper_failure is not None: + return paper_failure + + # System error if listing has fewer papers than requested + if len(papers) < top_n: + return GroundTruthResult.system_error( + f"Category {category_code} has only {len(papers)} papers, " + f"need {top_n} for this question" + ) + subset = papers[:top_n] + + if is_longest: + target = max(subset, key=lambda p: len(p["title"])) + else: + target = min(subset, key=lambda p: len(p["title"])) + + title = target["title"] + char_count = len(title) + + return GroundTruthResult.ok(f"{title} ({char_count} characters)") + + async def validate_answer( + self, answer: str, validation_info: Dict[str, Any] + ) -> ValidationResult: + """Not used — the pipeline uses LLM-based validation via get_validation_rules().""" + return ValidationResult( + score=0.0, is_correct=False, expected=None, actual=answer, + details="Use LLM validation", + ) + + def get_ground_truth_trigger(self, validation_info: dict) -> TriggerConfig: + trigger = UrlPatternTrigger(domains=["arxiv.org"]) + return TriggerConfig(trigger=trigger) + + @classmethod + def get_cache_source(cls) -> str: + return "arxiv" + + def get_gt_source(self) -> GTSourceType: + return self.GT_SOURCE diff --git a/liveweb_arena/plugins/arxiv/templates/variables.py b/liveweb_arena/plugins/arxiv/templates/variables.py new file mode 100644 index 0000000..6901507 --- /dev/null +++ b/liveweb_arena/plugins/arxiv/templates/variables.py @@ -0,0 +1,113 @@ +"""Category pool, constants, and metric definitions for ArXiv templates.""" + +from dataclasses import dataclass +from typing import List, Tuple + +# How many papers the agent must inspect for top-N templates. +# Min category volume is 8 papers/day → 1.6× safety margin at top_n=5. +TOP_N_CHOICES = [3, 4, 5] + +# Fixed window size for category_comparison (always uses the max). +TOP_N = max(TOP_N_CHOICES) + +# Rank positions for paper_info (Nth newest paper). +RANK_CHOICES = [1, 2, 3] + +RANK_LABELS = { + 1: "most recent", + 2: "second most recent", + 3: "third most recent", +} + + +@dataclass(frozen=True) +class Category: + """An ArXiv category with display name.""" + code: str # e.g., "cs.AI" + name: str # e.g., "Artificial Intelligence" + group: str # e.g., "cs" (for pairing across groups) + + @property + def listing_url(self) -> str: + """URL for the new-submissions listing page.""" + return f"https://arxiv.org/list/{self.code}/new" + + +# Diverse categories across ArXiv's subject groups. +# Selection criteria: +# - Daily new-submission volume ≥ 8 papers/day (verified 2026-03-19) +# - Stable existence on arxiv.org +# - Playwright-accessible listing pages +# - No aliases (cs.SY→eess.SY, cs.NA→math.NA excluded) +# +# Excluded for low volume (< 8 new papers/day, verified 2026-03-19): +# q-fin.ST (~1), q-bio.NC (~3), q-bio.QM (~5), cs.DB (~5), cs.IT (~5), +# cs.MA (~5), math.DS (~5), physics.flu-dyn (~5), astro-ph.EP (~5), +# eess.IV (~6), cs.NI (~6), math.MP (~6), math.FA (~7), cs.CE (~7), +# cs.SI (~7), cs.CY (~7), nucl-ex (~7), stat.AP (~7). +CATEGORIES: List[Category] = [ + # Computer Science (11) + Category("cs.AI", "Artificial Intelligence", "cs"), + Category("cs.CL", "Computation and Language", "cs"), + Category("cs.CV", "Computer Vision", "cs"), + Category("cs.LG", "Machine Learning", "cs"), + Category("cs.SE", "Software Engineering", "cs"), + Category("cs.CR", "Cryptography and Security", "cs"), + Category("cs.RO", "Robotics", "cs"), + Category("cs.DS", "Data Structures and Algorithms", "cs"), + Category("cs.HC", "Human-Computer Interaction", "cs"), + Category("cs.IR", "Information Retrieval", "cs"), + Category("cs.GT", "Computer Science and Game Theory", "cs"), + # Mathematics (9) + Category("math.CO", "Combinatorics", "math"), + Category("math.PR", "Probability", "math"), + Category("math.OC", "Optimization and Control", "math"), + Category("math.NA", "Numerical Analysis", "math"), + Category("math.AG", "Algebraic Geometry", "math"), + Category("math.AP", "Analysis of PDEs", "math"), + Category("math.NT", "Number Theory", "math"), + Category("math.DG", "Differential Geometry", "math"), + Category("math.GR", "Group Theory", "math"), + # Physics (16) + Category("hep-th", "High Energy Physics - Theory", "physics"), + Category("hep-ph", "High Energy Physics - Phenomenology", "physics"), + Category("quant-ph", "Quantum Physics", "physics"), + Category("gr-qc", "General Relativity and Quantum Cosmology", "physics"), + Category("astro-ph.CO", "Cosmology and Nongalactic Astrophysics", "physics"), + Category("astro-ph.GA", "Astrophysics of Galaxies", "physics"), + Category("astro-ph.HE", "High Energy Astrophysical Phenomena", "physics"), + Category("astro-ph.SR", "Solar and Stellar Astrophysics", "physics"), + Category("astro-ph.IM", "Instrumentation and Methods for Astrophysics", "physics"), + Category("cond-mat.str-el", "Strongly Correlated Electrons", "physics"), + Category("cond-mat.mes-hall", "Mesoscale and Nanoscale Physics", "physics"), + Category("cond-mat.mtrl-sci", "Materials Science", "physics"), + Category("cond-mat.stat-mech", "Statistical Mechanics", "physics"), + Category("cond-mat.supr-con", "Superconductivity", "physics"), + Category("cond-mat.soft", "Soft Condensed Matter", "physics"), + Category("physics.optics", "Optics", "physics"), + # Statistics (2) + Category("stat.ML", "Statistics - Machine Learning", "stat"), + Category("stat.ME", "Statistics - Methodology", "stat"), + # Electrical Engineering (3) + Category("eess.SP", "Signal Processing", "eess"), + Category("eess.SY", "Systems and Control", "eess"), + Category("eess.AS", "Audio and Speech Processing", "eess"), +] + + +def build_category_pairs() -> List[Tuple[Category, Category]]: + """Build all cross-group category pairs for comparison templates. + + Pairs categories across groups (e.g., cs vs math) so comparisons are + interesting — categories within the same group tend to have similar volumes. + """ + pairs: List[Tuple[Category, Category]] = [] + n = len(CATEGORIES) + for i in range(n): + for j in range(i + 1, n): + if CATEGORIES[i].group != CATEGORIES[j].group: + pairs.append((CATEGORIES[i], CATEGORIES[j])) + return pairs + + +CATEGORY_PAIRS: List[Tuple[Category, Category]] = build_category_pairs() diff --git a/liveweb_arena/plugins/base.py b/liveweb_arena/plugins/base.py index 6a9f134..5703a2a 100644 --- a/liveweb_arena/plugins/base.py +++ b/liveweb_arena/plugins/base.py @@ -150,6 +150,22 @@ def get_synthetic_page(self, url: str) -> Optional[str]: """ return None + def get_stable_url_patterns(self) -> List[str]: + """Return stable URL shapes that this plugin intentionally supports.""" + return [] + + def classify_url(self, url: str) -> Optional[str]: + """Return a model-side classification for clearly invalid URL shapes.""" + return None + + def is_plausible_asset_id(self, url: str) -> bool: + """Return True when URL-derived asset identifiers look valid for this plugin.""" + return True + + def audit_url(self, url: str) -> Optional[Dict[str, Any]]: + """Return optional extra audit details for the URL.""" + return None + def needs_api_data(self, url: str) -> bool: """ Check if this URL needs API data for ground truth. @@ -167,7 +183,7 @@ def needs_api_data(self, url: str) -> bool: """ return True - async def setup_page_for_cache(self, page, url: str) -> None: + async def setup_page_for_cache(self, page, url: str) -> Optional[Dict[str, Any]]: """ Perform page interactions before caching (e.g., click 'Show All'). @@ -178,8 +194,13 @@ async def setup_page_for_cache(self, page, url: str) -> None: page: Playwright Page object url: The page URL being cached + Returns: + Optional metadata describing soft setup issues or extra evidence + gathered during cache preparation. Returning ``None`` keeps the + previous behavior. + Example: - async def setup_page_for_cache(self, page, url: str) -> None: + async def setup_page_for_cache(self, page, url: str) -> Optional[Dict[str, Any]]: if '/subnets' in url: # Click "ALL" to show all rows await page.click('text=ALL') diff --git a/liveweb_arena/plugins/base_client.py b/liveweb_arena/plugins/base_client.py index 2a33a2b..f4394ca 100644 --- a/liveweb_arena/plugins/base_client.py +++ b/liveweb_arena/plugins/base_client.py @@ -3,7 +3,9 @@ import asyncio import time from abc import ABC -from typing import Any, ClassVar, Dict +from typing import Any, ClassVar, Dict, Optional + +import aiohttp class APIFetchError(Exception): @@ -14,10 +16,17 @@ class APIFetchError(Exception): All API clients should raise this instead of returning None/empty dict. """ - def __init__(self, message: str, source: str = None, status_code: int = None): + def __init__( + self, + message: str, + source: str = None, + status_code: int = None, + metadata: Optional[Dict[str, Any]] = None, + ): super().__init__(message) self.source = source self.status_code = status_code + self.metadata = dict(metadata or {}) def validate_api_response(data: Any, expected_type: type, context: str) -> None: @@ -69,3 +78,24 @@ class BaseAPIClient(ABC): async def _rate_limit(cls): """Apply rate limiting. Subclasses can override for custom behavior.""" await cls._rate_limiter.wait() + + +def create_http_session( + *, + headers: Optional[Dict[str, str]] = None, + timeout: Optional[float] = None, +) -> aiohttp.ClientSession: + """ + Create a shared-config aiohttp session for plugin API access. + + Important: trust_env=True allows the evaluation environment's proxy settings + to apply consistently, which is required on this machine for some sites/APIs. + """ + timeout_cfg = aiohttp.ClientTimeout(total=timeout) if timeout is not None else None + connector = aiohttp.TCPConnector(ttl_dns_cache=300, limit=32, ssl=False) + return aiohttp.ClientSession( + headers=headers, + timeout=timeout_cfg, + trust_env=True, + connector=connector, + ) diff --git a/liveweb_arena/plugins/coingecko/api_client.py b/liveweb_arena/plugins/coingecko/api_client.py index eed16ff..007d49c 100644 --- a/liveweb_arena/plugins/coingecko/api_client.py +++ b/liveweb_arena/plugins/coingecko/api_client.py @@ -3,11 +3,18 @@ import os import asyncio import logging +import time from typing import Any, Dict, List, Optional import aiohttp -from liveweb_arena.plugins.base_client import APIFetchError, BaseAPIClient, RateLimiter, validate_api_response +from liveweb_arena.plugins.base_client import ( + APIFetchError, + BaseAPIClient, + RateLimiter, + create_http_session, + validate_api_response, +) logger = logging.getLogger(__name__) @@ -26,6 +33,8 @@ class CoinGeckoClient(BaseAPIClient): # Free tier: 2s interval; Pro tier uses override in _rate_limit _rate_limiter = RateLimiter(min_interval=2.0) + _single_coin_cache: dict[str, tuple[float, dict[str, Any]]] = {} + _single_coin_cache_ttl_s = float(os.getenv("LIVEWEB_COINGECKO_SINGLE_COIN_CACHE_TTL_SECONDS", "900")) @classmethod def get_api_key(cls) -> Optional[str]: @@ -78,12 +87,11 @@ async def get( headers = cls.get_headers() try: - async with aiohttp.ClientSession() as session: + async with create_http_session() as session: async with session.get( url, params=params, headers=headers, - timeout=aiohttp.ClientTimeout(total=timeout), ) as response: if response.status == 429: # Rate limited - wait and retry once @@ -92,7 +100,6 @@ async def get( url, params=params, headers=headers, - timeout=aiohttp.ClientTimeout(total=timeout), ) as retry_response: if retry_response.status != 200: raise APIFetchError(f"CoinGecko retry failed: {retry_response.status}") @@ -156,7 +163,7 @@ async def fetch_cache_api_data() -> Optional[Dict[str, Any]]: logger.info(f"Fetching CoinGecko data for {len(coins)} coins...") try: - async with aiohttp.ClientSession() as session: + async with create_http_session(timeout=30) as session: # Use CoinGeckoClient's API key if available headers = CoinGeckoClient.get_headers() base_url = CoinGeckoClient.get_base_url() @@ -175,7 +182,6 @@ async def fetch_cache_api_data() -> Optional[Dict[str, Any]]: f"{base_url}/coins/markets", params=params, headers=headers, - timeout=aiohttp.ClientTimeout(total=30), ) as response: if response.status != 200: raise Exception(f"API error: {response.status}") @@ -236,8 +242,14 @@ async def fetch_single_coin_data(coin_id: str) -> Dict[str, Any]: Raises: APIFetchError: If API request fails or returns invalid data """ + cached = CoinGeckoClient._single_coin_cache.get(coin_id) + if cached: + cached_at, cached_payload = cached + if (time.time() - cached_at) <= CoinGeckoClient._single_coin_cache_ttl_s: + return cached_payload + try: - async with aiohttp.ClientSession() as session: + async with create_http_session(timeout=30) as session: headers = CoinGeckoClient.get_headers() base_url = CoinGeckoClient.get_base_url() @@ -251,27 +263,47 @@ async def fetch_single_coin_data(coin_id: str) -> Dict[str, Any]: "price_change_percentage": "24h,7d,30d", } - await CoinGeckoClient._rate_limit() - - async with session.get( - f"{base_url}/coins/markets", - params=params, - headers=headers, - timeout=aiohttp.ClientTimeout(total=30), - ) as response: - if response.status != 200: + data = None + last_error: APIFetchError | None = None + for attempt in range(3): + await CoinGeckoClient._rate_limit() + async with session.get( + f"{base_url}/coins/markets", + params=params, + headers=headers, + ) as response: + if response.status == 200: + data = await response.json() + break + if response.status == 429: + last_error = APIFetchError( + f"status=429 for coin_id={coin_id}", + source="coingecko", + status_code=429, + ) + if cached: + return cached[1] + await asyncio.sleep(min(8, 2 * (attempt + 1))) + continue raise APIFetchError( f"status={response.status} for coin_id={coin_id}", source="coingecko", status_code=response.status, ) - data = await response.json() + + if data is None: + if last_error is not None: + raise last_error + raise APIFetchError(f"CoinGecko request failed for coin_id={coin_id}", source="coingecko") if not data: + if cached: + return cached[1] raise APIFetchError(f"Empty response for coin_id={coin_id}", source="coingecko") validate_api_response(data, list, f"coin_id={coin_id}") validate_api_response(data[0], dict, f"coin_id={coin_id} first element") + CoinGeckoClient._single_coin_cache[coin_id] = (time.time(), data[0]) return data[0] except APIFetchError: diff --git a/liveweb_arena/plugins/coingecko/coingecko.py b/liveweb_arena/plugins/coingecko/coingecko.py index bbb0cf5..ab740e7 100644 --- a/liveweb_arena/plugins/coingecko/coingecko.py +++ b/liveweb_arena/plugins/coingecko/coingecko.py @@ -15,8 +15,16 @@ # URL slug to API coin ID mapping for coins where they differ # CoinGecko sometimes uses different identifiers in URLs vs API URL_SLUG_TO_COIN_ID = { + "bnb": "binancecoin", + "binance-coin": "binancecoin", + "binancecoin": "binancecoin", + "xrp": "ripple", + "ada": "cardano", + "polkadot-new": "polkadot", + "polkadot": "polkadot", "polygon": "polygon-ecosystem-token", # Polygon rebranded MATIC to POL "matic-network": "polygon-ecosystem-token", + "near-protocol": "near", "avalanche": "avalanche-2", "hedera": "hedera-hashgraph", "lido-staked-ether": "staked-ether", @@ -61,6 +69,29 @@ def get_blocked_patterns(self) -> List[str]: "*-emoji-*", ] + def get_stable_url_patterns(self) -> List[str]: + return [ + "/en", + "/en/coins/", + "/en/coins/*/historical_data", + "/en/highlights/", + ] + + def classify_url(self, url: str) -> str | None: + parsed = urlparse(url) + host = (parsed.hostname or "").lower() + path = parsed.path.lower() + if "coingecko.com" not in host: + return None + coin_id = self._extract_coin_id(url) + if coin_id and not self.is_plausible_asset_id(url): + return "model_invalid_asset_id" + if "/coins/" in path and coin_id: + return None + if self._is_homepage(url) or "/en/highlights/" in path: + return None + return None + async def fetch_api_data(self, url: str) -> Dict[str, Any]: """ Fetch API data for a CoinGecko page. @@ -138,3 +169,20 @@ def _extract_coin_id(self, url: str) -> str: return URL_SLUG_TO_COIN_ID.get(url_slug, url_slug) return "" + + def is_plausible_asset_id(self, url: str) -> bool: + coin_id = self._extract_coin_id(url) + if not coin_id: + return True + # Known false positives from hybrid/company-name hallucinations. + invalid = { + "microsoft", + "google", + "exxon-mobil", + "jpmorgan-chase", + "tesla", + "walmart", + "apple", + "amazon", + } + return coin_id not in invalid diff --git a/liveweb_arena/plugins/openlibrary/api_client.py b/liveweb_arena/plugins/openlibrary/api_client.py index ee90b04..4cfbfb7 100644 --- a/liveweb_arena/plugins/openlibrary/api_client.py +++ b/liveweb_arena/plugins/openlibrary/api_client.py @@ -2,6 +2,7 @@ import asyncio import logging +import re from typing import Any, Dict, List, Optional import aiohttp @@ -44,6 +45,22 @@ async def close_session(): ]) +def _candidate_search_queries(query: str) -> list[str]: + """Return progressively normalized search queries without changing intent.""" + candidates: list[str] = [] + + def _append(value: str): + value = value.strip() + if value and value not in candidates: + candidates.append(value) + + _append(query) + _append(re.sub(r"\s+", " ", query)) + _append(re.sub(r"[?!:;,.'\"()\\[\\]{}]+", " ", query)) + _append(re.sub(r"\s+", " ", re.sub(r"[-_/]+", " ", query))) + return candidates + + class OpenLibraryClient(BaseAPIClient): """ Open Library API client. @@ -58,7 +75,7 @@ class OpenLibraryClient(BaseAPIClient): # Rate limit: 1.5s between requests (Open Library asks for politeness) _rate_limiter = RateLimiter(min_interval=1.5) - MAX_RETRIES = 3 + MAX_RETRIES = 4 @classmethod async def get( @@ -114,19 +131,22 @@ async def search_works( Returns: List of work dicts with stats fields """ - params: Dict[str, Any] = { - "q": query, - "limit": limit, - "fields": SEARCH_FIELDS, - } - if sort: - params["sort"] = sort - if mode: - params["mode"] = mode - - data = await cls.get("/search.json", params=params) - if data and isinstance(data, dict): - return data.get("docs", []) + for candidate in _candidate_search_queries(query): + params: Dict[str, Any] = { + "q": candidate, + "limit": limit, + "fields": SEARCH_FIELDS, + } + if sort: + params["sort"] = sort + if mode: + params["mode"] = mode + + data = await cls.get("/search.json", params=params) + if data and isinstance(data, dict): + docs = data.get("docs", []) + if docs: + return docs return [] @classmethod diff --git a/liveweb_arena/plugins/openmeteo/__init__.py b/liveweb_arena/plugins/openmeteo/__init__.py new file mode 100644 index 0000000..1775690 --- /dev/null +++ b/liveweb_arena/plugins/openmeteo/__init__.py @@ -0,0 +1,8 @@ +"""Open Meteo plugin package""" + +from .openmeteo import OpenMeteoPlugin + +# Import templates to register them +from . import templates + +__all__ = ["OpenMeteoPlugin"] diff --git a/liveweb_arena/plugins/openmeteo/api_client.py b/liveweb_arena/plugins/openmeteo/api_client.py new file mode 100644 index 0000000..a934d8e --- /dev/null +++ b/liveweb_arena/plugins/openmeteo/api_client.py @@ -0,0 +1,109 @@ +"""Open Meteo API client. + +Free weather API — no auth, no rate limits. +Docs: https://open-meteo.com/en/docs +""" + +import logging +from typing import Any, ClassVar, Dict + +import aiohttp + +from liveweb_arena.plugins.base_client import APIFetchError, BaseAPIClient, RateLimiter + +logger = logging.getLogger(__name__) + +CACHE_SOURCE = "openmeteo" +API_BASE = "https://api.open-meteo.com/v1/forecast" + + +class OpenMeteoClient(BaseAPIClient): + """Open Meteo API client with rate limiting and session reuse.""" + + _rate_limiter: ClassVar[RateLimiter] = RateLimiter(min_interval=0.2) + _session: aiohttp.ClientSession = None + + @classmethod + async def _get_session(cls) -> aiohttp.ClientSession: + if cls._session is None or cls._session.closed: + cls._session = aiohttp.ClientSession( + headers={"User-Agent": "LiveWebArena/1.0"}, + ) + return cls._session + + @classmethod + async def close_session(cls): + if cls._session and not cls._session.closed: + await cls._session.close() + cls._session = None + + @classmethod + async def get( + cls, + params: Dict[str, Any], + timeout: float = 15.0, + ) -> Dict[str, Any]: + """Fetch from Open Meteo API with rate limiting.""" + await cls._rate_limit() + session = await cls._get_session() + req_timeout = aiohttp.ClientTimeout(total=timeout) + + try: + async with session.get(API_BASE, params=params, timeout=req_timeout) as resp: + if resp.status != 200: + text = await resp.text() + raise APIFetchError( + f"Open Meteo API returned {resp.status}: {text[:200]}", + source=CACHE_SOURCE, + status_code=resp.status, + ) + data = await resp.json(content_type=None) + if not isinstance(data, dict) or "current_weather" not in data: + raise APIFetchError( + "Open Meteo API returned unexpected format", + source=CACHE_SOURCE, + ) + return data + except APIFetchError: + raise + except Exception as e: + raise APIFetchError( + f"Open Meteo API request failed: {e}", + source=CACHE_SOURCE, + ) from e + + +async def fetch_forecast( + latitude: float, + longitude: float, + forecast_days: int = 3, +) -> Dict[str, Any]: + """ + Fetch weather forecast from Open Meteo API. + + Returns the full API response with current weather, hourly, and daily data. + + Raises: + APIFetchError: If the API request fails + """ + params = { + "latitude": latitude, + "longitude": longitude, + "current_weather": "true", + "hourly": ",".join([ + "temperature_2m", + "relative_humidity_2m", + "wind_speed_10m", + "precipitation_probability", + ]), + "daily": ",".join([ + "temperature_2m_max", + "temperature_2m_min", + "precipitation_probability_max", + "sunrise", + "sunset", + ]), + "timezone": "auto", + "forecast_days": forecast_days, + } + return await OpenMeteoClient.get(params) diff --git a/liveweb_arena/plugins/openmeteo/openmeteo.py b/liveweb_arena/plugins/openmeteo/openmeteo.py new file mode 100644 index 0000000..97c42a0 --- /dev/null +++ b/liveweb_arena/plugins/openmeteo/openmeteo.py @@ -0,0 +1,213 @@ +""" +Open Meteo Plugin. + +Uses the Open Meteo docs page as the browsable interface. +The agent navigates to open-meteo.com/en/docs with location coordinates +encoded as both query params (for cache uniqueness) and hash fragment +(for client-side JS form state). + +API data is fetched via the Open Meteo forecast API for GT extraction. + +Cache support: The docs page is a SvelteKit SPA whose weather data renders +in canvas charts (inaccessible to screen readers). setup_page_for_cache() +injects the API data as readable HTML tables so the cached DOM snapshot +contains accessible weather values without needing JS hydration. +""" + +import logging +from typing import Any, Dict, List, Optional +from urllib.parse import urlparse, parse_qs, unquote + +from liveweb_arena.plugins.base import BasePlugin +from .api_client import fetch_forecast + +logger = logging.getLogger(__name__) + + +class OpenMeteoPlugin(BasePlugin): + """ + Open Meteo plugin for weather forecast data. + + Handles the docs page: + - https://open-meteo.com/en/docs?latitude=35.68&longitude=139.65#... + + Query params provide unique cache keys (normalize_url preserves them). + Hash fragment configures the client-side JS form/chart. + + API data includes: current weather, hourly forecasts, daily aggregates, + sunrise/sunset times. + """ + + name = "openmeteo" + + allowed_domains = [ + "open-meteo.com", + ] + + def get_blocked_patterns(self) -> List[str]: + """No blocks — the docs page JS needs api.open-meteo.com for chart rendering.""" + return [] + + def needs_api_data(self, url: str) -> bool: + """Only docs pages with coordinates need API data.""" + lat, lon = self._extract_coords(url) + return lat is not None + + async def fetch_api_data(self, url: str) -> Dict[str, Any]: + """ + Fetch forecast data for the location encoded in the URL. + + Extracts latitude/longitude from query params or hash fragment, + then calls the Open Meteo forecast API. + """ + lat, lon = self._extract_coords(url) + if lat is None or lon is None: + return {} + + data = await fetch_forecast(lat, lon) + # Add a location key for GT collector identification + data["_location_key"] = f"{lat:.2f},{lon:.2f}" + return data + + async def setup_page_for_cache(self, page, url: str) -> None: + """Inject weather data as readable HTML tables for cache mode. + + The SvelteKit docs page renders weather data in canvas charts that + produce no useful accessibility tree text. This method fetches the + API data and prepends it as HTML tables so the cached DOM snapshot + contains all values the agent needs to read. + """ + lat, lon = self._extract_coords(url) + if lat is None: + return + + try: + data = await fetch_forecast(lat, lon) + except Exception as e: + logger.warning("setup_page_for_cache: API fetch failed: %s", e) + return + + html = self._build_data_html(data) + escaped = html.replace("\\", "\\\\").replace("`", "\\`").replace("$", "\\$") + + await page.evaluate(f""" + (() => {{ + const div = document.createElement('div'); + div.id = 'liveweb-weather-data'; + div.setAttribute('role', 'region'); + div.setAttribute('aria-label', 'Weather Data'); + div.innerHTML = `{escaped}`; + document.body.prepend(div); + }})() + """) + + @staticmethod + def _build_data_html(data: dict) -> str: + """Format API data as readable HTML tables.""" + parts: list = [] + cw = data.get("current_weather", {}) + if cw: + parts.append( + "

Current Weather

" + f"" + f"" + f"" + "
Temperature{cw.get('temperature', 'N/A')} C
Wind Speed{cw.get('windspeed', 'N/A')} km/h
Wind Direction{cw.get('winddirection', 'N/A')} deg
" + ) + + daily = data.get("daily", {}) + times = daily.get("time", []) + if times: + rows = [] + t_max = daily.get("temperature_2m_max", []) + t_min = daily.get("temperature_2m_min", []) + p_max = daily.get("precipitation_probability_max", []) + for i, t in enumerate(times): + mx = t_max[i] if i < len(t_max) else "N/A" + mn = t_min[i] if i < len(t_min) else "N/A" + pp = p_max[i] if i < len(p_max) else "N/A" + rows.append(f"{t}{mx} C{mn} C{pp}%") + parts.append( + "

Daily Forecast

" + "" + + "".join(rows) + "
DateMax TempMin TempPrecip Prob
" + ) + + hourly = data.get("hourly", {}) + h_times = hourly.get("time", []) + if h_times: + today = times[0] if times else h_times[0].split("T")[0] + rows = [] + h_temp = hourly.get("temperature_2m", []) + h_hum = hourly.get("relative_humidity_2m", []) + h_wind = hourly.get("wind_speed_10m", []) + h_prec = hourly.get("precipitation_probability", []) + for i, ht in enumerate(h_times): + if not ht.startswith(today): + continue + tm = h_temp[i] if i < len(h_temp) else "" + hu = h_hum[i] if i < len(h_hum) else "" + ws = h_wind[i] if i < len(h_wind) else "" + pp = h_prec[i] if i < len(h_prec) else "" + rows.append( + f"{ht}{tm} C{hu}%" + f"{ws} km/h{pp}%" + ) + parts.append( + "

Hourly Forecast (Today)

" + "" + "" + + "".join(rows) + "
TimeTempHumidityWind SpeedPrecip Prob
" + ) + + return "".join(parts) + + def _extract_coords(self, url: str) -> tuple: + """ + Extract latitude and longitude from URL. + + Tries query params first (preserved by normalize_url), then hash + fragment as fallback. Both formats are present in docs_url(). + """ + parsed = urlparse(url) + + # Query params first (reliable — survive normalize_url) + params = parse_qs(parsed.query) + lat_vals = params.get("latitude") + lon_vals = params.get("longitude") + if lat_vals and lon_vals: + try: + return float(lat_vals[0]), float(lon_vals[0]) + except (ValueError, IndexError): + pass + + # Fall back to hash fragment (client-side only, stripped by cache) + fragment = parsed.fragment + if fragment: + lat, lon = self._parse_coord_params(fragment) + if lat is not None: + return lat, lon + + return None, None + + @staticmethod + def _parse_coord_params(fragment: str) -> tuple: + """Parse latitude and longitude from a URL fragment like 'latitude=35.68&longitude=139.65&...'""" + lat = None + lon = None + for part in fragment.split("&"): + if "=" not in part: + continue + key, val = part.split("=", 1) + key = unquote(key).strip() + val = unquote(val).strip() + try: + if key == "latitude": + lat = float(val) + elif key == "longitude": + lon = float(val) + except ValueError: + continue + if lat is not None and lon is not None: + return lat, lon + return None, None diff --git a/liveweb_arena/plugins/openmeteo/templates/__init__.py b/liveweb_arena/plugins/openmeteo/templates/__init__.py new file mode 100644 index 0000000..9c3a246 --- /dev/null +++ b/liveweb_arena/plugins/openmeteo/templates/__init__.py @@ -0,0 +1,13 @@ +"""Open Meteo question templates""" + +from .current_weather import OpenMeteoCurrentWeatherTemplate +from .comparison import OpenMeteoComparisonTemplate +from .hourly_extrema import OpenMeteoHourlyExtremaTemplate +from .forecast_trend import OpenMeteoForecastTrendTemplate + +__all__ = [ + "OpenMeteoCurrentWeatherTemplate", + "OpenMeteoComparisonTemplate", + "OpenMeteoHourlyExtremaTemplate", + "OpenMeteoForecastTrendTemplate", +] diff --git a/liveweb_arena/plugins/openmeteo/templates/common.py b/liveweb_arena/plugins/openmeteo/templates/common.py new file mode 100644 index 0000000..9acfbc8 --- /dev/null +++ b/liveweb_arena/plugins/openmeteo/templates/common.py @@ -0,0 +1,98 @@ +"""Shared helpers for Open Meteo templates.""" + +from typing import Any, Dict, List, Optional, Tuple + +from liveweb_arena.core.ground_truth_trigger import GroundTruthResult +from liveweb_arena.core.gt_collector import get_current_gt_collector + +DOCS_HOME_URL = "https://open-meteo.com/en/docs" + + +def get_collected_location_data( + coord_key: str, + city_name: str, +) -> Tuple[Optional[Dict[str, Any]], Optional[GroundTruthResult]]: + """Return collected API data for a visited city, or a GT failure.""" + gt_collector = get_current_gt_collector() + if gt_collector is None: + return None, GroundTruthResult.fail("No GT collector") + + collected = gt_collector.get_collected_api_data() + data = collected.get(f"openmeteo:{coord_key}") + if data is None: + keys = [k for k in collected if k.startswith("openmeteo:")][:5] + return None, GroundTruthResult.not_collected( + f"Agent did not visit Open Meteo page for '{city_name}'. Collected keys: {keys}" + ) + + return data, None + + +def get_today_hourly_series( + data: Dict[str, Any], + field_name: str, +) -> Tuple[Optional[List[float]], Optional[GroundTruthResult]]: + """Extract today's hourly values for the given field from API data. + + Returns (values, None) on success, or (None, failure_result) on error. + """ + hourly = data.get("hourly") + if not hourly: + return None, GroundTruthResult.fail("No hourly data in API response") + + times = hourly.get("time") + series = hourly.get(field_name) + if not isinstance(times, list) or not isinstance(series, list): + return None, GroundTruthResult.fail( + f"Hourly data missing time/{field_name} arrays" + ) + if len(times) != len(series): + return None, GroundTruthResult.fail( + f"Hourly time and {field_name} arrays differ in length" + ) + if not times: + return None, GroundTruthResult.fail("Hourly forecast is empty") + + # Determine today's date from the data + today = None + current = data.get("current_weather") + if isinstance(current, dict): + current_time = current.get("time") + if isinstance(current_time, str) and "T" in current_time: + today = current_time.split("T", 1)[0] + + if not today: + daily = data.get("daily") + daily_times = daily.get("time") if isinstance(daily, dict) else None + if isinstance(daily_times, list) and daily_times: + today = str(daily_times[0]).split("T", 1)[0] + + if not today: + today = str(times[0]).split("T", 1)[0] + + values: List[float] = [] + for time_str, val in zip(times, series): + if not isinstance(time_str, str) or not time_str.startswith(today): + continue + if val is None: + continue + try: + values.append(float(val)) + except (TypeError, ValueError): + return None, GroundTruthResult.fail( + f"Non-numeric value in hourly {field_name}: {val!r}" + ) + + if not values: + return None, GroundTruthResult.fail( + f"No hourly {field_name} data found for today ({today})" + ) + + return values, None + + +def get_today_hourly_temperatures( + data: Dict[str, Any], +) -> Tuple[Optional[List[float]], Optional[GroundTruthResult]]: + """Extract today's hourly temperatures from a collected API payload.""" + return get_today_hourly_series(data, "temperature_2m") diff --git a/liveweb_arena/plugins/openmeteo/templates/comparison.py b/liveweb_arena/plugins/openmeteo/templates/comparison.py new file mode 100644 index 0000000..9991b8a --- /dev/null +++ b/liveweb_arena/plugins/openmeteo/templates/comparison.py @@ -0,0 +1,147 @@ +"""Weather comparison template for Open Meteo - HARD DIFFICULTY + +Computes temperature difference between two cities in different climate zones. +Requires the agent to visit two separate pages, read both temperatures, +and compute the numeric difference. + +Dynamic data: temperatures change continuously. +City pairs drawn from 170 cities: C(170,2) = 14365 possible pairs. +Answer is a numeric difference (deg C), not a binary choice — random baseline ~ 0%. +""" + +import random +from typing import Any, Dict, Optional + +from liveweb_arena.core.validators.base import ( + QuestionTemplate, GeneratedQuestion, ValidationResult, register_template, +) +from liveweb_arena.core.ground_truth_trigger import ( + UrlPatternTrigger, TriggerConfig, GroundTruthResult, +) +from liveweb_arena.core.gt_collector import GTSourceType, get_current_gt_collector + +from .variables import CITIES + + +PATTERNS = [ + "Using Open-Meteo, how many degrees warmer is {city1} than {city2} right now? Give the difference in °C.", + "On Open-Meteo, what is the temperature difference between {city1} and {city2} right now in °C? Positive means {city1} is warmer.", + "According to Open-Meteo, find the current temperatures of {city1} and {city2}, then compute the difference ({city1} minus {city2}) in °C.", + "Check the current temperature in {city1} and {city2} on Open-Meteo. What is the difference in degrees Celsius ({city1} minus {city2})?", +] + + +@register_template("openmeteo_comparison") +class OpenMeteoComparisonTemplate(QuestionTemplate): + """ + HARD: Compute temperature difference between two cities. + + Requires visiting two different location pages, reading both temperatures, + and computing the numeric difference. Answer is a signed number in deg C. + C(170, 2) = 14365 city pairs x 4 patterns x 2 orderings = 114920 variants. + """ + + GT_SOURCE = GTSourceType.PAGE_ONLY + + def __init__(self): + super().__init__("openmeteo_comparison") + + def generate(self, seed: int, variant: Optional[int] = None) -> GeneratedQuestion: + rng = random.Random(seed) + + pair = rng.sample(CITIES, 2) + city1, city2 = pair + + # Randomly swap order + if rng.random() > 0.5: + city1, city2 = city2, city1 + + pattern = rng.choice(PATTERNS) + question_text = pattern.format( + city1=city1.display_name, + city2=city2.display_name, + ) + + return GeneratedQuestion( + question_text=question_text, + start_url=city1.docs_url(), + variables={"city1": city1.name, "city2": city2.name}, + validation_info={ + "city1_name": city1.name, + "city1_coord_key": city1.coord_key, + "city2_name": city2.name, + "city2_coord_key": city2.coord_key, + "city2_url": city2.docs_url(), + }, + template_name=self.name, + expected_steps=8, + ) + + def get_validation_rules(self, validation_info: Dict[str, Any]) -> str: + city1 = validation_info.get("city1_name", "City1") + city2 = validation_info.get("city2_name", "City2") + return f"""Task-Specific Rules (Open Meteo Temperature Difference): +- Answer is the temperature difference: {city1} minus {city2} in °C +- Positive means {city1} is warmer, negative means {city2} is warmer +- Score 1.0: Difference within ±2°C of ground truth +- Score 0.5: Difference within ±5°C of ground truth +- Score 0.0: Difference off by more than 5°C, or wrong sign, or no numeric answer +- Accept formats: "5.2", "5.2°C", "-3.1°C", "+5.2" +- Do NOT accept answers that only name a city without the numeric difference""" + + async def get_ground_truth(self, validation_info: Dict[str, Any]) -> GroundTruthResult: + city1_name = validation_info.get("city1_name", "") + city2_name = validation_info.get("city2_name", "") + key1 = validation_info.get("city1_coord_key", "") + key2 = validation_info.get("city2_coord_key", "") + + gt_collector = get_current_gt_collector() + if gt_collector is None: + return GroundTruthResult.fail("No GT collector") + + collected = gt_collector.get_collected_api_data() + + data1 = collected.get(f"openmeteo:{key1}") + if data1 is None: + return GroundTruthResult.not_collected( + f"Weather data for '{city1_name}' not collected" + ) + + data2 = collected.get(f"openmeteo:{key2}") + if data2 is None: + return GroundTruthResult.not_collected( + f"Weather data for '{city2_name}' not collected" + ) + + cw1 = data1.get("current_weather") + cw2 = data2.get("current_weather") + if not cw1 or "temperature" not in cw1: + return GroundTruthResult.fail(f"No temperature data for '{city1_name}'") + if not cw2 or "temperature" not in cw2: + return GroundTruthResult.fail(f"No temperature data for '{city2_name}'") + + temp1 = float(cw1["temperature"]) + temp2 = float(cw2["temperature"]) + diff = round(temp1 - temp2, 1) + + return GroundTruthResult.ok(f"{diff}°C") + + async def validate_answer( + self, answer: str, validation_info: Dict[str, Any] + ) -> ValidationResult: + """Not used — the pipeline uses LLM-based validation via get_validation_rules().""" + return ValidationResult( + score=0.0, is_correct=False, expected=None, actual=answer, + details="Use LLM validation", + ) + + def get_ground_truth_trigger(self, validation_info: dict) -> TriggerConfig: + trigger = UrlPatternTrigger(domains=["open-meteo.com"]) + return TriggerConfig(trigger=trigger) + + @classmethod + def get_cache_source(cls) -> str: + return "openmeteo" + + def get_gt_source(self) -> GTSourceType: + return self.GT_SOURCE diff --git a/liveweb_arena/plugins/openmeteo/templates/current_weather.py b/liveweb_arena/plugins/openmeteo/templates/current_weather.py new file mode 100644 index 0000000..a748da1 --- /dev/null +++ b/liveweb_arena/plugins/openmeteo/templates/current_weather.py @@ -0,0 +1,137 @@ +"""Current weather template for Open Meteo - EASY DIFFICULTY. + +Asks for a single current weather metric (temperature, wind speed, or wind +direction) for a given city. The agent starts on the generic Open-Meteo docs +page, searches for the location, and then reads the current value. + +Dynamic data: current weather updates every 15 minutes. +Large entity pool: 170 cities x 3 metrics = 510 effective variants (>500). +""" + +import random +from typing import Any, Dict, Optional + +from liveweb_arena.core.validators.base import ( + QuestionTemplate, GeneratedQuestion, ValidationResult, register_template, +) +from liveweb_arena.core.ground_truth_trigger import ( + UrlPatternTrigger, TriggerConfig, GroundTruthResult, +) +from liveweb_arena.core.gt_collector import GTSourceType + +from .common import DOCS_HOME_URL, get_collected_location_data +from .variables import CITIES, CurrentMetric + + +PATTERNS = { + CurrentMetric.TEMPERATURE: [ + "What is the current temperature in {city} according to Open-Meteo?", + "On Open-Meteo, what is the temperature in {city} right now?", + "Using the Open-Meteo weather service, find the current temperature in {city}.", + ], + CurrentMetric.WIND_SPEED: [ + "What is the current wind speed in {city} according to Open-Meteo?", + "On Open-Meteo, what is the wind speed in {city} right now?", + "Using the Open-Meteo weather service, find the current wind speed in {city}.", + ], + CurrentMetric.WIND_DIRECTION: [ + "What is the current wind direction in {city} according to Open-Meteo? Answer in degrees.", + "On Open-Meteo, what direction is the wind blowing in {city} right now? Give the answer in degrees.", + "Using the Open-Meteo weather service, find the current wind direction in {city} in degrees.", + ], +} + + +@register_template("openmeteo_current") +class OpenMeteoCurrentWeatherTemplate(QuestionTemplate): + """ + EASY: Use location search, then read a single current metric. + + RL value: + - Form interaction: must search/select a location in the docs UI + - Dynamic data: weather changes every 15 minutes + - 170 cities x 3 metrics x 3 patterns = 1530 question variants + """ + + GT_SOURCE = GTSourceType.PAGE_ONLY + + def __init__(self): + super().__init__("openmeteo_current") + + def generate(self, seed: int, variant: Optional[int] = None) -> GeneratedQuestion: + rng = random.Random(seed) + + metrics = list(CurrentMetric) + metric = metrics[variant % len(metrics)] if variant is not None else rng.choice(metrics) + + city = rng.choice(CITIES) + pattern = rng.choice(PATTERNS[metric]) + question_text = pattern.format(city=city.display_name) + + return GeneratedQuestion( + question_text=question_text, + start_url=DOCS_HOME_URL, + variables={"city": city.name, "metric": metric.name}, + validation_info={ + "city_name": city.name, + "coord_key": city.coord_key, + "metric_field": metric.api_field, + "metric_label": metric.display_name, + "unit": metric.unit, + }, + template_name=self.name, + expected_steps=6, + ) + + def get_validation_rules(self, validation_info: Dict[str, Any]) -> str: + city = validation_info.get("city_name", "") + label = validation_info.get("metric_label", "") + unit = validation_info.get("unit", "") + return f"""Task-Specific Rules (Open Meteo Current Weather): +- City: {city} +- Metric: {label} +- Score 1.0: Value within ±2{unit} of correct answer +- Score 0.5: Value within ±5{unit} +- Score 0.0: Wrong value or no answer +- The answer should reflect the city selected on Open-Meteo, not a guessed climatology +- Data source: Open-Meteo weather service (open-meteo.com)""" + + async def get_ground_truth(self, validation_info: Dict[str, Any]) -> GroundTruthResult: + coord_key = validation_info.get("coord_key", "") + metric_field = validation_info.get("metric_field", "") + unit = validation_info.get("unit", "") + city_name = validation_info.get("city_name", "") + + data, failure = get_collected_location_data(coord_key, city_name) + if failure is not None: + return failure + + current = data.get("current_weather") + if not current: + return GroundTruthResult.fail("No current_weather in API data") + + value = current.get(metric_field) + if value is None: + return GroundTruthResult.fail(f"Field '{metric_field}' not in current_weather") + + return GroundTruthResult.ok(f"{value}{unit}") + + async def validate_answer( + self, answer: str, validation_info: Dict[str, Any] + ) -> ValidationResult: + """Not used — the pipeline uses LLM-based validation via get_validation_rules().""" + return ValidationResult( + score=0.0, is_correct=False, expected=None, actual=answer, + details="Use LLM validation", + ) + + def get_ground_truth_trigger(self, validation_info: dict) -> TriggerConfig: + trigger = UrlPatternTrigger(domains=["open-meteo.com"]) + return TriggerConfig(trigger=trigger) + + @classmethod + def get_cache_source(cls) -> str: + return "openmeteo" + + def get_gt_source(self) -> GTSourceType: + return self.GT_SOURCE diff --git a/liveweb_arena/plugins/openmeteo/templates/forecast_trend.py b/liveweb_arena/plugins/openmeteo/templates/forecast_trend.py new file mode 100644 index 0000000..0679dd1 --- /dev/null +++ b/liveweb_arena/plugins/openmeteo/templates/forecast_trend.py @@ -0,0 +1,167 @@ +"""Forecast trend template for Open Meteo - MEDIUM DIFFICULTY. + +Asks whether a daily metric will be higher or lower on one day vs another +in a given city. The agent starts on the generic docs page, finds the +location, then compares the relevant daily values. + +Dynamic data: forecasts update continuously. +Time-sensitive: day references change daily. +Computation required: must compare two values, not read a single one. + +Effective variants: 170 cities x 3 metrics x 3 day-pairs = 1530 (>500). +""" + +import random +from typing import Any, Dict, Optional + +from liveweb_arena.core.validators.base import ( + QuestionTemplate, GeneratedQuestion, ValidationResult, register_template, +) +from liveweb_arena.core.ground_truth_trigger import ( + UrlPatternTrigger, TriggerConfig, GroundTruthResult, +) +from liveweb_arena.core.gt_collector import GTSourceType + +from .common import DOCS_HOME_URL, get_collected_location_data +from .variables import CITIES, DailyMetric + + +DAY_PAIRS = [ + (0, 1, "today", "tomorrow"), + (0, 2, "today", "the day after tomorrow"), + (1, 2, "tomorrow", "the day after tomorrow"), +] + +PATTERNS = [ + "According to Open-Meteo, will {day2}'s {metric_label} in {city} be higher or lower than {day1}'s? By how many {unit}?", + "On Open-Meteo, compare the {metric_label} {day1} vs {day2} in {city}. Which day has a higher value and by how much?", + "Using Open-Meteo, is {day2}'s {metric_label} in {city} higher or lower than {day1}'s? What is the difference in {unit}?", +] + + +@register_template("openmeteo_forecast_trend") +class OpenMeteoForecastTrendTemplate(QuestionTemplate): + """ + MEDIUM: Compare a daily metric across two days. + + Requires reading daily forecast for two days and computing the difference. + 170 cities x 3 metrics x 3 day-pairs = 1530 effective variants. + """ + + GT_SOURCE = GTSourceType.PAGE_ONLY + + def __init__(self): + super().__init__("openmeteo_forecast_trend") + + def generate(self, seed: int, variant: Optional[int] = None) -> GeneratedQuestion: + rng = random.Random(seed) + + city = rng.choice(CITIES) + + metrics = list(DailyMetric) + metric = rng.choice(metrics) + + day_pair = rng.choice(DAY_PAIRS) + idx1, idx2, day1_label, day2_label = day_pair + + pattern = rng.choice(PATTERNS) + question_text = pattern.format( + city=city.display_name, + metric_label=metric.display_name, + day1=day1_label, + day2=day2_label, + unit=metric.unit, + ) + + return GeneratedQuestion( + question_text=question_text, + start_url=DOCS_HOME_URL, + variables={"city": city.name, "metric": metric.name}, + validation_info={ + "city_name": city.name, + "coord_key": city.coord_key, + "metric_field": metric.api_field, + "metric_label": metric.display_name, + "unit": metric.unit, + "day1_idx": idx1, + "day2_idx": idx2, + "day1_label": day1_label, + "day2_label": day2_label, + }, + template_name=self.name, + expected_steps=7, + ) + + def get_validation_rules(self, validation_info: Dict[str, Any]) -> str: + city = validation_info.get("city_name", "") + label = validation_info.get("metric_label", "daily maximum temperature") + unit = validation_info.get("unit", "°C") + day1 = validation_info.get("day1_label", "today") + day2 = validation_info.get("day2_label", "tomorrow") + return f"""Task-Specific Rules (Open Meteo Forecast Trend): +- City: {city} +- Compare {day1}'s {label} vs {day2}'s {label} +- Answer should state: higher/lower + the difference in {unit} +- Score 1.0: Correct direction (higher/lower) AND difference within ±1{unit} +- Score 0.5: Correct direction but difference off by more than 1{unit} +- Score 0.0: Wrong direction or no answer""" + + async def get_ground_truth(self, validation_info: Dict[str, Any]) -> GroundTruthResult: + coord_key = validation_info.get("coord_key", "") + city_name = validation_info.get("city_name", "") + metric_field = validation_info.get("metric_field", "temperature_2m_max") + unit = validation_info.get("unit", "°C") + idx1 = validation_info.get("day1_idx", 0) + idx2 = validation_info.get("day2_idx", 1) + day1_label = validation_info.get("day1_label", "today") + day2_label = validation_info.get("day2_label", "tomorrow") + + data, failure = get_collected_location_data(coord_key, city_name) + if failure is not None: + return failure + + daily = data.get("daily") + if not daily: + return GroundTruthResult.fail("No daily data in API response") + + values = daily.get(metric_field) + if not values or len(values) <= max(idx1, idx2): + return GroundTruthResult.fail( + f"Need at least {max(idx1, idx2) + 1} days of {metric_field}" + ) + + val1 = float(values[idx1]) + val2 = float(values[idx2]) + diff = val2 - val1 + + if diff > 0: + return GroundTruthResult.ok( + f"Higher by {abs(diff):.1f}{unit} ({day1_label}: {val1}{unit}, {day2_label}: {val2}{unit})" + ) + if diff < 0: + return GroundTruthResult.ok( + f"Lower by {abs(diff):.1f}{unit} ({day1_label}: {val1}{unit}, {day2_label}: {val2}{unit})" + ) + return GroundTruthResult.ok( + f"Same value ({val1}{unit} both days)" + ) + + async def validate_answer( + self, answer: str, validation_info: Dict[str, Any] + ) -> ValidationResult: + """Not used — the pipeline uses LLM-based validation via get_validation_rules().""" + return ValidationResult( + score=0.0, is_correct=False, expected=None, actual=answer, + details="Use LLM validation", + ) + + def get_ground_truth_trigger(self, validation_info: dict) -> TriggerConfig: + trigger = UrlPatternTrigger(domains=["open-meteo.com"]) + return TriggerConfig(trigger=trigger) + + @classmethod + def get_cache_source(cls) -> str: + return "openmeteo" + + def get_gt_source(self) -> GTSourceType: + return self.GT_SOURCE diff --git a/liveweb_arena/plugins/openmeteo/templates/hourly_extrema.py b/liveweb_arena/plugins/openmeteo/templates/hourly_extrema.py new file mode 100644 index 0000000..5fcd37a --- /dev/null +++ b/liveweb_arena/plugins/openmeteo/templates/hourly_extrema.py @@ -0,0 +1,161 @@ +"""Hourly extrema template for Open Meteo - MEDIUM DIFFICULTY. + +Asks for the highest or lowest hourly value of a given metric today in a city. +The agent starts on the generic docs page, finds the city, then scans the +hourly forecast series to locate the extreme value. + +Dynamic data: hourly forecasts update continuously. +Time-sensitive: asks about "today" which changes daily. +Computation required: agent must compare across hours, not read a single value. + +Effective variants: 170 cities x 4 metrics x 2 extrema = 1360 (>500). +""" + +import random +from typing import Any, Dict, Optional + +from liveweb_arena.core.validators.base import ( + QuestionTemplate, GeneratedQuestion, ValidationResult, register_template, +) +from liveweb_arena.core.ground_truth_trigger import ( + UrlPatternTrigger, TriggerConfig, GroundTruthResult, +) +from liveweb_arena.core.gt_collector import GTSourceType + +from .common import DOCS_HOME_URL, get_collected_location_data, get_today_hourly_series +from .variables import CITIES, HourlyMetric + + +PATTERNS_HIGH = { + HourlyMetric.TEMPERATURE: [ + "What is the highest hourly temperature forecast for {city} today on Open-Meteo?", + "Using Open-Meteo, find today's peak hourly temperature in {city}.", + "On Open-Meteo, what is the warmest hourly temperature expected in {city} today?", + ], + HourlyMetric.HUMIDITY: [ + "What is the highest hourly relative humidity forecast for {city} today on Open-Meteo?", + "Using Open-Meteo, find today's peak hourly humidity in {city}.", + ], + HourlyMetric.WIND_SPEED: [ + "What is the highest hourly wind speed forecast for {city} today on Open-Meteo?", + "Using Open-Meteo, find today's peak hourly wind speed in {city}.", + ], + HourlyMetric.PRECIP_PROBABILITY: [ + "What is the highest hourly precipitation probability forecast for {city} today on Open-Meteo?", + "Using Open-Meteo, find today's peak precipitation probability in {city}.", + ], +} + +PATTERNS_LOW = { + HourlyMetric.TEMPERATURE: [ + "What is the lowest hourly temperature forecast for {city} today on Open-Meteo?", + "Using Open-Meteo, find today's lowest hourly temperature in {city}.", + "On Open-Meteo, what is the coolest hourly temperature expected in {city} today?", + ], + HourlyMetric.HUMIDITY: [ + "What is the lowest hourly relative humidity forecast for {city} today on Open-Meteo?", + "Using Open-Meteo, find today's lowest hourly humidity in {city}.", + ], + HourlyMetric.WIND_SPEED: [ + "What is the lowest hourly wind speed forecast for {city} today on Open-Meteo?", + "Using Open-Meteo, find today's lowest hourly wind speed in {city}.", + ], + HourlyMetric.PRECIP_PROBABILITY: [ + "What is the lowest hourly precipitation probability forecast for {city} today on Open-Meteo?", + "Using Open-Meteo, find today's lowest precipitation probability in {city}.", + ], +} + + +@register_template("openmeteo_hourly_extrema") +class OpenMeteoHourlyExtremaTemplate(QuestionTemplate): + """ + MEDIUM: Find the highest or lowest hourly metric value today. + + Requires scanning hourly forecast data, not just reading a single value. + 170 cities x 4 metrics x 2 extrema = 1360 effective variants. + """ + + GT_SOURCE = GTSourceType.PAGE_ONLY + + def __init__(self): + super().__init__("openmeteo_hourly_extrema") + + def generate(self, seed: int, variant: Optional[int] = None) -> GeneratedQuestion: + rng = random.Random(seed) + + metrics = list(HourlyMetric) + metric = metrics[variant % len(metrics)] if variant is not None else rng.choice(metrics) + is_max = rng.choice([True, False]) + + city = rng.choice(CITIES) + patterns = PATTERNS_HIGH[metric] if is_max else PATTERNS_LOW[metric] + question_text = rng.choice(patterns).format(city=city.display_name) + + return GeneratedQuestion( + question_text=question_text, + start_url=DOCS_HOME_URL, + variables={"city": city.name, "is_max": is_max, "metric": metric.name}, + validation_info={ + "city_name": city.name, + "coord_key": city.coord_key, + "is_max": is_max, + "metric_field": metric.api_field, + "metric_label": metric.display_name, + "unit": metric.unit, + }, + template_name=self.name, + expected_steps=7, + ) + + def get_validation_rules(self, validation_info: Dict[str, Any]) -> str: + city = validation_info.get("city_name", "") + is_max = validation_info.get("is_max", True) + label = validation_info.get("metric_label", "hourly temperature") + unit = validation_info.get("unit", "°C") + extrema = "maximum (highest)" if is_max else "minimum (lowest)" + return f"""Task-Specific Rules (Open Meteo Hourly Extrema): +- City: {city} +- Looking for: {extrema} {label} today +- Score 1.0: Value within ±1{unit} of correct answer +- Score 0.5: Value within ±3{unit} +- Score 0.0: Wrong value or no answer +- Use the hourly forecast for today's local date, not the daily summary""" + + async def get_ground_truth(self, validation_info: Dict[str, Any]) -> GroundTruthResult: + coord_key = validation_info.get("coord_key", "") + is_max = validation_info.get("is_max", True) + city_name = validation_info.get("city_name", "") + metric_field = validation_info.get("metric_field", "temperature_2m") + unit = validation_info.get("unit", "°C") + + data, failure = get_collected_location_data(coord_key, city_name) + if failure is not None: + return failure + + values, val_failure = get_today_hourly_series(data, metric_field) + if val_failure is not None: + return val_failure + + value = max(values) if is_max else min(values) + return GroundTruthResult.ok(f"{value}{unit}") + + async def validate_answer( + self, answer: str, validation_info: Dict[str, Any] + ) -> ValidationResult: + """Not used — the pipeline uses LLM-based validation via get_validation_rules().""" + return ValidationResult( + score=0.0, is_correct=False, expected=None, actual=answer, + details="Use LLM validation", + ) + + def get_ground_truth_trigger(self, validation_info: dict) -> TriggerConfig: + trigger = UrlPatternTrigger(domains=["open-meteo.com"]) + return TriggerConfig(trigger=trigger) + + @classmethod + def get_cache_source(cls) -> str: + return "openmeteo" + + def get_gt_source(self) -> GTSourceType: + return self.GT_SOURCE diff --git a/liveweb_arena/plugins/openmeteo/templates/variables.py b/liveweb_arena/plugins/openmeteo/templates/variables.py new file mode 100644 index 0000000..aa39e05 --- /dev/null +++ b/liveweb_arena/plugins/openmeteo/templates/variables.py @@ -0,0 +1,289 @@ +"""Location pool and metric definitions for Open Meteo templates.""" + +from dataclasses import dataclass +from enum import Enum +from typing import Dict, List, Tuple + + +@dataclass(frozen=True) +class City: + """A city with coordinates for Open Meteo API.""" + name: str + country: str + latitude: float + longitude: float + + @property + def display_name(self) -> str: + return self.name + + @property + def coord_key(self) -> str: + """Stable key for GT collector: rounded to 2 decimals.""" + return f"{self.latitude:.2f},{self.longitude:.2f}" + + def docs_url(self) -> str: + """URL for the Open Meteo docs page with this city's coords. + + Uses query params for cache key uniqueness (normalize_url strips + hash fragments) and hash fragment for client-side JS form state. + """ + return ( + f"https://open-meteo.com/en/docs" + f"?latitude={self.latitude}&longitude={self.longitude}" + f"#latitude={self.latitude}&longitude={self.longitude}" + f"¤t=temperature_2m,wind_speed_10m,relative_humidity_2m" + f"&hourly=temperature_2m,precipitation_probability,wind_speed_10m" + f"&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max,sunrise,sunset" + ) + + +# Geographic spread: 170 cities across 6 regions +CITIES: List[City] = [ + # ── Asia (original 10) ── + City("Tokyo", "Japan", 35.6762, 139.6503), + City("Beijing", "China", 39.9042, 116.4074), + City("Seoul", "South Korea", 37.5665, 126.9780), + City("Mumbai", "India", 19.0760, 72.8777), + City("Singapore", "Singapore", 1.3521, 103.8198), + City("Bangkok", "Thailand", 13.7563, 100.5018), + City("Hong Kong", "China", 22.3193, 114.1694), + City("Shanghai", "China", 31.2304, 121.4737), + City("Delhi", "India", 28.7041, 77.1025), + City("Jakarta", "Indonesia", -6.2088, 106.8456), + # ── Europe (original 10) ── + City("London", "UK", 51.5074, -0.1278), + City("Paris", "France", 48.8566, 2.3522), + City("Berlin", "Germany", 52.5200, 13.4050), + City("Madrid", "Spain", 40.4168, -3.7038), + City("Rome", "Italy", 41.9028, 12.4964), + City("Stockholm", "Sweden", 59.3293, 18.0686), + City("Amsterdam", "Netherlands", 52.3676, 4.9041), + City("Prague", "Czech Republic", 50.0755, 14.4378), + City("Athens", "Greece", 37.9838, 23.7275), + City("Helsinki", "Finland", 60.1699, 24.9384), + # ── Americas (original 10) ── + City("New York", "USA", 40.7128, -74.0060), + City("Los Angeles", "USA", 34.0522, -118.2437), + City("Chicago", "USA", 41.8781, -87.6298), + City("Toronto", "Canada", 43.6532, -79.3832), + City("Mexico City", "Mexico", 19.4326, -99.1332), + City("Buenos Aires", "Argentina", -34.6037, -58.3816), + City("Miami", "USA", 25.7617, -80.1918), + City("Seattle", "USA", 47.6062, -122.3321), + City("Vancouver", "Canada", 49.2827, -123.1207), + City("Houston", "USA", 29.7604, -95.3698), + # ── Oceania (original 4) ── + City("Sydney", "Australia", -33.8688, 151.2093), + City("Melbourne", "Australia", -37.8136, 144.9631), + City("Auckland", "New Zealand", -36.8485, 174.7633), + City("Brisbane", "Australia", -27.4698, 153.0251), + # ── Africa / Middle East (original 6) ── + City("Dubai", "UAE", 25.2048, 55.2708), + City("Cairo", "Egypt", 30.0444, 31.2357), + City("Johannesburg", "South Africa", -26.2041, 28.0473), + City("Istanbul", "Turkey", 41.0082, 28.9784), + City("Lagos", "Nigeria", 6.5244, 3.3792), + City("Nairobi", "Kenya", -1.2921, 36.8219), + # ── Asia (add 15) ── + City("Taipei", "Taiwan", 25.03, 121.57), + City("Osaka", "Japan", 34.69, 135.50), + City("Hanoi", "Vietnam", 21.03, 105.85), + City("Kuala Lumpur", "Malaysia", 3.14, 101.69), + City("Kolkata", "India", 22.57, 88.36), + City("Dhaka", "Bangladesh", 23.81, 90.41), + City("Karachi", "Pakistan", 24.86, 67.01), + City("Manila", "Philippines", 14.60, 120.98), + City("Chengdu", "China", 30.57, 104.07), + City("Riyadh", "Saudi Arabia", 24.71, 46.68), + City("Tel Aviv", "Israel", 32.08, 34.78), + City("Doha", "Qatar", 25.29, 51.53), + City("Ho Chi Minh City", "Vietnam", 10.82, 106.63), + City("Ulaanbaatar", "Mongolia", 47.92, 106.91), + City("Almaty", "Kazakhstan", 43.24, 76.95), + # ── Europe (add 15) ── + City("Vienna", "Austria", 48.21, 16.37), + City("Warsaw", "Poland", 52.23, 21.01), + City("Zurich", "Switzerland", 47.38, 8.54), + City("Dublin", "Ireland", 53.35, -6.26), + City("Lisbon", "Portugal", 38.72, -9.14), + City("Copenhagen", "Denmark", 55.68, 12.57), + City("Brussels", "Belgium", 50.85, 4.35), + City("Bucharest", "Romania", 44.43, 26.10), + City("Oslo", "Norway", 59.91, 10.75), + City("Budapest", "Hungary", 47.50, 19.04), + City("Edinburgh", "UK", 55.95, -3.19), + City("Milan", "Italy", 45.46, 9.19), + City("Barcelona", "Spain", 41.39, 2.17), + City("Munich", "Germany", 48.14, 11.58), + City("Reykjavik", "Iceland", 64.15, -21.94), + # ── Americas (add 15) ── + City("Denver", "USA", 39.74, -104.99), + City("Boston", "USA", 42.36, -71.06), + City("Atlanta", "USA", 33.75, -84.39), + City("Phoenix", "USA", 33.45, -112.07), + City("San Francisco", "USA", 37.77, -122.42), + City("Lima", "Peru", -12.05, -77.04), + City("Santiago", "Chile", -33.45, -70.67), + City("Bogota", "Colombia", 4.71, -74.07), + City("Montreal", "Canada", 45.50, -73.57), + City("Havana", "Cuba", 23.11, -82.37), + City("Anchorage", "USA", 61.22, -149.90), + City("Honolulu", "USA", 21.31, -157.86), + City("Portland", "USA", 45.52, -122.68), + City("Minneapolis", "USA", 44.98, -93.27), + City("Nashville", "USA", 36.16, -86.78), + # ── Africa / Middle East (add 10) ── + City("Addis Ababa", "Ethiopia", 9.02, 38.75), + City("Casablanca", "Morocco", 33.57, -7.59), + City("Accra", "Ghana", 5.60, -0.19), + City("Dar es Salaam", "Tanzania", -6.79, 39.28), + City("Algiers", "Algeria", 36.74, 3.06), + City("Tunis", "Tunisia", 36.81, 10.18), + City("Kigali", "Rwanda", -1.94, 30.06), + City("Lusaka", "Zambia", -15.39, 28.32), + City("Maputo", "Mozambique", -25.97, 32.57), + City("Kampala", "Uganda", 0.35, 32.58), + # ── Oceania (add 5) ── + City("Perth", "Australia", -31.95, 115.86), + City("Wellington", "New Zealand", -41.29, 174.78), + City("Christchurch", "New Zealand", -43.53, 172.64), + City("Adelaide", "Australia", -34.93, 138.60), + City("Suva", "Fiji", -18.14, 178.44), + # ── Extreme climates (add 5) ── + City("Novosibirsk", "Russia", 55.03, 82.92), + City("Murmansk", "Russia", 68.97, 33.09), + City("Yakutsk", "Russia", 62.03, 129.73), + City("Manaus", "Brazil", -3.12, -60.02), + City("Lhasa", "China", 29.65, 91.17), + # ── More Americas (add 10) ── + City("Washington DC", "USA", 38.91, -77.04), + City("Dallas", "USA", 32.78, -96.80), + City("Philadelphia", "USA", 39.95, -75.17), + City("Las Vegas", "USA", 36.17, -115.14), + City("San Diego", "USA", 32.72, -117.16), + City("Ottawa", "Canada", 45.42, -75.70), + City("Guadalajara", "Mexico", 20.67, -103.35), + City("Medellin", "Colombia", 6.25, -75.56), + City("Montevideo", "Uruguay", -34.91, -56.19), + City("Quito", "Ecuador", -0.18, -78.47), + # ── More Europe (add 10) ── + City("Marseille", "France", 43.30, 5.37), + City("Hamburg", "Germany", 53.55, 10.00), + City("Lyon", "France", 45.76, 4.84), + City("Krakow", "Poland", 50.06, 19.94), + City("Sofia", "Bulgaria", 42.70, 23.32), + City("Belgrade", "Serbia", 44.79, 20.47), + City("Riga", "Latvia", 56.95, 24.11), + City("Vilnius", "Lithuania", 54.69, 25.28), + City("Tallinn", "Estonia", 59.44, 24.75), + City("Porto", "Portugal", 41.16, -8.63), + # ── More Asia (add 10) ── + City("Shenzhen", "China", 22.54, 114.06), + City("Nagoya", "Japan", 35.18, 136.91), + City("Lahore", "Pakistan", 31.55, 74.35), + City("Jeddah", "Saudi Arabia", 21.49, 39.19), + City("Baku", "Azerbaijan", 40.41, 49.87), + City("Tashkent", "Uzbekistan", 41.30, 69.28), + City("Yangon", "Myanmar", 16.87, 96.20), + City("Phnom Penh", "Cambodia", 11.56, 104.92), + City("Colombo", "Sri Lanka", 6.93, 79.84), + City("Kathmandu", "Nepal", 27.72, 85.32), + # ── More Africa (add 10) ── + City("Kinshasa", "DRC", -4.44, 15.27), + City("Luanda", "Angola", -8.84, 13.23), + City("Dakar", "Senegal", 14.69, -17.44), + City("Abidjan", "Ivory Coast", 5.36, -4.01), + City("Harare", "Zimbabwe", -17.83, 31.05), + City("Rabat", "Morocco", 34.02, -6.84), + City("Windhoek", "Namibia", -22.56, 17.08), + City("Antananarivo", "Madagascar", -18.91, 47.52), + City("Douala", "Cameroon", 4.05, 9.77), + City("Bamako", "Mali", 12.65, -8.00), + # ── More Oceania / Pacific (add 5) ── + City("Darwin", "Australia", -12.46, 130.84), + City("Nadi", "Fiji", -17.78, 177.94), + City("Port Moresby", "Papua New Guinea", -6.21, 147.00), + City("Noumea", "New Caledonia", -22.28, 166.46), + City("Apia", "Samoa", -13.83, -171.76), + # ── More extreme / misc (add 10) ── + City("Tromsoe", "Norway", 69.65, 18.96), + City("Fairbanks", "USA", 64.84, -147.72), + City("Ushuaia", "Argentina", -54.80, -68.30), + City("Barranquilla", "Colombia", 10.96, -74.78), + City("Marrakech", "Morocco", 31.63, -8.01), + City("Sapporo", "Japan", 43.06, 141.35), + City("Vladivostok", "Russia", 43.12, 131.87), + City("Irkutsk", "Russia", 52.29, 104.28), + City("Astana", "Kazakhstan", 51.17, 71.43), + City("Tbilisi", "Georgia", 41.69, 44.80), + # ── Additional cities to reach 170 ── + City("Pune", "India", 18.52, 73.86), + City("Brasilia", "Brazil", -15.79, -47.88), + City("Thessaloniki", "Greece", 40.64, 22.94), + City("Naypyidaw", "Myanmar", 19.76, 96.07), + City("Busan", "South Korea", 35.18, 129.08), + City("Cusco", "Peru", -13.53, -71.97), + City("Zanzibar", "Tanzania", -6.16, 39.19), + City("Reims", "France", 49.25, 4.03), + City("Split", "Croatia", 43.51, 16.44), + City("Bergen", "Norway", 60.39, 5.32), +] + + +class CurrentMetric(Enum): + """Metrics available from current_weather endpoint.""" + TEMPERATURE = ("temperature", "current temperature", "°C") + WIND_SPEED = ("windspeed", "current wind speed", "km/h") + WIND_DIRECTION = ("winddirection", "current wind direction", "°") + + @property + def api_field(self) -> str: + return self.value[0] + + @property + def display_name(self) -> str: + return self.value[1] + + @property + def unit(self) -> str: + return self.value[2] + + +class HourlyMetric(Enum): + """Metrics available from hourly forecast data.""" + TEMPERATURE = ("temperature_2m", "hourly temperature", "°C") + HUMIDITY = ("relative_humidity_2m", "hourly relative humidity", "%") + WIND_SPEED = ("wind_speed_10m", "hourly wind speed", "km/h") + PRECIP_PROBABILITY = ("precipitation_probability", "hourly precipitation probability", "%") + + @property + def api_field(self) -> str: + return self.value[0] + + @property + def display_name(self) -> str: + return self.value[1] + + @property + def unit(self) -> str: + return self.value[2] + + +class DailyMetric(Enum): + """Metrics available from daily forecast data.""" + TEMP_MAX = ("temperature_2m_max", "daily maximum temperature", "°C") + TEMP_MIN = ("temperature_2m_min", "daily minimum temperature", "°C") + PRECIP_PROB_MAX = ("precipitation_probability_max", "daily max precipitation probability", "%") + + @property + def api_field(self) -> str: + return self.value[0] + + @property + def display_name(self) -> str: + return self.value[1] + + @property + def unit(self) -> str: + return self.value[2] diff --git a/liveweb_arena/plugins/stooq/api_client.py b/liveweb_arena/plugins/stooq/api_client.py index 7a3b82d..55ceda4 100644 --- a/liveweb_arena/plugins/stooq/api_client.py +++ b/liveweb_arena/plugins/stooq/api_client.py @@ -11,7 +11,7 @@ import aiohttp -from liveweb_arena.plugins.base_client import RateLimiter +from liveweb_arena.plugins.base_client import APIFetchError, RateLimiter logger = logging.getLogger(__name__) @@ -34,6 +34,19 @@ _negative_cache: contextvars.ContextVar[Optional[set]] = contextvars.ContextVar( "_stooq_negative_cache", default=None ) +_last_failure_metadata: contextvars.ContextVar[Optional[dict]] = contextvars.ContextVar( + "_stooq_last_failure_metadata", default=None +) +_STOOQ_HTTP_TIMEOUT_S = float(os.environ.get("LIVEWEB_STOOQ_HTTP_TIMEOUT_S", "45")) + + +def _get_plugin_cache_root() -> Path: + return Path( + os.environ.get( + "LIVEWEB_SHARED_PLUGIN_CACHE_DIR", + str(Path(__file__).resolve().parents[3] / ".cache" / "plugin-cache"), + ) + ) def _get_negative_cache() -> set: @@ -44,11 +57,46 @@ def _get_negative_cache() -> set: return cache +def _set_last_failure_metadata(metadata: dict[str, Any] | None) -> None: + _last_failure_metadata.set(dict(metadata or {}) if metadata else None) + + +def get_last_failure_metadata() -> dict[str, Any]: + return dict(_last_failure_metadata.get() or {}) + + class StooqRateLimitError(Exception): """Raised when Stooq API rate limit is exceeded.""" pass +def _create_stooq_http_session( + *, + headers: Optional[Dict[str, str]] = None, + timeout: Optional[float] = None, +) -> aiohttp.ClientSession: + """ + Create a direct session for Stooq. + + Stooq is materially less reliable through the current machine proxy path, so + these requests bypass proxy env and allow a longer total timeout for large CSV + responses. + """ + total_timeout = timeout if timeout is not None else _STOOQ_HTTP_TIMEOUT_S + timeout_cfg = aiohttp.ClientTimeout( + total=total_timeout, + sock_connect=min(15, total_timeout), + sock_read=total_timeout, + ) + connector = aiohttp.TCPConnector(ttl_dns_cache=300, limit=8, ssl=False) + return aiohttp.ClientSession( + headers=headers, + timeout=timeout_cfg, + trust_env=False, + connector=connector, + ) + + def _parse_stooq_csv(csv_text: str, symbol: str = "") -> Optional[Dict[str, Any]]: """ Parse Stooq CSV response into price data dict. @@ -174,12 +222,11 @@ async def get_price_data( await _global_csv_limiter.wait() try: - async with aiohttp.ClientSession() as session: + async with _create_stooq_http_session(timeout=max(timeout, _STOOQ_HTTP_TIMEOUT_S), headers={"User-Agent": "Mozilla/5.0"}) as session: params = {"s": symbol, "i": "d"} async with session.get( cls.CSV_URL, params=params, - timeout=aiohttp.ClientTimeout(total=timeout), ) as response: if response.status != 200: logger.warning(f"Stooq error for {symbol}: {response.status}") @@ -247,16 +294,13 @@ async def fetch_cache_api_data() -> Optional[Dict[str, Any]]: failed = 0 # Sequential fetch with global rate limiter — avoid IP bans - async with aiohttp.ClientSession( - headers={"User-Agent": "Mozilla/5.0"}, - ) as session: + async with _create_stooq_http_session(timeout=_STOOQ_HTTP_TIMEOUT_S, headers={"User-Agent": "Mozilla/5.0"}) as session: for symbol in assets: await _global_csv_limiter.wait() try: url = f"https://stooq.com/q/d/l/?s={symbol}&i=d" async with session.get( url, - timeout=aiohttp.ClientTimeout(total=15), ) as response: if response.status != 200: failed += 1 @@ -282,8 +326,12 @@ async def fetch_cache_api_data() -> Optional[Dict[str, Any]]: def _get_file_cache_path() -> Path: """Get path for stooq homepage file cache.""" - cache_dir = os.environ.get("LIVEWEB_CACHE_DIR", "/var/lib/liveweb-arena/cache") - return Path(cache_dir) / "_plugin_init" / "stooq_homepage.json" + return _get_plugin_cache_root() / "stooq" / "homepage.json" + + +def _get_symbol_cache_path(symbol: str) -> Path: + safe_symbol = symbol.replace("/", "_").replace("\\", "_") + return _get_plugin_cache_root() / "stooq" / "symbols" / f"{safe_symbol}.json" def _get_cache_ttl() -> int: @@ -292,6 +340,33 @@ def _get_cache_ttl() -> int: return int(os.environ.get("LIVEWEB_CACHE_TTL", str(DEFAULT_TTL))) +def _load_symbol_cache(symbol: str, *, allow_stale: bool = False) -> Optional[Dict[str, Any]]: + cache_file = _get_symbol_cache_path(symbol) + if not cache_file.exists(): + return None + try: + cached = json.loads(cache_file.read_text()) + except Exception: + return None + fetched_at = float(cached.get("_fetched_at", 0)) + payload = cached.get("data") + if not payload: + return None + age = time.time() - fetched_at + ttl = _get_cache_ttl() + if age <= ttl: + return payload + if allow_stale and age <= ttl + 24 * 3600: + return payload + return None + + +def _save_symbol_cache(symbol: str, data: Dict[str, Any]) -> None: + cache_file = _get_symbol_cache_path(symbol) + cache_file.parent.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps({"data": data, "_fetched_at": time.time()})) + + def _is_file_cache_valid() -> bool: """Check if homepage file cache exists and is within TTL.""" cache_file = _get_file_cache_path() @@ -321,12 +396,23 @@ def initialize_cache(): logger.info("Stooq init: homepage cache valid (quick check)") return - # Acquire file lock — only one process fetches, others wait + strict_eval_mode = os.environ.get("LIVEWEB_RUNTIME_PROFILE", "").strip().lower() == "strict_eval" + + # Acquire file lock — only one process fetches, others wait. + # In strict-eval, this warmup is only an optimization; if another worker is + # already warming the cache, skip instead of blocking the whole episode. lock_path = _get_file_cache_path().with_suffix(".lock") lock_path.parent.mkdir(parents=True, exist_ok=True) fd = open(lock_path, "w") try: - fcntl.flock(fd.fileno(), fcntl.LOCK_EX) # Blocking wait + lock_flags = fcntl.LOCK_EX | (fcntl.LOCK_NB if strict_eval_mode else 0) + try: + fcntl.flock(fd.fileno(), lock_flags) + except BlockingIOError: + if strict_eval_mode: + logger.info("Stooq init: lock busy in strict-eval, skipping warmup") + return + raise # Re-check after acquiring lock — another process may have filled cache if _is_file_cache_valid(): @@ -390,7 +476,7 @@ async def fetch_homepage_api_data() -> Dict[str, Any]: return {"assets": assets} -async def fetch_single_asset_data(symbol: str) -> Optional[Dict[str, Any]]: +async def fetch_single_asset_data(symbol: str) -> Dict[str, Any]: """ Fetch price data for a single asset. @@ -398,48 +484,164 @@ async def fetch_single_asset_data(symbol: str) -> Optional[Dict[str, Any]]: since Stooq's CSV API requires suffixed symbols for some markets. Uses negative cache to avoid repeated requests for symbols with no data. """ - if _rate_limited.get(): - raise StooqRateLimitError("Stooq API rate limited (persistent for this session)") + _set_last_failure_metadata(None) - neg = _get_negative_cache() - if symbol in neg: - return {} - - # Try .us suffix first for bare symbols (canonical form for US stocks) variants = [symbol] if "." not in symbol and not symbol.startswith("^"): variants = [f"{symbol}.us", symbol] + for sym in variants: + cached = _load_symbol_cache(sym) + if cached: + _set_last_failure_metadata(None) + return cached + + if _rate_limited.get(): + for sym in variants: + cached = _load_symbol_cache(sym, allow_stale=True) + if cached: + _set_last_failure_metadata(None) + return cached + metadata = { + "plugin": "stooq", + "failure_stage": "api_fetch", + "failure_type": "rate_limit", + "symbol": symbol, + "request_url": None, + } + _set_last_failure_metadata(metadata) + raise APIFetchError( + "Stooq API rate limited (persistent for this session)", + source="stooq", + metadata=metadata, + ) + + neg = _get_negative_cache() + if symbol in neg: + for sym in variants: + cached = _load_symbol_cache(sym) + if cached: + _set_last_failure_metadata(None) + return cached + metadata = { + "plugin": "stooq", + "failure_stage": "api_fetch", + "failure_type": "negative_cache", + "symbol": symbol, + "request_url": None, + } + _set_last_failure_metadata(metadata) + raise APIFetchError( + f"Stooq API negative cache hit for symbol={symbol}", + source="stooq", + metadata=metadata, + ) + + last_metadata: dict[str, Any] | None = None for sym in variants: await _global_csv_limiter.wait() + url = f"https://stooq.com/q/d/l/?s={sym}&i=d" try: - async with aiohttp.ClientSession() as session: - url = f"https://stooq.com/q/d/l/?s={sym}&i=d" + async with _create_stooq_http_session(timeout=_STOOQ_HTTP_TIMEOUT_S, headers={"User-Agent": "Mozilla/5.0"}) as session: async with session.get( url, - timeout=aiohttp.ClientTimeout(total=15), - headers={"User-Agent": "Mozilla/5.0"}, ) as response: if response.status != 200: + last_metadata = { + "plugin": "stooq", + "failure_stage": "api_fetch", + "failure_type": "http_status", + "symbol": sym, + "request_url": url, + "http_status": response.status, + } continue text = await response.text() if "Exceeded the daily hits limit" in text: _rate_limited.set(True) - raise StooqRateLimitError("Stooq API daily limit exceeded") + cached = _load_symbol_cache(sym, allow_stale=True) + if cached: + _set_last_failure_metadata(None) + return cached + metadata = { + "plugin": "stooq", + "failure_stage": "api_fetch", + "failure_type": "rate_limit", + "symbol": sym, + "request_url": url, + } + _set_last_failure_metadata(metadata) + raise APIFetchError( + "Stooq API daily limit exceeded", + source="stooq", + metadata=metadata, + ) if "No data" in text: + last_metadata = { + "plugin": "stooq", + "failure_stage": "api_fetch", + "failure_type": "no_data", + "symbol": sym, + "request_url": url, + } continue result = _parse_stooq_csv(text, sym) if result: + with contextlib.suppress(Exception): + _save_symbol_cache(sym, result) + _set_last_failure_metadata(None) return result - - except StooqRateLimitError: + last_metadata = { + "plugin": "stooq", + "failure_stage": "csv_parse", + "failure_type": "invalid_csv", + "symbol": sym, + "request_url": url, + } + + except APIFetchError: raise + except asyncio.TimeoutError: + last_metadata = { + "plugin": "stooq", + "failure_stage": "api_fetch", + "failure_type": "timeout", + "symbol": sym, + "request_url": url, + } + cached = _load_symbol_cache(sym, allow_stale=True) + if cached: + _set_last_failure_metadata(None) + return cached except Exception: + last_metadata = { + "plugin": "stooq", + "failure_stage": "api_fetch", + "failure_type": "request_error", + "symbol": sym, + "request_url": url, + } + cached = _load_symbol_cache(sym, allow_stale=True) + if cached: + _set_last_failure_metadata(None) + return cached continue # All variants failed — add to negative cache neg.add(symbol) - return {} + metadata = last_metadata or { + "plugin": "stooq", + "failure_stage": "api_fetch", + "failure_type": "no_data", + "symbol": symbol, + "request_url": None, + } + _set_last_failure_metadata(metadata) + raise APIFetchError( + f"Stooq API returned no data for symbol={symbol}", + source="stooq", + metadata=metadata, + ) diff --git a/liveweb_arena/plugins/stooq/stooq.py b/liveweb_arena/plugins/stooq/stooq.py index 1668c9c..a750a87 100644 --- a/liveweb_arena/plugins/stooq/stooq.py +++ b/liveweb_arena/plugins/stooq/stooq.py @@ -4,11 +4,17 @@ Plugin for financial market data from stooq.com. """ +import asyncio +import os +import threading +from pathlib import Path from typing import Any, Dict, List, Optional from urllib.parse import urlparse, parse_qs from liveweb_arena.plugins.base import BasePlugin -from .api_client import fetch_single_asset_data, fetch_homepage_api_data, initialize_cache +from liveweb_arena.plugins.base_client import APIFetchError +from liveweb_arena.utils.logger import log +from .api_client import fetch_single_asset_data, fetch_homepage_api_data, get_last_failure_metadata, initialize_cache class StooqPlugin(BasePlugin): @@ -27,6 +33,8 @@ class StooqPlugin(BasePlugin): name = "stooq" _known_symbols_cache = None + _quote_warmup_started = False + _quote_warmup_lock = threading.Lock() allowed_domains = [ "stooq.com", @@ -34,8 +42,73 @@ class StooqPlugin(BasePlugin): ] def initialize(self): - """Pre-warm homepage file cache before evaluation starts.""" + """Pre-warm homepage file cache and a few common quote pages.""" initialize_cache() + self._initialize_quote_warmup() + + def _initialize_quote_warmup(self) -> None: + if os.environ.get("LIVEWEB_DISABLE_STOOQ_WARMUP", "").lower() in {"1", "true"}: + return + + with StooqPlugin._quote_warmup_lock: + if StooqPlugin._quote_warmup_started: + return + StooqPlugin._quote_warmup_started = True + + cache_dir = Path( + os.environ.get( + "LIVEWEB_CACHE_DIR", + str(Path(__file__).resolve().parents[3] / ".cache" / "liveweb"), + ) + ) + symbols_raw = os.environ.get("LIVEWEB_STOOQ_WARMUP_SYMBOLS", "jnj.us,aapl.us,^spx") + urls = [ + f"https://stooq.com/q/?s={symbol.strip()}" + for symbol in symbols_raw.split(",") + if symbol.strip() + ] + if not urls: + return + + async def _warm() -> None: + from liveweb_arena.core.cache import CacheManager, PageRequirement + + mgr = CacheManager(cache_dir=cache_dir) + pending = [] + try: + for url in urls: + cached = mgr.get_cached(url) + if cached and not cached.is_expired(mgr.ttl) and cached.is_complete(): + continue + pending.append(PageRequirement.data(url)) + if pending: + try: + await mgr.ensure_cached(pending, self) + except Exception as exc: + log("Stooq", f"Quote warmup skipped after fetch error: {exc}", force=True) + finally: + await mgr.shutdown() + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + try: + asyncio.run(_warm()) + except Exception as exc: + log("Stooq", f"Quote warmup skipped after init error: {exc}", force=True) + return + + def _run_background() -> None: + try: + asyncio.run(_warm()) + except Exception as exc: + log("Stooq", f"Quote warmup background task failed: {exc}", force=True) + + threading.Thread( + target=_run_background, + name="stooq-quote-warmup", + daemon=True, + ).start() def get_blocked_patterns(self) -> List[str]: """Block direct CSV download and ads.""" @@ -44,6 +117,41 @@ def get_blocked_patterns(self) -> List[str]: "*stooq.com/ads/*", # Ad frames ] + def get_stable_url_patterns(self) -> List[str]: + return [ + "/", + "/q/", + "/q/?s=", + "/q/i/?s=", + "/q/d/?s=", + ] + + def classify_url(self, url: str) -> Optional[str]: + parsed = urlparse(url) + host = (parsed.hostname or "").lower() + path = parsed.path.lower() + query = parsed.query.lower() + if "stooq.com" not in host: + return None + if host.startswith("www."): + return "env_tls_error" + if ( + "/q/conv/" in path + or "/s/mst/" in path + or "quote.php" in path + or "/q/plus/" in path + or path.startswith("/q/l/") + or path.startswith("/q/d/l/") + or path.startswith("/q/nl/") + or path.startswith("/t/") + ): + return "model_invalid_url_shape" + if "q=" in query and "s=" not in query and "e=" not in query: + return "model_invalid_url_shape" + if path.startswith("/q/s/"): + return "model_invalid_url_shape" + return None + def get_synthetic_page(self, url: str) -> Optional[str]: """Return synthetic error page for unknown symbols (zero network requests).""" symbol = self._extract_symbol(url) @@ -102,10 +210,19 @@ async def fetch_api_data(self, url: str) -> Dict[str, Any]: if symbol: if symbol not in self._get_known_symbols(): return {} # Unknown symbol — skip API, zero requests - data = await fetch_single_asset_data(symbol) - if not data: - raise ValueError(f"Stooq API returned no data for symbol={symbol}") - return data + try: + return await fetch_single_asset_data(symbol) + except APIFetchError as exc: + metadata = dict(exc.metadata or {}) + metadata.setdefault("plugin", self.name) + metadata.setdefault("symbol", symbol) + metadata.setdefault("page_url", url) + raise APIFetchError( + str(exc), + source=exc.source or self.name, + status_code=exc.status_code, + metadata=metadata or get_last_failure_metadata(), + ) from exc # Homepage - return all assets if self._is_homepage(url): @@ -162,3 +279,9 @@ def _extract_symbol(self, url: str) -> str: return query["e"][0].lower() return "" + + def is_plausible_asset_id(self, url: str) -> bool: + symbol = self._extract_symbol(url) + if not symbol: + return True + return symbol in self._get_known_symbols() diff --git a/liveweb_arena/plugins/taostats/api_client.py b/liveweb_arena/plugins/taostats/api_client.py index 79009ca..e0fc558 100644 --- a/liveweb_arena/plugins/taostats/api_client.py +++ b/liveweb_arena/plugins/taostats/api_client.py @@ -20,6 +20,7 @@ # Conversion factor: rao to TAO (1 TAO = 1e9 rao) RAO_TO_TAO = 1e9 +MAX_API_RETRIES = 3 def _safe_float(value) -> Optional[float]: @@ -117,34 +118,39 @@ async def fetch_all_subnets() -> Dict[str, Any]: try: async with aiohttp.ClientSession() as session: - # Fetch all subnets (paginated, get up to 200) - async with session.get( - f"{API_BASE_URL}/subnets", - params={"limit": 200}, - timeout=aiohttp.ClientTimeout(total=30), - ) as resp: - if resp.status != 200: - body = await resp.text() - raise APIFetchError( - f"status={resp.status}, body={body[:500]}", - source="taostats", - status_code=resp.status, - ) - - data = await resp.json() - results = data.get("results", []) - - for subnet in results: - netuid = str(subnet.get("netuid", "")) - if not netuid or netuid == "0": # Skip root network - continue - - subnets[netuid] = _parse_subnet_data(subnet) + for attempt in range(MAX_API_RETRIES): + try: + async with session.get( + f"{API_BASE_URL}/subnets", + params={"limit": 200}, + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + if resp.status != 200: + body = await resp.text() + raise APIFetchError( + f"status={resp.status}, body={body[:500]}", + source="taostats", + status_code=resp.status, + ) + + data = await resp.json() + results = data.get("results", []) + subnets.clear() + for subnet in results: + netuid = str(subnet.get("netuid", "")) + if not netuid or netuid == "0": + continue + subnets[netuid] = _parse_subnet_data(subnet) + break + except APIFetchError: + raise + except Exception as e: + if attempt == MAX_API_RETRIES - 1: + raise APIFetchError(f"Unexpected error: {e}", source="taostats") from e + await asyncio.sleep(2**attempt) except APIFetchError: raise - except Exception as e: - raise APIFetchError(f"Unexpected error: {e}", source="taostats") from e if not subnets: raise APIFetchError("API returned no subnet data", source="taostats") @@ -167,25 +173,31 @@ async def fetch_single_subnet_data(subnet_id: str) -> Dict[str, Any]: """ try: async with aiohttp.ClientSession() as session: - async with session.get( - f"{API_BASE_URL}/subnets/{subnet_id}", - timeout=aiohttp.ClientTimeout(total=30), - ) as resp: - if resp.status != 200: - body = await resp.text() - raise APIFetchError( - f"status={resp.status} for subnet_id={subnet_id}, body={body[:200]}", - source="taostats", - status_code=resp.status, - ) - - subnet = await resp.json() - return _parse_subnet_data(subnet) + for attempt in range(MAX_API_RETRIES): + try: + async with session.get( + f"{API_BASE_URL}/subnets/{subnet_id}", + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + if resp.status != 200: + body = await resp.text() + raise APIFetchError( + f"status={resp.status} for subnet_id={subnet_id}, body={body[:200]}", + source="taostats", + status_code=resp.status, + ) + + subnet = await resp.json() + return _parse_subnet_data(subnet) + except APIFetchError: + raise + except Exception as e: + if attempt == MAX_API_RETRIES - 1: + raise APIFetchError(f"Failed to fetch subnet {subnet_id}: {e}", source="taostats") from e + await asyncio.sleep(2**attempt) except APIFetchError: raise - except Exception as e: - raise APIFetchError(f"Failed to fetch subnet {subnet_id}: {e}", source="taostats") from e async def fetch_homepage_api_data() -> Dict[str, Any]: @@ -266,7 +278,10 @@ def _filter_by_emission(subnets: Dict[str, Any]) -> Dict[str, Any]: def _get_file_cache_path() -> Path: """Get path for taostats subnet file cache.""" - cache_dir = os.environ.get("LIVEWEB_CACHE_DIR", "/var/lib/liveweb-arena/cache") + cache_dir = os.environ.get( + "LIVEWEB_CACHE_DIR", + str(Path(__file__).resolve().parents[3] / ".cache" / "liveweb"), + ) return Path(cache_dir) / "_plugin_init" / "taostats_subnets.json" diff --git a/liveweb_arena/plugins/taostats/taostats.py b/liveweb_arena/plugins/taostats/taostats.py index 8e5dda7..c9ea1ef 100644 --- a/liveweb_arena/plugins/taostats/taostats.py +++ b/liveweb_arena/plugins/taostats/taostats.py @@ -5,14 +5,35 @@ Uses official taostats.io API for ground truth. """ +import time import re from typing import Any, Dict, List from urllib.parse import urlparse +from liveweb_arena.core.cache import normalize_url from liveweb_arena.plugins.base import BasePlugin from .api_client import fetch_single_subnet_data, fetch_homepage_api_data, initialize_cache +class TaostatsPrefetchSetupError(RuntimeError): + def __init__( + self, + message: str, + *, + prefetch_phase: str, + wait_target: str | None = None, + evidence: dict[str, Any] | None = None, + ): + super().__init__(message) + self.prefetch_phase = prefetch_phase + self.wait_target = wait_target + self.evidence = dict(evidence or {}) + + +_LIST_SHOW_ALL_COOLDOWN_S = 180 +_list_show_all_cooldown_until: dict[str, float] = {} + + class TaostatsPlugin(BasePlugin): """ Taostats plugin for Bittensor network data. @@ -36,6 +57,27 @@ def initialize(self): """Initialize plugin - fetch API data for question generation.""" initialize_cache() + def get_stable_url_patterns(self) -> List[str]: + return [ + "/", + "/subnets", + "/subnets/", + "/subnets//chart", + "/validators", + ] + + def classify_url(self, url: str) -> str | None: + parsed = urlparse(url) + host = (parsed.hostname or "").lower() + path = parsed.path.lower() + if "taostats.io" not in host: + return None + if path in {"", "/", "/subnets", "/validators"}: + return None + if path.startswith("/subnets/"): + return None + return "model_invalid_url_shape" + def get_blocked_patterns(self) -> List[str]: """Block direct API access to force agents to use the website.""" return [ @@ -115,29 +157,226 @@ def _extract_subnet_id(self, url: str) -> str: return "" - async def setup_page_for_cache(self, page, url: str) -> None: + async def _wait_for_minimum_body_text(self, page, *, min_chars: int, timeout_ms: int) -> None: + await page.wait_for_function( + """minChars => { + const text = (document.body?.innerText || '').trim(); + return text.length >= minChars; + }""", + min_chars, + timeout=timeout_ms, + ) + + async def _capture_body_snapshot(self, page) -> Dict[str, Any]: + snapshot = await page.evaluate( + """() => { + const text = (document.body?.innerText || '').trim(); + const title = document.title || ''; + const hasMain = !!document.querySelector('main'); + const hasTable = !!document.querySelector('table, [role="table"], .table, .rt-table'); + const textSample = text.slice(0, 2000); + return { + title, + textLength: text.length, + textSample, + hasMain, + hasTable, + }; + }""" + ) + return dict(snapshot or {}) + + def _is_list_body_ready(self, snapshot: Dict[str, Any]) -> bool: + sample = str(snapshot.get("textSample") or "").lower() + text_length = int(snapshot.get("textLength") or 0) + return bool( + text_length >= 120 + and ( + snapshot.get("hasTable") + or snapshot.get("hasMain") + or "subnet" in sample + or "subnets" in sample + or "netuid" in sample + ) + ) + + def _is_detail_body_ready(self, snapshot: Dict[str, Any]) -> bool: + sample = str(snapshot.get("textSample") or "").lower() + text_length = int(snapshot.get("textLength") or 0) + return bool( + text_length >= 120 + and ( + snapshot.get("hasMain") + or "statistics" in sample + or "subnet" in sample + or "netuid" in sample + or "emission" in sample + or "tempo" in sample + ) + ) + + def _list_show_all_cooldown_active(self, url: str) -> bool: + key = normalize_url(url) + until = _list_show_all_cooldown_until.get(key) + if until is None: + return False + if until > time.monotonic(): + return True + _list_show_all_cooldown_until.pop(key, None) + return False + + def _activate_list_show_all_cooldown(self, url: str) -> None: + _list_show_all_cooldown_until[normalize_url(url)] = time.monotonic() + _LIST_SHOW_ALL_COOLDOWN_S + + async def _best_effort_expand_list(self, page, url: str) -> Dict[str, Any]: + if self._list_show_all_cooldown_active(url): + return { + "interaction_kind": "show_all", + "target_locator": None, + "cooldown_active": True, + "expanded": False, + } + + attempts: list[tuple[str, Any]] = [ + ("text=ALL", page.get_by_text("ALL", exact=True).first), + ("role=button name=ALL", page.get_by_role("button", name="ALL").first), + ] + + last_error: Exception | None = None + last_locator: str | None = None + for locator_name, handle in attempts: + last_locator = locator_name + try: + if await handle.is_visible(timeout=2500): + await handle.click(timeout=2500) + await page.wait_for_timeout(1200) + try: + await page.wait_for_load_state("networkidle", timeout=5000) + except Exception: + pass + return { + "interaction_kind": "show_all", + "target_locator": locator_name, + "cooldown_active": False, + "expanded": True, + } + except Exception as exc: + last_error = exc + + select_locator = "select" + try: + await page.locator(select_locator).first.select_option(label="ALL", timeout=2500) + await page.wait_for_timeout(1200) + return { + "interaction_kind": "show_all", + "target_locator": select_locator, + "cooldown_active": False, + "expanded": True, + } + except Exception as exc: + last_error = exc + + try: + await page.locator(select_locator).first.select_option(value="-1", timeout=2500) + await page.wait_for_timeout(1200) + return { + "interaction_kind": "show_all", + "target_locator": select_locator, + "cooldown_active": False, + "expanded": True, + } + except Exception as exc: + last_error = exc + + self._activate_list_show_all_cooldown(url) + return { + "interaction_kind": "show_all", + "target_locator": last_locator or select_locator, + "cooldown_active": False, + "expanded": False, + "cooldown_applied": True, + "raw_exception_type": type(last_error).__name__ if last_error is not None else None, + "raw_exception_message": str(last_error) if last_error is not None else None, + } + + async def setup_page_for_cache(self, page, url: str) -> Dict[str, Any] | None: """ Setup page before caching - click "ALL" to show all subnets. On taostats.io/subnets, the default view shows only 10-25 rows. Click "ALL" to show all ~128 subnets for complete visibility. """ - if not self._is_list_page(url): - return + if self._is_list_page(url): + metadata: Dict[str, Any] = { + "page_kind": "taostats_list", + "interaction_kind": "show_all", + "selector_syntax_invalid": False, + } + try: + await self._wait_for_minimum_body_text(page, min_chars=120, timeout_ms=8000) + except Exception: + metadata["body_wait_timed_out"] = True - try: - # Click the "ALL" option in the rows selector - # The selector shows: 10, 25, 50, 100, ALL - all_button = page.locator('text="ALL"').first - if await all_button.is_visible(timeout=3000): - await all_button.click() - # Wait for table to update with all rows - await page.wait_for_timeout(2000) - # Wait for network to settle after loading all rows + snapshot = await self._capture_body_snapshot(page) + metadata["page_body_ready"] = self._is_list_body_ready(snapshot) + if metadata["page_body_ready"]: + expand_result = await self._best_effort_expand_list(page, url) + metadata.update(expand_result) + if not expand_result.get("expanded"): + metadata["list_setup_soft_failed"] = True + else: + metadata["list_setup_soft_failed"] = True + return metadata + + if self._extract_subnet_id(url): + try: + await self._wait_for_minimum_body_text(page, min_chars=140, timeout_ms=10000) + except Exception: + pass + + snapshot = await self._capture_body_snapshot(page) + page_body_ready = self._is_detail_body_ready(snapshot) + if not page_body_ready: + raise TaostatsPrefetchSetupError( + "Taostats detail body did not become ready", + prefetch_phase="setup_page_for_cache", + wait_target="detail_body_ready", + evidence={ + "page_kind": "taostats_detail", + "page_body_ready": False, + "detail_setup_soft_failed": False, + }, + ) + + soft_failure: dict[str, Any] | None = None + for selector in ( + "text=Subnet", + "text=Netuid", + "text=Statistics", + "text=Transactions", + "text=Holders", + "text=Price Impact", + ): try: - await page.wait_for_load_state("networkidle", timeout=10000) - except Exception: - pass - except Exception: - # If "ALL" button not found or click fails, continue with default view - pass + await page.locator(selector).first.wait_for(timeout=8000) + soft_failure = None + break + except Exception as exc: + soft_failure = { + "page_kind": "taostats_detail", + "page_body_ready": True, + "detail_setup_soft_failed": True, + "prefetch_phase": "setup_page_for_cache", + "wait_target": selector, + "raw_exception_type": type(exc).__name__, + "raw_exception_message": str(exc), + } + continue + + await page.wait_for_timeout(1200) + return soft_failure or { + "page_kind": "taostats_detail", + "page_body_ready": True, + "detail_setup_soft_failed": False, + "prefetch_phase": "setup_page_for_cache", + } diff --git a/liveweb_arena/utils/llm_client.py b/liveweb_arena/utils/llm_client.py index 849031c..c7c4b4a 100644 --- a/liveweb_arena/utils/llm_client.py +++ b/liveweb_arena/utils/llm_client.py @@ -1,16 +1,21 @@ -"""OpenAI-compatible LLM client with retry and streaming support""" +"""OpenAI-compatible LLM client with retry, streaming, tool calls, and multi-server routing.""" import asyncio -import json +import email.utils +import ipaddress +import os import random +import re import time +import uuid from dataclasses import dataclass, field -from typing import Any, List, Optional, Tuple +from urllib.parse import urlparse +from typing import Any, Dict, List, Optional, Tuple import httpx import openai -from .logger import log, progress, progress_done, is_verbose +from .logger import is_verbose, log, progress, progress_done class LLMFatalError(Exception): @@ -30,6 +35,7 @@ def __init__(self, message: str, original_error: Exception = None, attempts: int @dataclass class ToolCall: """Parsed tool call from LLM response.""" + id: str function: dict # {"name": str, "arguments": str} @@ -37,15 +43,212 @@ class ToolCall: @dataclass class LLMResponse: """Structured LLM response supporting both text and tool_calls.""" + content: str = "" tool_calls: List[ToolCall] = field(default_factory=list) usage: Optional[dict] = None + request_id: Optional[str] = None @property def has_tool_calls(self) -> bool: return len(self.tool_calls) > 0 +@dataclass(frozen=True) +class LLMServerConfig: + """Static config for one OpenAI-compatible endpoint.""" + + server_id: str + base_url: str + api_key: str + model_name: Optional[str] = None + metadata: Optional[Dict[str, object]] = None + + +@dataclass +class _LLMServerState: + config: LLMServerConfig + inflight: int = 0 + requests: int = 0 + failures: int = 0 + total_latency_s: float = 0.0 + + +@dataclass +class _ServerLease: + server_id: str + base_url: str + api_key: str + started_at: float + + +class MultiServerLLMRouter: + """ + Route LLM requests across multiple OpenAI-compatible servers. + + The default policy is sticky + steal: + - assign each route_key a preferred server for cache locality + - allow spilling to the least-loaded server when the preferred one is busier + """ + + def __init__( + self, + servers: List[LLMServerConfig], + route_policy: str = "sticky_steal", + max_inflight_requests: Optional[int] = None, + sticky_slack: int = 0, + sticky_latency_slack_s: float = 10.0, + ): + if not servers: + raise ValueError("MultiServerLLMRouter requires at least one server") + + self._states: Dict[str, _LLMServerState] = { + server.server_id: _LLMServerState(config=server) + for server in servers + } + self._order: List[str] = [server.server_id for server in servers] + self._route_policy = route_policy + self._sticky_slack = max(0, sticky_slack) + self._sticky_latency_slack_s = max(0.0, sticky_latency_slack_s) + self._preferred_server: Dict[str, str] = {} + self._lock = asyncio.Lock() + self._global_semaphore = ( + asyncio.Semaphore(max_inflight_requests) + if max_inflight_requests and max_inflight_requests > 0 + else None + ) + + @property + def base_urls(self) -> List[str]: + return [self._states[server_id].config.base_url for server_id in self._order] + + @property + def primary_base_url(self) -> str: + return self.base_urls[0] + + @classmethod + def from_server_pool_file( + cls, + path: str, + route_policy: str = "sticky_steal", + max_inflight_requests: Optional[int] = None, + sticky_slack: int = 0, + sticky_latency_slack_s: float = 10.0, + default_api_key: Optional[str] = None, + ) -> "MultiServerLLMRouter": + import json + from pathlib import Path + + payload = json.loads(Path(path).read_text()) + servers = [] + for idx, entry in enumerate(payload.get("servers", [])): + server_id = str(entry.get("server_id") or entry.get("id") or f"server-{idx}") + api_key = str(entry.get("api_key") or default_api_key or "") + if not api_key: + raise ValueError(f"Server {server_id} missing api_key") + servers.append( + LLMServerConfig( + server_id=server_id, + base_url=str(entry["base_url"]).rstrip("/"), + api_key=api_key, + model_name=entry.get("model_name"), + metadata=entry, + ) + ) + return cls( + servers=servers, + route_policy=route_policy, + max_inflight_requests=max_inflight_requests, + sticky_slack=sticky_slack, + sticky_latency_slack_s=sticky_latency_slack_s, + ) + + async def acquire(self, route_key: Optional[str] = None) -> _ServerLease: + if self._global_semaphore is not None: + await self._global_semaphore.acquire() + + async with self._lock: + selected_id = self._select_server_id(route_key) + state = self._states[selected_id] + state.inflight += 1 + state.requests += 1 + if route_key: + self._preferred_server.setdefault(route_key, selected_id) + return _ServerLease( + server_id=selected_id, + base_url=state.config.base_url, + api_key=state.config.api_key, + started_at=time.time(), + ) + + async def release(self, lease: _ServerLease, success: bool, latency_s: float): + async with self._lock: + state = self._states[lease.server_id] + state.inflight = max(0, state.inflight - 1) + state.total_latency_s += max(0.0, latency_s) + if not success: + state.failures += 1 + + if self._global_semaphore is not None: + self._global_semaphore.release() + + def snapshot(self) -> Dict[str, object]: + servers = [] + for server_id in self._order: + state = self._states[server_id] + avg_latency = state.total_latency_s / state.requests if state.requests else 0.0 + servers.append( + { + "server_id": server_id, + "base_url": state.config.base_url, + "inflight": state.inflight, + "requests": state.requests, + "failures": state.failures, + "avg_latency_s": avg_latency, + } + ) + return { + "route_policy": self._route_policy, + "num_servers": len(servers), + "servers": servers, + } + + def _select_server_id(self, route_key: Optional[str]) -> str: + states = [self._states[server_id] for server_id in self._order] + least_loaded = min(states, key=self._state_sort_key) + + if self._route_policy != "sticky_steal" or not route_key: + return least_loaded.config.server_id + + preferred_id = self._preferred_server.get(route_key) + if preferred_id is None: + return least_loaded.config.server_id + + preferred = self._states.get(preferred_id) + if preferred is None: + return least_loaded.config.server_id + + preferred_latency = self._avg_latency(preferred) + least_loaded_latency = self._avg_latency(least_loaded) + + if ( + preferred.inflight <= least_loaded.inflight + self._sticky_slack + and preferred_latency <= least_loaded_latency + self._sticky_latency_slack_s + ): + return preferred.config.server_id + + self._preferred_server[route_key] = least_loaded.config.server_id + return least_loaded.config.server_id + + @staticmethod + def _avg_latency(state: _LLMServerState) -> float: + return state.total_latency_s / state.requests if state.requests else 0.0 + + def _state_sort_key(self, state: _LLMServerState): + latency_penalty = min(self._avg_latency(state) / 60.0, 5.0) + return (state.inflight + latency_penalty, state.failures, state.requests) + + class LLMClient: """ OpenAI-compatible LLM client. @@ -54,33 +257,371 @@ class LLMClient: - Streaming support with usage tracking - Exponential backoff retry for recoverable errors - Configurable timeouts + - Optional multi-server routing with sticky + steal """ - # Recoverable error status codes RETRY_STATUS_CODES = {429, 503, 502, 500} - - # Retry configuration - MAX_RETRIES = 10 # Increased for rate limit resilience - BASE_DELAY = 1.0 # seconds - MAX_DELAY = 30.0 # seconds - - # Default timeout per request (should be less than total eval timeout) - DEFAULT_TIMEOUT = 600 # seconds - # Maximum chunks to receive (safety limit, ~32k chunks ≈ ~64k tokens) - MAX_CHUNKS = 32000 - - def __init__(self, base_url: str, api_key: str, default_timeout: int = None): - """ - Initialize LLM client. - - Args: - base_url: OpenAI-compatible API base URL - api_key: API key for authentication - default_timeout: Default request timeout in seconds - """ - self._base_url = base_url.rstrip("/") + MAX_RETRIES = 10 + BASE_DELAY = 1.0 + MAX_DELAY = 30.0 + RATE_LIMIT_MAX_DELAY = float(os.getenv("LIVEWEB_LLM_RATE_LIMIT_MAX_DELAY", "120")) + DEFAULT_TIMEOUT = 600 + MAX_CHUNKS = int(os.getenv("LIVEWEB_LLM_MAX_CHUNKS", "32000")) + _GLOBAL_RATE_LIMIT_UNTIL: Dict[str, float] = {} + _CONTEXT_LENGTH_DETAILS_RE = re.compile( + r"maximum context length of (\d+) tokens.*?" + r"requested a total of (\d+) tokens:\s*" + r"(\d+) tokens from the input messages and (\d+) tokens for the completion", + re.IGNORECASE | re.DOTALL, + ) + + def __init__( + self, + base_url: Optional[str] = None, + api_key: Optional[str] = None, + default_timeout: int = None, + router: Optional[MultiServerLLMRouter] = None, + route_key: Optional[str] = None, + max_retries: Optional[int] = None, + strict_serial: bool = False, + enable_thinking: Optional[bool] = None, + separate_reasoning: Optional[bool] = None, + reasoning_effort: Optional[str] = None, + strip_reasoning_output: bool = False, + ): + if router is None and (not base_url or not api_key): + raise ValueError("LLMClient requires either router or both base_url/api_key") + + self._router = router + self._route_key = route_key + self._base_url = base_url.rstrip("/") if base_url else (router.primary_base_url if router else "") + self._base_urls = router.base_urls if router else [self._base_url] self._api_key = api_key self._default_timeout = default_timeout or self.DEFAULT_TIMEOUT + self._strict_serial = strict_serial + self._max_retries = max(1, int(max_retries or self.MAX_RETRIES)) + self._max_completion_tokens = self._read_max_completion_tokens() + self._enable_thinking = ( + enable_thinking if enable_thinking is not None else self._read_optional_bool_env("LIVEWEB_ENABLE_THINKING") + ) + self._separate_reasoning = ( + separate_reasoning + if separate_reasoning is not None + else self._read_optional_bool_env("LIVEWEB_SEPARATE_REASONING") + ) + self._reasoning_effort = ( + reasoning_effort.strip().lower() + if isinstance(reasoning_effort, str) and reasoning_effort.strip() + else (os.getenv("LIVEWEB_REASONING_EFFORT", "").strip().lower() or None) + ) + self._strip_reasoning_output = bool( + strip_reasoning_output or (os.getenv("LIVEWEB_STRIP_REASONING_OUTPUT", "0") == "1") + ) + self._format_recovery_temperature = self._read_float_env("LIVEWEB_FORMAT_RECOVERY_TEMPERATURE", 0.35) + self._format_recovery_top_p = self._read_float_env("LIVEWEB_FORMAT_RECOVERY_TOP_P", 0.95) + self._last_failure_metadata: Dict[str, object] = {} + if self._strict_serial: + self._max_retries = 1 + + def _raise_strict_serial_error(self, message: str, error: Exception, attempts: int) -> None: + raise LLMFatalError( + message, + original_error=error, + attempts=attempts, + ) from error + + def _generate_request_id(self, request_kind: str) -> str: + route_key = (self._route_key or "default").replace(":", "-").replace("/", "-") + route_key = route_key[:48] + return f"liveweb-{request_kind}-{route_key}-{uuid.uuid4().hex[:12]}" + + def _set_last_failure_metadata(self, metadata: Optional[Dict[str, object]]) -> None: + self._last_failure_metadata = dict(metadata or {}) + + def get_last_failure_metadata(self) -> Dict[str, object]: + return dict(self._last_failure_metadata) + + @staticmethod + def _read_max_completion_tokens() -> Optional[int]: + raw = os.getenv("LIVEWEB_MAX_COMPLETION_TOKENS") + if raw is None or raw == "": + return None + try: + value = int(raw) + except ValueError: + log("LLM", f"Invalid LIVEWEB_MAX_COMPLETION_TOKENS={raw!r}; ignoring") + return None + return value if value > 0 else None + + @staticmethod + def _read_optional_bool_env(name: str) -> Optional[bool]: + raw = os.getenv(name) + if raw is None or raw == "": + return None + normalized = raw.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + log("LLM", f"Invalid {name}={raw!r}; ignoring") + return None + + @staticmethod + def _read_float_env(name: str, default: float) -> float: + raw = os.getenv(name) + if raw is None or raw == "": + return default + try: + return float(raw) + except ValueError: + log("LLM", f"Invalid {name}={raw!r}; using default {default}") + return default + + @classmethod + def _extract_context_length_details(cls, message: str) -> Optional[Tuple[int, int, int, int]]: + match = cls._CONTEXT_LENGTH_DETAILS_RE.search(message) + if not match: + return None + return tuple(int(group) for group in match.groups()) + + @classmethod + def _compute_reduced_completion_cap( + cls, + *, + error: Exception, + current_cap: Optional[int], + ) -> Optional[int]: + details = cls._extract_context_length_details(str(error)) + if details is None: + return None + + context_limit, _requested_total, prompt_tokens, requested_completion = details + reserve_tokens = max(64, min(512, context_limit // 64)) + safe_cap = context_limit - prompt_tokens - reserve_tokens + if current_cap is not None: + safe_cap = min(safe_cap, current_cap - 1) + else: + safe_cap = min(safe_cap, requested_completion - 1) + if safe_cap < 16: + return None + return safe_cap + + @staticmethod + def _is_openrouter_base_url(base_url: str) -> bool: + hostname = (urlparse(base_url).hostname or "").lower() + return hostname == "openrouter.ai" or hostname.endswith(".openrouter.ai") + + @staticmethod + def _is_openrouter_kimi_model(model: str) -> bool: + normalized = (model or "").strip().lower() + return normalized.startswith("moonshotai/kimi-k2.5") + + def _apply_reasoning_controls(self, params: Dict[str, object], *, base_url: str, model: str) -> None: + extra_body = dict(params.get("extra_body") or {}) + if self._enable_thinking is not None: + extra_body["chat_template_kwargs"] = {"enable_thinking": self._enable_thinking} + if self._separate_reasoning is not None: + extra_body["separate_reasoning"] = self._separate_reasoning + reasoning_payload: Dict[str, object] | None = None + if self._enable_thinking is False: + if self._is_openrouter_base_url(base_url) and self._is_openrouter_kimi_model(model): + reasoning_payload = {"enabled": False} + elif self._is_openrouter_base_url(base_url): + reasoning_payload = {"effort": "none", "exclude": True} + else: + reasoning_payload = {"enabled": False} + elif self._reasoning_effort: + reasoning_payload = {"effort": self._reasoning_effort} + if reasoning_payload is not None: + extra_body["reasoning"] = reasoning_payload + if extra_body: + params["extra_body"] = extra_body + + @staticmethod + def _extract_visible_content(message: Any) -> str: + content = getattr(message, "content", "") or "" + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + continue + if not isinstance(item, dict): + text = getattr(item, "text", None) + if text: + parts.append(str(text)) + continue + item_type = str(item.get("type") or "").strip().lower() + if item_type in {"reasoning", "reasoning_content", "thinking"}: + continue + text = item.get("text") + if text is None: + text = item.get("content") + if text: + parts.append(str(text)) + return "".join(parts) + return str(content) + + def _sanitize_usage(self, usage: Optional[dict]) -> Optional[dict]: + if usage is None or not self._strip_reasoning_output: + return usage + sanitized = dict(usage) + for key in ("reasoning_tokens", "reasoning", "reasoning_content"): + sanitized.pop(key, None) + for nested_key in ("completion_tokens_details", "prompt_tokens_details"): + nested = sanitized.get(nested_key) + if isinstance(nested, dict): + nested_copy = dict(nested) + for key in list(nested_copy.keys()): + if "reasoning" in str(key).lower(): + nested_copy.pop(key, None) + sanitized[nested_key] = nested_copy + return sanitized + + async def _wait_for_global_rate_limit_window(self, base_url: str) -> None: + while True: + until = self._GLOBAL_RATE_LIMIT_UNTIL.get(base_url, 0.0) + now = time.time() + if until <= now: + return + await asyncio.sleep(min(5.0, max(0.1, until - now))) + + @staticmethod + def _parse_retry_after(value: object) -> float | None: + if value is None: + return None + text = str(value).strip() + if not text: + return None + try: + seconds = float(text) + return max(0.0, seconds) + except ValueError: + pass + try: + dt = email.utils.parsedate_to_datetime(text) + return max(0.0, dt.timestamp() - time.time()) + except Exception: + return None + + def _compute_rate_limit_delay(self, error: Exception, attempt: int) -> float: + response = getattr(error, "response", None) + headers = getattr(response, "headers", {}) or {} + for key in ( + "retry-after", + "x-ratelimit-reset", + "ratelimit-reset", + "x-ratelimit-reset-requests", + ): + delay = self._parse_retry_after(headers.get(key)) + if delay is not None and delay > 0: + return min(self.RATE_LIMIT_MAX_DELAY, delay + random.uniform(0, 1)) + fallback = min(self.RATE_LIMIT_MAX_DELAY, self.BASE_DELAY * (2 ** min(attempt, 8))) + return fallback + random.uniform(0, 1) + + async def _wait_for_rate_limit_reset( + self, + *, + error: Exception, + attempt: int, + request_id: str | None, + model: str, + base_url: str, + ) -> None: + delay = self._compute_rate_limit_delay(error, attempt) + self._GLOBAL_RATE_LIMIT_UNTIL[base_url] = max( + self._GLOBAL_RATE_LIMIT_UNTIL.get(base_url, 0.0), + time.time() + delay, + ) + self._set_last_failure_metadata( + { + "failure_stage": "chat_with_tools", + "failure_type": "rate_limit", + "model": model, + "base_url": base_url, + "request_id": request_id, + "retry_delay_s": delay, + "attempt": attempt + 1, + } + ) + log("LLM", f"Rate limited; waiting {delay:.1f}s before retry") + await asyncio.sleep(delay) + + @staticmethod + def _server_root_url(base_url: str) -> str: + normalized = base_url.rstrip("/") + if normalized.endswith("/v1"): + return normalized[:-3] + return normalized + + @staticmethod + def _should_bypass_proxy(base_url: str) -> bool: + try: + hostname = (urlparse(base_url).hostname or "").strip() + if not hostname: + return False + if hostname in {"api.aicodemirror.com"}: + return True + if hostname in {"localhost", "127.0.0.1"}: + return True + ip = ipaddress.ip_address(hostname) + return ip.is_private or ip.is_loopback or ip.is_link_local + except ValueError: + return False + + def _build_httpx_client( + self, + *, + base_url: str, + timeout: httpx.Timeout, + ) -> httpx.AsyncClient: + # Local SGLang endpoints should bypass the host proxy, otherwise + # requests to the colocated router/servers can get sent to localhost:10812. + return httpx.AsyncClient( + timeout=timeout, + trust_env=not self._should_bypass_proxy(base_url), + ) + + async def _abort_request( + self, + lease: Optional[_ServerLease], + request_id: Optional[str], + reason: str, + ) -> None: + if lease is None or not request_id: + return + + abort_url = f"{self._server_root_url(lease.base_url)}/abort_request" + headers = { + "Content-Type": "application/json; charset=utf-8", + "Authorization": f"Bearer {lease.api_key}", + "X-API-Key": lease.api_key, + } + try: + async with self._build_httpx_client( + base_url=lease.base_url, + timeout=httpx.Timeout(10.0, connect=5.0), + ) as client: + response = await client.post( + abort_url, + json={ + "rid": request_id, + "abort_all": False, + "abort_message": reason[:200], + }, + headers=headers, + ) + if response.status_code == 200: + log("LLM", f"Abort requested for {request_id} on {lease.server_id}") + else: + log( + "LLM", + f"Abort request for {request_id} on {lease.server_id} returned {response.status_code}", + ) + except Exception as abort_error: + log("LLM", f"Abort request for {request_id} on {lease.server_id} failed: {abort_error}") async def chat( self, @@ -91,87 +632,189 @@ async def chat( seed: Optional[int] = None, timeout_s: int = None, ) -> Tuple[str, Optional[dict]]: - """ - Make a chat completion request. - - Args: - system: System prompt - user: User message - model: Model name - temperature: Sampling temperature - seed: Random seed for reproducibility - timeout_s: Request timeout in seconds (default: use client default) - - Returns: - Tuple of (response content, usage dict or None) - """ - # Use default timeout if not specified actual_timeout = timeout_s if timeout_s is not None else self._default_timeout - # Build messages messages = [] if system: messages.append({"role": "system", "content": system}) messages.append({"role": "user", "content": user}) - # Retry loop with exponential backoff last_error = None - for attempt in range(self.MAX_RETRIES): + attempt = 0 + rate_limit_attempt = 0 + max_completion_tokens_override = None + while True: + lease = None + request_id = None + request_start = time.time() try: - # Wrap in total timeout to prevent runaway requests + lease = await self._acquire_lease() + request_id = self._generate_request_id("chat") content, usage = await asyncio.wait_for( self._make_request( + base_url=lease.base_url, + api_key=lease.api_key, messages=messages, model=model, temperature=temperature, seed=seed, timeout_s=actual_timeout, + request_id=request_id, + max_completion_tokens_override=max_completion_tokens_override, ), timeout=actual_timeout, ) + await self._release_lease(lease, success=True, latency_s=time.time() - request_start) return content, usage except asyncio.TimeoutError: + await self._abort_request( + lease, + request_id, + f"timeout after {actual_timeout}s (attempt {attempt + 1})", + ) + if lease is not None: + await self._release_lease(lease, success=False, latency_s=time.time() - request_start) last_error = TimeoutError(f"LLM request timed out after {actual_timeout}s") - log("LLM", f"Total timeout ({actual_timeout}s) exceeded, attempt {attempt + 1}/{self.MAX_RETRIES}") + if self._strict_serial: + self._raise_strict_serial_error( + f"Strict-serial LLM request timed out after {actual_timeout}s", + last_error, + attempt + 1, + ) + log("LLM", f"Total timeout ({actual_timeout}s) exceeded, attempt {attempt + 1}/{self._max_retries}") await self._backoff(attempt) continue + except asyncio.CancelledError: + await self._abort_request( + lease, + request_id, + f"cancelled (attempt {attempt + 1})", + ) + if lease is not None: + await self._release_lease(lease, success=False, latency_s=time.time() - request_start) + raise + except openai.RateLimitError as e: + await self._abort_request( + lease, + request_id, + f"rate limit (attempt {attempt + 1})", + ) + if lease is not None: + await self._release_lease(lease, success=False, latency_s=time.time() - request_start) last_error = e - log("LLM", f"Rate limit hit, attempt {attempt + 1}/{self.MAX_RETRIES}") + if self._strict_serial: + self._raise_strict_serial_error( + "Strict-serial LLM request hit rate limit", + e, + attempt + 1, + ) + log("LLM", f"Rate limit hit, attempt {attempt + 1}/{self._max_retries}") await self._backoff(attempt) except openai.BadRequestError as e: - # Check for token limit errors - these are unrecoverable + await self._abort_request( + lease, + request_id, + f"bad request (attempt {attempt + 1})", + ) + if lease is not None: + await self._release_lease(lease, success=False, latency_s=time.time() - request_start) error_msg = str(e).lower() - if "is longer than the model" in error_msg or "context_length_exceeded" in error_msg: + has_context_details = self._extract_context_length_details(str(e)) is not None + if ( + "is longer than the model" in error_msg + or "context_length_exceeded" in error_msg + or "maximum context length" in error_msg + or has_context_details + ): + reduced_cap = self._compute_reduced_completion_cap( + error=e, + current_cap=( + max_completion_tokens_override + if max_completion_tokens_override is not None + else self._max_completion_tokens + ), + ) + if reduced_cap is not None: + current_cap = ( + max_completion_tokens_override + if max_completion_tokens_override is not None + else self._max_completion_tokens + ) + if current_cap is not None and reduced_cap >= current_cap: + raise LLMFatalError( + f"Token limit exceeded without room to reduce completion cap: {e}", + original_error=e, + attempts=attempt + 1, + ) + max_completion_tokens_override = reduced_cap + log("LLM", f"Retrying with reduced max_completion_tokens={reduced_cap} after context error") + continue log("LLM", f"Token limit exceeded - fatal error: {e}", force=True) raise LLMFatalError( f"Token limit exceeded: {e}", original_error=e, attempts=attempt + 1, ) - # Other bad request errors - don't retry raise except openai.APIStatusError as e: + await self._abort_request( + lease, + request_id, + f"api status {e.status_code} (attempt {attempt + 1})", + ) + if lease is not None: + await self._release_lease(lease, success=False, latency_s=time.time() - request_start) if e.status_code in self.RETRY_STATUS_CODES: last_error = e - log("LLM", f"API error {e.status_code}, attempt {attempt + 1}/{self.MAX_RETRIES}") + if self._strict_serial: + self._raise_strict_serial_error( + f"Strict-serial LLM API error {e.status_code}", + e, + attempt + 1, + ) + log("LLM", f"API error {e.status_code}, attempt {attempt + 1}/{self._max_retries}") await self._backoff(attempt) else: raise except (httpx.TimeoutException, httpx.ConnectError) as e: + await self._abort_request( + lease, + request_id, + f"connection error (attempt {attempt + 1})", + ) + if lease is not None: + await self._release_lease(lease, success=False, latency_s=time.time() - request_start) last_error = e - log("LLM", f"Connection error, attempt {attempt + 1}/{self.MAX_RETRIES}: {e}") + if self._strict_serial: + self._raise_strict_serial_error( + f"Strict-serial LLM connection error: {e}", + e, + attempt + 1, + ) + log("LLM", f"Connection error, attempt {attempt + 1}/{self._max_retries}: {e}") await self._backoff(attempt) except Exception as e: - # Check for token limit in generic exceptions too + await self._abort_request( + lease, + request_id, + f"client error: {type(e).__name__} (attempt {attempt + 1})", + ) + if lease is not None: + await self._release_lease(lease, success=False, latency_s=time.time() - request_start) error_msg = str(e).lower() - if "is longer than the model" in error_msg or "context_length_exceeded" in error_msg: + if ( + "is longer than the model" in error_msg + or "context_length_exceeded" in error_msg + or "maximum context length" in error_msg + or self._extract_context_length_details(str(e)) is not None + ): log("LLM", f"Token limit exceeded - fatal error: {e}", force=True) raise LLMFatalError( f"Token limit exceeded: {e}", @@ -179,10 +822,15 @@ async def chat( attempts=attempt + 1, ) last_error = e - log("LLM", f"Error, attempt {attempt + 1}/{self.MAX_RETRIES}: {e}") + if self._strict_serial: + self._raise_strict_serial_error( + f"Strict-serial LLM error: {e}", + e, + attempt + 1, + ) + log("LLM", f"Error, attempt {attempt + 1}/{self._max_retries}: {e}") await self._backoff(attempt) - # All retries exhausted raise last_error or Exception("LLM request failed after all retries") async def chat_with_tools( @@ -195,106 +843,361 @@ async def chat_with_tools( seed: Optional[int] = None, timeout_s: int = None, ) -> LLMResponse: - """ - Make a chat completion request with optional tool/function calling support. - - When tools are provided, the LLM may respond with tool_calls instead of - (or in addition to) text content. - - Args: - system: System prompt - user: User message - model: Model name - tools: Optional list of OpenAI-format tool definitions - temperature: Sampling temperature - seed: Random seed for reproducibility - timeout_s: Request timeout in seconds - - Returns: - LLMResponse with content, tool_calls, and usage - """ actual_timeout = timeout_s if timeout_s is not None else self._default_timeout messages = [] if system: messages.append({"role": "system", "content": system}) messages.append({"role": "user", "content": user}) + self._set_last_failure_metadata(None) last_error = None - for attempt in range(self.MAX_RETRIES): + attempt = 0 + rate_limit_attempt = 0 + max_completion_tokens_override = None + while True: + lease = None + request_id = None + request_start = time.time() try: + lease = await self._acquire_lease() + await self._wait_for_global_rate_limit_window(lease.base_url) + request_id = self._generate_request_id("tools") response = await asyncio.wait_for( self._make_request_with_tools( + base_url=lease.base_url, + api_key=lease.api_key, messages=messages, model=model, tools=tools, temperature=temperature, + top_p=None, seed=seed, timeout_s=actual_timeout, + request_id=request_id, + max_completion_tokens_override=max_completion_tokens_override, ), timeout=actual_timeout, ) + await self._release_lease(lease, success=True, latency_s=time.time() - request_start) return response except asyncio.TimeoutError: + await self._abort_request( + lease, + request_id, + f"timeout after {actual_timeout}s (attempt {attempt + 1})", + ) + if lease is not None: + await self._release_lease(lease, success=False, latency_s=time.time() - request_start) last_error = TimeoutError(f"LLM request timed out after {actual_timeout}s") - log("LLM", f"Total timeout ({actual_timeout}s) exceeded, attempt {attempt + 1}/{self.MAX_RETRIES}") + if self._strict_serial: + self._raise_strict_serial_error( + f"Strict-serial LLM request timed out after {actual_timeout}s", + last_error, + attempt + 1, + ) + log("LLM", f"Total timeout ({actual_timeout}s) exceeded, attempt {attempt + 1}/{self._max_retries}") + attempt += 1 + if attempt >= self._max_retries: + raise last_error or Exception("LLM request failed after all retries") await self._backoff(attempt) continue + except asyncio.CancelledError: + await self._abort_request( + lease, + request_id, + f"cancelled (attempt {attempt + 1})", + ) + if lease is not None: + await self._release_lease(lease, success=False, latency_s=time.time() - request_start) + raise + except openai.RateLimitError as e: + await self._abort_request( + lease, + request_id, + f"rate limit (attempt {rate_limit_attempt + 1})", + ) + if lease is not None: + await self._release_lease(lease, success=False, latency_s=time.time() - request_start) last_error = e - log("LLM", f"Rate limit hit, attempt {attempt + 1}/{self.MAX_RETRIES}") - await self._backoff(attempt) + await self._wait_for_rate_limit_reset( + error=e, + attempt=rate_limit_attempt, + request_id=request_id, + model=model, + base_url=lease.base_url if lease is not None else self._base_url, + ) + rate_limit_attempt += 1 + continue except openai.BadRequestError as e: + await self._abort_request( + lease, + request_id, + f"bad request (attempt {attempt + 1})", + ) + if lease is not None: + await self._release_lease(lease, success=False, latency_s=time.time() - request_start) error_msg = str(e).lower() - if "is longer than the model" in error_msg or "context_length_exceeded" in error_msg: + has_context_details = self._extract_context_length_details(str(e)) is not None + if ( + "is longer than the model" in error_msg + or "context_length_exceeded" in error_msg + or "maximum context length" in error_msg + or has_context_details + ): + reduced_cap = self._compute_reduced_completion_cap( + error=e, + current_cap=( + max_completion_tokens_override + if max_completion_tokens_override is not None + else self._max_completion_tokens + ), + ) + if reduced_cap is not None: + current_cap = ( + max_completion_tokens_override + if max_completion_tokens_override is not None + else self._max_completion_tokens + ) + if current_cap is not None and reduced_cap >= current_cap: + raise LLMFatalError( + f"Token limit exceeded without room to reduce completion cap: {e}", + original_error=e, + attempts=attempt + 1, + ) + max_completion_tokens_override = reduced_cap + log("LLM", f"Retrying with reduced max_completion_tokens={reduced_cap} after context error") + continue raise LLMFatalError(f"Token limit exceeded: {e}", original_error=e, attempts=attempt + 1) raise except openai.APIStatusError as e: + await self._abort_request( + lease, + request_id, + f"api status {e.status_code} (attempt {attempt + 1})", + ) + if lease is not None: + await self._release_lease(lease, success=False, latency_s=time.time() - request_start) if e.status_code in self.RETRY_STATUS_CODES: last_error = e - log("LLM", f"API error {e.status_code}, attempt {attempt + 1}/{self.MAX_RETRIES}") + if e.status_code == 429: + await self._wait_for_rate_limit_reset( + error=e, + attempt=rate_limit_attempt, + request_id=request_id, + model=model, + base_url=lease.base_url if lease is not None else self._base_url, + ) + rate_limit_attempt += 1 + continue + if self._strict_serial: + self._raise_strict_serial_error( + f"Strict-serial LLM API error {e.status_code}", + e, + attempt + 1, + ) + log("LLM", f"API error {e.status_code}, attempt {attempt + 1}/{self._max_retries}") + attempt += 1 + if attempt >= self._max_retries: + raise last_error or Exception("LLM request failed after all retries") await self._backoff(attempt) else: raise except (httpx.TimeoutException, httpx.ConnectError) as e: + await self._abort_request( + lease, + request_id, + f"connection error (attempt {attempt + 1})", + ) + if lease is not None: + await self._release_lease(lease, success=False, latency_s=time.time() - request_start) last_error = e - log("LLM", f"Connection error, attempt {attempt + 1}/{self.MAX_RETRIES}: {e}") + if self._strict_serial: + self._raise_strict_serial_error( + f"Strict-serial LLM connection error: {e}", + e, + attempt + 1, + ) + log("LLM", f"Connection error, attempt {attempt + 1}/{self._max_retries}: {e}") + attempt += 1 + if attempt >= self._max_retries: + raise last_error or Exception("LLM request failed after all retries") await self._backoff(attempt) except Exception as e: + await self._abort_request( + lease, + request_id, + f"client error: {type(e).__name__} (attempt {attempt + 1})", + ) + if lease is not None: + await self._release_lease(lease, success=False, latency_s=time.time() - request_start) error_msg = str(e).lower() - if "is longer than the model" in error_msg or "context_length_exceeded" in error_msg: + if ( + "is longer than the model" in error_msg + or "context_length_exceeded" in error_msg + or "maximum context length" in error_msg + or self._extract_context_length_details(str(e)) is not None + ): raise LLMFatalError(f"Token limit exceeded: {e}", original_error=e, attempts=attempt + 1) last_error = e - log("LLM", f"Error, attempt {attempt + 1}/{self.MAX_RETRIES}: {e}") + if self._strict_serial: + self._raise_strict_serial_error( + f"Strict-serial LLM error: {e}", + e, + attempt + 1, + ) + log("LLM", f"Error, attempt {attempt + 1}/{self._max_retries}: {e}") + attempt += 1 + if attempt >= self._max_retries: + raise last_error or Exception("LLM request failed after all retries") await self._backoff(attempt) - raise last_error or Exception("LLM request failed after all retries") + async def chat_with_tools_recovery( + self, + model: str, + messages: Optional[list] = None, + system: str = "", + user: str = "", + assistant_prefix: str = "", + tools: Optional[List[dict]] = None, + seed: Optional[int] = None, + timeout_s: int = None, + max_new_tokens: Optional[int] = None, + ) -> LLMResponse: + actual_timeout = timeout_s if timeout_s is not None else self._default_timeout + + if messages is None: + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": user}) + remediation = ( + "The previous assistant output had invalid tool-call formatting. " + "Emit exactly one valid tool call now. No explanation, no prose, no markdown, no XML." + ) + if assistant_prefix: + messages.append({"role": "assistant", "content": assistant_prefix}) + remediation = ( + "Continue from the assistant's last output and emit exactly one valid tool call now. " + "No explanation, no prose, no markdown, no XML." + ) + messages.append( + { + "role": "user", + "content": remediation, + } + ) + self._set_last_failure_metadata(None) + + last_error = None + for attempt in range(self._max_retries): + lease = None + request_id = None + request_start = time.time() + try: + lease = await self._acquire_lease() + await self._wait_for_global_rate_limit_window(lease.base_url) + request_id = self._generate_request_id("format-recovery") + response = await asyncio.wait_for( + self._make_request_with_tools( + base_url=lease.base_url, + api_key=lease.api_key, + messages=messages, + model=model, + tools=tools, + temperature=self._format_recovery_temperature, + top_p=self._format_recovery_top_p, + seed=seed, + timeout_s=actual_timeout, + request_id=request_id, + max_completion_tokens_override=max_new_tokens, + ), + timeout=actual_timeout, + ) + await self._release_lease(lease, success=True, latency_s=time.time() - request_start) + return response + except Exception as e: + await self._abort_request( + lease, + request_id, + f"format recovery error: {type(e).__name__} (attempt {attempt + 1})", + ) + if lease is not None: + await self._release_lease(lease, success=False, latency_s=time.time() - request_start) + last_error = e + if isinstance(e, openai.RateLimitError) or ( + isinstance(e, openai.APIStatusError) and getattr(e, "status_code", None) == 429 + ): + await self._wait_for_rate_limit_reset( + error=e, + attempt=rate_limit_attempt, + request_id=request_id, + model=model, + base_url=lease.base_url if lease is not None else self._base_url, + ) + rate_limit_attempt += 1 + continue + if self._strict_serial: + self._raise_strict_serial_error( + f"Strict-serial format recovery error: {e}", + e, + attempt + 1, + ) + attempt += 1 + if attempt >= self._max_retries: + raise last_error or Exception("LLM format recovery failed after all retries") + await self._backoff(attempt) + raise last_error or Exception("LLM format recovery failed after all retries") + + async def _acquire_lease(self) -> _ServerLease: + if self._router is not None: + return await self._router.acquire(route_key=self._route_key) + return _ServerLease( + server_id="single", + base_url=self._base_url, + api_key=self._api_key, + started_at=time.time(), + ) + + async def _release_lease(self, lease: _ServerLease, success: bool, latency_s: float): + if self._router is not None: + await self._router.release(lease, success=success, latency_s=latency_s) async def _make_request_with_tools( self, + base_url: str, + api_key: str, messages: list, model: str, tools: Optional[List[dict]], temperature: float, + top_p: Optional[float], seed: Optional[int], timeout_s: int, + request_id: str, + max_completion_tokens_override: Optional[int] = None, ) -> LLMResponse: - """Make a single API request with optional tool calling (non-streaming for tool support).""" timeout_config = httpx.Timeout( - connect=30.0, read=timeout_s, write=30.0, pool=30.0, + connect=30.0, + read=timeout_s, + write=30.0, + pool=30.0, ) + http_client = self._build_httpx_client(base_url=base_url, timeout=timeout_config) client = openai.AsyncOpenAI( - base_url=self._base_url, - api_key=self._api_key, + base_url=base_url, + api_key=api_key, timeout=timeout_config, max_retries=0, + http_client=http_client, ) try: @@ -303,67 +1206,115 @@ async def _make_request_with_tools( "messages": messages, "temperature": temperature, } + if top_p is not None: + params["top_p"] = top_p if tools: params["tools"] = tools if seed is not None: params["seed"] = seed + effective_max_completion_tokens = ( + max_completion_tokens_override + if max_completion_tokens_override is not None + else self._max_completion_tokens + ) + if effective_max_completion_tokens is not None: + # Pass both names for compatibility across OpenAI-compatible servers. + params["max_tokens"] = effective_max_completion_tokens + params["max_completion_tokens"] = effective_max_completion_tokens + params["extra_body"] = {"request_id": request_id} + self._apply_reasoning_controls(params, base_url=base_url, model=model) start_time = time.time() response = await client.chat.completions.create(**params) elapsed = time.time() - start_time if is_verbose(): - log("LLM", f"Tool call response in {elapsed:.1f}s") + log("LLM", f"Tool call response in {elapsed:.1f}s ({request_id})") choice = response.choices[0] if response.choices else None if not choice: + self._set_last_failure_metadata( + { + "failure_stage": "chat_with_tools", + "failure_type": "no_choices", + "model": model, + "base_url": base_url, + "request_id": request_id, + "attempt": 1, + } + ) raise ValueError("LLM returned no choices") - content = choice.message.content or "" + content = self._extract_visible_content(choice.message) parsed_tool_calls = [] - if choice.message.tool_calls: for tc in choice.message.tool_calls: - parsed_tool_calls.append(ToolCall( - id=tc.id, - function={"name": tc.function.name, "arguments": tc.function.arguments}, - )) + parsed_tool_calls.append( + ToolCall( + id=tc.id, + function={"name": tc.function.name, "arguments": tc.function.arguments}, + ) + ) - usage = response.usage.model_dump() if response.usage else None + usage = self._sanitize_usage(response.usage.model_dump() if response.usage else None) if not content and not parsed_tool_calls: + self._set_last_failure_metadata( + { + "failure_stage": "chat_with_tools", + "failure_type": "empty_response", + "model": model, + "base_url": base_url, + "request_id": getattr(response, "id", request_id), + "finish_reason": getattr(choice, "finish_reason", None), + "usage": usage, + "choice_message_preview": { + "content": content[:200], + "tool_call_count": len(parsed_tool_calls), + }, + "attempt": 1, + } + ) raise ValueError("LLM returned empty response (no content, no tool_calls)") - return LLMResponse(content=content.strip(), tool_calls=parsed_tool_calls, usage=usage) + return LLMResponse( + content=content.strip(), + tool_calls=parsed_tool_calls, + usage=usage, + request_id=getattr(response, "id", request_id), + ) finally: await client.close() async def _make_request( self, + base_url: str, + api_key: str, messages: list, model: str, temperature: float, seed: Optional[int], timeout_s: int, + request_id: str, + max_completion_tokens_override: Optional[int] = None, ) -> Tuple[str, Optional[dict]]: - """Make a single API request with streaming""" - # Use longer timeouts for connection and read timeout_config = httpx.Timeout( - connect=30.0, # Connection timeout - read=timeout_s, # Read timeout (for streaming) - write=30.0, # Write timeout - pool=30.0, # Pool timeout + connect=30.0, + read=timeout_s, + write=30.0, + pool=30.0, ) + http_client = self._build_httpx_client(base_url=base_url, timeout=timeout_config) client = openai.AsyncOpenAI( - base_url=self._base_url, - api_key=self._api_key, + base_url=base_url, + api_key=api_key, timeout=timeout_config, - max_retries=0, # We handle retries ourselves + max_retries=0, + http_client=http_client, ) try: - # Build request parameters params = { "model": model, "messages": messages, @@ -371,41 +1322,59 @@ async def _make_request( "stream": True, "stream_options": {"include_usage": True}, } - if seed is not None: params["seed"] = seed + effective_max_completion_tokens = ( + max_completion_tokens_override + if max_completion_tokens_override is not None + else self._max_completion_tokens + ) + if effective_max_completion_tokens is not None: + # Pass both names for compatibility across OpenAI-compatible servers. + params["max_tokens"] = effective_max_completion_tokens + params["max_completion_tokens"] = effective_max_completion_tokens + params["extra_body"] = {"request_id": request_id} + self._apply_reasoning_controls(params, base_url=base_url, model=model) - # Make streaming request start_time = time.time() stream = await client.chat.completions.create(**params) - # Collect streamed content and usage content_parts = [] usage = None chunk_count = 0 - last_progress = 0 + last_progress = 0.0 async for chunk in stream: chunk_count += 1 - - # Safety limit on chunks if chunk_count > self.MAX_CHUNKS: - log("LLM", f"Chunk limit exceeded ({self.MAX_CHUNKS}), truncating response") + log("LLM", f"Chunk limit exceeded ({self.MAX_CHUNKS}), aborting {request_id}") + await self._abort_request( + _ServerLease( + server_id="stream-local", + base_url=base_url, + api_key=api_key, + started_at=start_time, + ), + request_id, + f"chunk limit exceeded ({self.MAX_CHUNKS})", + ) break - if chunk.choices and chunk.choices[0].delta.content: - content_parts.append(chunk.choices[0].delta.content) + if chunk.choices: + delta = chunk.choices[0].delta + delta_content = self._extract_visible_content(delta) + if delta_content: + content_parts.append(delta_content) if chunk.usage: - usage = chunk.usage.model_dump() + usage = self._sanitize_usage(chunk.usage.model_dump()) - # Update progress every second elapsed = time.time() - start_time if is_verbose() and elapsed - last_progress >= 1.0: last_progress = elapsed progress("LLM", elapsed, timeout_s, f"chunks:{chunk_count}") if is_verbose() and last_progress > 0: - progress_done("LLM", f"Done in {time.time()-start_time:.1f}s, {chunk_count} chunks") + progress_done("LLM", f"Done in {time.time() - start_time:.1f}s, {chunk_count} chunks") content = "".join(content_parts) if not content: @@ -413,14 +1382,11 @@ async def _make_request( return content.strip(), usage finally: - # CRITICAL: Close the client to release HTTP connections - # Without this, connections accumulate and eventually exhaust the pool await client.close() async def _backoff(self, attempt: int): - """Exponential backoff with jitter""" delay = min( self.BASE_DELAY * (2 ** attempt) + random.uniform(0, 1), - self.MAX_DELAY + self.MAX_DELAY, ) await asyncio.sleep(delay) diff --git a/scripts/batch_eval.py b/scripts/batch_eval.py new file mode 100644 index 0000000..9de16f2 --- /dev/null +++ b/scripts/batch_eval.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +"""High-throughput batch evaluation for LiveWeb Arena.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import random +import traceback +import sys +from collections import Counter, defaultdict +from datetime import datetime +from pathlib import Path +from statistics import mean +from typing import Any + +from dotenv import load_dotenv + +load_dotenv(override=True) + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from env import Actor +from liveweb_arena.core.task_registry import TaskRegistry, parse_task_id +from liveweb_arena.utils.llm_client import MultiServerLLMRouter + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Batch evaluation for LiveWeb Arena") + parser.add_argument("--model", type=str, required=True, help="LLM model name") + parser.add_argument("--base-url", type=str, default=None, help="OpenAI-compatible API base URL") + parser.add_argument( + "--server-pool-file", + type=str, + default=None, + help="Optional server_pool.json for multi-server routing; overrides --base-url for request routing", + ) + parser.add_argument("--api-key", type=str, default=None, help="API key (default: API_KEY env var)") + parser.add_argument("--num-prompts", type=int, default=200, help="Number of prompts/tasks to evaluate") + parser.add_argument("--seed", type=int, default=42, help="Seed used to sample deterministic task ids") + parser.add_argument("--task-ids-file", type=str, default=None, help="Optional newline-delimited task id file") + parser.add_argument("--output-dir", type=str, default=str(PROJECT_ROOT / "eval"), help="Output directory") + parser.add_argument("--output-prefix", type=str, default="batch_eval", help="Output file prefix") + parser.add_argument("--max-concurrency", type=int, default=32, help="Max concurrent evaluations") + parser.add_argument("--timeout", type=int, default=1800, help="Per-task timeout in seconds") + parser.add_argument("--temperature", type=float, default=0.0, help="LLM temperature") + parser.add_argument("--max-steps", type=int, default=None, help="Per-task max browser steps") + parser.add_argument("--registry-version", type=str, default=None, help="Override TASK_REGISTRY_VERSION for sampling") + parser.add_argument( + "--exclude-plugins", + type=str, + default="", + help="Comma separated plugin names to exclude from sampled tasks", + ) + parser.add_argument( + "--include-plugins", + type=str, + default="", + help="Comma separated plugin names to keep; empty means all", + ) + parser.add_argument( + "--min-unique-plugins", + type=int, + default=1, + help="Minimum number of unique plugins a sampled task must contain", + ) + parser.add_argument("--verbose", action="store_true", help="Print per-task progress") + parser.add_argument( + "--flush-every", + type=int, + default=10, + help="Write partial results/summary every N completed tasks", + ) + return parser + + +def _load_task_ids(args: argparse.Namespace) -> list[int]: + if args.task_ids_file: + task_ids = [ + int(line.strip()) + for line in Path(args.task_ids_file).read_text().splitlines() + if line.strip() + ] + return task_ids[: args.num_prompts] + + include_plugins = {p.strip() for p in args.include_plugins.split(",") if p.strip()} + exclude_plugins = {p.strip() for p in args.exclude_plugins.split(",") if p.strip()} + rng = random.Random(args.seed) + + candidate_combo_indices: list[int] = [] + for combo_index, template_ids in enumerate(TaskRegistry._combinations): + plugins = {TaskRegistry.TEMPLATES[tid][0] for tid in template_ids} + if len(plugins) < args.min_unique_plugins: + continue + if include_plugins and not (plugins & include_plugins): + continue + if exclude_plugins and (plugins & exclude_plugins): + continue + candidate_combo_indices.append(combo_index) + + rng.shuffle(candidate_combo_indices) + if not candidate_combo_indices: + return [] + + selected: list[int] = [] + used_task_ids: set[int] = set() + round_index = 0 + while len(selected) < args.num_prompts: + added_in_round = 0 + for combo_index in candidate_combo_indices: + variation_seed = ( + args.seed + combo_index + round_index * 9973 + ) % TaskRegistry.TASK_IDS_PER_COMBO + task_id = combo_index * TaskRegistry.TASK_IDS_PER_COMBO + variation_seed + 1 + if task_id in used_task_ids: + continue + selected.append(task_id) + used_task_ids.add(task_id) + added_in_round += 1 + if len(selected) >= args.num_prompts: + break + if added_in_round == 0: + break + round_index += 1 + return selected + + +async def _run_one( + actor: Actor, + task_id: int, + args: argparse.Namespace, + max_concurrency: int, +) -> dict[str, Any]: + result = await actor.evaluate( + model=args.model, + base_url=args.base_url, + api_key=args.api_key, + task_id=task_id, + timeout=args.timeout, + temperature=args.temperature, + max_steps=args.max_steps, + max_concurrency=max_concurrency, + mode="eval", + route_key=f"batch-eval:{args.seed}:task:{task_id}", + ) + result.setdefault("extra", {}) + result["extra"]["task_id"] = task_id + return result + + +def _summarize(results: list[dict[str, Any]]) -> dict[str, Any]: + scores = [float(r.get("score", 0.0)) for r in results] + successes = [bool(r.get("success", False)) for r in results] + errored = [r for r in results if r.get("error")] + by_plugin_scores: dict[str, list[float]] = defaultdict(list) + by_plugin_success: dict[str, list[int]] = defaultdict(list) + by_plugin_errors: Counter[str] = Counter() + + for result in results: + cfg = parse_task_id(int(result.get("extra", {}).get("task_id"))) + plugins = sorted({plugin for plugin, _name in cfg["templates"]}) + plugin_key = "+".join(plugins) + by_plugin_scores[plugin_key].append(float(result.get("score", 0.0))) + by_plugin_success[plugin_key].append(1 if result.get("success", False) else 0) + if result.get("error"): + by_plugin_errors[plugin_key] += 1 + + return { + "num_tasks": len(results), + "mean_score": mean(scores) if scores else 0.0, + "success_rate": (sum(successes) / len(successes)) if successes else 0.0, + "error_rate": (len(errored) / len(results)) if results else 0.0, + "num_errors": len(errored), + "by_plugin": { + plugin: { + "count": len(vals), + "mean_score": mean(vals) if vals else 0.0, + "success_rate": (sum(by_plugin_success[plugin]) / len(by_plugin_success[plugin])) + if by_plugin_success[plugin] + else 0.0, + "num_errors": by_plugin_errors[plugin], + } + for plugin, vals in sorted(by_plugin_scores.items()) + }, + } + + +def _write_results_jsonl(path: Path, results: list[dict[str, Any]]) -> None: + with path.open("w", encoding="utf-8") as f: + for result in results: + f.write(json.dumps(result, ensure_ascii=False) + "\n") + + +def _build_summary( + results: list[dict[str, Any]], + *, + task_ids_path: Path, + result_path: Path, + args: argparse.Namespace, + status: str, + error_path: Path | None = None, +) -> dict[str, Any]: + summary = _summarize(results) + summary["status"] = status + summary["task_ids_path"] = str(task_ids_path) + summary["results_path"] = str(result_path) + summary["model"] = args.model + summary["base_url"] = args.base_url + summary["server_pool_file"] = args.server_pool_file + summary["max_concurrency"] = args.max_concurrency + summary["registry_version"] = os.environ.get("TASK_REGISTRY_VERSION") + if error_path is not None: + summary["error_path"] = str(error_path) + return summary + + +def _write_summary( + path: Path, + results: list[dict[str, Any]], + *, + task_ids_path: Path, + result_path: Path, + args: argparse.Namespace, + status: str, + error_path: Path | None = None, +) -> dict[str, Any]: + summary = _build_summary( + results, + task_ids_path=task_ids_path, + result_path=result_path, + args=args, + status=status, + error_path=error_path, + ) + path.write_text(json.dumps(summary, indent=2, ensure_ascii=False)) + return summary + + +async def _maybe_cleanup_actor(actor: Actor) -> None: + cleanup = getattr(actor, "cleanup", None) + if cleanup is not None: + maybe_awaitable = cleanup() + if asyncio.iscoroutine(maybe_awaitable): + await maybe_awaitable + return + + browser = getattr(actor, "browser", None) + if browser is not None and hasattr(browser, "close"): + maybe_awaitable = browser.close() + if asyncio.iscoroutine(maybe_awaitable): + await maybe_awaitable + + +async def main() -> int: + args = _build_parser().parse_args() + + if args.registry_version: + os.environ["TASK_REGISTRY_VERSION"] = args.registry_version + + api_key = args.api_key or os.getenv("API_KEY") or os.getenv("CHUTES_API_KEY") + if not api_key: + print("API key required via --api-key or API_KEY/CHUTES_API_KEY", file=sys.stderr) + return 1 + args.api_key = api_key + if not args.base_url and not args.server_pool_file: + print("Either --base-url or --server-pool-file is required.", file=sys.stderr) + return 1 + + task_ids = _load_task_ids(args) + if not task_ids: + print("No task ids selected for evaluation.", file=sys.stderr) + return 1 + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y_%m_%d_%H_%M_%S") + result_path = output_dir / f"{args.output_prefix}_{timestamp}.jsonl" + summary_path = output_dir / f"{args.output_prefix}_{timestamp}.summary.json" + task_ids_path = output_dir / f"{args.output_prefix}_{timestamp}.task_ids.txt" + task_ids_path.write_text("\n".join(str(tid) for tid in task_ids) + "\n") + + print(f"Evaluating {len(task_ids)} tasks with concurrency={args.max_concurrency}") + print(f"Task IDs saved to: {task_ids_path}") + + llm_router = None + if args.server_pool_file: + llm_router = MultiServerLLMRouter.from_server_pool_file( + args.server_pool_file, + default_api_key=api_key, + max_inflight_requests=args.max_concurrency, + ) + actor = Actor(api_key=api_key, use_cache=True, llm_router=llm_router) + semaphore = asyncio.Semaphore(args.max_concurrency) + results: list[dict[str, Any]] = [] + uncaught_error: Exception | None = None + error_path = output_dir / f"{args.output_prefix}_{timestamp}.error.txt" + + async def guarded(task_id: int) -> dict[str, Any]: + async with semaphore: + result = await _run_one(actor, task_id, args, args.max_concurrency) + if args.verbose: + print( + f"task_id={task_id} score={result.get('score', 0.0):.3f} " + f"success={result.get('success', False)} error={bool(result.get('error'))}" + ) + return result + + try: + for coro in asyncio.as_completed([guarded(task_id) for task_id in task_ids]): + results.append(await coro) + if len(results) % args.flush_every == 0 or len(results) == len(task_ids): + _write_results_jsonl(result_path, results) + _write_summary( + summary_path, + results, + task_ids_path=task_ids_path, + result_path=result_path, + args=args, + status="running" if len(results) < len(task_ids) else "completed", + ) + print(f"Completed {len(results)}/{len(task_ids)} tasks") + except Exception as exc: + uncaught_error = exc + error_path.write_text(traceback.format_exc()) + finally: + try: + await _maybe_cleanup_actor(actor) + except Exception: + cleanup_trace = traceback.format_exc() + if error_path.exists(): + error_path.write_text(error_path.read_text() + "\n\nCleanup error:\n" + cleanup_trace) + else: + error_path.write_text("Cleanup error:\n" + cleanup_trace) + + _write_results_jsonl(result_path, results) + final_status = "failed" if uncaught_error is not None else "completed" + summary = _write_summary( + summary_path, + results, + task_ids_path=task_ids_path, + result_path=result_path, + args=args, + status=final_status, + error_path=error_path if error_path.exists() else None, + ) + + print(json.dumps(summary, indent=2, ensure_ascii=False)) + print(f"Results saved to: {result_path}") + print(f"Summary saved to: {summary_path}") + if uncaught_error is not None: + print(f"Error details saved to: {error_path}", file=sys.stderr) + raise uncaught_error + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/scripts/reachability_audit_report.py b/scripts/reachability_audit_report.py new file mode 100644 index 0000000..c49ceb6 --- /dev/null +++ b/scripts/reachability_audit_report.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections import Counter +from dataclasses import asdict +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from liveweb_arena.core.reachability_audit import ReachabilityAuditResult, classify_model_hallucination +from liveweb_arena.core.site_probe import probe_site +from liveweb_arena.plugins import get_all_plugins + + +SITE_UNREACHABLE_RE = re.compile(r"Required site unreachable:\s+(https?://\S+)") +DOMAIN_UNREACHABLE_RE = re.compile(r"Required domain unreachable:\s+(https?://\S+)") +REACHABILITY_AUDIT_RE = re.compile(r"\[ReachabilityAudit\]\s+(\{.*\})") + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Audit unreachable URL causes from LiveWeb logs") + parser.add_argument("--log-file", type=str, required=True, help="Driver log file to analyze") + parser.add_argument( + "--max-unique-urls", + type=int, + default=200, + help="Maximum number of unique URLs to probe", + ) + parser.add_argument( + "--output-json", + type=str, + default="", + help="Optional path to write full JSON report", + ) + parser.add_argument( + "--show-top-urls", + type=int, + default=20, + help="How many top URLs to show in the terminal summary", + ) + parser.add_argument( + "--probe-timeout", + type=float, + default=3.0, + help="Timeout in seconds for each direct site probe", + ) + return parser + + +def _extract_unreachable_urls(log_path: Path) -> Counter[str]: + counts: Counter[str] = Counter() + with log_path.open("r", encoding="utf-8", errors="ignore") as handle: + for line in handle: + match = SITE_UNREACHABLE_RE.search(line) or DOMAIN_UNREACHABLE_RE.search(line) + if match: + counts[match.group(1)] += 1 + return counts + + +def _extract_reachability_audits(log_path: Path) -> list[dict[str, Any]]: + audits: list[dict[str, Any]] = [] + with log_path.open("r", encoding="utf-8", errors="ignore") as handle: + for line in handle: + match = REACHABILITY_AUDIT_RE.search(line) + if not match: + continue + try: + audits.append(json.loads(match.group(1))) + except Exception: + continue + return audits + + +def _infer_plugin(url: str) -> tuple[str | None, Any | None]: + host = (urlparse(url).hostname or "").lower() + candidates: list[tuple[int, int, str, Any]] = [] + for name, plugin_cls in get_all_plugins().items(): + for allowed in getattr(plugin_cls, "allowed_domains", []): + allowed = allowed.lower() + if host == allowed or host.endswith("." + allowed) or allowed.endswith("." + host): + # Prefer exact/specific domains first, and keep hybrid last. + hybrid_penalty = 1 if name == "hybrid" else 0 + candidates.append((hybrid_penalty, -len(allowed), name, plugin_cls)) + if candidates: + _penalty, _neg_len, name, plugin_cls = sorted(candidates)[0] + try: + return name, plugin_cls() + except Exception: + return name, None + return None, None + + +def _classify_from_probe( + url: str, + plugin_name: str | None, + plugin: Any | None, + *, + probe_timeout: float, +) -> ReachabilityAuditResult: + model_class = None + if plugin is not None and hasattr(plugin, "classify_url"): + model_class = plugin.classify_url(url) + model_class = model_class or classify_model_hallucination(url) + if model_class is not None: + is_env = model_class.startswith("env_") or model_class.startswith("ambiguous_") + return ReachabilityAuditResult( + status="unreachable", + classification=model_class, + layer="model" if not is_env else "tls", + url=url, + normalized_url=url, + domain=(urlparse(url).hostname or "").lower(), + plugin_name=plugin_name, + reason="plugin/url-shape classification", + is_environment_failure=is_env, + is_model_hallucination=not is_env, + evidence={}, + ) + + probe = probe_site(url, timeout=probe_timeout) + evidence = {"site_probe": asdict(probe)} + domain = (urlparse(url).hostname or "").lower() + + if probe.ok and (probe.http_status or 0) < 400: + classification = "env_browser_navigation_failure" + layer = "browser" + env_failure = True + model_hallucination = False + elif probe.exception_type == "SSLError": + classification = "env_tls_error" + layer = "tls" + env_failure = True + model_hallucination = False + elif probe.exception_type in {"ConnectTimeout", "ReadTimeout", "Timeout"}: + classification = "env_prefetch_timeout" + layer = "browser" + env_failure = True + model_hallucination = False + elif probe.exception_type in {"ConnectionError"}: + classification = "env_dns_or_connect_error" + layer = "browser" + env_failure = True + model_hallucination = False + elif probe.http_status == 403 and ("coingecko" in domain or probe.cf_ray or (probe.server or "").lower() == "cloudflare"): + classification = "env_cdn_blocked" + layer = "cdn" + env_failure = True + model_hallucination = False + elif probe.http_status is not None and 400 <= probe.http_status < 500: + classification = "env_http_4xx" + layer = "cdn" + env_failure = True + model_hallucination = False + elif probe.http_status is not None and probe.http_status >= 500: + classification = "env_http_5xx" + layer = "cdn" + env_failure = True + model_hallucination = False + else: + classification = "ambiguous_navigation_failure" + layer = "browser" + env_failure = True + model_hallucination = False + + return ReachabilityAuditResult( + status="unreachable", + classification=classification, + layer=layer, + url=url, + normalized_url=url, + domain=domain, + plugin_name=plugin_name, + reason=probe.reason, + http_status=probe.http_status, + exception_type=probe.exception_type, + is_environment_failure=env_failure, + is_model_hallucination=model_hallucination, + evidence=evidence, + ) + + +def _weighted_ratio(counter: Counter[str], total: int) -> dict[str, float]: + if total <= 0: + return {} + return {key: value / total for key, value in counter.most_common()} + + +def _print_summary(report: dict[str, Any], show_top_urls: int) -> None: + print("\n**Reachability Audit Summary**") + print(f"log_file: {report['log_file']}") + print(f"total_unreachable_events: {report['total_unreachable_events']}") + print(f"unique_unreachable_urls: {report['unique_unreachable_urls']}") + print(f"environment_failure_ratio: {report['environment_failure_ratio']:.4f}") + print(f"model_hallucination_ratio: {report['model_hallucination_ratio']:.4f}") + + print("\nTop classifications:") + for classification, ratio in report["classification_ratios"].items(): + count = report["classification_counts"][classification] + print(f" {classification}: {count} ({ratio:.4f})") + + print("\nEnvironment-only details:") + for classification, ratio in report["environment_detail_ratios"].items(): + count = report["environment_detail_counts"][classification] + print(f" {classification}: {count} ({ratio:.4f})") + + if report.get("page_kind_counts"): + print("\nPage kind breakdown:") + for key, count in report["page_kind_counts"].items(): + print(f" {key}: {count}") + + if report.get("prefetch_phase_counts"): + print("\nPrefetch phase breakdown:") + for key, count in report["prefetch_phase_counts"].items(): + print(f" {key}: {count}") + + if report.get("audit_classification_counts"): + print("\nStructured audit classifications:") + for key, count in report["audit_classification_counts"].items(): + print(f" {key}: {count}") + + if report.get("audit_domain_counts"): + print("\nStructured audit domains:") + for key, count in report["audit_domain_counts"].items(): + print(f" {key}: {count}") + + print("\nTop unreachable URLs:") + for item in report["top_urls"][:show_top_urls]: + print( + " " + f"{item['count']:>3}x {item['url']} -> {item['classification']} " + f"(plugin={item['plugin_name']}, layer={item['layer']})" + ) + + +def main() -> int: + args = _build_parser().parse_args() + log_path = Path(args.log_file) + url_counts = _extract_unreachable_urls(log_path) + audit_rows = _extract_reachability_audits(log_path) + + most_common_urls = url_counts.most_common(args.max_unique_urls) + total_events = sum(url_counts.values()) + + classification_counts: Counter[str] = Counter() + env_detail_counts: Counter[str] = Counter() + plugin_counts: Counter[str] = Counter() + page_kind_counts: Counter[str] = Counter() + prefetch_phase_counts: Counter[str] = Counter() + audit_classification_counts: Counter[str] = Counter() + audit_domain_counts: Counter[str] = Counter() + env_failures = 0 + model_hallucinations = 0 + rows: list[dict[str, Any]] = [] + + for audit in audit_rows: + classification = str(audit.get("classification") or "") + domain = str(audit.get("domain") or "") + evidence = dict(audit.get("evidence") or {}) + page_kind = evidence.get("page_kind") + prefetch_phase = evidence.get("prefetch_phase") + if classification: + audit_classification_counts[classification] += 1 + if domain: + audit_domain_counts[domain] += 1 + if page_kind: + page_kind_counts[str(page_kind)] += 1 + if prefetch_phase: + prefetch_phase_counts[str(prefetch_phase)] += 1 + + for url, count in most_common_urls: + plugin_name, plugin = _infer_plugin(url) + audit = _classify_from_probe(url, plugin_name, plugin, probe_timeout=args.probe_timeout) + classification_counts[audit.classification] += count + if audit.is_environment_failure: + env_detail_counts[audit.classification] += count + env_failures += count + if audit.is_model_hallucination: + model_hallucinations += count + if plugin_name: + plugin_counts[plugin_name] += count + row = audit.to_dict() + row["count"] = count + rows.append(row) + + report = { + "log_file": str(log_path), + "total_unreachable_events": total_events, + "unique_unreachable_urls": len(url_counts), + "audited_unique_urls": len(rows), + "environment_failure_ratio": (env_failures / total_events) if total_events else 0.0, + "model_hallucination_ratio": (model_hallucinations / total_events) if total_events else 0.0, + "classification_counts": dict(classification_counts), + "classification_ratios": _weighted_ratio(classification_counts, total_events), + "environment_detail_counts": dict(env_detail_counts), + "environment_detail_ratios": _weighted_ratio(env_detail_counts, total_events), + "audit_classification_counts": dict(audit_classification_counts), + "audit_domain_counts": dict(audit_domain_counts), + "page_kind_counts": dict(page_kind_counts), + "prefetch_phase_counts": dict(prefetch_phase_counts), + "plugin_counts": dict(plugin_counts), + "top_urls": sorted(rows, key=lambda item: item["count"], reverse=True), + } + + if args.output_json: + output_path = Path(args.output_json) + output_path.write_text(json.dumps(report, indent=2, ensure_ascii=False)) + + _print_summary(report, args.show_top_urls) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..74bbaed --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,59 @@ +import sys +import types +import os + +USE_REAL_BROWSER_SMOKE = os.getenv("LIVEWEB_REAL_BROWSER_SMOKE") == "1" + +if not USE_REAL_BROWSER_SMOKE: + playwright_module = types.ModuleType("playwright") + async_api_module = types.ModuleType("playwright.async_api") + + + async def _async_playwright(): + raise RuntimeError("playwright is stubbed for unit tests") + + + async_api_module.async_playwright = _async_playwright + async_api_module.Browser = object + async_api_module.BrowserContext = object + async_api_module.Page = object + async_api_module.Playwright = object + + playwright_module.async_api = async_api_module + + sys.modules.setdefault("playwright", playwright_module) + sys.modules.setdefault("playwright.async_api", async_api_module) + + +openai_module = types.ModuleType("openai") + + +class _OpenAIError(Exception): + pass + + +class RateLimitError(_OpenAIError): + pass + + +class BadRequestError(_OpenAIError): + pass + + +class APIStatusError(_OpenAIError): + def __init__(self, status_code=500, *args, **kwargs): + super().__init__(*args) + self.status_code = status_code + + +class AsyncOpenAI: + def __init__(self, *args, **kwargs): + raise RuntimeError("openai.AsyncOpenAI must be monkeypatched in tests") + + +openai_module.RateLimitError = RateLimitError +openai_module.BadRequestError = BadRequestError +openai_module.APIStatusError = APIStatusError +openai_module.AsyncOpenAI = AsyncOpenAI + +sys.modules.setdefault("openai", openai_module) diff --git a/tests/core/test_actor_session_setup.py b/tests/core/test_actor_session_setup.py new file mode 100644 index 0000000..3ae6698 --- /dev/null +++ b/tests/core/test_actor_session_setup.py @@ -0,0 +1,179 @@ +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from env import Actor, _maybe_promote_disallowed_domain_failure +from liveweb_arena.core.task_registry_loader import parse_task_id as parse_task_id_for_runtime + + +class _FakePlugin: + allowed_domains = ["stooq.com"] + + def is_url_allowed(self, url: str) -> bool: + return "stooq.com" in url + + +class _FakeSession: + def __init__(self, *, fail_once: bool = False): + self._fail_once = fail_once + self.closed = False + + async def set_cache_interceptor(self, interceptor): + if self._fail_once: + self._fail_once = False + raise RuntimeError("Target page, context or browser has been closed") + return None + + async def close(self): + self.closed = True + + +class _FakeBrowser: + def __init__(self): + self.calls = 0 + self.sessions = [_FakeSession(fail_once=True), _FakeSession(fail_once=False)] + self.ensure_calls = 0 + + async def ensure_healthy(self): + self.ensure_calls += 1 + + async def new_session(self): + session = self.sessions[self.calls] + self.calls += 1 + return session + + +class _FakeInterceptor: + def __init__(self, metadata): + self._metadata = metadata + + def get_last_blocked_document_metadata(self): + return dict(self._metadata) + + +@pytest.mark.anyio +async def test_setup_interceptor_rebuilds_session_after_transport_error(tmp_path): + actor = Actor(api_key="local", cache_dir=tmp_path, use_cache=True, llm_router=None) + actor.browser = _FakeBrowser() + + session, interceptor = await actor._setup_interceptor( + session=await actor.browser.new_session(), + cached_pages={}, + allowed_domains={"stooq.com"}, + blocked_patterns=[], + plugins_used={"stooq": _FakePlugin()}, + ) + + assert interceptor is not None + assert actor.browser.calls == 2 + assert actor.browser.ensure_calls == 1 + assert session is actor.browser.sessions[1] + assert actor.browser.sessions[0].closed is True + + +def test_actor_build_protocol_defaults_to_eval_mode(tmp_path): + actor = Actor( + api_key="local", + cache_dir=tmp_path, + use_cache=True, + llm_router=None, + llm_prompt_profile="gpt54_strict_domains", + ) + + assert actor.runtime_profile == "strict_eval" + + eval_prompt = actor._build_protocol(runtime_profile="strict_eval").build_step_prompt( + obs=type("Obs", (), {"url": "https://example.com", "title": "t", "accessibility_tree": "tree"})(), + trajectory=[], + current_step=1, + max_steps=5, + ) + collect_prompt = actor._build_protocol(runtime_profile="fast_collect").build_step_prompt( + obs=type("Obs", (), {"url": "https://example.com", "title": "t", "accessibility_tree": "tree"})(), + trajectory=[], + current_step=1, + max_steps=5, + ) + + assert "Never call stop with {\"answers\": {}}." not in eval_prompt + assert "Never call stop with {\"answers\": {}}." in collect_prompt + + +def test_maybe_promote_disallowed_domain_failure_only_in_collect_mode(): + interceptor = _FakeInterceptor( + { + "blocked_url": "https://finance.yahoo.com/quote/V/", + "blocked_domain": "finance.yahoo.com", + "allowed_domains": ["coingecko.com"], + } + ) + trajectory = [ + type( + "Step", + (), + { + "observation": type( + "Obs", + (), + { + "url": "https://finance.yahoo.com/quote/V/", + "accessibility_tree": "Domain not allowed", + }, + )() + }, + )() + ] + + eval_failure, eval_audit = _maybe_promote_disallowed_domain_failure( + interceptor=interceptor, + trajectory=trajectory, + allowed_domains={"coingecko.com"}, + failure_reason="browser_error", + reachability_audit=None, + mode="eval", + ) + collect_failure, collect_audit = _maybe_promote_disallowed_domain_failure( + interceptor=interceptor, + trajectory=trajectory, + allowed_domains={"coingecko.com"}, + failure_reason="browser_error", + reachability_audit=None, + mode="collect", + ) + + assert eval_failure == "browser_error" + assert eval_audit is None + assert collect_failure == "disallowed_domain" + assert collect_audit is not None + assert collect_audit.classification == "model_disallowed_domain" + + +def test_strict_eval_can_use_external_task_registry(monkeypatch, tmp_path): + registry_root = tmp_path / "upstream_like" + registry_dir = registry_root / "liveweb_arena" / "core" + registry_dir.mkdir(parents=True) + (registry_dir / "task_registry.py").write_text( + "def parse_task_id(task_id):\n" + " return {\n" + " 'task_id': task_id,\n" + " 'combo_index': 0,\n" + " 'template_ids': (999,),\n" + " 'templates': [('coingecko', 'coingecko_price')],\n" + " 'variation_seed': 7,\n" + " 'num_tasks': 3,\n" + " }\n" + "\n" + "def max_task_id():\n" + " return 999999\n", + encoding="utf-8", + ) + monkeypatch.setenv("LIVEWEB_STRICT_TASK_REGISTRY_DIR", str(registry_root)) + + parsed = parse_task_id_for_runtime(123, runtime_profile="strict_eval") + assert parsed["templates"] == [("coingecko", "coingecko_price")] + assert parsed["num_tasks"] == 3 diff --git a/tests/core/test_agent_loop_recovery.py b/tests/core/test_agent_loop_recovery.py new file mode 100644 index 0000000..a10bff4 --- /dev/null +++ b/tests/core/test_agent_loop_recovery.py @@ -0,0 +1,1131 @@ +import pytest + +from liveweb_arena.core.agent_loop import AgentLoop, BrowserFatalError +from liveweb_arena.core.agent_protocol import FunctionCallingProtocol +from liveweb_arena.core.models import BrowserObservation, CompositeTask +from liveweb_arena.utils.llm_client import LLMResponse, ToolCall + + +class _FakeSession: + async def goto(self, url: str): + return BrowserObservation(url=url, title="Blank", accessibility_tree="root") + + async def execute_action(self, action): + return BrowserObservation(url="https://example.com", title="Done", accessibility_tree="root") + + def get_last_navigation_metadata(self): + return None + + +class _FakeSessionWithBlockedSequence: + def __init__(self, observations, metadatas): + self._observations = list(observations) + self._metadatas = list(metadatas) + self._last_navigation_metadata = None + + async def goto(self, url: str): + self._last_navigation_metadata = None + return BrowserObservation(url=url, title="Blank", accessibility_tree="root") + + async def execute_action(self, action): + self._last_navigation_metadata = self._metadatas.pop(0) + return self._observations.pop(0) + + def get_last_navigation_metadata(self): + return self._last_navigation_metadata + + def clear_last_navigation_metadata(self): + self._last_navigation_metadata = None + + +class _FakeSessionWithActionSequence: + def __init__(self, outcomes, metadatas): + self._outcomes = list(outcomes) + self._metadatas = list(metadatas) + self._last_navigation_metadata = None + + async def goto(self, url: str): + self._last_navigation_metadata = None + return BrowserObservation( + url=url, + title="Blank", + accessibility_tree="root content with enough accessible text to avoid blank observation failfast", + ) + + async def execute_action(self, action): + self._last_navigation_metadata = self._metadatas.pop(0) + outcome = self._outcomes.pop(0) + if isinstance(outcome, BaseException): + raise outcome + return outcome + + def get_last_navigation_metadata(self): + return self._last_navigation_metadata + + def clear_last_navigation_metadata(self): + self._last_navigation_metadata = None + + +class _FakeSubTask: + plugin_name = "coingecko" + answer_tag = "a1" + expected_steps = 1 + + +class _FakeSubTask2: + plugin_name = "taostats" + answer_tag = "a2" + expected_steps = 1 + + +class _FakeLLMClient: + def __init__(self, initial_response, recovery_responses=None): + self.initial_response = initial_response + self.recovery_responses = list(recovery_responses or []) + self.recovery_calls = 0 + self.recovery_messages = [] + + async def chat_with_tools(self, **kwargs): + return self.initial_response + + async def chat_with_tools_recovery(self, **kwargs): + self.recovery_calls += 1 + self.recovery_messages.append(kwargs.get("messages")) + return self.recovery_responses.pop(0) + + +class _QueuedLLMClient: + def __init__(self, responses, recovery_responses=None): + self._responses = list(responses) + self._recovery_responses = list(recovery_responses or []) + self.recovery_calls = 0 + self.recovery_messages = [] + + async def chat_with_tools(self, **kwargs): + return self._responses.pop(0) + + async def chat_with_tools_recovery(self, **kwargs): + self.recovery_calls += 1 + self.recovery_messages.append(kwargs.get("messages")) + if not self._recovery_responses: + raise AssertionError("Recovery should not be called in this test") + return self._recovery_responses.pop(0) + + def get_last_failure_metadata(self): + return {} + + +def _task(): + return CompositeTask( + subtasks=[_FakeSubTask()], + combined_intent="Find the answer", + plugin_hints={}, + seed=1, + ) + + +def _task_two_answers(): + return CompositeTask( + subtasks=[_FakeSubTask(), _FakeSubTask2()], + combined_intent="Find the answers", + plugin_hints={}, + seed=1, + ) + + +@pytest.mark.anyio +async def test_agent_loop_recovers_from_truncated_tool_json(monkeypatch): + monkeypatch.setenv("LIVEWEB_ENABLE_FORMAT_RECOVERY", "1") + monkeypatch.setenv("LIVEWEB_FORMAT_RECOVERY_MAX_RETRIES", "2") + monkeypatch.setenv("LIVEWEB_FORMAT_RECOVERY_MAX_NEW_TOKENS", "64") + + llm_client = _FakeLLMClient( + initial_response=LLMResponse(content="{\"name\":\"goto\",\"arguments\":{\"url\":\"https://example.com\"}"), + recovery_responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_1", function={"name": "stop", "arguments": "{\"answers\":{\"a1\":\"42\"}}"})]), + ], + ) + loop = AgentLoop( + session=_FakeSession(), + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=2, + ) + + trajectory, final_answer, usage = await loop.run(task=_task(), model="qwen", temperature=0.0, seed=1) + + assert final_answer == {"answers": {"a1": "42"}} + assert loop.is_parse_failed() is False + stats = loop.get_format_recovery_stats() + assert stats["format_recovery_attempts"] == 1 + assert stats["format_recovery_successes"] == 1 + assert llm_client.recovery_calls == 1 + assert trajectory[-1].action.action_type == "stop" + messages = llm_client.recovery_messages[0] + assistant_messages = [item for item in messages if item["role"] == "assistant"] + assert assistant_messages == [] + assert messages[-1]["role"] == "user" + assert "invalid tool-call formatting" in messages[-1]["content"].lower() + + +@pytest.mark.anyio +async def test_agent_loop_does_not_recover_terminal_natural_language(monkeypatch): + monkeypatch.setenv("LIVEWEB_ENABLE_FORMAT_RECOVERY", "1") + + llm_client = _FakeLLMClient( + initial_response=LLMResponse(content="I will inspect the page and then answer in plain English."), + recovery_responses=[], + ) + loop = AgentLoop( + session=_FakeSession(), + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=2, + ) + + trajectory, final_answer, usage = await loop.run(task=_task(), model="qwen", temperature=0.0, seed=1) + + assert final_answer is None + assert loop.is_parse_failed() is True + stats = loop.get_format_recovery_stats() + assert stats["format_recovery_attempts"] == 0 + assert llm_client.recovery_calls == 0 + assert trajectory[-1].action is None + + +@pytest.mark.anyio +async def test_agent_loop_recovers_terminal_natural_language_for_kimi_in_collect_mode(monkeypatch): + monkeypatch.setenv("LIVEWEB_NATURAL_LANGUAGE_PARSE_RECOVERY_MAX_RETRIES", "1") + + llm_client = _FakeLLMClient( + initial_response=LLMResponse(content="I found the page and will answer next."), + recovery_responses=[ + LLMResponse( + tool_calls=[ + ToolCall( + id="call_1", + function={"name": "stop", "arguments": "{\"answers\":{\"a1\":\"42\"}}"}, + ) + ] + ), + ], + ) + loop = AgentLoop( + session=_FakeSession(), + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=2, + behavior_mode="collect", + ) + + trajectory, final_answer, _usage = await loop.run( + task=_task(), model="moonshotai/kimi-k2.5", temperature=0.0, seed=1 + ) + + assert final_answer == {"answers": {"a1": "42"}} + assert loop.is_parse_failed() is False + stats = loop.get_local_recovery_stats() + assert stats["natural_language_parse_recovery_attempts"] == 1 + assert stats["natural_language_parse_recovery_successes"] == 1 + assert llm_client.recovery_calls == 1 + assert len(trajectory) == 1 + assert trajectory[0].action.action_type == "stop" + + +@pytest.mark.anyio +async def test_agent_loop_marks_parse_failed_after_recovery_exhausted(monkeypatch): + monkeypatch.setenv("LIVEWEB_ENABLE_FORMAT_RECOVERY", "1") + monkeypatch.setenv("LIVEWEB_FORMAT_RECOVERY_MAX_RETRIES", "2") + + llm_client = _FakeLLMClient( + initial_response=LLMResponse(content="{\"name\":\"goto\""), + recovery_responses=[ + LLMResponse(content="{\"name\":\"goto\""), + LLMResponse(content="{\"name\":\"goto\""), + ], + ) + loop = AgentLoop( + session=_FakeSession(), + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=2, + ) + + trajectory, final_answer, usage = await loop.run(task=_task(), model="qwen", temperature=0.0, seed=1) + + assert final_answer is None + assert loop.is_parse_failed() is True + stats = loop.get_format_recovery_stats() + assert stats["format_recovery_attempts"] == 1 + assert stats["format_recovery_successes"] == 0 + + +def test_trim_recovery_messages_truncates_large_recent_observation(monkeypatch): + monkeypatch.setenv("LIVEWEB_FORMAT_RECOVERY_CONTEXT_LENGTH", "1024") + monkeypatch.setenv("LIVEWEB_FORMAT_RECOVERY_TOKEN_MARGIN", "128") + + loop = AgentLoop( + session=_FakeSession(), + llm_client=_FakeLLMClient(initial_response=LLMResponse(content="")), + protocol=FunctionCallingProtocol(), + max_steps=2, + ) + giant_observation = "Observation:\n" + ("A" * 12000) + "\nTail marker" + messages = [ + {"role": "system", "content": "system"}, + {"role": "assistant", "content": "{\"name\":\"goto\"}"}, + {"role": "user", "content": giant_observation}, + {"role": "user", "content": "Emit exactly one valid tool call now."}, + ] + + trimmed, overflowed = loop._trim_recovery_messages(messages=messages, max_new_tokens=64) + + assert overflowed is True + assert trimmed is not None + assert loop._estimate_message_tokens(trimmed) <= (1024 - 64 - 128) + assert trimmed[-2]["role"] == "user" + assert "Tail marker" in trimmed[-2]["content"] + assert len(trimmed[-2]["content"]) < len(giant_observation) + assert "[truncated for format recovery]" in trimmed[-2]["content"] + + +@pytest.mark.anyio +async def test_attempt_format_recovery_skips_when_budget_is_non_positive(monkeypatch): + monkeypatch.setenv("LIVEWEB_FORMAT_RECOVERY_CONTEXT_LENGTH", "128") + monkeypatch.setenv("LIVEWEB_FORMAT_RECOVERY_TOKEN_MARGIN", "128") + + llm_client = _FakeLLMClient( + initial_response=LLMResponse(content="{\"name\":\"goto\""), + recovery_responses=[], + ) + loop = AgentLoop( + session=_FakeSession(), + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=2, + ) + + raw_response, action, usage, failure_override = await loop._attempt_format_recovery( + model="qwen", + seed=1, + raw_response="{\"name\":\"goto\"", + messages=[ + {"role": "system", "content": "system"}, + {"role": "user", "content": "Find the answer"}, + {"role": "user", "content": "Emit one tool call"}, + ], + failure_class="recoverable_partial_json", + ) + + assert action is None + assert usage is None + assert failure_override == "recoverable_context_overflow" + stats = loop.get_local_recovery_stats() + assert llm_client.recovery_calls == 0 + assert stats.get("format_recovery_exhausted", 0) == 0 + + +@pytest.mark.anyio +async def test_agent_loop_limits_empty_recovery_retries(monkeypatch): + monkeypatch.setenv("LIVEWEB_ENABLE_FORMAT_RECOVERY", "1") + monkeypatch.setenv("LIVEWEB_FORMAT_RECOVERY_MAX_RETRIES", "4") + monkeypatch.setenv("LIVEWEB_FORMAT_RECOVERY_EMPTY_MAX_RETRIES", "2") + + llm_client = _FakeLLMClient( + initial_response=LLMResponse(content=""), + recovery_responses=[ + LLMResponse(content=""), + LLMResponse(content=""), + ], + ) + loop = AgentLoop( + session=_FakeSession(), + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=2, + ) + + trajectory, final_answer, usage = await loop.run(task=_task(), model="qwen", temperature=0.0, seed=1) + + assert final_answer is None + assert loop.is_parse_failed() is True + stats = loop.get_format_recovery_stats() + assert stats["format_recovery_attempts"] == 1 + assert stats["format_recovery_exhausted"] == 1 + assert llm_client.recovery_calls == 2 + + +@pytest.mark.anyio +async def test_agent_loop_skips_recovery_when_context_budget_exceeded(monkeypatch): + monkeypatch.setenv("LIVEWEB_ENABLE_FORMAT_RECOVERY", "1") + monkeypatch.setenv("LIVEWEB_FORMAT_RECOVERY_CONTEXT_LENGTH", "64") + monkeypatch.setenv("LIVEWEB_FORMAT_RECOVERY_MAX_NEW_TOKENS", "32") + monkeypatch.setenv("LIVEWEB_FORMAT_RECOVERY_TOKEN_MARGIN", "16") + + llm_client = _FakeLLMClient( + initial_response=LLMResponse(content="{\"name\":\"goto\""), + recovery_responses=[], + ) + loop = AgentLoop( + session=_FakeSession(), + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=2, + ) + long_obs = BrowserObservation(url="https://example.com", title="Long", accessibility_tree="x" * 2000) + loop._trajectory = [] + messages = loop._build_recovery_messages( + system_prompt="sys", + user_prompt=f"obs {long_obs.accessibility_tree}", + failure_class="recoverable_truncated_tool_json", + ) + + raw, action, usage, override = await loop._attempt_format_recovery( + model="qwen", + seed=1, + raw_response="{\"name\":\"goto\"", + messages=messages, + failure_class="recoverable_truncated_tool_json", + ) + + assert action is None + assert usage is None + assert override == "recoverable_context_overflow" + assert llm_client.recovery_calls == 0 + + +@pytest.mark.anyio +async def test_agent_loop_explicit_disable_recovery_overrides_env(monkeypatch): + monkeypatch.setenv("LIVEWEB_ENABLE_FORMAT_RECOVERY", "1") + + llm_client = _FakeLLMClient( + initial_response=LLMResponse(content="{\"name\":\"goto\""), + recovery_responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_1", function={"name": "stop", "arguments": "{\"answers\":{\"a1\":\"42\"}}"})]), + ], + ) + loop = AgentLoop( + session=_FakeSession(), + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=2, + enable_format_recovery=False, + ) + + trajectory, final_answer, usage = await loop.run(task=_task(), model="qwen", temperature=0.0, seed=1) + + assert final_answer is None + assert loop.is_parse_failed() is True + assert llm_client.recovery_calls == 0 + + +@pytest.mark.anyio +async def test_agent_loop_failfasts_after_consecutive_disallowed_domain_hits(monkeypatch): + monkeypatch.setenv("LIVEWEB_FAILFAST_DISALLOWED_DOMAIN_CONSECUTIVE", "2") + monkeypatch.setenv("LIVEWEB_FAILFAST_DISALLOWED_DOMAIN_TOTAL", "3") + monkeypatch.setenv("LIVEWEB_DISALLOWED_DOMAIN_RECOVERY_MAX_RETRIES", "1") + + blocked_obs = BrowserObservation( + url="https://finance.yahoo.com/quote/V/", + title="Domain not allowed", + accessibility_tree="Domain not allowed. Please return to an allowed site and continue the task.", + ) + session = _FakeSessionWithBlockedSequence( + observations=[blocked_obs, blocked_obs], + metadatas=[ + { + "classification_hint": "model_disallowed_domain", + "url": "https://finance.yahoo.com/quote/V/", + "evidence": { + "blocked_url": "https://finance.yahoo.com/quote/V/", + "allowed_domains": ["coingecko.com"], + }, + }, + { + "classification_hint": "model_disallowed_domain", + "url": "https://finance.yahoo.com/quote/V/", + "evidence": { + "blocked_url": "https://finance.yahoo.com/quote/V/", + "allowed_domains": ["coingecko.com"], + }, + }, + ], + ) + llm_client = _QueuedLLMClient( + responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_1", function={"name": "goto", "arguments": "{\"url\":\"https://finance.yahoo.com/quote/V/\"}"})]), + ], + recovery_responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_r1", function={"name": "goto", "arguments": "{\"url\":\"https://finance.yahoo.com/quote/V/\"}"})]), + ], + ) + loop = AgentLoop( + session=session, + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=5, + ) + + with pytest.raises(BrowserFatalError) as exc_info: + await loop.run(task=_task(), model="qwen", temperature=0.0, seed=1) + + assert "disallowed-domain" in str(exc_info.value) + assert exc_info.value.url == "https://finance.yahoo.com/quote/V/" + assert len(loop.get_trajectory()) == 0 + assert llm_client.recovery_calls == 1 + + +@pytest.mark.anyio +async def test_agent_loop_resets_disallowed_domain_consecutive_counter_after_recovery(monkeypatch): + monkeypatch.setenv("LIVEWEB_FAILFAST_DISALLOWED_DOMAIN_CONSECUTIVE", "2") + monkeypatch.setenv("LIVEWEB_FAILFAST_DISALLOWED_DOMAIN_TOTAL", "3") + monkeypatch.setenv("LIVEWEB_DISALLOWED_DOMAIN_RECOVERY_MAX_RETRIES", "1") + + blocked_obs = BrowserObservation( + url="https://finance.yahoo.com/quote/V/", + title="Domain not allowed", + accessibility_tree="Domain not allowed. Please return to an allowed site and continue the task.", + ) + good_obs = BrowserObservation( + url="https://example.com", + title="Example", + accessibility_tree="root content with enough accessible text to avoid blank observation failfast", + ) + session = _FakeSessionWithBlockedSequence( + observations=[blocked_obs, good_obs, blocked_obs, good_obs], + metadatas=[ + { + "classification_hint": "model_disallowed_domain", + "url": "https://finance.yahoo.com/quote/V/", + "evidence": { + "blocked_url": "https://finance.yahoo.com/quote/V/", + "allowed_domains": ["example.com"], + }, + }, + None, + { + "classification_hint": "model_disallowed_domain", + "url": "https://finance.yahoo.com/quote/V/", + "evidence": { + "blocked_url": "https://finance.yahoo.com/quote/V/", + "allowed_domains": ["example.com"], + }, + }, + None, + ], + ) + llm_client = _QueuedLLMClient( + responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_1", function={"name": "goto", "arguments": "{\"url\":\"https://finance.yahoo.com/quote/V/\"}"})]), + LLMResponse(tool_calls=[ToolCall(id="call_2", function={"name": "goto", "arguments": "{\"url\":\"https://finance.yahoo.com/quote/V/\"}"})]), + LLMResponse(tool_calls=[ToolCall(id="call_3", function={"name": "stop", "arguments": "{\"answers\":{\"a1\":\"42\"}}"})]), + ], + recovery_responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_r1", function={"name": "goto", "arguments": "{\"url\":\"https://example.com\"}"})]), + LLMResponse(tool_calls=[ToolCall(id="call_r2", function={"name": "goto", "arguments": "{\"url\":\"https://example.com\"}"})]), + ], + ) + loop = AgentLoop( + session=session, + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=5, + ) + + trajectory, final_answer, _usage = await loop.run(task=_task(), model="qwen", temperature=0.0, seed=1) + + assert final_answer == {"answers": {"a1": "42"}} + assert len(trajectory) == 3 + assert llm_client.recovery_calls == 2 + assert trajectory[0].action.params["url"] == "https://example.com" + assert trajectory[0].action_result == "Success (recovered after disallowed-domain retry)" + assert trajectory[1].action.params["url"] == "https://example.com" + + +@pytest.mark.anyio +async def test_agent_loop_recovers_disallowed_domain_without_appending_blocked_step(monkeypatch): + monkeypatch.setenv("LIVEWEB_FAILFAST_DISALLOWED_DOMAIN_CONSECUTIVE", "2") + monkeypatch.setenv("LIVEWEB_FAILFAST_DISALLOWED_DOMAIN_TOTAL", "3") + monkeypatch.setenv("LIVEWEB_DISALLOWED_DOMAIN_RECOVERY_MAX_RETRIES", "1") + + blocked_obs = BrowserObservation( + url="https://finance.yahoo.com/quote/V/", + title="Domain not allowed", + accessibility_tree="Domain not allowed. Please return to an allowed site and continue the task.", + ) + good_obs = BrowserObservation( + url="https://www.coingecko.com/en/coins/optimism", + title="Optimism", + accessibility_tree="Circulating Supply 2,117,847,344 OP", + ) + session = _FakeSessionWithBlockedSequence( + observations=[blocked_obs, good_obs], + metadatas=[ + { + "classification_hint": "model_disallowed_domain", + "url": "https://finance.yahoo.com/quote/V/", + "evidence": { + "blocked_url": "https://finance.yahoo.com/quote/V/", + "allowed_domains": ["coingecko.com", "taostats.io"], + }, + }, + None, + ], + ) + llm_client = _QueuedLLMClient( + responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_1", function={"name": "goto", "arguments": "{\"url\":\"https://finance.yahoo.com/quote/V/\"}"})]), + LLMResponse(tool_calls=[ToolCall(id="call_2", function={"name": "stop", "arguments": "{\"answers\":{\"a1\":\"42\"}}"})]), + ], + recovery_responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_r1", function={"name": "goto", "arguments": "{\"url\":\"https://www.coingecko.com/en/coins/optimism\"}"})]), + ], + ) + loop = AgentLoop( + session=session, + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=4, + ) + + trajectory, final_answer, _usage = await loop.run(task=_task(), model="qwen", temperature=0.0, seed=1) + + assert final_answer == {"answers": {"a1": "42"}} + assert len(trajectory) == 2 + assert trajectory[0].action.params["url"] == "https://www.coingecko.com/en/coins/optimism" + assert "recovered after disallowed-domain retry" in trajectory[0].action_result.lower() + assert llm_client.recovery_calls == 1 + messages = llm_client.recovery_messages[0] + assert "Blocked URL: https://finance.yahoo.com/quote/V/" in messages[-1]["content"] + assert "Allowed domains: coingecko.com, taostats.io" in messages[-1]["content"] + + +@pytest.mark.anyio +async def test_agent_loop_recovers_empty_stop_without_appending_bad_stop(): + llm_client = _QueuedLLMClient( + responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_1", function={"name": "stop", "arguments": "{\"answers\":{}}"})]), + ], + recovery_responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_r1", function={"name": "stop", "arguments": "{\"answers\":{\"a1\":\"42\",\"a2\":\"Trishool\"}}"})]), + ], + ) + loop = AgentLoop( + session=_FakeSession(), + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=2, + behavior_mode="collect", + ) + + trajectory, final_answer, _usage = await loop.run(task=_task_two_answers(), model="qwen", temperature=0.0, seed=1) + + assert final_answer == {"answers": {"a1": "42", "a2": "Trishool"}} + assert len(trajectory) == 1 + assert trajectory[0].action.action_type == "stop" + assert "recovered" not in trajectory[0].action_result.lower() + local_stats = loop.get_local_recovery_stats() + assert local_stats["empty_stop_recovery_attempts"] == 1 + assert local_stats["empty_stop_recovery_successes"] == 1 + + +@pytest.mark.anyio +async def test_agent_loop_marks_invalid_stop_payload_when_recovery_exhausted(): + llm_client = _QueuedLLMClient( + responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_1", function={"name": "stop", "arguments": "{\"answers\":{}}"})]), + ], + recovery_responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_r1", function={"name": "stop", "arguments": "{\"answers\":{}}"})]), + ], + ) + loop = AgentLoop( + session=_FakeSession(), + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=2, + behavior_mode="collect", + ) + + trajectory, final_answer, _usage = await loop.run(task=_task_two_answers(), model="qwen", temperature=0.0, seed=1) + + assert final_answer is None + assert loop.is_invalid_stop_payload() is True + assert loop.get_last_stop_failure_class() == "empty_answers" + assert len(trajectory) == 1 + assert trajectory[0].action.action_type == "stop" + assert "invalid stop payload" in trajectory[0].action_result.lower() + + +@pytest.mark.anyio +async def test_agent_loop_recovers_invalid_ui_target_with_same_page_action(): + taostats_obs = BrowserObservation( + url="https://taostats.io/subnets", + title="Subnets", + accessibility_tree="Subnet list root content with enough text for recovery decisions", + ) + session = _FakeSessionWithActionSequence( + outcomes=[ + RuntimeError("No element found with role='button' name='Rows: 25'"), + taostats_obs, + ], + metadatas=[ + { + "url": "https://taostats.io/subnets", + "navigation_stage": "action_click_role", + "raw_exception_type": "RuntimeError", + "raw_exception_message": "No element found with role='button' name='Rows: 25'", + "evidence": { + "ui_target_missing": True, + "page_kind": "taostats_list", + "interaction_kind": "show_all", + "target_locator": "role=button[name='Rows: 25']", + }, + }, + None, + ], + ) + llm_client = _QueuedLLMClient( + responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_1", function={"name": "click_role", "arguments": "{\"role\":\"button\",\"name\":\"Rows: 25\"}"})]), + LLMResponse(tool_calls=[ToolCall(id="call_2", function={"name": "stop", "arguments": "{\"answers\":{\"a1\":\"42\"}}"})]), + ], + recovery_responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_r1", function={"name": "scroll", "arguments": "{\"direction\":\"down\"}"})]), + ], + ) + loop = AgentLoop( + session=session, + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=3, + behavior_mode="collect", + ) + + trajectory, final_answer, _usage = await loop.run(task=_task(), model="qwen", temperature=0.0, seed=1) + + assert final_answer == {"answers": {"a1": "42"}} + assert trajectory[0].action.action_type == "scroll" + assert "invalid_ui_target_recovery" in trajectory[0].action_result + stats = loop.get_local_recovery_stats() + assert stats["invalid_ui_target_recovery_attempts"] == 1 + assert stats["invalid_ui_target_recovery_successes"] == 1 + + +@pytest.mark.anyio +async def test_agent_loop_rejects_invalid_ui_target_recovery_that_uses_goto(): + taostats_obs = BrowserObservation( + url="https://taostats.io/subnets", + title="Subnets", + accessibility_tree="Subnet list root content with enough text for recovery decisions", + ) + session = _FakeSessionWithActionSequence( + outcomes=[ + RuntimeError("No element found with role='button' name='Rows: 25'"), + ], + metadatas=[ + { + "url": "https://taostats.io/subnets", + "navigation_stage": "action_click_role", + "raw_exception_type": "RuntimeError", + "raw_exception_message": "No element found with role='button' name='Rows: 25'", + "evidence": { + "ui_target_missing": True, + "page_kind": "taostats_list", + "interaction_kind": "show_all", + "target_locator": "role=button[name='Rows: 25']", + }, + }, + ], + ) + llm_client = _QueuedLLMClient( + responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_1", function={"name": "click_role", "arguments": "{\"role\":\"button\",\"name\":\"Rows: 25\"}"})]), + ], + recovery_responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_r1", function={"name": "goto", "arguments": "{\"url\":\"https://finance.yahoo.com\"}"})]), + ], + ) + loop = AgentLoop( + session=session, + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=1, + behavior_mode="collect", + ) + + trajectory, final_answer, _usage = await loop.run(task=_task(), model="qwen", temperature=0.0, seed=1) + + assert final_answer is None + assert len(trajectory) == 1 + assert trajectory[0].action.action_type == "click_role" + assert trajectory[0].action_result.startswith("Failed:") + + +@pytest.mark.anyio +async def test_agent_loop_recovers_taostats_list_timeout_with_same_page_action(): + taostats_obs = BrowserObservation( + url="https://taostats.io/subnets", + title="Subnets", + accessibility_tree="Subnet list root content with enough text for recovery decisions", + ) + session = _FakeSessionWithActionSequence( + outcomes=[ + RuntimeError("Page.click: Timeout 5000ms exceeded."), + taostats_obs, + ], + metadatas=[ + { + "url": "https://taostats.io/subnets", + "navigation_stage": "action_click", + "raw_exception_type": "TimeoutError", + "raw_exception_message": "Page.click: Timeout 5000ms exceeded.", + "evidence": { + "page_kind": "taostats_list", + "interaction_kind": "sort", + "target_locator": "thead th:has-text(\"24H\")", + }, + }, + None, + ], + ) + llm_client = _QueuedLLMClient( + responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_1", function={"name": "click", "arguments": "{\"selector\":\"thead th:has-text(\\\"24H\\\")\"}"})]), + LLMResponse(tool_calls=[ToolCall(id="call_2", function={"name": "stop", "arguments": "{\"answers\":{\"a1\":\"42\"}}"})]), + ], + recovery_responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_r1", function={"name": "scroll", "arguments": "{\"direction\":\"down\"}"})]), + ], + ) + loop = AgentLoop( + session=session, + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=3, + behavior_mode="collect", + ) + + trajectory, final_answer, _usage = await loop.run(task=_task(), model="qwen", temperature=0.0, seed=1) + + assert final_answer == {"answers": {"a1": "42"}} + assert trajectory[0].action.action_type == "scroll" + assert "taostats_list_action_recovery" in trajectory[0].action_result + stats = loop.get_local_recovery_stats() + assert stats["taostats_list_action_recovery_attempts"] == 1 + assert stats["taostats_list_action_recovery_successes"] == 1 + + +@pytest.mark.anyio +async def test_agent_loop_collect_only_recoveries_are_disabled_in_eval_mode(): + llm_client = _QueuedLLMClient( + responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_1", function={"name": "stop", "arguments": "{\"answers\":{}}"})]), + ], + recovery_responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_r1", function={"name": "stop", "arguments": "{\"answers\":{\"a1\":\"42\"}}"})]), + ], + ) + loop = AgentLoop( + session=_FakeSession(), + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=1, + behavior_mode="eval", + ) + + trajectory, final_answer, _usage = await loop.run(task=_task(), model="qwen", temperature=0.0, seed=1) + + assert final_answer == {"answers": {}} + assert llm_client.recovery_calls == 0 + assert len(trajectory) == 1 + + +@pytest.mark.anyio +async def test_agent_loop_recovers_invalid_generated_url_without_appending_bad_step(): + llm_client = _QueuedLLMClient( + responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_1", function={"name": "goto", "arguments": "{\"url\":\"https://:\"}"})]), + LLMResponse(tool_calls=[ToolCall(id="call_2", function={"name": "stop", "arguments": "{\"answers\":{\"a1\":\"42\"}}"})]), + ], + recovery_responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_r1", function={"name": "goto", "arguments": "{\"url\":\"https://example.com\"}"})]), + ], + ) + loop = AgentLoop( + session=_FakeSession(), + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=3, + behavior_mode="collect", + ) + + trajectory, final_answer, _usage = await loop.run(task=_task(), model="qwen", temperature=0.0, seed=1) + + assert final_answer == {"answers": {"a1": "42"}} + assert loop.is_invalid_generated_url() is False + assert len(trajectory) == 2 + assert trajectory[0].action.params["url"] == "https://example.com" + assert "invalid_generated_url_recovery" in trajectory[0].action_result + stats = loop.get_local_recovery_stats() + assert stats["invalid_generated_url_recovery_attempts"] == 1 + assert stats["invalid_generated_url_recovery_successes"] == 1 + + +@pytest.mark.anyio +async def test_agent_loop_marks_invalid_generated_url_when_recovery_exhausted(): + llm_client = _QueuedLLMClient( + responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_1", function={"name": "goto", "arguments": "{\"url\":\"https://:\"}"})]), + ], + recovery_responses=[ + LLMResponse(tool_calls=[ToolCall(id="call_r1", function={"name": "goto", "arguments": "{\"url\":\"https://:\"}"})]), + ], + ) + loop = AgentLoop( + session=_FakeSession(), + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=2, + behavior_mode="collect", + ) + + trajectory, final_answer, _usage = await loop.run(task=_task(), model="qwen", temperature=0.0, seed=1) + + assert final_answer is None + assert loop.is_invalid_generated_url() is True + assert loop.get_last_invalid_generated_url_detail()["kind"] == "missing_host" + assert len(trajectory) == 1 + assert "Invalid generated URL" in trajectory[0].action_result + + +@pytest.mark.anyio +async def test_agent_loop_detects_repetitive_same_action_loop_for_gemini(monkeypatch): + monkeypatch.setenv("LIVEWEB_GEMINI_LOOP_SAME_ACTION_THRESHOLD", "6") + + repeated_obs = BrowserObservation( + url="https://taostats.io/subnets/", + title="Subnets", + accessibility_tree="Subnet table with enough accessible text to support repeated interaction.", + ) + session = _FakeSessionWithActionSequence( + outcomes=[repeated_obs] * 6, + metadatas=[None] * 6, + ) + click_args = "{\"selector\":\"th:nth-child(3)\"}" + llm_client = _QueuedLLMClient( + responses=[ + LLMResponse(tool_calls=[ToolCall(id="goto_1", function={"name": "goto", "arguments": "{\"url\":\"https://taostats.io/subnets/\"}"})]), + *[ + LLMResponse(tool_calls=[ToolCall(id=f"click_{idx}", function={"name": "click", "arguments": click_args})]) + for idx in range(6) + ], + ], + ) + loop = AgentLoop( + session=session, + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=10, + behavior_mode="collect", + ) + + trajectory, final_answer, _usage = await loop.run( + task=_task(), model="google/gemini-3-flash-preview", temperature=0.0, seed=1 + ) + + assert final_answer is None + assert loop.is_action_loop_detected() is True + assert loop.get_last_action_loop_detail()["kind"] == "same_action_repeat" + assert trajectory[-1].action_result.startswith("Aborted: repetitive_action_loop") + assert "th:nth-child(3)" in trajectory[-1].action_result + + +@pytest.mark.anyio +async def test_agent_loop_detects_repetitive_same_type_loop_for_gemini(monkeypatch): + monkeypatch.setenv("LIVEWEB_GEMINI_LOOP_SAME_TYPE_THRESHOLD", "4") + + search_obs = BrowserObservation( + url="https://taostats.io/subnets/", + title="Subnets", + accessibility_tree="Subnet list page with search box and enough accessible text.", + ) + session = _FakeSessionWithActionSequence( + outcomes=[search_obs] * 5, + metadatas=[None] * 5, + ) + type_args = "{\"selector\":\"input[placeholder='Search']\",\"text\":\"SN23\"}" + llm_client = _QueuedLLMClient( + responses=[ + LLMResponse(tool_calls=[ToolCall(id="goto_1", function={"name": "goto", "arguments": "{\"url\":\"https://taostats.io/subnets/\"}"})]), + *[ + LLMResponse(tool_calls=[ToolCall(id=f"type_{idx}", function={"name": "type", "arguments": type_args})]) + for idx in range(4) + ], + ], + ) + loop = AgentLoop( + session=session, + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=8, + behavior_mode="collect", + ) + + trajectory, final_answer, _usage = await loop.run( + task=_task(), model="google/gemini-3-flash-preview", temperature=0.0, seed=1 + ) + + assert final_answer is None + assert loop.is_action_loop_detected() is True + assert loop.get_last_action_loop_detail()["kind"] == "same_type_repeat" + + +@pytest.mark.anyio +async def test_agent_loop_detects_goto_oscillation_for_gemini(monkeypatch): + monkeypatch.setenv("LIVEWEB_GEMINI_LOOP_GOTO_OSCILLATION_THRESHOLD", "4") + + subnet_list_obs = BrowserObservation( + url="https://taostats.io/subnets/", + title="Subnets", + accessibility_tree="Subnet list with enough accessible text to support navigation.", + ) + subnet_detail_obs = BrowserObservation( + url="https://taostats.io/subnet/23/", + title="Subnet 23", + accessibility_tree="Subnet detail with enough accessible text to support navigation.", + ) + session = _FakeSessionWithActionSequence( + outcomes=[ + subnet_list_obs, + subnet_detail_obs, + subnet_list_obs, + subnet_detail_obs, + subnet_list_obs, + subnet_detail_obs, + subnet_list_obs, + ], + metadatas=[None] * 7, + ) + llm_client = _QueuedLLMClient( + responses=[ + LLMResponse(tool_calls=[ToolCall(id="goto_1", function={"name": "goto", "arguments": "{\"url\":\"https://taostats.io/subnets/\"}"})]), + LLMResponse(tool_calls=[ToolCall(id="goto_2", function={"name": "goto", "arguments": "{\"url\":\"https://taostats.io/subnet/23/\"}"})]), + LLMResponse(tool_calls=[ToolCall(id="goto_3", function={"name": "goto", "arguments": "{\"url\":\"https://taostats.io/subnets/\"}"})]), + LLMResponse(tool_calls=[ToolCall(id="goto_4", function={"name": "goto", "arguments": "{\"url\":\"https://taostats.io/subnet/23/\"}"})]), + LLMResponse(tool_calls=[ToolCall(id="goto_5", function={"name": "goto", "arguments": "{\"url\":\"https://taostats.io/subnets/\"}"})]), + LLMResponse(tool_calls=[ToolCall(id="goto_6", function={"name": "goto", "arguments": "{\"url\":\"https://taostats.io/subnet/23/\"}"})]), + LLMResponse(tool_calls=[ToolCall(id="goto_7", function={"name": "goto", "arguments": "{\"url\":\"https://taostats.io/subnets/\"}"})]), + LLMResponse(tool_calls=[ToolCall(id="goto_8", function={"name": "goto", "arguments": "{\"url\":\"https://taostats.io/subnet/23/\"}"})]), + ], + ) + loop = AgentLoop( + session=session, + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=12, + behavior_mode="collect", + ) + + trajectory, final_answer, _usage = await loop.run( + task=_task(), model="google/gemini-3-flash-preview", temperature=0.0, seed=1 + ) + + assert final_answer is None + assert loop.is_action_loop_detected() is True + detail = loop.get_last_action_loop_detail() + assert detail["kind"] == "goto_oscillation" + assert set(detail["oscillation_urls"]) == { + "https://taostats.io/subnets/", + "https://taostats.io/subnet/23/", + } + assert trajectory[-1].action_result.startswith("Aborted: repetitive_action_loop") + + +@pytest.mark.anyio +async def test_agent_loop_detects_search_finance_bounce_for_gemini(monkeypatch): + monkeypatch.setenv("LIVEWEB_GEMINI_LOOP_SEARCH_BOUNCE_THRESHOLD", "4") + + google_obs = BrowserObservation( + url="https://www.google.com/search?q=dogecoin", + title="Google Search", + accessibility_tree="Search results page with enough text to support navigation.", + ) + yahoo_obs = BrowserObservation( + url="https://finance.yahoo.com/quote/DOGE-USD/", + title="Yahoo Finance", + accessibility_tree="Finance quote page with enough text to support navigation.", + ) + cmc_obs = BrowserObservation( + url="https://coinmarketcap.com/currencies/dogecoin/", + title="CoinMarketCap", + accessibility_tree="Coin page with enough text to support navigation.", + ) + session = _FakeSessionWithActionSequence( + outcomes=[google_obs, yahoo_obs, google_obs, cmc_obs], + metadatas=[None] * 4, + ) + llm_client = _QueuedLLMClient( + responses=[ + LLMResponse(tool_calls=[ToolCall(id="goto_1", function={"name": "goto", "arguments": "{\"url\":\"https://www.google.com/search?q=dogecoin\"}"})]), + LLMResponse(tool_calls=[ToolCall(id="goto_2", function={"name": "goto", "arguments": "{\"url\":\"https://finance.yahoo.com/quote/DOGE-USD/\"}"})]), + LLMResponse(tool_calls=[ToolCall(id="goto_3", function={"name": "goto", "arguments": "{\"url\":\"https://www.google.com/search?q=dogecoin\"}"})]), + LLMResponse(tool_calls=[ToolCall(id="goto_4", function={"name": "goto", "arguments": "{\"url\":\"https://coinmarketcap.com/currencies/dogecoin/\"}"})]), + ], + ) + loop = AgentLoop( + session=session, + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=6, + behavior_mode="collect", + ) + + trajectory, final_answer, _usage = await loop.run( + task=_task(), model="google/gemini-3-flash-preview", temperature=0.0, seed=1 + ) + + assert final_answer is None + assert loop.is_action_loop_detected() is True + assert loop.get_last_action_loop_detail()["kind"] == "search_finance_bounce" + + +@pytest.mark.anyio +async def test_agent_loop_does_not_apply_loop_guard_to_non_gemini(monkeypatch): + monkeypatch.setenv("LIVEWEB_GEMINI_LOOP_SAME_ACTION_THRESHOLD", "6") + + repeated_obs = BrowserObservation( + url="https://taostats.io/subnets/", + title="Subnets", + accessibility_tree="Subnet table with enough accessible text to support repeated interaction.", + ) + session = _FakeSessionWithActionSequence( + outcomes=[repeated_obs] * 7, + metadatas=[None] * 7, + ) + click_args = "{\"selector\":\"th:nth-child(3)\"}" + llm_client = _QueuedLLMClient( + responses=[ + LLMResponse(tool_calls=[ToolCall(id="goto_1", function={"name": "goto", "arguments": "{\"url\":\"https://taostats.io/subnets/\"}"})]), + *[ + LLMResponse(tool_calls=[ToolCall(id=f"click_{idx}", function={"name": "click", "arguments": click_args})]) + for idx in range(6) + ], + LLMResponse(tool_calls=[ToolCall(id="stop_1", function={"name": "stop", "arguments": "{\"answers\":{\"a1\":\"42\"}}"})]), + ], + ) + loop = AgentLoop( + session=session, + llm_client=llm_client, + protocol=FunctionCallingProtocol(), + max_steps=12, + behavior_mode="collect", + ) + + trajectory, final_answer, _usage = await loop.run( + task=_task(), model="mimo_v2_pro_openrouter", temperature=0.0, seed=1 + ) + + assert final_answer == {"answers": {"a1": "42"}} + assert loop.is_action_loop_detected() is False + assert trajectory[-1].action.action_type == "stop" diff --git a/tests/core/test_agent_protocol.py b/tests/core/test_agent_protocol.py index 8791675..71f5f03 100644 --- a/tests/core/test_agent_protocol.py +++ b/tests/core/test_agent_protocol.py @@ -15,6 +15,34 @@ def protocol(): return FunctionCallingProtocol() +def test_gpt54_prompt_profile_adds_strict_system_rules(): + from liveweb_arena.core.models import CompositeTask + from liveweb_arena.plugins.base import SubTask + + task = CompositeTask( + subtasks=[SubTask(plugin_name="coingecko", intent="Example?", validation_info={}, answer_tag="answer1")], + combined_intent="## Tasks to Complete\n\n1. Example?\n Answer tag: answer1\n\n## Output Requirements\n\nWhen you have completed all tasks, use the \"stop\" action with your answers in this JSON format:\n\n```json\n{\"answers\": {\"answer1\": \"...\"}}\n```", + plugin_hints={}, + seed=1, + ) + profile_protocol = FunctionCallingProtocol(prompt_profile="gpt54_strict_domains") + prompt = profile_protocol.build_system_prompt(task) + assert "Do not use Google Search, Google Finance, Yahoo Finance" in prompt + assert "Never call stop with an empty answers object." in prompt + + +def test_gpt54_prompt_profile_adds_step_reminders(): + obs = BrowserObservation( + url="https://www.coingecko.com/en/coins/optimism", + title="Optimism", + accessibility_tree="Circulating Supply 2,117,847,344", + ) + profile_protocol = FunctionCallingProtocol(prompt_profile="gpt54_strict_domains") + prompt = profile_protocol.build_step_prompt(obs, [], current_step=2, max_steps=40) + assert 'Never call stop with {"answers": {}}.' in prompt + assert "Do not use Google, Yahoo, CoinMarketCap, XE, TradingView, or search engines." in prompt + + # ── get_tools ────────────────────────────────────────────────────── def test_get_tools_returns_all_actions(protocol): @@ -106,6 +134,138 @@ def test_parse_uses_first_tool_call_only(protocol): assert action.action_type == "goto" +def test_parse_qwen_tool_call_tag_fallback(protocol): + raw = """ + + + +{"name":"goto","arguments":{"url":"https://example.com"}} + +""" + action = protocol.parse_response(raw, None) + assert action is not None + assert action.action_type == "goto" + assert action.params["url"] == "https://example.com" + + +def test_strict_compat_protocol_does_not_accept_qwen_fallback_text(): + protocol = FunctionCallingProtocol(strict_compat=True) + raw = """ + + + +{"name":"goto","arguments":{"url":"https://example.com"}} + +""" + assert protocol.parse_response(raw, None) is None + assert protocol.classify_format_failure(raw, None) == "terminal" + + +def test_parse_qwen_stop_fallback(protocol): + raw = '{"name":"stop","arguments":{"answers":{"a1":"42"}}}' + action = protocol.parse_response(raw, None) + assert action is not None + assert action.action_type == "stop" + assert action.params == {"final": {"answers": {"a1": "42"}}} + + +def test_parse_qwen_stop_after_think_fallback(protocol): + raw = '\n\n\n{"name":"stop","arguments":{"answers":{"a1":"42"}}}' + action = protocol.parse_response(raw, None) + assert action is not None + assert action.action_type == "stop" + assert action.params == {"final": {"answers": {"a1": "42"}}} + + +def test_parse_qwen_function_style_fallback(protocol): + raw = 'click_role({"role":"link","name":"Ask HN","exact":false})' + action = protocol.parse_response(raw, None) + assert action is not None + assert action.action_type == "click_role" + assert action.params == {"role": "link", "name": "Ask HN", "exact": False} + + +def test_parse_qwen_tool_tag_function_style_fallback(protocol): + raw = """ + +stop({"answers":{"a1":"42"}}) + +""" + action = protocol.parse_response(raw, None) + assert action is not None + assert action.action_type == "stop" + assert action.params == {"final": {"answers": {"a1": "42"}}} + + +def test_parse_qwen_function_style_with_wrapper_noise(protocol): + raw = '_goto({"url":"https://news.ycombinator.com"}_)' + action = protocol.parse_response(raw, None) + assert action is not None + assert action.action_type == "goto" + assert action.params == {"url": "https://news.ycombinator.com"} + + +def test_parse_bracket_tool_call_stop_fallback(protocol): + raw = '[tool_call: stop({"answers":{"a1":"42"}})]' + action = protocol.parse_response(raw, None) + assert action is not None + assert action.action_type == "stop" + assert action.params == {"final": {"answers": {"a1": "42"}}} + + +def test_parse_bracket_tool_call_goto_fallback(protocol): + raw = '[tool_call: goto({"url":"https://example.com"})]' + action = protocol.parse_response(raw, None) + assert action is not None + assert action.action_type == "goto" + assert action.params == {"url": "https://example.com"} + + +def test_debug_parse_metadata_identifies_bracket_tool_call(protocol): + raw = '[tool_call: stop({"answers":{"a1":"42"}})]' + metadata = protocol.debug_parse_metadata(raw, None) + assert metadata["protocol_parser_branch"] == "bracket_tool_call" + assert metadata["tool_calls_preview"] == [] + + +def test_debug_parse_metadata_identifies_unparsed_natural_language(protocol): + raw = "I will think first and then browse later." + metadata = protocol.debug_parse_metadata(raw, None) + assert metadata["protocol_parser_branch"] == "natural_language" + + +def test_parse_qwen_fallback_rejects_natural_language(protocol): + raw = 'I should stop now. {"name":"stop","arguments":{"answers":{"a1":"42"}}}' + assert protocol.parse_response(raw, None) is None + + +def test_classify_format_failure_empty_response_is_recoverable(protocol): + assert protocol.classify_format_failure("", None) == "recoverable_empty" + + +def test_classify_format_failure_truncated_tool_call_is_recoverable(protocol): + raw = "{\"name\":\"goto\",\"arguments\":{\"url\":\"https://example.com\"}" + assert protocol.classify_format_failure(raw, None) == "recoverable_truncated_tool_json" + + +def test_classify_format_failure_valid_bracket_tool_call_is_not_failure(protocol): + raw = '[tool_call: stop({"answers":{"a1":"42"}})]' + assert protocol.classify_format_failure(raw, None) == "none" + + +def test_parse_bracket_tool_call_repairs_missing_outer_brace(protocol): + raw = '[tool_call: stop({"answers":{"a1":"42"})]' + action = protocol.parse_response(raw, None) + assert action is not None + assert action.action_type == "stop" + assert action.params == {"final": {"answers": {"a1": "42"}}} + + +def test_classify_format_failure_natural_language_is_terminal(protocol): + raw = "I will now browse the page and report back with the answer." + assert protocol.classify_format_failure(raw, None) == "terminal_natural_language" + + # ── serialize_step ───────────────────────────────────────────────── def _make_step(step_num, action_type, params, action_result="Success", prompt="obs"): diff --git a/tests/core/test_browser.py b/tests/core/test_browser.py new file mode 100644 index 0000000..7a3bfd1 --- /dev/null +++ b/tests/core/test_browser.py @@ -0,0 +1,194 @@ +from types import SimpleNamespace + +import pytest + +from liveweb_arena.core.browser import ( + BrowserSession, + _looks_like_non_html_navigation_target, + _normalize_stooq_url, + _should_fallback_to_direct_navigation, +) + + +class _FakeElement: + def __init__(self, href: str | None, *, click_error: Exception | None = None): + self._href = href + self._click_error = click_error + self.evaluated = False + + async def get_attribute(self, name: str): + if name == "href": + return self._href + return None + + async def click(self, force: bool = False, timeout: int | None = None): + if self._click_error is not None: + raise self._click_error + return None + + async def evaluate(self, script: str): + self.evaluated = True + return None + + +class _FakeLocator: + def __init__(self, href: str | None, *, click_error: Exception | None = None): + self._href = href + self._click_error = click_error + self.evaluated = False + self.first = self + + async def get_attribute(self, name: str): + if name == "href": + return self._href + return None + + async def click(self, force: bool = False, timeout: int | None = None): + if self._click_error is not None: + raise self._click_error + return None + + async def evaluate(self, script: str): + self.evaluated = True + return None + + +class _FakePage: + def __init__(self, href: str | None, *, element: _FakeElement | None = None): + self.url = "https://taostats.io/" + self._href = href + self._element = element or _FakeElement(href) + + async def query_selector(self, selector: str): + return self._element + + +class _FallbackClickPage: + def __init__(self, *, url: str, click_results: dict[str, Exception | None]): + self.url = url + self._click_results = click_results + self.clicked: list[str] = [] + + async def click(self, selector: str, timeout: int | None = None): + self.clicked.append(selector) + outcome = self._click_results.get(selector, RuntimeError("not found")) + if outcome is not None: + raise outcome + return None + + async def query_selector(self, selector: str): + return None + + +@pytest.mark.asyncio +async def test_browser_session_direct_nav_fallback_from_selector(): + session = BrowserSession.__new__(BrowserSession) + session._page = _FakePage("/subnets") + + visited = {} + + async def _goto(url: str): + visited["url"] = url + + session._goto_with_recovery = _goto + + ok = await session._direct_nav_fallback_from_selector("a[href='/subnets']") + assert ok is True + assert visited["url"] == "https://taostats.io/subnets" + + +@pytest.mark.asyncio +async def test_browser_session_direct_nav_fallback_from_locator(): + session = BrowserSession.__new__(BrowserSession) + session._page = SimpleNamespace(url="https://taostats.io/") + + visited = {} + + async def _goto(url: str): + visited["url"] = url + + session._goto_with_recovery = _goto + + ok = await session._direct_nav_fallback_from_locator(_FakeLocator("/subnets/23")) + assert ok is True + assert visited["url"] == "https://taostats.io/subnets/23" + + +@pytest.mark.asyncio +async def test_browser_session_force_click_selector_fallback_uses_js_click(): + session = BrowserSession.__new__(BrowserSession) + element = _FakeElement(None, click_error=RuntimeError("pointer intercepted")) + session._page = _FakePage(None, element=element) + + ok = await session._force_click_selector_fallback(".dropdown-toggle") + assert ok is True + assert element.evaluated is True + + +@pytest.mark.asyncio +async def test_browser_session_force_click_locator_fallback_uses_js_click(): + session = BrowserSession.__new__(BrowserSession) + locator = _FakeLocator(None, click_error=RuntimeError("pointer intercepted")) + + ok = await session._force_click_locator_fallback(locator) + assert ok is True + assert locator.evaluated is True + + +@pytest.mark.asyncio +async def test_browser_session_taostats_role_fallback_can_navigate_home_to_subnets(): + session = BrowserSession.__new__(BrowserSession) + session._page = SimpleNamespace(url="https://taostats.io") + + visited = {} + + async def _goto(url: str): + visited["url"] = url + + session._goto_with_recovery = _goto + + ok = await session._taostats_list_role_fallback("link", "View All", 5000) + assert ok is True + assert visited["url"] == "https://taostats.io/subnets" + + +@pytest.mark.asyncio +async def test_browser_session_taostats_selector_fallback_tries_semantic_candidates(): + session = BrowserSession.__new__(BrowserSession) + session._page = _FallbackClickPage( + url="https://taostats.io/subnets", + click_results={ + '[data-testid="rows-select"]': RuntimeError("timeout"), + ".ant-select-selector": None, + }, + ) + + ok = await session._taostats_list_selector_fallback('[data-testid="rows-select"]', 5000) + assert ok is True + assert session._page.clicked[:2] == ['[data-testid="rows-select"]', '.ant-select-selector'] + + +def test_should_fallback_to_direct_navigation_for_intercepted_click_timeout(): + exc = RuntimeError("Page.click: Timeout 5000ms exceeded... intercepts pointer events") + assert _should_fallback_to_direct_navigation(exc) is True + + +def test_normalize_stooq_list_endpoint_preserves_original_path(): + normalized = _normalize_stooq_url("https://stooq.com/q/l/?s=jnj.us&f=sd2t2ohlcv&h=&e=csv") + assert normalized == "https://stooq.com/q/l/?s=jnj.us&f=sd2t2ohlcv&h=&e=csv" + + +def test_normalize_stooq_slug_alias_preserves_original_slug(): + normalized = _normalize_stooq_url("https://stooq.com/q/uk100/") + assert normalized == "https://stooq.com/q/uk100/" + + +def test_non_html_navigation_heuristic_flags_downloadish_urls(): + assert _looks_like_non_html_navigation_target("https://stooq.com/q/l/?s=jnj.us&f=sd2t2ohlcv&h=&e=csv") is True + assert _looks_like_non_html_navigation_target("https://example.com/export/report.csv") is True + assert _looks_like_non_html_navigation_target("https://example.com/data?format=json") is True + + +def test_non_html_navigation_heuristic_skips_normal_pages(): + assert _looks_like_non_html_navigation_target("https://stooq.com/q/?s=jnj.us") is False + assert _looks_like_non_html_navigation_target("https://taostats.io/subnets") is False diff --git a/tests/core/test_browser_real_urls.py b/tests/core/test_browser_real_urls.py new file mode 100644 index 0000000..0d6b157 --- /dev/null +++ b/tests/core/test_browser_real_urls.py @@ -0,0 +1,84 @@ +import os + +import pytest + + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.skipif( + os.getenv("LIVEWEB_REAL_BROWSER_SMOKE") != "1", + reason="set LIVEWEB_REAL_BROWSER_SMOKE=1 to run real-browser smoke tests", + ), +] + + +async def test_real_browser_taostats_subnets_click_fallback(): + from liveweb_arena.core.browser import BrowserAction, BrowserEngine + + engine = BrowserEngine(headless=True) + session = await engine.new_session() + try: + obs = await session.goto("https://taostats.io") + assert "taostats.io" in obs.url + + obs = await session.execute_action( + BrowserAction( + action_type="click", + params={"selector": "a[href='/subnets']", "timeout_ms": 5000}, + ) + ) + assert "taostats.io" in obs.url + assert "Subnets" in obs.title + assert len(obs.accessibility_tree.strip()) > 100 + finally: + await session.close() + await engine.stop() + + +async def test_real_browser_coingecko_binance_coin_page_reachable(): + from liveweb_arena.core.browser import BrowserEngine + + engine = BrowserEngine(headless=True) + session = await engine.new_session() + try: + obs = await session.goto("https://www.coingecko.com/en/coins/binance-coin") + assert "coingecko.com" in obs.url + assert "/en/coins/" in obs.url + assert obs.url.endswith("/bnb") or obs.url.endswith("/binance-coin") + assert "BNB" in obs.title or "Binance" in obs.title + assert len(obs.accessibility_tree.strip()) > 100 + finally: + await session.close() + await engine.stop() + + +async def test_real_browser_stooq_quote_page_reachable(): + from liveweb_arena.core.browser import BrowserEngine + + engine = BrowserEngine(headless=True) + session = await engine.new_session() + try: + obs = await session.goto("https://stooq.com/q/?s=jnj.us") + assert "stooq.com" in obs.url + assert "/q/?s=jnj.us" in obs.url.lower() + assert "jnj" in obs.title.lower() + assert len(obs.accessibility_tree.strip()) > 50 + finally: + await session.close() + await engine.stop() + + +async def test_real_browser_stooq_download_endpoint_returns_synthetic_observation(): + from liveweb_arena.core.browser import BrowserEngine + + engine = BrowserEngine(headless=True) + session = await engine.new_session() + try: + obs = await session.goto("https://stooq.com/q/l/?s=jnj.us&f=sd2t2ohlcv&h=&e=csv") + assert obs.url == "https://stooq.com/q/l/?s=jnj.us&f=sd2t2ohlcv&h=&e=csv" + assert obs.title == "Download" + assert "file download" in obs.accessibility_tree + assert "did not call page.goto()" in obs.accessibility_tree + finally: + await session.close() + await engine.stop() diff --git a/tests/core/test_cache.py b/tests/core/test_cache.py index 3571295..5deac13 100644 --- a/tests/core/test_cache.py +++ b/tests/core/test_cache.py @@ -15,6 +15,7 @@ safe_path_component, url_to_cache_dir, ) +from liveweb_arena.plugins.taostats.taostats import TaostatsPlugin # ── CachedPage ────────────────────────────────────────────────────── @@ -77,6 +78,19 @@ def test_to_dict_includes_a11y(self): d = page.to_dict() assert d["accessibility_tree"] == "tree" + def test_from_dict_enriches_sparse_a11y_from_html(self): + d = { + "url": "https://taostats.io/subnets/75", + "html": "

Hippius

Statistics
Price Impact
", + "api_data": {"netuid": 75}, + "fetched_at": 1.0, + "accessibility_tree": "taostats", + "need_api": True, + } + page = CachedPage.from_dict(d) + assert "Hippius" in page.accessibility_tree + assert "Statistics" in page.accessibility_tree + # ── PageRequirement ────────────────────────────────────────────────── @@ -194,6 +208,22 @@ def test_no_url(self): err = CacheFatalError("generic failure") assert err.url is None + def test_structured_fields(self): + err = CacheFatalError( + "timeout", + url="https://x.com", + status_code=504, + evidence={"k": "v"}, + soft_fail_applied=True, + stale_fallback_used=False, + plugin_name="coingecko", + ) + assert err.status_code == 504 + assert err.evidence == {"k": "v"} + assert err.soft_fail_applied is True + assert err.stale_fallback_used is False + assert err.plugin_name == "coingecko" + # ── CacheManager._load_if_valid (via file I/O) ────────────────────── @@ -246,3 +276,72 @@ def test_upgrade_nav_to_data_rejects(self, tmp_path): mgr = CacheManager(cache_dir=tmp_path, ttl=3600) # need_api=True but cache has no api_data → rejected assert mgr._load_if_valid(cache_file, need_api=True) is None + + def test_load_with_status_uses_shared_cache_fallback(self, tmp_path, monkeypatch): + local_cache = tmp_path / "local" + shared_cache = tmp_path / "shared" + monkeypatch.setenv("LIVEWEB_SHARED_CACHE_DIR", str(shared_cache)) + monkeypatch.setenv("LIVEWEB_ENABLE_SHARED_CACHE", "1") + mgr = CacheManager(cache_dir=local_cache, ttl=3600) + + url = "https://stooq.com/q/?s=jnj.us" + normalized = normalize_url(url) + shared_file = url_to_cache_dir(shared_cache, normalized) / "page.json" + shared_file.parent.mkdir(parents=True, exist_ok=True) + shared_page = CachedPage( + url=url, + html="

JNJ

Price 123

", + api_data={"symbol": "jnj.us"}, + fetched_at=time.time(), + need_api=True, + ) + with open(shared_file, "w") as f: + json.dump(shared_page.to_dict(), f) + + local_file = url_to_cache_dir(local_cache, normalized) / "page.json" + status, cached = mgr._load_with_status(normalized, local_file, need_api=True) + assert status == "valid" + assert cached is not None + assert cached.api_data == {"symbol": "jnj.us"} + assert local_file.exists() + + +def test_cache_manager_records_taostats_prefetch_cooldown(tmp_path): + mgr = CacheManager(cache_dir=tmp_path, ttl=3600) + url = "https://taostats.io/subnets/73" + err = CacheFatalError( + "Taostats detail prefetch invalidated", + url=url, + kind="taostats_prefetch_invalidated", + fatal=False, + evidence={ + "classification": "env_taostats_detail_prefetch_invalidated", + "page_kind": "taostats_detail", + "prefetch_phase": "setup_page_for_cache", + }, + plugin_name="taostats", + ) + + mgr._maybe_activate_taostats_prefetch_cooldown(url, err) + cooldown = mgr._get_taostats_prefetch_cooldown(url) + assert cooldown is not None + assert cooldown["classification"] == "env_taostats_detail_prefetch_invalidated" + assert cooldown["cooldown_applied"] is True + + +@pytest.mark.asyncio +async def test_cache_manager_short_circuits_taostats_detail_during_cooldown(tmp_path): + mgr = CacheManager(cache_dir=tmp_path, ttl=3600) + url = "https://taostats.io/subnets/73" + mgr._taostats_prefetch_cooldowns[normalize_url(url)] = ( + time.monotonic() + 60, + { + "classification": "env_taostats_detail_prefetch_invalidated", + "page_kind": "taostats_detail", + }, + ) + + with pytest.raises(CacheFatalError, match="cooldown active") as excinfo: + await mgr._ensure_single(url, TaostatsPlugin(), need_api=False) + + assert excinfo.value.kind == "taostats_prefetch_cooldown" diff --git a/tests/core/test_coingecko_plugin.py b/tests/core/test_coingecko_plugin.py new file mode 100644 index 0000000..702c530 --- /dev/null +++ b/tests/core/test_coingecko_plugin.py @@ -0,0 +1,12 @@ +from liveweb_arena.plugins.coingecko.coingecko import CoinGeckoPlugin + + +def test_coingecko_aliases_normalize_common_coin_slugs(): + plugin = CoinGeckoPlugin() + assert plugin._extract_coin_id("https://www.coingecko.com/en/coins/bnb") == "binancecoin" + assert plugin._extract_coin_id("https://www.coingecko.com/en/coins/binance-coin") == "binancecoin" + assert plugin._extract_coin_id("https://www.coingecko.com/en/coins/xrp") == "ripple" + assert plugin._extract_coin_id("https://www.coingecko.com/en/coins/ada") == "cardano" + assert plugin._extract_coin_id("https://www.coingecko.com/en/coins/polkadot-new") == "polkadot" + assert plugin._extract_coin_id("https://www.coingecko.com/en/coins/near-protocol") == "near" + assert plugin._extract_coin_id("https://www.coingecko.com/en/coins/hedera") == "hedera-hashgraph" diff --git a/tests/core/test_interceptor.py b/tests/core/test_interceptor.py index d89bc57..4e76c3b 100644 --- a/tests/core/test_interceptor.py +++ b/tests/core/test_interceptor.py @@ -20,6 +20,29 @@ def _interceptor(cached=None, domains=None, blocked=None, url_validator=None, of ) +class _FakeRequest: + def __init__(self, url: str, resource_type: str = "document"): + self.url = url + self.resource_type = resource_type + + +class _FakeRoute: + def __init__(self, url: str, resource_type: str = "document"): + self.request = _FakeRequest(url, resource_type) + self.fulfilled = None + self.aborted = None + self.continued = False + + async def fulfill(self, **kwargs): + self.fulfilled = kwargs + + async def abort(self, error_code): + self.aborted = error_code + + async def continue_(self): + self.continued = True + + # ── _should_block ────────────────────────────────────────────────── class TestShouldBlock: @@ -70,6 +93,34 @@ def test_port_stripped(self): assert i._is_domain_allowed("http://localhost:8080/api") +class TestSoftFailPolicy: + def test_soft_fail_url_pattern(self, monkeypatch): + monkeypatch.setenv("LIVEWEB_SOFT_FAIL_DOMAINS", "") + monkeypatch.setenv("LIVEWEB_SOFT_FAIL_URL_PATTERNS", "news.ycombinator.com/ask") + i = _interceptor() + assert i._should_soft_fail_domain("https://news.ycombinator.com/ask") + assert not i._should_soft_fail_domain("https://news.ycombinator.com/newest") + + def test_required_soft_regex_policy(self, monkeypatch): + monkeypatch.setenv("LIVEWEB_SOFT_FAIL_DOMAINS", "") + monkeypatch.setenv("LIVEWEB_SOFT_FAIL_URL_PATTERNS", "") + monkeypatch.setenv("LIVEWEB_REQUIRED_SOFT_URL_REGEXES", r"^news\.ycombinator\.com/?$,^news\.ycombinator\.com/(ask|show)(?:[/?].*)?$") + monkeypatch.setenv("LIVEWEB_PREFETCH_SOFT_URL_REGEXES", r"^channelsurfer\.tv(?:/.*)?$") + i = _interceptor() + assert i._soft_fail_policy("https://news.ycombinator.com/") == "required_soft" + assert i._soft_fail_policy("https://news.ycombinator.com/ask") == "required_soft" + assert i._soft_fail_policy("https://channelsurfer.tv/") == "prefetch_soft" + assert i._soft_fail_policy("https://news.ycombinator.com/newest") is None + + def test_default_soft_fail_domains_cover_high_noise_sites(self, monkeypatch): + monkeypatch.delenv("LIVEWEB_SOFT_FAIL_DOMAINS", raising=False) + monkeypatch.delenv("LIVEWEB_PREFETCH_SOFT_URL_REGEXES", raising=False) + i = _interceptor() + assert i._should_soft_fail_domain("https://www.taostats.io/subnets") + assert i._should_soft_fail_domain("https://www.coingecko.com/en/coins/bitcoin") + assert i._should_soft_fail_domain("https://www.stooq.com/q/currency/usd-eur") + + # ── _find_cached_page ───────────────────────────────────────────── class TestFindCachedPage: @@ -98,6 +149,18 @@ def test_incomplete_page_skipped(self): i = _interceptor(cached={normalize_url(url): page}) assert i._find_cached_page(url) is None + def test_find_any_cached_page_allows_stale_html_fallback(self): + url = "https://www.taostats.io/subnets" + page = CachedPage(url=url, html="

Subnets

", api_data=None, fetched_at=1.0, need_api=True) + i = _interceptor(cached={normalize_url(url): page}) + assert i._find_any_cached_page(url) is page + + def test_prefetch_timeout_for_high_noise_domains(self): + i = _interceptor() + assert i._prefetch_timeout_for_url("https://www.coingecko.com/en/coins/bitcoin", need_api=True) >= 35 + assert i._prefetch_timeout_for_url("https://www.taostats.io/subnets", need_api=True) >= 35 + assert i._prefetch_timeout_for_url("https://www.stooq.com/q/currency/usd-eur", need_api=False) >= 16 + # ── InterceptorStats ─────────────────────────────────────────────── @@ -139,6 +202,32 @@ def test_raise_wraps_generic_exception(self): with pytest.raises(CacheFatalError): i.raise_if_error("https://x.com") + def test_error_metadata_roundtrip(self): + i = _interceptor() + i._last_error_metadata = {"classification": "env_prefetch_timeout", "soft_fail_triggered": True} + assert i.get_and_clear_error_metadata() == { + "classification": "env_prefetch_timeout", + "soft_fail_triggered": True, + } + assert i.get_and_clear_error_metadata() == {} + + +@pytest.mark.anyio +async def test_disallowed_document_navigation_stores_structured_metadata(): + interceptor = _interceptor(domains={"coingecko.com"}) + route = _FakeRoute("https://finance.yahoo.com/quote/AAPL/") + + await interceptor.handle_route(route) + + assert route.fulfilled is not None + assert route.fulfilled["status"] == 403 + metadata = interceptor.get_last_blocked_document_metadata() + assert metadata["classification"] == "model_disallowed_domain" + assert metadata["blocked_domain"] == "finance.yahoo.com" + assert metadata["allowed_domains"] == ["coingecko.com"] + assert metadata["blocked_resource_type"] == "document" + assert metadata["blocked_by"] == "interceptor" + # ── Accessibility tree cache ─────────────────────────────────────── diff --git a/tests/core/test_llm_client.py b/tests/core/test_llm_client.py new file mode 100644 index 0000000..e4cfb17 --- /dev/null +++ b/tests/core/test_llm_client.py @@ -0,0 +1,553 @@ +import asyncio +from types import SimpleNamespace + +import httpx +import openai +import pytest + +from liveweb_arena.utils.llm_client import LLMClient, LLMResponse + + +class _FakeResponseUsage: + def __init__(self, payload=None): + self._payload = payload or {"prompt_tokens": 10, "completion_tokens": 2} + + def model_dump(self): + return dict(self._payload) + + +class _FakeToolCall: + def __init__(self, name: str, arguments: str): + self.id = "call_1" + self.function = SimpleNamespace(name=name, arguments=arguments) + + +class _FakeChoice: + def __init__(self, content: str = "", tool_calls=None, reasoning_content=None): + self.message = SimpleNamespace(content=content, tool_calls=tool_calls or [], reasoning_content=reasoning_content) + self.finish_reason = "stop" + + +class _FakeChatCompletions: + def __init__(self, recorder, *, sleep_s: float = 0.0): + self._recorder = recorder + self._sleep_s = sleep_s + + async def create(self, **kwargs): + self._recorder.append(kwargs) + if self._sleep_s: + await asyncio.sleep(self._sleep_s) + return SimpleNamespace( + id=kwargs["extra_body"]["request_id"], + choices=[_FakeChoice(tool_calls=[_FakeToolCall("goto", '{"url":"https://example.com"}')])], + usage=_FakeResponseUsage(), + ) + + +class _FakeAsyncOpenAI: + def __init__(self, recorder, *, sleep_s: float = 0.0, **kwargs): + self._recorder = recorder + self.chat = SimpleNamespace(completions=_FakeChatCompletions(recorder, sleep_s=sleep_s)) + + async def close(self): + return None + + +class _FakeHTTPXResponse: + def __init__(self, status_code=200): + self.status_code = status_code + + +class _FakeAsyncHTTPClient: + def __init__(self, recorder, *args, **kwargs): + self._recorder = recorder + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def post(self, url, json, headers=None): + self._recorder.append((url, json, headers or {})) + return _FakeHTTPXResponse() + + +class _FakeBuiltHTTPClient: + def __init__(self, recorder, *args, **kwargs): + recorder.append(kwargs) + + +class _FakeRateLimitError(Exception): + def __init__(self, message: str, *, response): + super().__init__(message) + self.response = response + + +class _RateLimitThenSuccessCompletions: + def __init__(self, recorder, failures_before_success: int = 2): + self._recorder = recorder + self._remaining = failures_before_success + + async def create(self, **kwargs): + self._recorder.append(kwargs) + if self._remaining > 0: + self._remaining -= 1 + response = httpx.Response( + 429, + headers={"retry-after": "0"}, + request=httpx.Request("POST", "https://openrouter.ai/api/v1/chat/completions"), + ) + raise _FakeRateLimitError("rate limited", response=response) + return SimpleNamespace( + id=kwargs["extra_body"]["request_id"], + choices=[_FakeChoice(tool_calls=[_FakeToolCall("goto", '{"url":"https://example.com"}')])], + usage=_FakeResponseUsage(), + ) + + +class _RateLimitThenSuccessOpenAI: + def __init__(self, completions): + self.chat = SimpleNamespace(completions=completions) + + async def close(self): + return None + + +class _ContextLimitThenSuccessCompletions: + def __init__(self, recorder): + self._recorder = recorder + self._calls = 0 + + async def create(self, **kwargs): + self._recorder.append(kwargs) + self._calls += 1 + if self._calls == 1: + raise openai.BadRequestError( + "Requested token count exceeds the model's maximum context length of 32768 tokens. " + "You requested a total of 34078 tokens: 1310 tokens from the input messages and 32768 tokens " + "for the completion. Please reduce the number of tokens in the input messages or the completion " + "to fit within the limit." + ) + return SimpleNamespace( + id=kwargs["extra_body"]["request_id"], + choices=[_FakeChoice(tool_calls=[_FakeToolCall("goto", '{"url":"https://example.com"}')])], + usage=_FakeResponseUsage(), + ) + + +class _ContextLimitThenSuccessOpenAI: + def __init__(self, recorder): + self.chat = SimpleNamespace(completions=_ContextLimitThenSuccessCompletions(recorder)) + + async def close(self): + return None + + +@pytest.mark.anyio +async def test_chat_with_tools_sends_request_id_in_extra_body(monkeypatch): + requests = [] + + def _factory(**kwargs): + return _FakeAsyncOpenAI(requests, **kwargs) + + monkeypatch.setattr("liveweb_arena.utils.llm_client.openai.AsyncOpenAI", _factory) + + client = LLMClient(base_url="http://127.0.0.1:31050/v1", api_key="local") + response = await client.chat_with_tools( + system="system", + user="user", + model="qwen", + tools=[{"type": "function", "function": {"name": "goto", "parameters": {"type": "object"}}}], + temperature=0.0, + timeout_s=5, + ) + + assert isinstance(response, LLMResponse) + assert requests + request_payload = requests[0] + assert "extra_body" in request_payload + assert request_payload["extra_body"]["request_id"].startswith("liveweb-tools-") + assert response.request_id == request_payload["extra_body"]["request_id"] + + +@pytest.mark.anyio +async def test_chat_with_tools_sends_reasoning_controls_via_extra_body(monkeypatch): + requests = [] + + def _factory(**kwargs): + return _FakeAsyncOpenAI(requests, **kwargs) + + monkeypatch.setattr("liveweb_arena.utils.llm_client.openai.AsyncOpenAI", _factory) + monkeypatch.setenv("LIVEWEB_ENABLE_THINKING", "0") + monkeypatch.setenv("LIVEWEB_SEPARATE_REASONING", "1") + + client = LLMClient(base_url="http://127.0.0.1:31050/v1", api_key="local") + await client.chat_with_tools( + system="system", + user="user", + model="qwen", + tools=[{"type": "function", "function": {"name": "goto", "parameters": {"type": "object"}}}], + temperature=0.0, + timeout_s=5, + ) + + payload = requests[0] + assert payload["extra_body"]["chat_template_kwargs"] == {"enable_thinking": False} + assert payload["extra_body"]["separate_reasoning"] is True + + +@pytest.mark.anyio +async def test_chat_with_tools_uses_openrouter_reasoning_none_when_thinking_disabled(monkeypatch): + requests = [] + + def _factory(**kwargs): + return _FakeAsyncOpenAI(requests, **kwargs) + + monkeypatch.setattr("liveweb_arena.utils.llm_client.openai.AsyncOpenAI", _factory) + monkeypatch.setenv("LIVEWEB_ENABLE_THINKING", "0") + + client = LLMClient(base_url="https://openrouter.ai/api/v1", api_key="or") + await client.chat_with_tools( + system="system", + user="user", + model="minimax/minimax-m2.7", + tools=[{"type": "function", "function": {"name": "goto", "parameters": {"type": "object"}}}], + temperature=0.0, + timeout_s=5, + ) + + payload = requests[0] + assert payload["extra_body"]["reasoning"] == {"effort": "none", "exclude": True} + + +@pytest.mark.anyio +async def test_chat_with_tools_uses_kimi_reasoning_enabled_false_when_thinking_disabled(monkeypatch): + requests = [] + + def _factory(**kwargs): + return _FakeAsyncOpenAI(requests, **kwargs) + + monkeypatch.setattr("liveweb_arena.utils.llm_client.openai.AsyncOpenAI", _factory) + monkeypatch.setenv("LIVEWEB_ENABLE_THINKING", "0") + + client = LLMClient(base_url="https://openrouter.ai/api/v1", api_key="or") + await client.chat_with_tools( + system="system", + user="user", + model="moonshotai/kimi-k2.5", + tools=[{"type": "function", "function": {"name": "goto", "parameters": {"type": "object"}}}], + temperature=0.0, + timeout_s=5, + ) + + payload = requests[0] + assert payload["extra_body"]["reasoning"] == {"enabled": False} + + +@pytest.mark.anyio +async def test_chat_with_tools_uses_low_reasoning_effort_when_enabled(monkeypatch): + requests = [] + + def _factory(**kwargs): + return _FakeAsyncOpenAI(requests, **kwargs) + + monkeypatch.setattr("liveweb_arena.utils.llm_client.openai.AsyncOpenAI", _factory) + + client = LLMClient( + base_url="https://api.aicodemirror.com/api/codex/backend-api/codex/v1", + api_key="k", + enable_thinking=True, + separate_reasoning=True, + reasoning_effort="low", + strip_reasoning_output=True, + ) + await client.chat_with_tools( + system="system", + user="user", + model="gpt-5.4", + tools=[{"type": "function", "function": {"name": "goto", "parameters": {"type": "object"}}}], + temperature=0.0, + timeout_s=5, + ) + + payload = requests[0] + assert payload["extra_body"]["chat_template_kwargs"] == {"enable_thinking": True} + assert payload["extra_body"]["separate_reasoning"] is True + assert payload["extra_body"]["reasoning"] == {"effort": "low"} + + +@pytest.mark.anyio +async def test_chat_with_tools_strips_reasoning_output_from_content_and_usage(monkeypatch): + requests = [] + + class _ReasoningOpenAI: + def __init__(self, **kwargs): + self.chat = SimpleNamespace(completions=self) + + async def create(self, **kwargs): + requests.append(kwargs) + return SimpleNamespace( + id=kwargs["extra_body"]["request_id"], + choices=[ + _FakeChoice( + content=[ + {"type": "reasoning", "text": "hidden chain of thought"}, + {"type": "output_text", "text": "Visible answer"}, + ], + tool_calls=[], + reasoning_content="hidden chain of thought", + ) + ], + usage=_FakeResponseUsage( + { + "prompt_tokens": 10, + "completion_tokens": 4, + "completion_tokens_details": {"reasoning_tokens": 9, "accepted_prediction_tokens": 0}, + } + ), + ) + + async def close(self): + return None + + monkeypatch.setattr("liveweb_arena.utils.llm_client.openai.AsyncOpenAI", _ReasoningOpenAI) + + client = LLMClient( + base_url="https://api.aicodemirror.com/api/codex/backend-api/codex/v1", + api_key="k", + enable_thinking=True, + separate_reasoning=True, + reasoning_effort="low", + strip_reasoning_output=True, + ) + response = await client.chat_with_tools( + system="system", + user="user", + model="gpt-5.4", + tools=None, + temperature=0.0, + timeout_s=5, + ) + + assert response.content == "Visible answer" + assert response.usage["completion_tokens_details"] == {"accepted_prediction_tokens": 0} + + +@pytest.mark.anyio +async def test_chat_with_tools_recovery_uses_stochastic_small_request(monkeypatch): + requests = [] + + def _factory(**kwargs): + return _FakeAsyncOpenAI(requests, **kwargs) + + monkeypatch.setattr("liveweb_arena.utils.llm_client.openai.AsyncOpenAI", _factory) + monkeypatch.setenv("LIVEWEB_FORMAT_RECOVERY_TEMPERATURE", "0.35") + monkeypatch.setenv("LIVEWEB_FORMAT_RECOVERY_TOP_P", "0.95") + + client = LLMClient(base_url="http://127.0.0.1:31050/v1", api_key="local") + await client.chat_with_tools_recovery( + system="system", + user="user prompt", + assistant_prefix="{\"name\":\"goto\"", + model="qwen", + tools=[{"type": "function", "function": {"name": "goto", "parameters": {"type": "object"}}}], + max_new_tokens=96, + ) + + payload = requests[0] + assert payload["temperature"] == 0.35 + assert payload["top_p"] == 0.95 + assert payload["max_tokens"] == 96 + assert payload["max_completion_tokens"] == 96 + assert payload["messages"][2] == {"role": "assistant", "content": "{\"name\":\"goto\""} + assert "Continue from the assistant's last output" in payload["messages"][3]["content"] + + +@pytest.mark.anyio +async def test_chat_with_tools_timeout_triggers_abort(monkeypatch): + requests = [] + aborts = [] + + def _openai_factory(**kwargs): + return _FakeAsyncOpenAI(requests, sleep_s=0.2, **kwargs) + + def _httpx_factory(*args, **kwargs): + return _FakeAsyncHTTPClient(aborts, *args, **kwargs) + + monkeypatch.setattr("liveweb_arena.utils.llm_client.openai.AsyncOpenAI", _openai_factory) + monkeypatch.setattr("liveweb_arena.utils.llm_client.httpx.AsyncClient", _httpx_factory) + + client = LLMClient(base_url="http://127.0.0.1:31050/v1", api_key="local", strict_serial=True) + + with pytest.raises(Exception): + await client.chat_with_tools( + system="system", + user="user", + model="qwen", + tools=[{"type": "function", "function": {"name": "goto", "parameters": {"type": "object"}}}], + temperature=0.0, + timeout_s=0.01, + ) + + assert requests + assert aborts + abort_url, abort_payload, abort_headers = aborts[0] + assert abort_url == "http://127.0.0.1:31050/abort_request" + assert abort_payload["rid"] == requests[0]["extra_body"]["request_id"] + assert abort_payload["abort_all"] is False + assert abort_headers["Authorization"] == "Bearer local" + assert abort_headers["X-API-Key"] == "local" + + +@pytest.mark.parametrize( + ("base_url", "should_bypass"), + [ + ("http://localhost:31050/v1", True), + ("http://127.0.0.1:31050/v1", True), + ("http://10.0.0.8:31050/v1", True), + ("http://192.168.1.10:31050/v1", True), + ("http://172.16.0.5:31050/v1", True), + ("http://169.254.0.20:31050/v1", True), + ("http://[::1]:31050/v1", True), + ("https://api.aicodemirror.com/api/codex/backend-api/codex/v1", True), + ("https://api.openai.com/v1", False), + ("https://example.com/v1", False), + ], +) +def test_should_bypass_proxy_for_local_and_private_addresses(base_url, should_bypass): + assert LLMClient._should_bypass_proxy(base_url) is should_bypass + + +def test_build_httpx_client_disables_trust_env_for_local_routes(monkeypatch): + built_clients = [] + + def _httpx_factory(*args, **kwargs): + return _FakeBuiltHTTPClient(built_clients, *args, **kwargs) + + monkeypatch.setattr("liveweb_arena.utils.llm_client.httpx.AsyncClient", _httpx_factory) + + client = LLMClient(base_url="http://127.0.0.1:31050/v1", api_key="local") + client._build_httpx_client( + base_url="http://127.0.0.1:31050/v1", + timeout=object(), + ) + client._build_httpx_client( + base_url="https://api.openai.com/v1", + timeout=object(), + ) + client._build_httpx_client( + base_url="https://api.aicodemirror.com/api/codex/backend-api/codex/v1", + timeout=object(), + ) + + assert built_clients[0]["trust_env"] is False + assert built_clients[1]["trust_env"] is True + assert built_clients[2]["trust_env"] is False + + +@pytest.mark.anyio +async def test_chat_with_tools_records_empty_response_audit(monkeypatch): + requests = [] + + class _EmptyChatCompletions: + async def create(self, **kwargs): + requests.append(kwargs) + return SimpleNamespace( + id=kwargs["extra_body"]["request_id"], + choices=[_FakeChoice(content="", tool_calls=[])], + usage=_FakeResponseUsage(), + ) + + class _EmptyAsyncOpenAI: + def __init__(self, **kwargs): + self.chat = SimpleNamespace(completions=_EmptyChatCompletions()) + + async def close(self): + return None + + monkeypatch.setattr("liveweb_arena.utils.llm_client.openai.AsyncOpenAI", lambda **kwargs: _EmptyAsyncOpenAI(**kwargs)) + + client = LLMClient(base_url="https://openrouter.ai/api/v1", api_key="k", strict_serial=True) + with pytest.raises(Exception): + await client.chat_with_tools( + system="system", + user="user", + model="z-ai/glm-5", + tools=[{"type": "function", "function": {"name": "goto", "parameters": {"type": "object"}}}], + temperature=0.0, + timeout_s=5, + ) + + audit = client.get_last_failure_metadata() + assert audit["failure_type"] == "empty_response" + assert audit["model"] == "z-ai/glm-5" + assert audit["base_url"] == "https://openrouter.ai/api/v1" + assert audit["usage"]["completion_tokens"] == 2 + + +@pytest.mark.anyio +async def test_chat_with_tools_retries_on_rate_limit_even_in_strict_serial(monkeypatch): + requests = [] + completions = _RateLimitThenSuccessCompletions(requests) + + monkeypatch.setattr( + "liveweb_arena.utils.llm_client.openai.AsyncOpenAI", + lambda **kwargs: _RateLimitThenSuccessOpenAI(completions), + ) + monkeypatch.setattr("liveweb_arena.utils.llm_client.openai.RateLimitError", _FakeRateLimitError) + + client = LLMClient(base_url="https://openrouter.ai/api/v1", api_key="k", strict_serial=True) + response = await client.chat_with_tools( + system="system", + user="user", + model="minimax/minimax-m2.7", + tools=[{"type": "function", "function": {"name": "goto", "parameters": {"type": "object"}}}], + temperature=0.0, + timeout_s=5, + ) + + assert isinstance(response, LLMResponse) + assert len(requests) == 3 + + +@pytest.mark.anyio +async def test_chat_with_tools_reduces_completion_cap_after_context_error(monkeypatch): + requests = [] + completions = _ContextLimitThenSuccessCompletions(requests) + monkeypatch.setenv("LIVEWEB_MAX_COMPLETION_TOKENS", "32768") + monkeypatch.setattr( + "liveweb_arena.utils.llm_client.openai.AsyncOpenAI", + lambda **kwargs: _RateLimitThenSuccessOpenAI(completions), + ) + + client = LLMClient(base_url="http://127.0.0.1:31050/v1", api_key="local") + async def _noop_abort(*args, **kwargs): + return None + monkeypatch.setattr(client, "_abort_request", _noop_abort) + response = await client.chat_with_tools( + system="system", + user="user", + model="qwen", + tools=[{"type": "function", "function": {"name": "goto", "parameters": {"type": "object"}}}], + temperature=0.0, + timeout_s=5, + ) + + assert isinstance(response, LLMResponse) + assert len(requests) == 2 + assert requests[0]["max_completion_tokens"] == 32768 + assert requests[1]["max_completion_tokens"] < 32768 + + +@pytest.mark.anyio +async def test_global_rate_limit_window_delays_following_requests(monkeypatch): + client = LLMClient(base_url="https://openrouter.ai/api/v1", api_key="k", strict_serial=True) + client._GLOBAL_RATE_LIMIT_UNTIL[client._base_url] = asyncio.get_running_loop().time() + 0.2 + # convert monotonic-ish test window to wall time used by implementation + import time as _time + client._GLOBAL_RATE_LIMIT_UNTIL[client._base_url] = _time.time() + 0.2 + start = _time.time() + await client._wait_for_global_rate_limit_window(client._base_url) + elapsed = _time.time() - start + assert elapsed >= 0.15 diff --git a/tests/core/test_reachability_audit.py b/tests/core/test_reachability_audit.py new file mode 100644 index 0000000..9d907a6 --- /dev/null +++ b/tests/core/test_reachability_audit.py @@ -0,0 +1,223 @@ +from liveweb_arena.core.reachability_audit import audit_reachability_failure +from liveweb_arena.plugins.coingecko.coingecko import CoinGeckoPlugin +from liveweb_arena.plugins.stooq.stooq import StooqPlugin +from liveweb_arena.plugins.taostats.taostats import TaostatsPlugin + + +def test_reachability_audit_classifies_nav_aborted_from_navigation_metadata(): + audit = audit_reachability_failure( + url="https://taostats.io/subnets/73", + plugin_name="taostats", + plugin=TaostatsPlugin(), + exception=RuntimeError("navigation failed"), + evidence={ + "navigation_metadata": { + "classification_hint": "env_nav_aborted", + "navigation_stage": "goto_domcontentloaded", + "raw_exception_type": "Error", + "raw_exception_message": "net::ERR_ABORTED; maybe frame was detached", + "attempt_index": 1, + "max_attempts": 2, + } + }, + ) + assert audit.classification == "env_nav_aborted" + assert audit.navigation_stage == "goto_domcontentloaded" + assert audit.raw_exception_type == "Error" + + +def test_reachability_audit_classifies_target_closed(): + audit = audit_reachability_failure( + url="https://www.coingecko.com/en/coins/bitcoin", + plugin_name="coingecko", + plugin=CoinGeckoPlugin(), + exception=RuntimeError("Target page, context or browser has been closed"), + evidence={ + "navigation_metadata": { + "classification_hint": "env_target_closed", + "raw_exception_type": "TargetClosedError", + "raw_exception_message": "Target page, context or browser has been closed", + } + }, + ) + assert audit.classification == "env_target_closed" + assert audit.is_environment_failure is True + + +def test_reachability_audit_classifies_from_raw_navigation_message_when_outer_error_is_generic(): + audit = audit_reachability_failure( + url="https://taostats.io/subnets/73", + plugin_name="taostats", + plugin=TaostatsPlugin(), + exception=RuntimeError("Too many consecutive action failures (5)"), + evidence={ + "navigation_metadata": { + "raw_exception_type": "Error", + "raw_exception_message": "net::ERR_ABORTED; maybe frame was detached", + "navigation_stage": "action_click", + } + }, + ) + assert audit.classification == "env_nav_aborted" + assert audit.navigation_stage == "action_click" + + +def test_reachability_audit_classifies_cdn_blocked(): + audit = audit_reachability_failure( + url="https://www.coingecko.com/en/coins/bitcoin", + plugin_name="coingecko", + plugin=CoinGeckoPlugin(), + http_status=403, + evidence={"site_probe": {"cf_ray": "abc123", "server": "cloudflare"}}, + ) + assert audit.classification == "env_cdn_blocked" + + +def test_reachability_audit_classifies_stooq_invalid_shape(): + audit = audit_reachability_failure( + url="https://stooq.com/q/?q=WMT", + plugin_name="stooq", + plugin=StooqPlugin(), + ) + assert audit.classification == "model_invalid_url_shape" + assert audit.is_model_hallucination is True + + +def test_reachability_audit_classifies_disallowed_domain_as_model_error(): + audit = audit_reachability_failure( + url="https://finance.yahoo.com/quote/AAPL/", + plugin_name=None, + plugin=None, + reason="Domain not allowed", + allowed_domains={"stooq.com"}, + evidence={ + "interceptor": { + "blocked_url": "https://finance.yahoo.com/quote/AAPL/", + "blocked_domain": "finance.yahoo.com", + "allowed_domains": ["stooq.com"], + "blocked_resource_type": "document", + "blocked_by": "interceptor", + } + }, + ) + assert audit.classification == "model_disallowed_domain" + assert audit.layer == "model" + assert audit.is_environment_failure is False + assert audit.is_model_hallucination is True + + +def test_reachability_audit_classifies_taostats_list_action_timeout(): + audit = audit_reachability_failure( + url="https://taostats.io/subnets", + plugin_name="taostats", + plugin=TaostatsPlugin(), + exception=RuntimeError("Page.click: Timeout 5000ms exceeded"), + evidence={ + "navigation_metadata": { + "navigation_stage": "action_click", + "raw_exception_type": "TimeoutError", + "raw_exception_message": "Page.click: Timeout 5000ms exceeded", + "evidence": {"selector": ".rt-th:nth-child(6)"}, + } + }, + ) + assert audit.classification == "env_taostats_list_action_timeout" + assert audit.is_environment_failure is True + assert audit.evidence["page_kind"] == "taostats_list" + assert audit.evidence["interaction_kind"] == "sort" + assert audit.evidence["target_locator"] == ".rt-th:nth-child(6)" + + +def test_reachability_audit_classifies_taostats_invalid_selector_as_model_error(): + audit = audit_reachability_failure( + url="https://taostats.io/subnets", + plugin_name="taostats", + plugin=TaostatsPlugin(), + exception=RuntimeError("Locator failed"), + evidence={ + "navigation_metadata": { + "navigation_stage": "action_click", + "raw_exception_type": "Error", + "raw_exception_message": "button:contains('30D') is not a valid selector", + "evidence": {"selector": "button:contains('30D')"}, + } + }, + ) + assert audit.classification == "model_invalid_selector" + assert audit.layer == "model" + assert audit.is_environment_failure is False + assert audit.is_model_hallucination is True + assert audit.evidence["page_kind"] == "taostats_list" + assert audit.evidence["selector_syntax_invalid"] is True + + +def test_reachability_audit_classifies_taostats_missing_ui_target_as_model_error(): + audit = audit_reachability_failure( + url="https://taostats.io/subnets", + plugin_name="taostats", + plugin=TaostatsPlugin(), + exception=RuntimeError("Locator failed"), + evidence={ + "navigation_metadata": { + "navigation_stage": "action_click_role", + "raw_exception_type": "Exception", + "raw_exception_message": "No element found with role='button' name='Rows: 25'", + "evidence": {"role": "button", "name": "Rows: 25"}, + } + }, + ) + assert audit.classification == "model_invalid_ui_target" + assert audit.layer == "model" + assert audit.is_environment_failure is False + assert audit.is_model_hallucination is True + assert audit.evidence["page_kind"] == "taostats_list" + assert audit.evidence["ui_target_missing"] is True + + +def test_reachability_audit_classifies_taostats_detail_prefetch_invalidated(): + audit = audit_reachability_failure( + url="https://taostats.io/subnets/73", + plugin_name="taostats", + plugin=TaostatsPlugin(), + exception=RuntimeError("Target page, context or browser has been closed"), + evidence={ + "taostats_prefetch": { + "page_kind": "taostats_detail", + "prefetch_phase": "setup_page_for_cache", + "wait_target": "text=Statistics", + "background_refresh": False, + }, + "navigation_metadata": { + "raw_exception_type": "TargetClosedError", + "raw_exception_message": "Target page, context or browser has been closed", + }, + }, + ) + assert audit.classification == "env_taostats_detail_prefetch_invalidated" + assert audit.is_environment_failure is True + assert audit.evidence["page_kind"] == "taostats_detail" + assert audit.evidence["prefetch_phase"] == "setup_page_for_cache" + assert audit.evidence["wait_target"] == "text=Statistics" + + +def test_reachability_audit_keeps_taostats_detail_soft_setup_out_of_invalidated(): + audit = audit_reachability_failure( + url="https://taostats.io/subnets/73", + plugin_name="taostats", + plugin=TaostatsPlugin(), + exception=RuntimeError("Page.click: Timeout 5000ms exceeded"), + evidence={ + "taostats_prefetch": { + "page_kind": "taostats_detail", + "prefetch_phase": "setup_page_for_cache", + "wait_target": "text=Price Impact", + "page_body_ready": True, + "detail_setup_soft_failed": True, + }, + "navigation_metadata": { + "raw_exception_type": "TimeoutError", + "raw_exception_message": "Page.click: Timeout 5000ms exceeded", + }, + }, + ) + assert audit.classification == "env_nav_timeout" diff --git a/tests/core/test_stooq_plugin.py b/tests/core/test_stooq_plugin.py new file mode 100644 index 0000000..eb95ac6 --- /dev/null +++ b/tests/core/test_stooq_plugin.py @@ -0,0 +1,88 @@ +import pytest + +from liveweb_arena.plugins.base_client import APIFetchError +from liveweb_arena.plugins.stooq import api_client +from liveweb_arena.plugins.stooq.stooq import StooqPlugin + + +@pytest.mark.anyio +async def test_stooq_plugin_propagates_failure_metadata(monkeypatch): + plugin = StooqPlugin() + + async def _raise(*args, **kwargs): + raise APIFetchError( + "Stooq API returned no data for symbol=msft", + source="stooq", + metadata={ + "plugin": "stooq", + "failure_stage": "api_fetch", + "failure_type": "no_data", + "symbol": "msft", + "request_url": "https://stooq.com/q/d/l/?s=msft&i=d", + }, + ) + + monkeypatch.setattr( + "liveweb_arena.plugins.stooq.stooq.fetch_single_asset_data", + _raise, + ) + + with pytest.raises(APIFetchError) as exc_info: + await plugin.fetch_api_data("https://stooq.com/q/?s=msft") + + metadata = exc_info.value.metadata + assert metadata["plugin"] == "stooq" + assert metadata["failure_stage"] == "api_fetch" + assert metadata["failure_type"] == "no_data" + assert metadata["symbol"] == "msft" + assert metadata["request_url"] == "https://stooq.com/q/d/l/?s=msft&i=d" + assert metadata["page_url"] == "https://stooq.com/q/?s=msft" + + +@pytest.mark.anyio +async def test_fetch_single_asset_data_uses_cached_symbol_when_rate_limited(monkeypatch): + monkeypatch.setattr(api_client, "_load_symbol_cache", lambda symbol, allow_stale=False: {"symbol": symbol, "close": 1.23}) + token = api_client._rate_limited.set(True) + try: + data = await api_client.fetch_single_asset_data("jnj.us") + finally: + api_client._rate_limited.reset(token) + assert data["symbol"] == "jnj.us" + + +def test_stooq_quote_warmup_swallows_sync_failures(monkeypatch): + plugin = StooqPlugin() + monkeypatch.setattr(StooqPlugin, "_quote_warmup_started", False) + monkeypatch.setattr("liveweb_arena.plugins.stooq.stooq.asyncio.get_running_loop", lambda: (_ for _ in ()).throw(RuntimeError())) + def _raise_run(coro): + coro.close() + raise RuntimeError("warmup failed") + monkeypatch.setattr("liveweb_arena.plugins.stooq.stooq.asyncio.run", _raise_run) + + plugin._initialize_quote_warmup() + + +def test_stooq_quote_warmup_is_single_shot(monkeypatch): + plugin = StooqPlugin() + monkeypatch.setattr("liveweb_arena.plugins.stooq.stooq.initialize_cache", lambda: None) + + started = [] + + class _DummyThread: + def __init__(self, *, target, name, daemon): + self._target = target + self.name = name + self.daemon = daemon + + def start(self): + started.append((self.name, self.daemon)) + + monkeypatch.setattr("liveweb_arena.plugins.stooq.stooq.threading.Thread", _DummyThread) + monkeypatch.setattr("liveweb_arena.plugins.stooq.stooq.asyncio.get_running_loop", lambda: object()) + monkeypatch.setattr(api_client, "_load_symbol_cache", lambda symbol, allow_stale=False: None) + monkeypatch.setattr(StooqPlugin, "_quote_warmup_started", False) + + plugin._initialize_quote_warmup() + plugin._initialize_quote_warmup() + + assert started == [("stooq-quote-warmup", True)] diff --git a/tests/core/validators/test_llm_validator_models.py b/tests/core/validators/test_llm_validator_models.py index 3986485..f4d4458 100644 --- a/tests/core/validators/test_llm_validator_models.py +++ b/tests/core/validators/test_llm_validator_models.py @@ -1,4 +1,5 @@ from liveweb_arena.core.validators.llm_validator import ( + OPENROUTER_VALIDATION_MODELS, OPENAI_VALIDATION_MODELS, VALIDATION_MODELS, _get_validation_models, @@ -18,11 +19,27 @@ def test_validation_models_use_env_override(monkeypatch): def test_validation_models_use_openai_defaults_when_on_openai(monkeypatch): monkeypatch.delenv("VALIDATION_MODELS", raising=False) + monkeypatch.delenv("VALIDATION_OPENAI_MODELS", raising=False) client = _DummyLLMClient("https://api.openai.com/v1") assert _get_validation_models(client) == OPENAI_VALIDATION_MODELS +def test_validation_models_use_openrouter_defaults_when_on_openrouter(monkeypatch): + monkeypatch.delenv("VALIDATION_MODELS", raising=False) + monkeypatch.delenv("VALIDATION_OPENROUTER_MODELS", raising=False) + client = _DummyLLMClient("https://openrouter.ai/api/v1") + assert _get_validation_models(client) == OPENROUTER_VALIDATION_MODELS + + +def test_validation_models_use_openrouter_env_override(monkeypatch): + monkeypatch.delenv("VALIDATION_MODELS", raising=False) + monkeypatch.setenv("VALIDATION_OPENROUTER_MODELS", "model-x, model-y") + client = _DummyLLMClient("https://openrouter.ai/api/v1") + assert _get_validation_models(client) == ["model-x", "model-y"] + + def test_validation_models_use_project_defaults_for_other_providers(monkeypatch): monkeypatch.delenv("VALIDATION_MODELS", raising=False) + monkeypatch.delenv("VALIDATION_OPENROUTER_MODELS", raising=False) client = _DummyLLMClient("https://llm.chutes.ai/v1") assert _get_validation_models(client) == VALIDATION_MODELS diff --git a/tests/experiments/test_think_ablation_common.py b/tests/experiments/test_think_ablation_common.py new file mode 100644 index 0000000..f662bb2 --- /dev/null +++ b/tests/experiments/test_think_ablation_common.py @@ -0,0 +1,49 @@ +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from experiments.think_ablation.common import ( + crop_observation_prompt, + exact_action_match, + normalize_action, + normalized_action_match, + observation_overlap_ratio, +) + + +def test_normalize_action_denormalizes_stop(): + action = {"action_type": "stop", "params": {"final": {"answers": {"answer1": "42"}}}} + normalized = normalize_action(action) + assert normalized == {"action_type": "stop", "params": {"answers": {"answer1": "42"}}} + + +def test_exact_and_normalized_action_match_for_goto(): + reference = {"action_type": "goto", "params": {"url": "https://news.ycombinator.com/ask/"}} + predicted = {"action_type": "goto", "params": {"url": "http://news.ycombinator.com/ask"}} + assert not exact_action_match(reference, predicted) + assert normalized_action_match(reference, predicted) + + +def test_crop_observation_prompt_only_crops_tree(): + prompt = ( + "### Accessibility Tree\n```\n" + + "A" * 200 + + "\n```\n\nWhat is your next action?" + ) + cropped = crop_observation_prompt(prompt, 50) + assert len(cropped) < len(prompt) + assert "What is your next action?" in cropped + + +def test_observation_overlap_ratio_uses_page_tokens(): + think = "I should click the ask hn link on hacker news to compare the ask posts." + observation = { + "url": "https://news.ycombinator.com/", + "title": "Hacker News", + "accessibility_tree": "link Ask HN\nlink Show HN\nlink Jobs", + } + ratio = observation_overlap_ratio(think, observation) + assert ratio > 0.2 diff --git a/tests/plugins/test_taostats_plugin.py b/tests/plugins/test_taostats_plugin.py new file mode 100644 index 0000000..212915e --- /dev/null +++ b/tests/plugins/test_taostats_plugin.py @@ -0,0 +1,136 @@ +import pytest + +from liveweb_arena.plugins.taostats.taostats import TaostatsPlugin + + +class _FakeLocator: + def __init__( + self, + *, + visible: bool = False, + click_exc: Exception | None = None, + wait_exc: Exception | None = None, + select_exc: Exception | None = None, + ): + self._visible = visible + self._click_exc = click_exc + self._wait_exc = wait_exc + self._select_exc = select_exc + + @property + def first(self): + return self + + async def is_visible(self, timeout=None): + return self._visible + + async def click(self, timeout=None): + if self._click_exc is not None: + raise self._click_exc + + async def wait_for(self, timeout=None): + if self._wait_exc is not None: + raise self._wait_exc + + async def select_option(self, label=None, value=None, timeout=None): + if self._select_exc is not None: + raise self._select_exc + + +class _FakePage: + def __init__(self, *, snapshot, text_wait_exc=None, text_locators=None, role_locators=None, css_locators=None): + self._snapshot = snapshot + self._text_wait_exc = text_wait_exc + self._text_locators = text_locators or {} + self._role_locators = role_locators or {} + self._css_locators = css_locators or {} + + async def wait_for_function(self, script, arg, timeout=None): + if self._text_wait_exc is not None: + raise self._text_wait_exc + + async def evaluate(self, script): + return self._snapshot + + def get_by_text(self, text, exact=False): + return self._text_locators.get(text, _FakeLocator()) + + def get_by_role(self, role, name=None): + return self._role_locators.get((role, name), _FakeLocator()) + + def locator(self, selector): + return self._css_locators.get(selector, _FakeLocator()) + + async def wait_for_timeout(self, timeout_ms): + return None + + async def wait_for_load_state(self, state, timeout=None): + return None + + +@pytest.mark.asyncio +async def test_taostats_list_setup_succeeds_when_show_all_fails_but_body_ready(): + plugin = TaostatsPlugin() + page = _FakePage( + snapshot={ + "textLength": 240, + "textSample": "Subnets Netuid 1 2 3", + "hasMain": True, + "hasTable": True, + }, + text_locators={"ALL": _FakeLocator(visible=False)}, + role_locators={("button", "ALL"): _FakeLocator(visible=False)}, + css_locators={"select": _FakeLocator(select_exc=RuntimeError("no select"))}, + ) + + result = await plugin.setup_page_for_cache(page, "https://taostats.io/subnets") + + assert result["page_kind"] == "taostats_list" + assert result["page_body_ready"] is True + assert result["list_setup_soft_failed"] is True + assert result["expanded"] is False + + +@pytest.mark.asyncio +async def test_taostats_detail_setup_succeeds_when_price_impact_missing_but_body_ready(): + plugin = TaostatsPlugin() + page = _FakePage( + snapshot={ + "textLength": 320, + "textSample": "Subnet 73 Netuid 73 Statistics Emission Tempo", + "hasMain": True, + "hasTable": False, + }, + css_locators={ + "text=Subnet": _FakeLocator(wait_exc=RuntimeError("missing heading")), + "text=Netuid": _FakeLocator(wait_exc=RuntimeError("missing netuid")), + "text=Statistics": _FakeLocator(wait_exc=RuntimeError("missing statistics")), + "text=Transactions": _FakeLocator(wait_exc=RuntimeError("missing transactions")), + "text=Holders": _FakeLocator(wait_exc=RuntimeError("missing holders")), + "text=Price Impact": _FakeLocator(wait_exc=RuntimeError("missing price impact")), + }, + ) + + result = await plugin.setup_page_for_cache(page, "https://taostats.io/subnets/73") + + assert result["page_kind"] == "taostats_detail" + assert result["page_body_ready"] is True + assert result["detail_setup_soft_failed"] is True + assert result["wait_target"] == "text=Price Impact" + + +@pytest.mark.asyncio +async def test_taostats_detail_setup_fails_when_primary_body_never_ready(): + plugin = TaostatsPlugin() + page = _FakePage( + snapshot={ + "textLength": 24, + "textSample": "loading", + "hasMain": False, + "hasTable": False, + }, + text_wait_exc=RuntimeError("body not ready"), + ) + + with pytest.raises(RuntimeError, match="body did not become ready"): + await plugin.setup_page_for_cache(page, "https://taostats.io/subnets/73") diff --git a/tests/test_batch_eval_script.py b/tests/test_batch_eval_script.py new file mode 100644 index 0000000..83eeb69 --- /dev/null +++ b/tests/test_batch_eval_script.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import argparse +import asyncio +import importlib.util +import json +import sys +import types +from pathlib import Path + + +SCRIPT_PATH = Path("/home/xmyf/liveweb-arena/scripts/batch_eval.py") +dotenv_stub = types.ModuleType("dotenv") +dotenv_stub.load_dotenv = lambda *args, **kwargs: None +sys.modules.setdefault("dotenv", dotenv_stub) +env_stub = types.ModuleType("env") +env_stub.Actor = object +sys.modules.setdefault("env", env_stub) +SPEC = importlib.util.spec_from_file_location("batch_eval_script", SCRIPT_PATH) +assert SPEC is not None and SPEC.loader is not None +batch_eval = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(batch_eval) + + +class _FakeBrowser: + def __init__(self): + self.closed = False + + async def close(self): + self.closed = True + + +class _FakeActor: + def __init__(self): + self.browser = _FakeBrowser() + + +def test_maybe_cleanup_actor_falls_back_to_browser_close(): + actor = _FakeActor() + asyncio.run(batch_eval._maybe_cleanup_actor(actor)) + assert actor.browser.closed is True + + +def test_write_summary_includes_status_and_error_path(tmp_path: Path): + result_path = tmp_path / "results.jsonl" + task_ids_path = tmp_path / "task_ids.txt" + summary_path = tmp_path / "summary.json" + error_path = tmp_path / "error.txt" + task_ids_path.write_text("1\n") + error_path.write_text("traceback") + + args = argparse.Namespace( + model="test-model", + base_url=None, + server_pool_file="/tmp/server_pool.json", + max_concurrency=8, + ) + + summary = batch_eval._write_summary( + summary_path, + [{"score": 1.0, "success": True, "extra": {"task_id": 1}}], + task_ids_path=task_ids_path, + result_path=result_path, + args=args, + status="failed", + error_path=error_path, + ) + + loaded = json.loads(summary_path.read_text()) + assert loaded["status"] == "failed" + assert loaded["error_path"] == str(error_path) + assert loaded["mean_score"] == 1.0 + assert loaded["success_rate"] == 1.0 + assert summary["status"] == "failed" + + +def test_load_task_ids_respects_min_unique_plugins(monkeypatch): + args = argparse.Namespace( + task_ids_file=None, + num_prompts=5, + seed=123, + include_plugins="", + exclude_plugins="openlibrary,weather", + min_unique_plugins=2, + ) + + task_ids = batch_eval._load_task_ids(args) + + assert len(task_ids) == 5 + for task_id in task_ids: + cfg = batch_eval.parse_task_id(task_id) + assert len({plugin for plugin, _ in cfg["templates"]}) >= 2 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..c3592a9 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1870 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "affinetes" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "docker" }, + { name = "httpx" }, + { name = "nest-asyncio" }, + { name = "paramiko" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "tabulate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/ac/84cb9e52aa333df27cf24d403d099b7e66acffe0be8ed966e0adcb8909ce/affinetes-0.2.0.tar.gz", hash = "sha256:5d0711f32f1600e2575acf917d821849f2bd9290afe3cbb07412c497b61268a8", size = 72814, upload-time = "2026-02-03T06:05:38.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/5a/bbd2a5d30a20c727ef334d3c6e13b351087c06b769295c005be9ead36276/affinetes-0.2.0-py3-none-any.whl", hash = "sha256:e74e4f4f4f31815ae5e55478ac651c36da16f8763cd08e8a94ec5d5572b05be7", size = 77831, upload-time = "2026-02-03T06:05:36.994Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/d6/5aec9313ee6ea9c7cde8b891b69f4ff4001416867104580670a31daeba5b/aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7", size = 738950, upload-time = "2026-01-03T17:29:13.002Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/8fa90a7e6d11ff20a18837a8e2b5dd23db01aabc475aa9271c8ad33299f5/aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821", size = 496099, upload-time = "2026-01-03T17:29:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/b81f744d402510a8366b74eb420fc0cc1170d0c43daca12d10814df85f10/aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845", size = 491072, upload-time = "2026-01-03T17:29:16.922Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e1/56d1d1c0dd334cd203dd97706ce004c1aa24b34a813b0b8daf3383039706/aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af", size = 1671588, upload-time = "2026-01-03T17:29:18.539Z" }, + { url = "https://files.pythonhosted.org/packages/5f/34/8d7f962604f4bc2b4e39eb1220dac7d4e4cba91fb9ba0474b4ecd67db165/aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940", size = 1640334, upload-time = "2026-01-03T17:29:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/94/1d/fcccf2c668d87337ddeef9881537baee13c58d8f01f12ba8a24215f2b804/aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160", size = 1722656, upload-time = "2026-01-03T17:29:22.531Z" }, + { url = "https://files.pythonhosted.org/packages/aa/98/c6f3b081c4c606bc1e5f2ec102e87d6411c73a9ef3616fea6f2d5c98c062/aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7", size = 1817625, upload-time = "2026-01-03T17:29:24.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c0/cfcc3d2e11b477f86e1af2863f3858c8850d751ce8dc39c4058a072c9e54/aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455", size = 1672604, upload-time = "2026-01-03T17:29:26.099Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/6b4ffcbcac4c6a5d041343a756f34a6dd26174ae07f977a64fe028dda5b0/aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279", size = 1554370, upload-time = "2026-01-03T17:29:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f0/e3ddfa93f17d689dbe014ba048f18e0c9f9b456033b70e94349a2e9048be/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e", size = 1642023, upload-time = "2026-01-03T17:29:30.002Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/c14019c9ec60a8e243d06d601b33dcc4fd92379424bde3021725859d7f99/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d", size = 1649680, upload-time = "2026-01-03T17:29:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fd/09c9451dae5aa5c5ed756df95ff9ef549d45d4be663bafd1e4954fd836f0/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808", size = 1692407, upload-time = "2026-01-03T17:29:33.392Z" }, + { url = "https://files.pythonhosted.org/packages/a6/81/938bc2ec33c10efd6637ccb3d22f9f3160d08e8f3aa2587a2c2d5ab578eb/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40", size = 1543047, upload-time = "2026-01-03T17:29:34.855Z" }, + { url = "https://files.pythonhosted.org/packages/f7/23/80488ee21c8d567c83045e412e1d9b7077d27171591a4eb7822586e8c06a/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29", size = 1715264, upload-time = "2026-01-03T17:29:36.389Z" }, + { url = "https://files.pythonhosted.org/packages/e2/83/259a8da6683182768200b368120ab3deff5370bed93880fb9a3a86299f34/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11", size = 1657275, upload-time = "2026-01-03T17:29:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4f/2c41f800a0b560785c10fb316216ac058c105f9be50bdc6a285de88db625/aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd", size = 434053, upload-time = "2026-01-03T17:29:40.074Z" }, + { url = "https://files.pythonhosted.org/packages/80/df/29cd63c7ecfdb65ccc12f7d808cac4fa2a19544660c06c61a4a48462de0c/aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c", size = 456687, upload-time = "2026-01-03T17:29:41.819Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, + { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, + { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, + { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, + { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, + { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, + { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, + { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, + { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, + { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, + { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, + { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, + { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, + { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, + { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, + { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, + { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, + { url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" }, + { url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/21/a2b1505639008ba2e6ef03733a81fc6cfd6a07ea6139a2b76421230b8dad/charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765", size = 283319, upload-time = "2026-03-06T06:00:26.433Z" }, + { url = "https://files.pythonhosted.org/packages/70/67/df234c29b68f4e1e095885c9db1cb4b69b8aba49cf94fac041db4aaf1267/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990", size = 189974, upload-time = "2026-03-06T06:00:28.222Z" }, + { url = "https://files.pythonhosted.org/packages/df/7f/fc66af802961c6be42e2c7b69c58f95cbd1f39b0e81b3365d8efe2a02a04/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2", size = 207866, upload-time = "2026-03-06T06:00:29.769Z" }, + { url = "https://files.pythonhosted.org/packages/c9/23/404eb36fac4e95b833c50e305bba9a241086d427bb2167a42eac7c4f7da4/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765", size = 203239, upload-time = "2026-03-06T06:00:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2f/8a1d989bfadd120c90114ab33e0d2a0cbde05278c1fc15e83e62d570f50a/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d", size = 196529, upload-time = "2026-03-06T06:00:32.608Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0c/c75f85ff7ca1f051958bb518cd43922d86f576c03947a050fbedfdfb4f15/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8", size = 184152, upload-time = "2026-03-06T06:00:33.93Z" }, + { url = "https://files.pythonhosted.org/packages/f9/20/4ed37f6199af5dde94d4aeaf577f3813a5ec6635834cda1d957013a09c76/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412", size = 195226, upload-time = "2026-03-06T06:00:35.469Z" }, + { url = "https://files.pythonhosted.org/packages/28/31/7ba1102178cba7c34dcc050f43d427172f389729e356038f0726253dd914/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2", size = 192933, upload-time = "2026-03-06T06:00:36.83Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/f86443ab3921e6a60b33b93f4a1161222231f6c69bc24fb18f3bee7b8518/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1", size = 185647, upload-time = "2026-03-06T06:00:38.367Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/08b8be891760f1f5a6d23ce11d6d50c92981603e6eb740b4f72eea9424e2/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4", size = 209533, upload-time = "2026-03-06T06:00:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/df114f23406199f8af711ddccfbf409ffbc5b7cdc18fa19644997ff0c9bb/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f", size = 195901, upload-time = "2026-03-06T06:00:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/07/83/71ef34a76fe8aa05ff8f840244bda2d61e043c2ef6f30d200450b9f6a1be/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550", size = 204950, upload-time = "2026-03-06T06:00:45.202Z" }, + { url = "https://files.pythonhosted.org/packages/58/40/0253be623995365137d7dc68e45245036207ab2227251e69a3d93ce43183/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2", size = 198546, upload-time = "2026-03-06T06:00:46.481Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5c/5f3cb5b259a130895ef5ae16b38eaf141430fa3f7af50cd06c5d67e4f7b2/charset_normalizer-3.4.5-cp310-cp310-win32.whl", hash = "sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475", size = 132516, upload-time = "2026-03-06T06:00:47.924Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c3/84fb174e7770f2df2e1a2115090771bfbc2227fb39a765c6d00568d1aab4/charset_normalizer-3.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05", size = 142906, upload-time = "2026-03-06T06:00:49.389Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b2/6f852f8b969f2cbd0d4092d2e60139ab1af95af9bb651337cae89ec0f684/charset_normalizer-3.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064", size = 133258, upload-time = "2026-03-06T06:00:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9e/bcec3b22c64ecec47d39bf5167c2613efd41898c019dccd4183f6aa5d6a7/charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", size = 279531, upload-time = "2026-03-06T06:00:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/58/12/81fd25f7e7078ab5d1eedbb0fac44be4904ae3370a3bf4533c8f2d159acd/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", size = 188006, upload-time = "2026-03-06T06:00:53.8Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6e/f2d30e8c27c1b0736a6520311982cf5286cfc7f6cac77d7bc1325e3a23f2/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", size = 205085, upload-time = "2026-03-06T06:00:55.311Z" }, + { url = "https://files.pythonhosted.org/packages/d0/90/d12cefcb53b5931e2cf792a33718d7126efb116a320eaa0742c7059a95e4/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923", size = 200545, upload-time = "2026-03-06T06:00:56.532Z" }, + { url = "https://files.pythonhosted.org/packages/03/f4/44d3b830a20e89ff82a3134912d9a1cf6084d64f3b95dcad40f74449a654/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81", size = 193863, upload-time = "2026-03-06T06:00:57.823Z" }, + { url = "https://files.pythonhosted.org/packages/25/4b/f212119c18a6320a9d4a730d1b4057875cdeabf21b3614f76549042ef8a8/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497", size = 181827, upload-time = "2026-03-06T06:00:59.323Z" }, + { url = "https://files.pythonhosted.org/packages/74/00/b26158e48b425a202a92965f8069e8a63d9af1481dfa206825d7f74d2a3c/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c", size = 191085, upload-time = "2026-03-06T06:01:00.546Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1c1737bf6fd40335fe53d28fe49afd99ee4143cc57a845e99635ce0b9b6d/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e", size = 190688, upload-time = "2026-03-06T06:01:02.479Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3d/abb5c22dc2ef493cd56522f811246a63c5427c08f3e3e50ab663de27fcf4/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f", size = 183077, upload-time = "2026-03-06T06:01:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/44/33/5298ad4d419a58e25b3508e87f2758d1442ff00c2471f8e0403dab8edad5/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e", size = 206706, upload-time = "2026-03-06T06:01:05.773Z" }, + { url = "https://files.pythonhosted.org/packages/7b/17/51e7895ac0f87c3b91d276a449ef09f5532a7529818f59646d7a55089432/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af", size = 191665, upload-time = "2026-03-06T06:01:07.473Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/cce9adf1883e98906dbae380d769b4852bb0fa0004bc7d7a2243418d3ea8/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85", size = 201950, upload-time = "2026-03-06T06:01:08.973Z" }, + { url = "https://files.pythonhosted.org/packages/08/ca/bce99cd5c397a52919e2769d126723f27a4c037130374c051c00470bcd38/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f", size = 195830, upload-time = "2026-03-06T06:01:10.155Z" }, + { url = "https://files.pythonhosted.org/packages/87/4f/2e3d023a06911f1281f97b8f036edc9872167036ca6f55cc874a0be6c12c/charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4", size = 132029, upload-time = "2026-03-06T06:01:11.706Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/a853b73d386521fd44b7f67ded6b17b7b2367067d9106a5c4b44f9a34274/charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a", size = 142404, upload-time = "2026-03-06T06:01:12.865Z" }, + { url = "https://files.pythonhosted.org/packages/b4/10/dba36f76b71c38e9d391abe0fd8a5b818790e053c431adecfc98c35cd2a9/charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c", size = 132796, upload-time = "2026-03-06T06:01:14.106Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" }, + { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" }, + { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" }, + { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" }, + { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" }, + { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" }, + { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, + { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, + { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, + { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, + { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, + { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, + { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" }, + { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" }, + { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" }, + { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" }, + { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, + { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, + { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, + { url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" }, + { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, + { url = "https://files.pythonhosted.org/packages/ac/78/f93e840cbaef8becaf6adafbaf1319682a6c2d8c1c20224267a5c6c8c891/greenlet-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:5d0e35379f93a6d0222de929a25ab47b5eb35b5ef4721c2b9cbcc4036129ff1f", size = 230092, upload-time = "2026-02-20T20:17:09.379Z" }, + { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, + { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, + { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, + { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3a/efb2cf697fbccdf75b24e2c18025e7dfa54c4f31fab75c51d0fe79942cef/greenlet-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5", size = 230389, upload-time = "2026-02-20T20:17:18.772Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a1/65bbc059a43a7e2143ec4fc1f9e3f673e04f9c7b371a494a101422ac4fd5/greenlet-3.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd", size = 229645, upload-time = "2026-02-20T20:18:18.695Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, + { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +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 = "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 = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "invoke" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/bd/b461d3424a24c80490313fd77feeb666ca4f6a28c7e72713e3d9095719b4/invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707", size = 304762, upload-time = "2025-10-11T00:36:35.172Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8", size = 160287, upload-time = "2025-10-11T00:36:33.703Z" }, +] + +[[package]] +name = "jiter" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/5a/41da76c5ea07bec1b0472b6b2fdb1b651074d504b19374d7e130e0cdfb25/jiter-0.13.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2ffc63785fd6c7977defe49b9824ae6ce2b2e2b77ce539bdaf006c26da06342e", size = 311164, upload-time = "2026-02-02T12:35:17.688Z" }, + { url = "https://files.pythonhosted.org/packages/40/cb/4a1bf994a3e869f0d39d10e11efb471b76d0ad70ecbfb591427a46c880c2/jiter-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a638816427006c1e3f0013eb66d391d7a3acda99a7b0cf091eff4497ccea33a", size = 320296, upload-time = "2026-02-02T12:35:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/09/82/acd71ca9b50ecebadc3979c541cd717cce2fe2bc86236f4fa597565d8f1a/jiter-0.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19928b5d1ce0ff8c1ee1b9bdef3b5bfc19e8304f1b904e436caf30bc15dc6cf5", size = 352742, upload-time = "2026-02-02T12:35:21.258Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/d1fc996f3aecfd42eb70922edecfb6dd26421c874503e241153ad41df94f/jiter-0.13.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:309549b778b949d731a2f0e1594a3f805716be704a73bf3ad9a807eed5eb5721", size = 363145, upload-time = "2026-02-02T12:35:24.653Z" }, + { url = "https://files.pythonhosted.org/packages/f1/61/a30492366378cc7a93088858f8991acd7d959759fe6138c12a4644e58e81/jiter-0.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcdabaea26cb04e25df3103ce47f97466627999260290349a88c8136ecae0060", size = 487683, upload-time = "2026-02-02T12:35:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/20/4e/4223cffa9dbbbc96ed821c5aeb6bca510848c72c02086d1ed3f1da3d58a7/jiter-0.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a377af27b236abbf665a69b2bdd680e3b5a0bd2af825cd3b81245279a7606c", size = 373579, upload-time = "2026-02-02T12:35:27.582Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c9/b0489a01329ab07a83812d9ebcffe7820a38163c6d9e7da644f926ff877c/jiter-0.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe49d3ff6db74321f144dff9addd4a5874d3105ac5ba7c5b77fac099cfae31ae", size = 362904, upload-time = "2026-02-02T12:35:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/05/af/53e561352a44afcba9a9bc67ee1d320b05a370aed8df54eafe714c4e454d/jiter-0.13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2113c17c9a67071b0f820733c0893ed1d467b5fcf4414068169e5c2cabddb1e2", size = 392380, upload-time = "2026-02-02T12:35:30.385Z" }, + { url = "https://files.pythonhosted.org/packages/76/2a/dd805c3afb8ed5b326c5ae49e725d1b1255b9754b1b77dbecdc621b20773/jiter-0.13.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ab1185ca5c8b9491b55ebf6c1e8866b8f68258612899693e24a92c5fdb9455d5", size = 517939, upload-time = "2026-02-02T12:35:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/7b67d76f55b8fe14c937e7640389612f05f9a4145fc28ae128aaa5e62257/jiter-0.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9621ca242547edc16400981ca3231e0c91c0c4c1ab8573a596cd9bb3575d5c2b", size = 551696, upload-time = "2026-02-02T12:35:33.306Z" }, + { url = "https://files.pythonhosted.org/packages/85/9c/57cdd64dac8f4c6ab8f994fe0eb04dc9fd1db102856a4458fcf8a99dfa62/jiter-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a7637d92b1c9d7a771e8c56f445c7f84396d48f2e756e5978840ecba2fac0894", size = 204592, upload-time = "2026-02-02T12:35:34.58Z" }, + { url = "https://files.pythonhosted.org/packages/a7/38/f4f3ea5788b8a5bae7510a678cdc747eda0c45ffe534f9878ff37e7cf3b3/jiter-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c1b609e5cbd2f52bb74fb721515745b407df26d7b800458bd97cb3b972c29e7d", size = 206016, upload-time = "2026-02-02T12:35:36.435Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, + { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0d/061faffcfe94608cbc28a0d42a77a74222bdf5055ccdbe5fd2292b94f510/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c", size = 362587, upload-time = "2026-02-02T12:35:42.025Z" }, + { url = "https://files.pythonhosted.org/packages/92/c9/c66a7864982fd38a9773ec6e932e0398d1262677b8c60faecd02ffb67bf3/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4", size = 487537, upload-time = "2026-02-02T12:35:43.459Z" }, + { url = "https://files.pythonhosted.org/packages/6c/86/84eb4352cd3668f16d1a88929b5888a3fe0418ea8c1dfc2ad4e7bf6e069a/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165", size = 373717, upload-time = "2026-02-02T12:35:44.928Z" }, + { url = "https://files.pythonhosted.org/packages/6e/09/9fe4c159358176f82d4390407a03f506a8659ed13ca3ac93a843402acecf/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018", size = 362683, upload-time = "2026-02-02T12:35:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5e/85f3ab9caca0c1d0897937d378b4a515cae9e119730563572361ea0c48ae/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411", size = 392345, upload-time = "2026-02-02T12:35:48.088Z" }, + { url = "https://files.pythonhosted.org/packages/12/4c/05b8629ad546191939e6f0c2f17e29f542a398f4a52fb987bc70b6d1eb8b/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5", size = 517775, upload-time = "2026-02-02T12:35:49.482Z" }, + { url = "https://files.pythonhosted.org/packages/4d/88/367ea2eb6bc582c7052e4baf5ddf57ebe5ab924a88e0e09830dfb585c02d/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3", size = 551325, upload-time = "2026-02-02T12:35:51.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/12/fa377ffb94a2f28c41afaed093e0d70cfe512035d5ecb0cad0ae4792d35e/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1", size = 204709, upload-time = "2026-02-02T12:35:52.467Z" }, + { url = "https://files.pythonhosted.org/packages/cb/16/8e8203ce92f844dfcd3d9d6a5a7322c77077248dbb12da52d23193a839cd/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654", size = 204560, upload-time = "2026-02-02T12:35:53.925Z" }, + { url = "https://files.pythonhosted.org/packages/44/26/97cc40663deb17b9e13c3a5cf29251788c271b18ee4d262c8f94798b8336/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5", size = 189608, upload-time = "2026-02-02T12:35:55.304Z" }, + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, + { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" }, + { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" }, + { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" }, + { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" }, + { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" }, + { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" }, + { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" }, + { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, + { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, + { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, + { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, + { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, + { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, + { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016, upload-time = "2026-02-02T12:37:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024, upload-time = "2026-02-02T12:37:44.774Z" }, + { url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337, upload-time = "2026-02-02T12:37:46.668Z" }, + { url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395, upload-time = "2026-02-02T12:37:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, +] + +[[package]] +name = "liveweb-arena" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "affinetes" }, + { name = "aiohttp" }, + { name = "httpx" }, + { name = "openai" }, + { name = "playwright" }, + { name = "pydantic" }, + { name = "python-dotenv" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "affinetes", specifier = ">=0.2.0" }, + { name = "aiohttp", specifier = ">=3.9.0" }, + { name = "httpx", specifier = ">=0.25.0" }, + { name = "openai", specifier = ">=1.0.0" }, + { name = "playwright", specifier = ">=1.40.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, + { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, + { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, + { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "openai" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/87/eb0abb4ef88ddb95b3c13149384c4c288f584f3be17d6a4f63f8c3e3c226/openai-2.28.0.tar.gz", hash = "sha256:bb7fdff384d2a787fa82e8822d1dd3c02e8cf901d60f1df523b7da03cbb6d48d", size = 670334, upload-time = "2026-03-13T19:56:27.306Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/df122348638885526e53140e9c6b0d844af7312682b3bde9587eebc28b47/openai-2.28.0-py3-none-any.whl", hash = "sha256:79aa5c45dba7fef84085701c235cf13ba88485e1ef4f8dfcedc44fc2a698fc1d", size = 1141218, upload-time = "2026-03-13T19:56:25.46Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "paramiko" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bcrypt" }, + { name = "cryptography" }, + { name = "invoke" }, + { name = "pynacl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/e7/81fdcbc7f190cdb058cffc9431587eb289833bdd633e2002455ca9bb13d4/paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f", size = 1630743, upload-time = "2025-08-04T01:02:03.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9", size = 223932, upload-time = "2025-08-04T01:02:02.029Z" }, +] + +[[package]] +name = "playwright" +version = "1.58.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" }, + { url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" }, + { url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/0e/934b541323035566a9af292dba85a195f7b78179114f2c6ebb24551118a9/propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", size = 79534, upload-time = "2025-10-08T19:46:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/db0d03d96726d995dc7171286c6ba9d8d14251f37433890f88368951a44e/propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", size = 45526, upload-time = "2025-10-08T19:46:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c3/82728404aea669e1600f304f2609cde9e665c18df5a11cdd57ed73c1dceb/propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", size = 47263, upload-time = "2025-10-08T19:46:05.405Z" }, + { url = "https://files.pythonhosted.org/packages/df/1b/39313ddad2bf9187a1432654c38249bab4562ef535ef07f5eb6eb04d0b1b/propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", size = 201012, upload-time = "2025-10-08T19:46:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/5b/01/f1d0b57d136f294a142acf97f4ed58c8e5b974c21e543000968357115011/propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", size = 209491, upload-time = "2025-10-08T19:46:08.909Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c8/038d909c61c5bb039070b3fb02ad5cccdb1dde0d714792e251cdb17c9c05/propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", size = 215319, upload-time = "2025-10-08T19:46:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/08/57/8c87e93142b2c1fa2408e45695205a7ba05fb5db458c0bf5c06ba0e09ea6/propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", size = 196856, upload-time = "2025-10-08T19:46:12.003Z" }, + { url = "https://files.pythonhosted.org/packages/42/df/5615fec76aa561987a534759b3686008a288e73107faa49a8ae5795a9f7a/propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", size = 193241, upload-time = "2025-10-08T19:46:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/62949eb3a7a54afe8327011c90aca7e03547787a88fb8bd9726806482fea/propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", size = 190552, upload-time = "2025-10-08T19:46:14.938Z" }, + { url = "https://files.pythonhosted.org/packages/30/ee/ab4d727dd70806e5b4de96a798ae7ac6e4d42516f030ee60522474b6b332/propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", size = 200113, upload-time = "2025-10-08T19:46:16.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0b/38b46208e6711b016aa8966a3ac793eee0d05c7159d8342aa27fc0bc365e/propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", size = 200778, upload-time = "2025-10-08T19:46:18.023Z" }, + { url = "https://files.pythonhosted.org/packages/cf/81/5abec54355ed344476bee711e9f04815d4b00a311ab0535599204eecc257/propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", size = 193047, upload-time = "2025-10-08T19:46:19.449Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b6/1f237c04e32063cb034acd5f6ef34ef3a394f75502e72703545631ab1ef6/propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", size = 38093, upload-time = "2025-10-08T19:46:20.643Z" }, + { url = "https://files.pythonhosted.org/packages/a6/67/354aac4e0603a15f76439caf0427781bcd6797f370377f75a642133bc954/propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", size = 41638, upload-time = "2025-10-08T19:46:21.935Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e1/74e55b9fd1a4c209ff1a9a824bf6c8b3d1fc5a1ac3eabe23462637466785/propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", size = 38229, upload-time = "2025-10-08T19:46:23.368Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, + { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, + { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, + { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, + { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, + { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "yarl" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0d/9cc638702f6fc3c7a3685bcc8cf2a9ed7d6206e932a49f5242658047ef51/yarl-1.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cff6d44cb13d39db2663a22b22305d10855efa0fa8015ddeacc40bc59b9d8107", size = 123764, upload-time = "2026-03-01T22:04:09.7Z" }, + { url = "https://files.pythonhosted.org/packages/7a/35/5a553687c5793df5429cd1db45909d4f3af7eee90014888c208d086a44f0/yarl-1.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4c53f8347cd4200f0d70a48ad059cabaf24f5adc6ba08622a23423bc7efa10d", size = 86282, upload-time = "2026-03-01T22:04:11.892Z" }, + { url = "https://files.pythonhosted.org/packages/68/2e/c5a2234238f8ce37a8312b52801ee74117f576b1539eec8404a480434acc/yarl-1.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a6940a074fb3c48356ed0158a3ca5699c955ee4185b4d7d619be3c327143e05", size = 86053, upload-time = "2026-03-01T22:04:13.292Z" }, + { url = "https://files.pythonhosted.org/packages/74/3f/bbd8ff36fb038622797ffbaf7db314918bb4d76f1cc8a4f9ca7a55fe5195/yarl-1.23.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed5f69ce7be7902e5c70ea19eb72d20abf7d725ab5d49777d696e32d4fc1811d", size = 99395, upload-time = "2026-03-01T22:04:15.133Z" }, + { url = "https://files.pythonhosted.org/packages/77/04/9516bc4e269d2a3ec9c6779fcdeac51ce5b3a9b0156f06ac7152e5bba864/yarl-1.23.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:389871e65468400d6283c0308e791a640b5ab5c83bcee02a2f51295f95e09748", size = 92143, upload-time = "2026-03-01T22:04:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/c7/63/88802d1f6b1cb1fc67d67a58cd0cf8a1790de4ce7946e434240f1d60ab4a/yarl-1.23.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dda608c88cf709b1d406bdfcd84d8d63cff7c9e577a403c6108ce8ce9dcc8764", size = 107643, upload-time = "2026-03-01T22:04:18.519Z" }, + { url = "https://files.pythonhosted.org/packages/8e/db/4f9b838f4d8bdd6f0f385aed8bbf21c71ed11a0b9983305c302cbd557815/yarl-1.23.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c4fe09e0780c6c3bf2b7d4af02ee2394439d11a523bbcf095cf4747c2932007", size = 108700, upload-time = "2026-03-01T22:04:20.373Z" }, + { url = "https://files.pythonhosted.org/packages/50/12/95a1d33f04a79c402664070d43b8b9f72dc18914e135b345b611b0b1f8cc/yarl-1.23.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31c9921eb8bd12633b41ad27686bbb0b1a2a9b8452bfdf221e34f311e9942ed4", size = 102769, upload-time = "2026-03-01T22:04:23.055Z" }, + { url = "https://files.pythonhosted.org/packages/86/65/91a0285f51321369fd1a8308aa19207520c5f0587772cfc2e03fc2467e90/yarl-1.23.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5f10fd85e4b75967468af655228fbfd212bdf66db1c0d135065ce288982eda26", size = 101114, upload-time = "2026-03-01T22:04:25.031Z" }, + { url = "https://files.pythonhosted.org/packages/58/80/c7c8244fc3e5bc483dc71a09560f43b619fab29301a0f0a8f936e42865c7/yarl-1.23.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dbf507e9ef5688bada447a24d68b4b58dd389ba93b7afc065a2ba892bea54769", size = 98883, upload-time = "2026-03-01T22:04:27.281Z" }, + { url = "https://files.pythonhosted.org/packages/86/e7/71ca9cc9ca79c0b7d491216177d1aed559d632947b8ffb0ee60f7d8b23e3/yarl-1.23.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:85e9beda1f591bc73e77ea1c51965c68e98dafd0fec72cdd745f77d727466716", size = 94172, upload-time = "2026-03-01T22:04:28.554Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3f/6c6c8a0fe29c26fb2db2e8d32195bb84ec1bfb8f1d32e7f73b787fcf349b/yarl-1.23.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0e1fdaa14ef51366d7757b45bde294e95f6c8c049194e793eedb8387c86d5993", size = 107010, upload-time = "2026-03-01T22:04:30.385Z" }, + { url = "https://files.pythonhosted.org/packages/56/38/12730c05e5ad40a76374d440ed8b0899729a96c250516d91c620a6e38fc2/yarl-1.23.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:75e3026ab649bf48f9a10c0134512638725b521340293f202a69b567518d94e0", size = 100285, upload-time = "2026-03-01T22:04:31.752Z" }, + { url = "https://files.pythonhosted.org/packages/34/92/6a7be9239f2347234e027284e7a5f74b1140cc86575e7b469d13fba1ebfe/yarl-1.23.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:80e6d33a3d42a7549b409f199857b4fb54e2103fc44fb87605b6663b7a7ff750", size = 108230, upload-time = "2026-03-01T22:04:33.844Z" }, + { url = "https://files.pythonhosted.org/packages/5e/81/4aebccfa9376bd98b9d8bfad20621a57d3e8cfc5b8631c1fa5f62cdd03f4/yarl-1.23.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5ec2f42d41ccbd5df0270d7df31618a8ee267bfa50997f5d720ddba86c4a83a6", size = 103008, upload-time = "2026-03-01T22:04:35.856Z" }, + { url = "https://files.pythonhosted.org/packages/38/0f/0b4e3edcec794a86b853b0c6396c0a888d72dfce19b2d88c02ac289fb6c1/yarl-1.23.0-cp310-cp310-win32.whl", hash = "sha256:debe9c4f41c32990771be5c22b56f810659f9ddf3d63f67abfdcaa2c6c9c5c1d", size = 83073, upload-time = "2026-03-01T22:04:38.268Z" }, + { url = "https://files.pythonhosted.org/packages/a0/71/ad95c33da18897e4c636528bbc24a1dd23fe16797de8bc4ec667b8db0ba4/yarl-1.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:ab5f043cb8a2d71c981c09c510da013bc79fd661f5c60139f00dd3c3cc4f2ffb", size = 87328, upload-time = "2026-03-01T22:04:39.558Z" }, + { url = "https://files.pythonhosted.org/packages/e2/14/dfa369523c79bccf9c9c746b0a63eb31f65db9418ac01275f7950962e504/yarl-1.23.0-cp310-cp310-win_arm64.whl", hash = "sha256:263cd4f47159c09b8b685890af949195b51d1aa82ba451c5847ca9bc6413c220", size = 82463, upload-time = "2026-03-01T22:04:41.454Z" }, + { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641, upload-time = "2026-03-01T22:04:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248, upload-time = "2026-03-01T22:04:44.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988, upload-time = "2026-03-01T22:04:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566, upload-time = "2026-03-01T22:04:47.639Z" }, + { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079, upload-time = "2026-03-01T22:04:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741, upload-time = "2026-03-01T22:04:50.838Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099, upload-time = "2026-03-01T22:04:52.499Z" }, + { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678, upload-time = "2026-03-01T22:04:55.176Z" }, + { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803, upload-time = "2026-03-01T22:04:56.588Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163, upload-time = "2026-03-01T22:04:58.492Z" }, + { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859, upload-time = "2026-03-01T22:05:00.268Z" }, + { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202, upload-time = "2026-03-01T22:05:02.273Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866, upload-time = "2026-03-01T22:05:03.597Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852, upload-time = "2026-03-01T22:05:04.986Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919, upload-time = "2026-03-01T22:05:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602, upload-time = "2026-03-01T22:05:08.444Z" }, + { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461, upload-time = "2026-03-01T22:05:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336, upload-time = "2026-03-01T22:05:11.554Z" }, + { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, + { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, + { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, + { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, + { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, + { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, + { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, + { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, + { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +]