diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7ade1a7 --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# Neo4j +NEO4J_URI=bolt://localhost:7687 +NEO4J_USER=neo4j +NEO4J_PASSWORD=password + +# Crawler behavior +HEADLESS=true +TIMEOUT_MS=3000 +MAX_STATES=1000 +MAX_TRANSITIONS=5000 +MAX_ELEMENTS_PER_STATE=3 +USE_SEMANTIC_DIVERSITY=true +SEMANTIC_DIVERSITY_THRESHOLD=0.90 +SEMANTIC_UNCERTAINTY_MARGIN=0.05 +SEMANTIC_MAX_BANK_SIZE=1000 +SEMANTIC_ARTIFACT_DIR=src/models/semantic +MAX_SELECT_OPTIONS_PER_ELEMENT=3 +MAX_ACTION_REPEATS_PER_URL=2 +ACTION_RETRY_COUNT=1 +REPLAY_RETRY_COUNT=1 +POPUP_TIMEOUT_MS=3000 +DOM_QUIET_MS=400 +DOM_SETTLE_TIMEOUT_MS=3000 +USE_DOM_QUIESCENCE=true +PAGE_LOAD_STATE=networkidle +CLICK_NON_HTTP_LINKS=false +DEFER_DESTRUCTIVE_ACTIONS=true +DESTRUCTIVE_KEYWORDS="logout,log out,sign out,delete,remove,unsubscribe,cancel,checkout,pay,purchase,order,place order,reset,deactivate,terminate,drop,empty cart,clear cart" + +# Optional services +DATABASE_URL= +REDIS_URL= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2374cb0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Dependencies +node_modules +.pnp +.pnp.js + +# Build output +dist +*.egg-info + +# Test / coverage +coverage + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# TypeScript cache +*.tsbuildinfo + +# Environment variables +.env +.env.* +!.env.example + +**/__pycache__/ +/src/generated/prisma +data/semantic_pipeline/ +data/semantic_source_archive/ +src/models/semantic/*.joblib diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..86a03e3 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,9 @@ +repos: + - repo: local + hooks: + - id: ruff + name: ruff (format + check) + entry: uv run python scripts/precommit_ruff.py + language: system + pass_filenames: true + types: [python] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b9a1376 --- /dev/null +++ b/LICENSE @@ -0,0 +1,40 @@ +COVERIT LABS PROPRIETARY SOFTWARE LICENSE + +Copyright (c) 2026 CoverIt Labs. All Rights Reserved. + +NOTICE: This software and its source code are the exclusive property of +CoverIt Labs and constitute confidential and proprietary trade secrets. + +RESTRICTIONS: +1. No part of this software, including source code, documentation, or + associated materials, may be copied, reproduced, modified, translated, + adapted, distributed, transmitted, displayed, performed, published, + licensed, transferred, sold, or used to create derivative works — in + whole or in part — by any means or in any form, without the prior + explicit written consent of CoverIt Labs. + +2. Access to this software is granted solely to authorized personnel and + contractors of CoverIt Labs for the purpose of developing, testing, and + maintaining CoverIt products and services. + +3. Authorized users must not disclose any part of this software or its + contents to any third party without the prior written approval of + CoverIt Labs. + +4. All intellectual property rights in and to this software, including + patents, copyrights, trademarks, and trade secrets, are and shall + remain the exclusive property of CoverIt Labs. + +DISCLAIMER: +THIS SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. COVERIT +LABS DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. IN NO EVENT SHALL COVERIT LABS BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF OR IN CONNECTION WITH THE USE OF THIS SOFTWARE. + +Any violation of these terms will result in immediate termination of +access rights and may be subject to civil and criminal penalties under +applicable law. + +For licensing inquiries, contact the main contributor of this repository. diff --git a/example_flows.py b/example_flows.py new file mode 100644 index 0000000..6776256 --- /dev/null +++ b/example_flows.py @@ -0,0 +1,58 @@ +""" +example_flows.py +---------------- +Drop-in example showing how to call find_flows against a live session. +Run from the repo root: + + python example_flows.py +""" + +import asyncio +import logging + +from src.config import config +from src.graph import create_graph +from src.graph.flow_finder import find_flows + +logging.basicConfig(level=logging.INFO) + +SESSION_ID = "5adeca26-d6e3-41a7-b528-ba308614444b" +TARGET_HASH = "97f69d333c60b1d384fdc968a8bc0f8a0669fdcb76d105d79fb7094232f67bdd" + + +async def main() -> None: + client, graph = await create_graph( + config.NEO4J_URI, + config.NEO4J_USER, + config.NEO4J_PASSWORD, + ) + + try: + flows = await find_flows( + graph, + session_id=SESSION_ID, + target_hash=TARGET_HASH, + max_paths=50, + max_depth=20, + ) + + print(f"\nFound {len(flows)} flow(s) to {TARGET_HASH}\n") + + for i, flow in enumerate(flows, 1): + clip_note = f"clipped at checkpoint {flow.checkpoint}" if flow.is_clipped else "from root" + print(f"── Flow {i} ({len(flow.clipped_path)} steps, {clip_note}) ──") + + for step in flow.clipped_path: + if step.transition is None: + print(f" START {step.state_hash}") + else: + t = step.transition + print(f" → [{t.get('action_type', '?')}] {t.get('action_description') or t.get('locator_value', '')} ▶ {step.state_hash}") + print() + + finally: + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/example_usage.py b/example_usage.py new file mode 100644 index 0000000..90ee13a --- /dev/null +++ b/example_usage.py @@ -0,0 +1,56 @@ +import asyncio +import logging +import os +import uuid + +from src.config import config +from src.crawler.session import CrawlSession +from src.graph import create_graph + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +BASE_URL = "https://tryscrapeme.com/" +QUOTES = "https://quotes.toscrape.com/" +BOOKS = "https://books.toscrape.com/" +OTHER_URL = "https://en.wikipedia.org/wiki/Main_Page" +X = "https://the-internet.herokuapp.com/challenging_dom" +WEBSITE_1 = "file:///D:/crawler_test_website/nexus_commerce/index.html" + + +async def main(): + logger.info("Starting CoverIt Crawler...") + logger.info("Connecting to Neo4j...") + client, graph = await create_graph( + config.NEO4J_URI, + config.NEO4J_USER, + config.NEO4J_PASSWORD, + ) + + try: + crawl_session_id = str(uuid.uuid4()) + config_path = os.path.join(os.path.dirname(__file__), "src", "configs", "input_defaults.json") + session = CrawlSession( + base_url=QUOTES, + graph_builder=graph, + config_path=config_path, + session_id=crawl_session_id, + headless=config.HEADLESS, + ) + + logger.info("Starting crawl...") + await session.run_crawl() + + logger.info("\nCrawler execution successful!") + + except Exception as e: + logger.error(f"Error: {e}", exc_info=True) + + finally: + logger.info("Cleaning up...") + await client.close() + logger.info("Done!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/find_flows_script.py b/find_flows_script.py new file mode 100644 index 0000000..a571b44 --- /dev/null +++ b/find_flows_script.py @@ -0,0 +1,125 @@ +""" +Run this from the coverit-crawler root after populating Neo4j via example_usage.py. +this script is to call find_all_flows() for a given session and print the results, for testing/debugging purposes +it uses the neo4j graph that is already populated by the crawler, so it doesn't require running the full crawl flow + +Usage: + python find_flows_script.py + +What it checks: + - find_all_flows() runs without error + - Every state with a flow has at least one path + - No path exceeds max_depth + - No path contains duplicate state hashes (no loops) + - Checkpoint reset works: clipped paths don't contain states from before the checkpoint + - Serialization produces valid JSON +""" + +from __future__ import annotations + +import asyncio +import json +import sys + + +async def main(session_id: str) -> None: + from src.config import config + from src.graph.factory import create_graph + from src.graph.flow_finder import _serialize_all_flows, find_all_flows + + print("\nConnecting to Neo4j...") + client, graph_repo = await create_graph( + config.NEO4J_URI, + config.NEO4J_USER, + config.NEO4J_PASSWORD, + ) + + try: + print(f"Running find_all_flows for session: {session_id}\n") + all_flows = await find_all_flows( + graph_repo, + session_id=session_id, + max_paths_per_state=3, + max_depth=20, + ) + + if not all_flows: + print("ERROR: No flows returned — is Neo4j populated for this session?") + return + + # ---------------------------------------------------------------- + # Basic stats + # ---------------------------------------------------------------- + total_flows = sum(len(flows) for flows in all_flows.values()) + path_lengths = [len(flows) for flows in all_flows.values()] + + print(f"States with flows : {len(all_flows)}") + print(f"Total flows : {total_flows}") + print(f"Min path length : {min(path_lengths)}") + print(f"Max path length : {max(path_lengths)}") + print(f"Avg path length : {sum(path_lengths) / len(path_lengths):.1f}") + + # ---------------------------------------------------------------- + # Correctness checks + # ---------------------------------------------------------------- + errors: list[str] = [] + + for state_hash, flows in all_flows.items(): + if not flows: + errors.append(f"State {state_hash[:8]} has no flows") + continue + + for flow in flows: + if len(flow.transition_refs) > 20: + errors.append(f"State {state_hash[:8]} has a long path ({len(flow.transition_refs)} steps)") + + if len(set(flow.transition_refs)) != len(flow.transition_refs): + errors.append(f"State {state_hash[:8]} has duplicate states in its path (loop detected)") + + if flow.checkpoint_hash and flow.checkpoint_hash in flow.transition_refs: + errors.append(f"State {state_hash[:8]} has checkpoint {flow.checkpoint_hash[:8]} in its path (checkpoint reset failed)") + + # ---------------------------------------------------------------- + # Serialization check + # ---------------------------------------------------------------- + try: + serialized = _serialize_all_flows(all_flows) + json_str = json.dumps(serialized) + reparsed = json.loads(json_str) + # save the json, even if file doesntt exist, for inspection + with open("all_flows.json", "w", encoding="utf-8") as f: + f.write(json_str) + assert len(reparsed) == len(all_flows), "Serialized state count mismatch" + print(f"\nSerialized payload size: {len(json_str) / 1024:.1f} KB") + except Exception as e: + errors.append(f"Serialization failed: {e}") + + # ---------------------------------------------------------------- + # Sample output print + # ---------------------------------------------------------------- + print("\n---all flows for all states ---") + for state_hash, flows in list(all_flows.items()): + for flow in flows: + print(f"State {state_hash} <- checkpoint {flow.checkpoint_hash} via {[t for t in flow.transition_refs]}") + + # ---------------------------------------------------------------- + # Result + # ---------------------------------------------------------------- + print("\n--- Checks ---") + if errors: + for err in errors: + print(f" FAIL: {err}") + print(f"\n{len(errors)} check(s) failed.") + else: + print(" All checks passed.") + + finally: + await client.close() + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python test_find_all_flows.py ") + sys.exit(1) + + asyncio.run(main(sys.argv[1])) diff --git a/manual_flow_add.py b/manual_flow_add.py new file mode 100644 index 0000000..7844e7a --- /dev/null +++ b/manual_flow_add.py @@ -0,0 +1,36 @@ +import argparse +import asyncio +import logging +import os +import uuid +from src.config import config +from src.graph.factory import create_graph +from src.crawler.session.manual_crawl.manual_crawl import ManualCrawlSession + +async def main(): + logging.basicConfig(level=logging.INFO) + parser = argparse.ArgumentParser(description="Run a manual, human-guided crawl session.") + parser.add_argument("--url", required=True, help="The starting URL") + args = parser.parse_args() + + client, graph_builder = await create_graph( + config.NEO4J_URI, + config.NEO4J_USER, + config.NEO4J_PASSWORD + ) + try: + crawl_session_id = str(uuid.uuid4()) + config_path = os.path.join(os.path.dirname(__file__), "src", "configs", "input_defaults.json") + session = ManualCrawlSession( + base_url=args.url, + graph_builder=graph_builder, + config_path=config_path, + session_id=crawl_session_id, + headless=False, + ) + await session.run_crawl() + finally: + await client.close() + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..eb91866 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,85 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "coverit-crawler" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "arq>=0.26.0", + "asyncpg>=0.30.0,<1.0", + "greenlet>=3.1.0", + "neo4j>=5.18.0", + "playwright>=1.45.0", + "python-dotenv>=1.0.0", + "redis>=5.0.0", + "coverit-contracts", + "sqlalchemy>=2.0.0", + "aiohttp>=3.8.0", + "ruff>=0.15.17", + "numpy>=1.26.0", + "scikit-learn>=1.4.0", + "sentence-transformers>=3.0.0", +] + +[project.optional-dependencies] +dev = [ + "pre-commit>=3.7.0", + "ruff>=0.15.17", + "pytest>=8.3.0", + "pytest-asyncio>=0.24.0", +] +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["."] +include = ["src", "src.*"] + +[tool.uv] +extra-index-url = ["https://coveritlabs.github.io/coverit-contracts/simple/"] + +# ============================================================================== +# NEW AUTOMATED QUALITY CONTROLS ADDED BELOW +# ============================================================================== + +[tool.ruff] +target-version = "py312" +line-length = 88 + +[tool.ruff.lint] +# E/F = standard errors, I = strict import orders, B = bugs, C4/SIM = bad code smells +select = ["E", "F", "I", "B", "C4", "SIM"] +fixable = ["ALL"] + +[tool.ruff.lint.isort] +combine-as-imports = true +lines-after-imports = 2 + +[tool.pyright] +include = ["src", "find_flows_script.py"] +exclude = [ + "**/__pycache__", + "**/.*", + ".venv", + ".uv", + "**/node_modules", +] +typeCheckingMode = "standard" +reportMissingImports = "error" +reportMissingModuleSource = "error" +reportUndefinedVariable = "error" +reportUnknownMemberType = "warning" +reportUnknownVariableType = "warning" +reportUnknownArgumentType = "warning" + +[dependency-groups] +dev = [ + "pre-commit>=3.7.0", + "pytest>=8.3.0", + "pytest-asyncio>=0.24.0", + "pyright>=1.1.410", + "ruff>=0.15.17", + "uv>=0.11.21", +] diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..6bec557 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,12 @@ +target-version = "py312" +line-length = 150 + +[lint] +select = ["E", "F", "W", "I", "UP", "B"] +ignore = ["E501", "UP006", "UP015", "UP017", "UP035", "UP042", "UP045"] + +[lint.isort] +known-first-party = ["src"] + +[lint.per-file-ignores] +"src/**/__init__.py" = ["F401"] diff --git a/scripts/precommit_ruff.py b/scripts/precommit_ruff.py new file mode 100644 index 0000000..22f6d55 --- /dev/null +++ b/scripts/precommit_ruff.py @@ -0,0 +1,37 @@ +import subprocess +import sys + + +def run(cmd: list[str]) -> int: + result = subprocess.run(cmd) + return result.returncode + + +def format_hint(paths: list[str]) -> str: + if paths == ["."]: + return "." + if len(paths) > 10: + return "." + return " ".join(paths) + + +def main() -> int: + paths = sys.argv[1:] or ["."] + + format_rc = run(["ruff", "format", "--check", *paths]) + if format_rc != 0: + print("Ruff format check failed.", file=sys.stderr) + print(f"Fix with: uv run ruff format {format_hint(paths)}", file=sys.stderr) + return format_rc + + check_rc = run(["ruff", "check", *paths]) + if check_rc != 0: + print("Ruff lint check failed.", file=sys.stderr) + print(f"Fix with: uv run ruff check {format_hint(paths)}", file=sys.stderr) + return check_rc + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..be7b995 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,7 @@ +"""CoverIt Crawler - Web application crawling and testing framework.""" + +__version__ = "0.1.0" + +from src.config import Config, config + +__all__ = ["__version__", "Config", "config"] diff --git a/src/browser/__init__.py b/src/browser/__init__.py new file mode 100644 index 0000000..7c3cfe4 --- /dev/null +++ b/src/browser/__init__.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any + +__all__ = ["BrowserEngine"] + +_EXPORTS: dict[str, tuple[str, str]] = { + "BrowserEngine": ("src.browser.engine", "BrowserEngine"), +} + + +def __getattr__(name: str) -> Any: + target = _EXPORTS.get(name) + if target is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module_name, attr = target + value = getattr(import_module(module_name), attr) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals().keys()) | set(_EXPORTS.keys())) + + +if TYPE_CHECKING: + from src.browser.engine import BrowserEngine diff --git a/src/browser/actions.py b/src/browser/actions.py new file mode 100644 index 0000000..0cbe7f2 --- /dev/null +++ b/src/browser/actions.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Awaitable, Callable, Optional + +from playwright.async_api import ( + Error as PlaywrightError, +) +from playwright.async_api import ( + Page, +) +from playwright.async_api import ( + TimeoutError as PlaywrightTimeoutError, +) + +Action = Callable[[], Awaitable[None]] + + +class BrowserActions: + def __init__( + self, + *, + page_provider, + wait_for_settle, + timeout_ms: int, + retry_count: int, + ): + self._page_provider = page_provider + self._wait_for_settle = wait_for_settle + self._timeout_ms = timeout_ms + self._retry_count = retry_count + + async def retry( + self, + action: Action, + *, + selector: str, + fallback: Action | None = None, + ) -> None: + last_error: Optional[Exception] = None + + for attempt in range(self._retry_count + 1): + try: + await action() + return + + except ( + PlaywrightTimeoutError, + PlaywrightError, + ) as e: + last_error = e + + if attempt >= self._retry_count: + break + + if fallback: + try: + await fallback() + return + except Exception: + pass + + try: + await self._page.wait_for_selector( + selector, + state="visible", + timeout=min(self._timeout_ms, 3000), + ) + except Exception: + pass + + await self._wait_for_settle(load_state="domcontentloaded") + + if last_error: + raise last_error + + @property + def _page(self) -> Page: + return self._page_provider() diff --git a/src/browser/engine.py b/src/browser/engine.py new file mode 100644 index 0000000..0ce591b --- /dev/null +++ b/src/browser/engine.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +import os +from typing import Any, Dict, List, Optional + +from playwright.async_api import ( + Browser, + BrowserContext, + Page, + async_playwright, +) + +from src.browser.actions import BrowserActions +from src.browser.frames import FrameResolver +from src.browser.js_loader import JsLoader +from src.browser.page_manager import PageManager +from src.browser.state import StateManager +from src.browser.storage_state import normalize_storage_state +from src.config import Config, config +from src.models import AbstractState +from src.utils import attach_selectors_to_forms, build_selector + + +class BrowserEngine: + def __init__( + self, + headless: bool = True, + timeout_ms: Optional[int] = None, + settings: Config = config, + ): + self._settings = settings + self.headless = headless + self.timeout_ms = int(timeout_ms if timeout_ms is not None else settings.TIMEOUT_MS) + + self.page_load_state = str(getattr(settings, "PAGE_LOAD_STATE", "networkidle") or "networkidle") + + self.browser: Optional[Browser] = None + self.context: Optional[BrowserContext] = None + self.page: Optional[Page] = None + self.playwright = None + + js_dir = os.path.join(os.path.dirname(__file__), "js") + self._js = JsLoader(js_dir) + + self._frames = FrameResolver(self._require_page) + self._pages = PageManager( + context_provider=self._require_context, + popup_timeout_ms=settings.POPUP_TIMEOUT_MS, + ) + self._state = StateManager(self) + self._actions = BrowserActions( + page_provider=self._require_page, + wait_for_settle=self.wait_for_settle, + timeout_ms=self.timeout_ms, + retry_count=self._settings.ACTION_RETRY_COUNT, + ) + + async def start(self) -> None: + self.playwright = await async_playwright().start() + self.browser = await self.playwright.chromium.launch(headless=self.headless) + self.context = await self.browser.new_context() + self.page = await self.context.new_page() + self.page.set_default_timeout(self.timeout_ms) + + async def start_with_storage_state(self, storage_state: Any = None) -> None: + self.playwright = await async_playwright().start() + self.browser = await self.playwright.chromium.launch(headless=self.headless) + + normalized = normalize_storage_state(storage_state) + + if normalized is None: + self.context = await self.browser.new_context() + else: + self.context = await self.browser.new_context(storage_state=normalized) + + self.page = await self.context.new_page() + self.page.set_default_timeout(self.timeout_ms) + + async def stop(self) -> None: + if self.context: + await self.context.close() + if self.browser: + await self.browser.close() + if self.playwright: + await self.playwright.stop() + + async def navigate(self, url: str) -> None: + page = self._require_page() + await page.goto(url, wait_until=self.page_load_state, timeout=self.timeout_ms) + + async def go_back(self) -> None: + page = self._require_page() + await page.go_back(wait_until=self.page_load_state, timeout=self.timeout_ms) + + async def click( + self, + selector: str, + *, + frame_url: Optional[str] = None, + frame_name: Optional[str] = None, + ) -> None: + target = self._frames.resolve(frame_url=frame_url, frame_name=frame_name) + + await self._actions.retry( + lambda: target.click(selector, timeout=self.timeout_ms), + selector=selector, + ) + + async def type_text( + self, + selector: str, + text: str, + *, + frame_url: Optional[str] = None, + frame_name: Optional[str] = None, + ) -> None: + target = self._frames.resolve(frame_url=frame_url, frame_name=frame_name) + page = self._require_page() + + async def action(): + await target.fill(selector, text, timeout=self.timeout_ms) + + async def fallback(): + await self.click(selector, frame_url=frame_url, frame_name=frame_name) + await page.keyboard.press("Control+A") + await page.keyboard.type(text) + + await self._actions.retry(action, selector=selector, fallback=fallback) + + async def select_option( + self, + selector: str, + value: str, + *, + frame_url: Optional[str] = None, + frame_name: Optional[str] = None, + ) -> None: + target = self._frames.resolve(frame_url=frame_url, frame_name=frame_name) + + await self._actions.retry( + lambda: target.select_option(selector, value, timeout=self.timeout_ms), + selector=selector, + ) + + async def press_key( + self, + selector: str, + key: str, + *, + frame_url: Optional[str] = None, + frame_name: Optional[str] = None, + ) -> None: + target = self._frames.resolve(frame_url=frame_url, frame_name=frame_name) + + await self._actions.retry( + lambda: target.press(selector, key, timeout=self.timeout_ms), + selector=selector, + ) + + async def wait_for_settle( + self, + *, + load_state: Optional[str] = None, + timeout_ms: Optional[int] = None, + ) -> None: + page = self._require_page() + timeout = timeout_ms or self.timeout_ms + + try: + await page.wait_for_load_state( + load_state or self.page_load_state, + timeout=timeout, + ) + except Exception: + pass + + if not self._settings.USE_DOM_QUIESCENCE: + return + + try: + await page.evaluate( + self._js.load("wait_for_dom_quiescence.js"), + { + "quietMs": int(self._settings.DOM_QUIET_MS), + "timeoutMs": int(self._settings.DOM_SETTLE_TIMEOUT_MS), + }, + ) + except Exception: + pass + + async def get_current_url(self) -> str: + url = self._require_page().url + return url[:-1] if url.endswith("?") else url + + async def get_page_title(self) -> str: + return await self._require_page().title() + + async def get_page_content(self) -> str: + return await self._require_page().content() + + async def get_annotated_page_content(self) -> str: + try: + return await self._evaluate_js( + self._js.load("get_annotated_page_content.js") + ) + except Exception: + return await self.get_page_content() + + async def get_state_hash(self) -> str: + semantic = await self._evaluate_js(self._js.load("get_state_hash.js")) + return self._state.hash_content(str(semantic)) + + async def get_interactable_elements(self) -> List[Dict[str, Any]]: + return await self._evaluate_js(self._js.load("get_interactable_elements.js")) + + async def get_forms(self) -> List[Dict[str, Any]]: + raw = await self._evaluate_js(self._js.load("get_forms.js")) + return attach_selectors_to_forms(raw) + + async def capture_state(self) -> AbstractState: + return await self._state.capture() + + async def export_storage_state(self) -> Dict[str, Any]: + return await self._require_context().storage_state() + + async def new_context_from_storage_state(self, storage_state: Any = None) -> BrowserContext: + browser = self._require_browser() + normalized = normalize_storage_state(storage_state) + + if normalized is None: + return await browser.new_context() + + return await browser.new_context(storage_state=normalized) + + async def reset_context_from_storage_state(self, storage_state: Any = None) -> None: + if self.context: + await self.context.close() + + self.context = await self.new_context_from_storage_state(storage_state) + self.page = await self.context.new_page() + self.page.set_default_timeout(self.timeout_ms) + + def get_selector_for_element(self, element: dict) -> str | None: + return build_selector(element) + + async def wait_for_new_page(self, timeout_ms: Optional[int] = None): + return await self._pages.wait_for_new_page(timeout_ms=timeout_ms) + + async def close_pages_opened_since(self, initial_count: int, timeout_ms: Optional[int] = None) -> int: + pages = await self._pages.collect_new_pages(initial_count, timeout_ms=timeout_ms) + + for page in pages: + try: + await page.close() + except Exception: + pass + + return len(pages) + + async def collect_and_close_pages_opened_since(self, initial_count: int, *, timeout_ms: Optional[int] = None) -> List[str]: + pages = await self._pages.collect_new_pages(initial_count, timeout_ms=timeout_ms) + + urls: List[str] = [] + + for page in pages: + try: + await page.wait_for_load_state("domcontentloaded", timeout=3000) + except Exception: + pass + + try: + if page.url: + urls.append(page.url) + except Exception: + pass + + try: + await page.close() + except Exception: + pass + + return urls + + async def _evaluate_js(self, js_code: str, *, retries: int = 1): + page = self._require_page() + attempt = 0 + + while True: + try: + return await page.evaluate(js_code) + except Exception: + if attempt >= retries: + raise + attempt += 1 + await page.wait_for_load_state(self.page_load_state, timeout=self.timeout_ms) + + def _require_browser(self) -> Browser: + if not self.browser: + raise RuntimeError("Browser not started") + return self.browser + + def _require_context(self) -> BrowserContext: + if not self.context: + raise RuntimeError("Browser not started") + return self.context + + def _require_page(self) -> Page: + if not self.page: + raise RuntimeError("Browser not started") + return self.page diff --git a/src/browser/frames.py b/src/browser/frames.py new file mode 100644 index 0000000..98344c3 --- /dev/null +++ b/src/browser/frames.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from typing import Optional + +from playwright.async_api import Frame, Page + + +class FrameResolver: + def __init__(self, page_provider): + self._page_provider = page_provider + + def resolve( + self, + *, + frame_url: Optional[str] = None, + frame_name: Optional[str] = None, + ) -> Frame | Page: + return ( + self._resolve_frame( + frame_url=frame_url, + frame_name=frame_name, + ) + or self._page + ) + + def _resolve_frame( + self, + *, + frame_url: Optional[str] = None, + frame_name: Optional[str] = None, + ) -> Optional[Frame]: + if frame_name: + try: + frame = self._page.frame(name=frame_name) + + if frame: + return frame + + except Exception: + pass + + if frame_url: + try: + for frame in self._page.frames: + if frame.url == frame_url or frame.url.startswith(frame_url): + return frame + + except Exception: + return None + + return None + + @property + def _page(self) -> Page: + return self._page_provider() diff --git a/src/browser/js/get_annotated_page_content.js b/src/browser/js/get_annotated_page_content.js new file mode 100644 index 0000000..a86f53f --- /dev/null +++ b/src/browser/js/get_annotated_page_content.js @@ -0,0 +1,63 @@ +() => { + const BBOX_ATTRS = ["data-x", "data-y", "data-width", "data-height"]; + + const isVisible = (el) => { + if (!el || el.nodeType !== Node.ELEMENT_NODE) return false; + if (el.hidden) return false; + + const style = getComputedStyle(el); + if (!style || style.display === "none" || style.visibility === "hidden") { + return false; + } + + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const allElements = (doc) => { + try { + return Array.from(doc.querySelectorAll("*")); + } catch { + return []; + } + }; + + const clearBoundingBoxAttrs = (doc) => { + for (const el of allElements(doc)) { + for (const attr of BBOX_ATTRS) { + el.removeAttribute(attr); + } + } + }; + + const annotateBoundingBoxes = (doc) => { + for (const el of allElements(doc)) { + if (!isVisible(el)) continue; + + const rect = el.getBoundingClientRect(); + el.setAttribute("data-x", String(rect.x)); + el.setAttribute("data-y", String(rect.y)); + el.setAttribute("data-width", String(rect.width)); + el.setAttribute("data-height", String(rect.height)); + } + }; + + const docs = [document]; + for (const iframe of Array.from(document.querySelectorAll("iframe"))) { + try { + if (iframe.contentDocument) docs.push(iframe.contentDocument); + } catch { + } + } + + for (const doc of docs) { + clearBoundingBoxAttrs(doc); + annotateBoundingBoxes(doc); + } + + const doctype = document.doctype + ? `` + : ""; + + return `${doctype}${document.documentElement.outerHTML}`; +}; diff --git a/src/browser/js/get_forms.js b/src/browser/js/get_forms.js new file mode 100644 index 0000000..32450d4 --- /dev/null +++ b/src/browser/js/get_forms.js @@ -0,0 +1,217 @@ +() => { + const cssEscape = (value) => { + try { + return CSS.escape(String(value)); + } catch { + return String(value).replace(/[^a-zA-Z0-9_\-]/g, (c) => `\\${c}`); + } + }; + + const escapeAttrValue = (value) => { + return String(value).replace(/\\/g, "\\\\").replace(/\"/g, "\\\""); + }; + + const isVisible = (el) => { + if (!el || el.nodeType !== Node.ELEMENT_NODE) return false; + if (el.hidden) return false; + const style = getComputedStyle(el); + if (!style || style.display === "none" || style.visibility === "hidden" || style.opacity === "0" || style.pointerEvents === "none") { + return false; + } + const r = el.getBoundingClientRect(); + return r.width > 0 && r.height > 0; + }; + + const labelFor = (doc, el) => { + if (el.id) { + const lbl = doc.querySelector(`label[for="${cssEscape(el.id)}"]`); + if (lbl) return (lbl.innerText || "").trim(); + } + const parent = el.closest ? el.closest("label") : null; + return parent ? (parent.innerText || "").trim() : ""; + }; + + const cssPath = (el) => { + if (!el || el.nodeType !== Node.ELEMENT_NODE) return ""; + const parts = []; + let cur = el; + while (cur && cur.nodeType === Node.ELEMENT_NODE) { + const tag = cur.tagName.toLowerCase(); + if (cur.id && cur.id.length > 0 && !/^\d+$/.test(cur.id)) { + parts.unshift(`${tag}#${cssEscape(cur.id)}`); + break; + } + const parent = cur.parentElement; + if (!parent) { + parts.unshift(tag); + break; + } + const siblings = Array.from(parent.children).filter((c) => c.tagName === cur.tagName); + const index = siblings.indexOf(cur); + const needsNth = siblings.length > 1; + parts.unshift(needsNth ? `${tag}:nth-of-type(${index + 1})` : tag); + cur = parent; + if (tag === "html" || tag === "body") break; + } + return parts.join(" > "); + }; + + const selectorCandidates = (el) => { + const tag = el.tagName ? el.tagName.toLowerCase() : ""; + const candidates = []; + if (el.getAttribute) { + for (const attrName of ["data-testid", "data-test", "data-qa"]) { + const v = el.getAttribute(attrName); + if (v) { + candidates.push(`[${attrName}="${escapeAttrValue(v)}"]`); + break; + } + } + } + if (el.id && el.id.length > 0 && !/^\d+$/.test(el.id)) candidates.push(`#${cssEscape(el.id)}`); + const type = (el.type || "").toLowerCase(); + const value = el.getAttribute && el.getAttribute("value"); + if (tag === "input" && (type === "radio" || type === "checkbox") && el.name && value) { + candidates.unshift(`input[type="${escapeAttrValue(type)}"][name="${escapeAttrValue(el.name)}"][value="${escapeAttrValue(value)}"]`); + } + if (el.name) candidates.push(`${tag}[name="${escapeAttrValue(el.name)}"]`); + const ariaLabel = el.getAttribute && el.getAttribute("aria-label"); + if (ariaLabel) candidates.push(`${tag}[aria-label="${escapeAttrValue(ariaLabel)}"]`); + const path = cssPath(el); + if (path) candidates.push(path); + return candidates; + }; + + const deepQueryAll = (root, selector) => { + const out = []; + const visited = new Set(); + const walk = (node) => { + if (!node || visited.has(node)) return; + visited.add(node); + if (node.querySelectorAll) { + out.push(...Array.from(node.querySelectorAll(selector))); + const all = Array.from(node.querySelectorAll("*")); + for (const el of all) { + if (el.shadowRoot) walk(el.shadowRoot); + } + } + }; + walk(root); + return out; + }; + + const allRoots = [{ doc: document, frame: null }]; + for (const iframe of Array.from(document.querySelectorAll("iframe"))) { + try { + const doc = iframe.contentDocument; + if (!doc) continue; + let frameUrl = ""; + try { + frameUrl = iframe.contentWindow && iframe.contentWindow.location ? String(iframe.contentWindow.location.href || "") : ""; + } catch { + frameUrl = ""; + } + allRoots.push({ + doc, + frame: { + name: String(iframe.name || ""), + id: String(iframe.id || ""), + src: String(iframe.getAttribute("src") || ""), + url: frameUrl, + }, + }); + } catch { + } + } + + const toField = (el, frame) => { + const tag = el.tagName.toLowerCase(); + const type = (el.type || "").toLowerCase(); + const options = tag === "select" + ? Array.from(el.options).slice(0, 50).map(o => ({ value: o.value, text: (o.text || "").trim() })) + : []; + return { + tag, + id: el.id || "", + name: el.name || "", + type, + value: el.value || "", + placeholder: el.placeholder || "", + label: labelFor(el.ownerDocument || document, el), + checked: !!el.checked, + required: !!el.required, + disabled: !!el.disabled, + readonly: !!el.readOnly, + min: el.getAttribute && el.getAttribute("min"), + max: el.getAttribute && el.getAttribute("max"), + maxlength: el.getAttribute && el.getAttribute("maxlength"), + pattern: el.getAttribute && el.getAttribute("pattern"), + aria_label: el.getAttribute && (el.getAttribute("aria-label") || ""), + role: el.getAttribute && (el.getAttribute("role") || ""), + autocomplete: el.getAttribute && (el.getAttribute("autocomplete") || ""), + options, + selector_candidates: selectorCandidates(el), + frame, + }; + }; + + const toSubmit = (el, frame) => { + if (!el) return null; + return { + tag: el.tagName.toLowerCase(), + id: el.id || "", + name: el.name || "", + type: (el.type || "").toLowerCase(), + value: el.value || "", + text: (el.innerText || el.value || "").trim(), + aria_label: el.getAttribute && (el.getAttribute("aria-label") || ""), + role: el.getAttribute && (el.getAttribute("role") || ""), + selector_candidates: selectorCandidates(el), + frame, + }; + }; + + const isFillableField = (f) => { + if (!f) return false; + if (f.disabled || f.readonly) return false; + if (f.tag === "select" || f.tag === "textarea") return true; + if (f.tag !== "input") return false; + const t = (f.type || "").toLowerCase(); + return !["submit", "button", "reset", "hidden", "image", "file"].includes(t); + }; + + const forms = []; + for (const root of allRoots) { + const formEls = deepQueryAll(root.doc, "form"); + for (let i = 0; i < formEls.length; i++) { + const form = formEls[i]; + const fields = Array.from(form.querySelectorAll("input, select, textarea")).filter(isVisible).map((el) => toField(el, root.frame)); + const candidates = Array.from( + form.querySelectorAll('button[type="submit"], input[type="submit"], button:not([type]), [role="button"], [onclick]') + ).filter(isVisible); + const submit = toSubmit(candidates[0] || null, root.frame); + const method = (form.method || "get").toLowerCase(); + const hasFillable = fields.some(isFillableField); + const resolvedAction = form.action || ""; + const baseCurrentUrl = window.location.href.split('?')[0].split('#')[0]; + const baseActionUrl = resolvedAction.split('?')[0].split('#')[0]; + + if (method === "get" && fields.length === 0) { + if (!resolvedAction || baseActionUrl === baseCurrentUrl) { + continue; + } + } + forms.push({ + form_id: form.id || `form-${forms.length}`, + method, + action: form.action || "", + fields, + submit, + has_fillable_fields: hasFillable, + frame: root.frame, + }); + } + } + + return forms.filter(f => !!f.submit); +} \ No newline at end of file diff --git a/src/browser/js/get_interactable_elements.js b/src/browser/js/get_interactable_elements.js new file mode 100644 index 0000000..4f3fc6a --- /dev/null +++ b/src/browser/js/get_interactable_elements.js @@ -0,0 +1,230 @@ +() => { + const cssEscape = (value) => { + try { + return CSS.escape(String(value)); + } catch { + return String(value).replace(/[^a-zA-Z0-9_\-]/g, (c) => `\\${c}`); + } + }; + + const escapeAttrValue = (value) => { + return String(value).replace(/\\/g, "\\\\").replace(/\"/g, "\\\""); + }; + + const normalizeComparableUrl = (value) => { + return String(value || "") + .trim() + .replace(/^https?:\/\//i, "//") + .replace(/[?#]$/, ""); + }; + + const isVisible = (el) => { + if (!el || el.nodeType !== Node.ELEMENT_NODE) return false; + if (el.hidden) return false; + const style = getComputedStyle(el); + if (!style || style.display === "none" || style.visibility === "hidden" || style.opacity === "0" || style.pointerEvents === "none") { + return false; + } + const r = el.getBoundingClientRect(); + return r.width > 0 && r.height > 0; + }; + + const labelFor = (doc, el) => { + if (el.id) { + const lbl = doc.querySelector(`label[for="${cssEscape(el.id)}"]`); + if (lbl) return (lbl.innerText || "").trim(); + } + const parent = el.closest ? el.closest("label") : null; + return parent ? (parent.innerText || "").trim() : ""; + }; + + const cssPath = (el) => { + if (!el || el.nodeType !== Node.ELEMENT_NODE) return ""; + const parts = []; + let cur = el; + while (cur && cur.nodeType === Node.ELEMENT_NODE) { + const tag = cur.tagName.toLowerCase(); + if (cur.id && cur.id.length > 0 && !/^\d+$/.test(cur.id)) { + parts.unshift(`${tag}#${cssEscape(cur.id)}`); + break; + } + const parent = cur.parentElement; + if (!parent) { + parts.unshift(tag); + break; + } + const siblings = Array.from(parent.children).filter((c) => c.tagName === cur.tagName); + const index = siblings.indexOf(cur); + const needsNth = siblings.length > 1; + parts.unshift(needsNth ? `${tag}:nth-of-type(${index + 1})` : tag); + cur = parent; + if (tag === "html" || tag === "body") break; + } + return parts.join(" > "); + }; + + const selectorCandidates = (el) => { + const tag = el.tagName ? el.tagName.toLowerCase() : ""; + const candidates = []; + if (el.getAttribute) { + for (const attrName of ["data-testid", "data-test", "data-qa"]) { + const v = el.getAttribute(attrName); + if (v) { + candidates.push(`[${attrName}="${escapeAttrValue(v)}"]`); + break; + } + } + } + if (el.id && el.id.length > 0 && !/^\d+$/.test(el.id)) candidates.push(`#${cssEscape(el.id)}`); + if (el.name) candidates.push(`${tag}[name="${escapeAttrValue(el.name)}"]`); + const ariaLabel = el.getAttribute && el.getAttribute("aria-label"); + if (ariaLabel) candidates.push(`${tag}[aria-label="${escapeAttrValue(ariaLabel)}"]`); + + const visibleText = (tag === "input" ? "" : (el.innerText || "")).trim(); + if (visibleText) { + const safeText = escapeAttrValue(visibleText); + if (tag === "button" || tag === "a") { + candidates.push(`${tag}:has-text("${safeText}")`); + } + } + if (tag === "input") { + const type = (el.type || "").toLowerCase(); + const value = el.getAttribute && el.getAttribute("value"); + if ((type === "radio" || type === "checkbox") && el.name && value) { + candidates.unshift(`input[type="${escapeAttrValue(type)}"][name="${escapeAttrValue(el.name)}"][value="${escapeAttrValue(value)}"]`); + } + } + const path = cssPath(el); + if (path) candidates.push(path); + return candidates; + }; + + const deepQueryAll = (root, selector) => { + const out = []; + const visited = new Set(); + const walk = (node) => { + if (!node || visited.has(node)) return; + visited.add(node); + if (node.querySelectorAll) { + out.push(...Array.from(node.querySelectorAll(selector))); + const all = Array.from(node.querySelectorAll("*")); + for (const el of all) { + if (el.shadowRoot) walk(el.shadowRoot); + } + } + }; + walk(root); + return out; + }; + + const allRoots = [{ doc: document, frame: null }]; + for (const iframe of Array.from(document.querySelectorAll("iframe"))) { + try { + const doc = iframe.contentDocument; + if (!doc) continue; + let frameUrl = ""; + try { + frameUrl = iframe.contentWindow && iframe.contentWindow.location ? normalizeComparableUrl(iframe.contentWindow.location.href) : ""; + } catch { + frameUrl = ""; + } + allRoots.push({ + doc, + frame: { + name: String(iframe.name || ""), + id: String(iframe.id || ""), + src: normalizeComparableUrl(iframe.getAttribute("src")), + url: frameUrl, + }, + }); + } catch { + } + } + + const selector = "button, a[href], input, select, textarea, [role='button'], [onclick], [contenteditable]"; + const elements = []; + for (const root of allRoots) { + const found = deepQueryAll(root.doc, selector); + for (const el of found) { + elements.push({ el, frame: root.frame }); + } + } + + const unique = []; + const seen = new Set(); + + for (let i = 0; i < elements.length; i++) { + const el = elements[i].el; + const frame = elements[i].frame; + if (!isVisible(el)) continue; + + const tag = el.tagName.toLowerCase(); + const type = (el.type || "").toLowerCase(); + + const href = tag === "a" ? normalizeComparableUrl(el.href) : ""; + const name = el.name || ""; + const placeholder = el.placeholder || ""; + const role = el.getAttribute && (el.getAttribute("role") || ""); + const ariaLabel = el.getAttribute && (el.getAttribute("aria-label") || ""); + const ariaInvalid = el.getAttribute && (el.getAttribute("aria-invalid") || ""); + const ariaExpanded = el.getAttribute && (el.getAttribute("aria-expanded") || ""); + const disabled = !!el.disabled; + const readonly = !!el.readOnly; + const required = !!el.required; + const checked = !!el.checked; + const contenteditable = !!(el.isContentEditable || el.getAttribute("contenteditable")); + + const text = (tag === "input" ? "" : (el.innerText || "")).trim(); + const value = ( + tag === "input" + || tag === "select" + || tag === "textarea" + || contenteditable + ) ? (el.value || el.innerText || "") : ""; + const options = tag === "select" + ? Array.from(el.options).slice(0, 50).map(o => ({ value: o.value, text: (o.text || "").trim() })) + : []; + + const inForm = !!(el.closest && el.closest("form")); + const path = cssPath(el); + const candidates = selectorCandidates(el); + const primarySelector = candidates && candidates.length ? candidates[0] : path; + const frameSig = frame && (frame.url || frame.src || frame.name || frame.id) ? `${frame.url || frame.src || frame.name || frame.id}` : ""; + const signature = (frameSig ? `${frameSig}::` : "") + (primarySelector || `${tag}|${type}|${name}|${href}|${i}`); + + if (seen.has(signature)) continue; + seen.add(signature); + + unique.push({ + id: el.id || String(i), + tag, + type, + text, + value, + name, + href, + placeholder, + role, + aria_label: ariaLabel, + aria_invalid: ariaInvalid, + aria_expanded: ariaExpanded, + label: labelFor(el.ownerDocument || document, el), + in_form: inForm, + disabled, + readonly, + required, + checked, + contenteditable, + min: el.getAttribute && el.getAttribute("min"), + max: el.getAttribute && el.getAttribute("max"), + maxlength: el.getAttribute && el.getAttribute("maxlength"), + pattern: el.getAttribute && el.getAttribute("pattern"), + options, + css_path: path, + selector_candidates: selectorCandidates(el), + frame, + }); + } + + return unique; +} diff --git a/src/browser/js/get_state_hash.js b/src/browser/js/get_state_hash.js new file mode 100644 index 0000000..8b14d0e --- /dev/null +++ b/src/browser/js/get_state_hash.js @@ -0,0 +1,136 @@ +() => { + const isVisible = (el) => { + if (!el || el.nodeType !== Node.ELEMENT_NODE) return false; + if (el.hidden) return false; + const style = getComputedStyle(el); + if (!style || style.display === "none" || style.visibility === "hidden" || style.opacity === "0" || style.pointerEvents === "none") { + return false; + } + const r = el.getBoundingClientRect(); + return r.width > 0 && r.height > 0; + }; + + const deepQueryAll = (root, selector) => { + const out = []; + const visited = new Set(); + const walk = (node) => { + if (!node || visited.has(node)) return; + visited.add(node); + if (node.querySelectorAll) { + out.push(...Array.from(node.querySelectorAll(selector))); + const all = Array.from(node.querySelectorAll("*")); + for (const el of all) { + if (el.shadowRoot) walk(el.shadowRoot); + } + } + }; + walk(root); + return out; + }; + + const normalizeComparableUrl = (value) => { + return String(value || "") + .trim() + .replace(/^https?:\/\//i, "//") + .replace(/[?#]$/, ""); + }; + + const normalizeComparableText = (value) => { + return String(value || "") + .replace(/https?:\/\//ig, "//") + .toLowerCase() + .trim() + .replace(/\s+/g, " "); + }; + + const allDocs = [document]; + for (const iframe of Array.from(document.querySelectorAll("iframe"))) { + try { + const doc = iframe.contentDocument; + if (doc) allDocs.push(doc); + } catch { + } + } + + const textParts = []; + for (const doc of allDocs) { + try { + const t = (doc.body && doc.body.innerText) ? doc.body.innerText : ""; + if (t) textParts.push(t); + } catch { + } + } + const pageText = normalizeComparableText(textParts.join("\n")); + + const interactiveSelector = "button, a[href], input, select, textarea, [role='button'], [onclick], [contenteditable]"; + const sigs = []; + for (const doc of allDocs) { + const els = deepQueryAll(doc, interactiveSelector).filter(isVisible); + for (const el of els) { + const tag = el.tagName.toLowerCase(); + const type = (el.type || "").toLowerCase(); + const name = el.name || ""; + const id = el.id || ""; + const placeholder = el.placeholder || ""; + const role = el.getAttribute && (el.getAttribute("role") || ""); + const ariaLabel = el.getAttribute && (el.getAttribute("aria-label") || ""); + const ariaInvalid = el.getAttribute && (el.getAttribute("aria-invalid") || ""); + const ariaExpanded = el.getAttribute && (el.getAttribute("aria-expanded") || ""); + const disabled = !!el.disabled; + const readonly = !!el.readOnly; + const required = !!el.required; + const checked = (type === "checkbox" || type === "radio") ? !!el.checked : false; + const href = tag === "a" ? normalizeComparableUrl(el.href) : ""; + + let selected = ""; + if (tag === "select") { + try { + const opt = el.selectedOptions && el.selectedOptions.length ? el.selectedOptions[0] : null; + if (opt) selected = `${opt.value || ""}|${(opt.text || "").trim()}`; + } catch { + } + } + + const text = (tag === "input" ? "" : (el.innerText || "")).trim().slice(0, 80); + sigs.push([ + tag, + type, + name, + id, + placeholder, + role, + ariaLabel, + ariaInvalid, + ariaExpanded, + required ? "req" : "", + disabled ? "dis" : "", + readonly ? "ro" : "", + checked ? "chk" : "", + selected, + href, + text, + ].join("|")); + } + } + + const errorSelector = "[role='alert'], [aria-live], .error, .errors, .validation-error"; + const errorTexts = []; + for (const doc of allDocs) { + const errEls = deepQueryAll(doc, errorSelector).filter(isVisible); + for (const el of errEls) { + const t = (el.innerText || "").trim(); + if (t) errorTexts.push(t.slice(0, 200)); + } + } + + sigs.sort(); + errorTexts.sort(); + + return [ + pageText, + ":::", + sigs.join("\n"), + ":::", + errorTexts.join("\n"), + ].join(""); +} diff --git a/src/browser/js/wait_for_dom_quiescence.js b/src/browser/js/wait_for_dom_quiescence.js new file mode 100644 index 0000000..8e9889b --- /dev/null +++ b/src/browser/js/wait_for_dom_quiescence.js @@ -0,0 +1,42 @@ +async ({ quietMs, timeoutMs }) => { + const root = document.documentElement; + if (!root) return true; + + return await new Promise((resolve) => { + let done = false; + let timer = null; + + const finish = () => { + if (done) return; + done = true; + if (timer) clearTimeout(timer); + observer.disconnect(); + resolve(true); + }; + + let last = Date.now(); + const observer = new MutationObserver(() => { + last = Date.now(); + }); + + observer.observe(root, { + subtree: true, + childList: true, + attributes: true, + characterData: true, + }); + + const check = () => { + if (done) return; + const quietFor = Date.now() - last; + if (quietFor >= quietMs) { + finish(); + return; + } + timer = setTimeout(check, quietMs); + }; + + timer = setTimeout(check, quietMs); + setTimeout(finish, timeoutMs); + }); +}; diff --git a/src/browser/js_loader.py b/src/browser/js_loader.py new file mode 100644 index 0000000..14dcb0c --- /dev/null +++ b/src/browser/js_loader.py @@ -0,0 +1,22 @@ +import os + +from src.utils import read_file + + +class JsLoader: + def __init__(self, js_dir_path: str): + self._js_dir_path = js_dir_path + self._cache: dict[str, str] = {} + + def load(self, filename: str) -> str: + cached = self._cache.get(filename) + + if cached is not None: + return cached + + js_path = os.path.join(self._js_dir_path, filename) + content = read_file(js_path) + + self._cache[filename] = content + + return content diff --git a/src/browser/page_manager.py b/src/browser/page_manager.py new file mode 100644 index 0000000..f9934eb --- /dev/null +++ b/src/browser/page_manager.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from typing import Callable, List, Optional + +from playwright.async_api import ( + BrowserContext, + Page, +) +from playwright.async_api import ( + TimeoutError as PlaywrightTimeoutError, +) + + +class PageManager: + def __init__( + self, + *, + context_provider: Callable[[], BrowserContext], + popup_timeout_ms: int, + ): + self._context_provider = context_provider + self._popup_timeout_ms = popup_timeout_ms + + async def wait_for_new_page( + self, + timeout_ms: Optional[int] = None, + ) -> Optional[Page]: + timeout = int(timeout_ms if timeout_ms is not None else self._popup_timeout_ms) + + context = self._context() + + try: + return await context.wait_for_event( + "page", + timeout=timeout, + ) + except PlaywrightTimeoutError: + return None + + async def collect_new_pages( + self, + initial_count: int, + *, + timeout_ms: Optional[int] = None, + ) -> List[Page]: + timeout = int(timeout_ms if timeout_ms is not None else self._popup_timeout_ms) + + context = self._context() + + if len(context.pages) <= initial_count: + await self.wait_for_new_page(timeout_ms=timeout) + + return context.pages[initial_count:] + + def _context(self) -> BrowserContext: + return self._context_provider() diff --git a/src/browser/state.py b/src/browser/state.py new file mode 100644 index 0000000..003be1c --- /dev/null +++ b/src/browser/state.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import hashlib +import re +from datetime import datetime, timezone + +from src.models import AbstractState + + +class StateManager: + def __init__(self, browser): + self._browser = browser + + async def capture(self) -> AbstractState: + await self._browser.wait_for_settle() + + state_hash = await self._browser.get_state_hash() + url = await self._browser.get_current_url() + title = await self._browser.get_page_title() + content = await self._browser.get_annotated_page_content() + interactable = await self._browser.get_interactable_elements() + + return AbstractState( + state_hash=state_hash, + url=url, + title=title, + html=content, + dom_snapshot={ + "content_length": len(content), + "element_count": len(interactable), + }, + metadata={ + "interactable_count": len(interactable), + "timestamp": datetime.now(timezone.utc), + }, + ) + + @staticmethod + def hash_content(content: str) -> str: + normalized = re.sub( + r"\s+", + " ", + content.lower(), + ).strip() + + normalized = re.sub( + r"https?://", + "//", + normalized, + flags=re.IGNORECASE, + ) + + normalized = re.sub( + r"[\u0000-\u001f]+", + " ", + normalized, + ).strip() + + return hashlib.sha256( + normalized.encode("utf-8") + ).hexdigest() diff --git a/src/browser/storage_state.py b/src/browser/storage_state.py new file mode 100644 index 0000000..80245b7 --- /dev/null +++ b/src/browser/storage_state.py @@ -0,0 +1,55 @@ +import json +from typing import Any + + +def parse_storage_state(storage_state: Any) -> dict | None: + if storage_state is None: + return None + + if isinstance(storage_state, dict): + return storage_state + + if isinstance(storage_state, (bytes, bytearray)): + storage_state = storage_state.decode("utf-8") + + if isinstance(storage_state, str): + raw = storage_state.strip() + + if not raw: + return None + + try: + parsed = json.loads(raw) + + except json.JSONDecodeError as e: + raise ValueError("Invalid storage_state JSON") from e + + if parsed is None: + return None + + if not isinstance(parsed, dict): + raise ValueError("storage_state JSON must decode to an object") + + return parsed + + raise TypeError("storage_state must be a dict, JSON string, or bytes") + + +def normalize_storage_state( + storage_state: Any, +) -> dict | None: + parsed = parse_storage_state(storage_state) + + if parsed is None: + return None + + cookies = parsed.get("cookies") + origins = parsed.get("origins") + + if cookies is not None and not isinstance(cookies, list): + raise ValueError("storage_state.cookies must be a list") + + if origins is not None and not isinstance(origins, list): + raise ValueError("storage_state.origins must be a list") + + return parsed diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..3a7e112 --- /dev/null +++ b/src/config.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass + +from dotenv import load_dotenv + +load_dotenv() + + +def _env_bool(key: str, default: str) -> bool: + return str(os.getenv(key, default)).lower() == "true" + + +def _env_int(key: str, default: int) -> int: + raw = os.getenv(key) + if raw is None: + return int(default) + return int(raw) + + +@dataclass(slots=True) +class Config: + NEO4J_URI: str + NEO4J_USER: str + NEO4J_PASSWORD: str + + HEADLESS: bool + TIMEOUT_MS: int + MAX_STATES: int + MAX_TRANSITIONS: int + MAX_ELEMENTS_PER_STATE: int + MAX_SELECT_OPTIONS_PER_ELEMENT: int + MAX_ACTION_REPEATS_PER_URL: int + + ACTION_RETRY_COUNT: int + REPLAY_RETRY_COUNT: int + POPUP_TIMEOUT_MS: int + + DOM_QUIET_MS: int + DOM_SETTLE_TIMEOUT_MS: int + USE_DOM_QUIESCENCE: bool + + PAGE_LOAD_STATE: str + + CLICK_NON_HTTP_LINKS: bool + + DEFER_DESTRUCTIVE_ACTIONS: bool + DESTRUCTIVE_KEYWORDS: str + + USE_SEMANTIC_DIVERSITY: bool + SEMANTIC_DIVERSITY_THRESHOLD: float + SEMANTIC_UNCERTAINTY_MARGIN: float + SEMANTIC_MAX_BANK_SIZE: int + SEMANTIC_ARTIFACT_DIR: str + + DATABASE_URL: str | None + REDIS_URL: str | None + + @classmethod + def from_env(cls) -> Config: + return cls( + NEO4J_URI=os.getenv("NEO4J_URI", "bolt://localhost:7687"), + NEO4J_USER=os.getenv("NEO4J_USER", "neo4j"), + NEO4J_PASSWORD=os.getenv("NEO4J_PASSWORD", "password"), + HEADLESS=_env_bool("HEADLESS", "true"), + TIMEOUT_MS=_env_int("TIMEOUT_MS", 3000), + MAX_STATES=_env_int("MAX_STATES", 1000), + MAX_TRANSITIONS=_env_int("MAX_TRANSITIONS", 5000), + MAX_ELEMENTS_PER_STATE=_env_int("MAX_ELEMENTS_PER_STATE", 5), + MAX_SELECT_OPTIONS_PER_ELEMENT=_env_int("MAX_SELECT_OPTIONS_PER_ELEMENT", 3), + MAX_ACTION_REPEATS_PER_URL=_env_int("MAX_ACTION_REPEATS_PER_URL", 2), + ACTION_RETRY_COUNT=_env_int("ACTION_RETRY_COUNT", 1), + REPLAY_RETRY_COUNT=_env_int("REPLAY_RETRY_COUNT", 1), + POPUP_TIMEOUT_MS=_env_int("POPUP_TIMEOUT_MS", 3000), + DOM_QUIET_MS=_env_int("DOM_QUIET_MS", 400), + DOM_SETTLE_TIMEOUT_MS=_env_int("DOM_SETTLE_TIMEOUT_MS", 3000), + USE_DOM_QUIESCENCE=_env_bool("USE_DOM_QUIESCENCE", "true"), + PAGE_LOAD_STATE=os.getenv("PAGE_LOAD_STATE", "networkidle"), + CLICK_NON_HTTP_LINKS=_env_bool("CLICK_NON_HTTP_LINKS", "true"), + DEFER_DESTRUCTIVE_ACTIONS=_env_bool("DEFER_DESTRUCTIVE_ACTIONS", "true"), + DESTRUCTIVE_KEYWORDS=os.getenv( + "DESTRUCTIVE_KEYWORDS", + "logout,log out,sign out,delete,remove,unsubscribe,cancel,checkout,pay,purchase,order,place order,reset,deactivate,terminate,drop,empty cart,clear cart", + ), + SEMANTIC_DIVERSITY_THRESHOLD=float(os.getenv("SEMANTIC_DIVERSITY_THRESHOLD", "0.90")), + SEMANTIC_UNCERTAINTY_MARGIN=float(os.getenv("SEMANTIC_UNCERTAINTY_MARGIN", "0.05")), + SEMANTIC_MAX_BANK_SIZE=_env_int("SEMANTIC_MAX_BANK_SIZE", 1000), + SEMANTIC_ARTIFACT_DIR=os.getenv( + "SEMANTIC_ARTIFACT_DIR", + os.path.join(os.path.dirname(__file__), "models", "semantic"), + ), + USE_SEMANTIC_DIVERSITY=_env_bool("USE_SEMANTIC_DIVERSITY", "true"), + DATABASE_URL=os.getenv("DATABASE_URL") or None, + REDIS_URL=os.getenv("REDIS_URL") or None, + ) + + +config = Config.from_env() diff --git a/src/configs/input_defaults.json b/src/configs/input_defaults.json new file mode 100644 index 0000000..d3d4700 --- /dev/null +++ b/src/configs/input_defaults.json @@ -0,0 +1,48 @@ +{ + "field_patterns": { + "email": "test@example.com", + "password": "Test1234!", + "username": "testuser", + "firstname": "Test", + "lastname": "User", + "fullname": "Test User", + "phone": "5551234567", + "address": "123 Test Street", + "city": "New York", + "zip": "10001", + "country": "US", + "search": "test query", + "query": "test query", + "age": "25", + "date": "2024-01-01", + "birthday": "1990-01-01", + "url": "https://example.com", + "message": "This is a test message.", + "comment": "Test comment.", + "description": "Test description.", + "title": "Test Title", + "amount": "100", + "price": "9.99", + "quantity": "1", + "code": "TEST123", + "company": "Test Corp", + "organization": "Test Corp" + }, + "type_fallbacks": { + "email": "test@example.com", + "password": "Test1234!", + "tel": "5551234567", + "number": "42", + "date": "2024-01-01", + "datetime-local": "2024-01-01T10:00", + "month": "2024-01", + "week": "2024-W01", + "time": "10:00", + "url": "https://example.com", + "search": "test query", + "color": "#ff5733", + "range": "50", + "text": "test", + "textarea": "Test input text." + } +} \ No newline at end of file diff --git a/src/crawler/__init__.py b/src/crawler/__init__.py new file mode 100644 index 0000000..608d0f0 --- /dev/null +++ b/src/crawler/__init__.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any + +__all__ = [ + "ActionRepeatLimiter", + "ActionType", + "CrawlSession", + "EventExecutor", + "HtmlTag", + "InputType", + "InputValueResolver", + "RiskClassifier", + "StateReplayInfo", + "StateReplayer", +] + +_EXPORTS: dict[str, tuple[str, str]] = { + "ActionRepeatLimiter": ("src.crawler.action_limits", "ActionRepeatLimiter"), + "ActionType": ("src.crawler.enums", "ActionType"), + "CrawlSession": ("src.crawler.session", "CrawlSession"), + "EventExecutor": ("src.crawler.executor", "EventExecutor"), + "HtmlTag": ("src.crawler.enums", "HtmlTag"), + "InputType": ("src.crawler.enums", "InputType"), + "InputValueResolver": ("src.crawler.input_resolver", "InputValueResolver"), + "RiskClassifier": ("src.crawler.risk", "RiskClassifier"), + "StateReplayInfo": ("src.crawler.replay", "StateReplayInfo"), + "StateReplayer": ("src.crawler.replay", "StateReplayer"), +} + + +def __getattr__(name: str) -> Any: + target = _EXPORTS.get(name) + if target is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module_name, attr = target + value = getattr(import_module(module_name), attr) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals().keys()) | set(_EXPORTS.keys())) + + +if TYPE_CHECKING: + from src.crawler.action_limits import ActionRepeatLimiter + from src.crawler.enums import ActionType, HtmlTag, InputType + from src.crawler.executor import EventExecutor + from src.crawler.input_resolver import InputValueResolver + from src.crawler.replay import StateReplayer, StateReplayInfo + from src.crawler.risk import RiskClassifier + from src.crawler.session import CrawlSession diff --git a/src/crawler/action_limits.py b/src/crawler/action_limits.py new file mode 100644 index 0000000..472e629 --- /dev/null +++ b/src/crawler/action_limits.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict + + +@dataclass +class ActionRepeatLimiter: + max_repeats_per_scope: int + _counts: Dict[str, Dict[str, int]] + + def __init__(self, *, max_repeats_per_scope: int): + self.max_repeats_per_scope = max(0, int(max_repeats_per_scope)) + self._counts = {} + + def can_run(self, *, scope: str, action_key: str) -> bool: + if self.max_repeats_per_scope <= 0: + return False + return self._counts.get(scope, {}).get(action_key, 0) < self.max_repeats_per_scope + + def record(self, *, scope: str, action_key: str) -> None: + scope_counts = self._counts.setdefault(scope, {}) + scope_counts[action_key] = scope_counts.get(action_key, 0) + 1 diff --git a/src/crawler/enums.py b/src/crawler/enums.py new file mode 100644 index 0000000..97bbff8 --- /dev/null +++ b/src/crawler/enums.py @@ -0,0 +1,28 @@ +from enum import StrEnum + + +class ActionType(StrEnum): + CLICK = "click" + TYPE = "type" + SELECT = "select" + NAVIGATE = "navigate" + PRESS = "press" + + +class HtmlTag(StrEnum): + INPUT = "input" + TEXTAREA = "textarea" + SELECT = "select" + BUTTON = "button" + ANCHOR = "a" + + +class InputType(StrEnum): + SUBMIT = "submit" + BUTTON = "button" + RESET = "reset" + HIDDEN = "hidden" + IMAGE = "image" + FILE = "file" + CHECKBOX = "checkbox" + RADIO = "radio" diff --git a/src/crawler/executor.py b/src/crawler/executor.py new file mode 100644 index 0000000..aea2577 --- /dev/null +++ b/src/crawler/executor.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +from typing import Any + +from src.browser import BrowserEngine +from src.crawler.enums import ActionType, HtmlTag, InputType +from src.crawler.input_resolver import InputValueResolver +from src.crawler.semantic_engine import SemanticEngine +from src.models import AbstractTransition, CrawlAction +from src.utils import element_display_hint + + +class EventExecutor: + def __init__( + self, + browser: BrowserEngine, + config_path: str | None = None, + input_defaults: dict[str, Any] | None = None, + semantic_engine: SemanticEngine | None = None, + ): + self._browser = browser + self._resolver = InputValueResolver( + config_path=config_path, + input_defaults=input_defaults, + semantic_engine=semantic_engine, + ) + self._transition_log: list[AbstractTransition] = [] + + def resolve_value(self, element: dict) -> str: + return self._resolver.resolve(element) + + async def execute_action(self, action: CrawlAction) -> None: + try: + frame_url, frame_name = self._extract_frame(action.metadata) + match action.action_type: + case ActionType.CLICK: + await self._browser.click(action.selector, frame_url=frame_url, frame_name=frame_name) + + case ActionType.TYPE: + await self._browser.type_text( + action.selector, + action.value, + frame_url=frame_url, + frame_name=frame_name, + ) + + case ActionType.SELECT: + await self._browser.select_option( + action.selector, + action.value, + frame_url=frame_url, + frame_name=frame_name, + ) + + case ActionType.NAVIGATE: + await self._browser.navigate(action.value) + + case ActionType.PRESS: + await self._browser.press_key( + action.selector, + action.value, + frame_url=frame_url, + frame_name=frame_name, + ) + + case _: + raise ValueError(f"Unknown action type: {action.action_type}") + + except Exception as e: + target = action.selector or action.value + + raise RuntimeError(f"Failed to execute {action.action_type} on {target}: {e}") from e + + def plan_form_submission(self, form: dict) -> list[CrawlAction] | None: + submit = form.get("submit") + + if not self._is_valid_submit(submit): + return None + + actions: list[CrawlAction] = [] + chosen_radios = self._choose_radio_defaults(form) + + for field in form.get("fields", []): + action = self._build_field_action(field, form, chosen_radios) + + if action: + actions.append(action) + + actions.append(self._build_submit_action(form)) + + return actions + + def log_transition(self, transition: AbstractTransition) -> None: + self._transition_log.append(transition) + + def get_transition_log(self) -> list[AbstractTransition]: + return self._transition_log + + def _extract_frame(self, metadata: dict | None) -> tuple[str | None, str | None]: + if not metadata: + return None, None + + frame = metadata.get("frame") + + if not isinstance(frame, dict): + return None, None + + frame_url = frame.get("url") or frame.get("src") + frame_name = frame.get("name") + + return frame_url, frame_name + + def _is_valid_submit(self, submit: dict | None) -> bool: + return bool(submit and submit.get("selector")) + + def _choose_radio_defaults(self, form: dict) -> list[dict]: + radio_groups: dict[str, list[dict]] = {} + + for field in form.get("fields", []): + field_type = self._field_type(field) + + if field_type != InputType.RADIO: + continue + + key = str(field.get("name") or field.get("id") or "radio") + radio_groups.setdefault(key, []).append(field) + + selected: list[dict] = [] + + for group_fields in radio_groups.values(): + if any(bool(field.get("checked")) for field in group_fields): + continue + + selected.append(group_fields[0]) + + return selected + + def _build_field_action( + self, + field: dict, + form: dict, + chosen_radios: list[dict], + ) -> CrawlAction | None: + if self._should_skip_field(field): + return None + + tag = self._field_tag(field) + field_type = self._field_type(field) + + if tag == HtmlTag.SELECT: + return self._build_select_action(field, form) + + if field_type == InputType.CHECKBOX: + return self._build_checkbox_action(field, form) + + if field_type == InputType.RADIO: + return self._build_radio_action(field, form, chosen_radios) + + if tag in (HtmlTag.INPUT, HtmlTag.TEXTAREA): + return self._build_text_action(field, form) + + return None + + def _build_select_action(self, field: dict, form: dict) -> CrawlAction | None: + options = [option for option in field.get("options", []) if option.get("value")] + + if not options: + return None + + value = str(options[0]["value"]) + option_text = str(options[0].get("text", value) or value).strip() + label_hint = element_display_hint(field, label_keys=("label", "aria_label")) + + return CrawlAction( + action_type=ActionType.SELECT, + selector=str(field["selector"]), + value=value, + description=f"Select '{option_text}' for {label_hint or 'select'}", + metadata=self._field_metadata(field, form), + ) + + def _build_checkbox_action(self, field: dict, form: dict) -> CrawlAction | None: + required = bool(field.get("required")) + checked = bool(field.get("checked")) + + if not required or checked: + return None + + label_hint = element_display_hint(field, label_keys=("label", "aria_label")) + + return CrawlAction( + action_type=ActionType.CLICK, + selector=str(field["selector"]), + description=f"Check required checkbox {label_hint}".strip(), + metadata=self._field_metadata(field, form), + ) + + def _build_radio_action( + self, + field: dict, + form: dict, + chosen_radios: list[dict], + ) -> CrawlAction | None: + if field not in chosen_radios: + return None + + label_hint = element_display_hint(field, label_keys=("label", "aria_label")) + + return CrawlAction( + action_type=ActionType.CLICK, + selector=str(field["selector"]), + description=f"Select radio option {label_hint}".strip(), + metadata=self._field_metadata(field, form), + ) + + def _build_text_action(self, field: dict, form: dict) -> CrawlAction: + value = self._resolver.resolve(field) + + label_hint = element_display_hint(field, label_keys=("label", "aria_label")) + type_hint = self._field_type(field) or self._field_tag(field) + + return CrawlAction( + action_type=ActionType.TYPE, + selector=str(field["selector"]), + value=value, + description=f"Type into {type_hint} {label_hint}".strip(), + metadata={ + **self._field_metadata(field, form), + "type": self._field_type(field), + }, + ) + + def _build_submit_action(self, form: dict) -> CrawlAction: + submit = form["submit"] + + form_id = str(form.get("form_id", "") or "").strip() + form_method = str(form.get("method", "get") or "get").lower() + form_action = str(form.get("action", "") or "").strip() + + submit_label = element_display_hint( + submit, + label_keys=("label", "aria_label"), + ) + + description_parts = [ + f"Submit form '{form_id}'" if form_id else "Submit form", + form_method.upper(), + ] + + if form_action: + description_parts.append(form_action) + + if submit_label: + description_parts.append(f"via {submit_label}") + + return CrawlAction( + action_type=ActionType.CLICK, + selector=str(submit["selector"]), + description=" ".join(description_parts).strip(), + metadata={ + "form_id": form.get("form_id"), + "form_method": form_method, + "form_action": form_action, + "frame": submit.get("frame") or form.get("frame"), + }, + ) + + def _should_skip_field(self, field: dict) -> bool: + selector = str(field.get("selector", "") or "") + + if not selector: + return True + + if field.get("disabled") or field.get("readonly"): + return True + + field_type = self._field_type(field) + + return field_type in { + InputType.SUBMIT, + InputType.BUTTON, + InputType.RESET, + InputType.HIDDEN, + InputType.IMAGE, + InputType.FILE, + } + + def _field_metadata(self, field: dict, form: dict) -> dict: + return { + "form_id": form.get("form_id"), + "field": field.get("name") or field.get("id"), + "frame": field.get("frame") or form.get("frame"), + } + + def _field_tag(self, field: dict) -> str: + return str(field.get("tag", "") or "").lower() + + def _field_type(self, field: dict) -> str: + return str(field.get("type", "") or "").lower() diff --git a/src/crawler/fingerprints.py b/src/crawler/fingerprints.py new file mode 100644 index 0000000..be2df26 --- /dev/null +++ b/src/crawler/fingerprints.py @@ -0,0 +1,54 @@ +import hashlib +from typing import Any + +from src.models import CrawlAction +from src.utils import stable_json_dumps + + +def action_attempt_fingerprint(source_state_hash: str, action: CrawlAction) -> str: + payload = { + "action_type": action.action_type, + "selector": action.selector, + "value": action.value, + "metadata": action.metadata or {}, + } + raw = f"{source_state_hash}|{stable_json_dumps(payload)}" + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def action_key_fingerprint(action: CrawlAction) -> str: + meta = action.metadata or {} + payload = { + "action_type": action.action_type, + "selector": action.selector, + "value": action.value, + "sequence_digest": meta.get("sequence_digest"), + } + raw = stable_json_dumps(payload) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def transition_fingerprint( + *, + session_id: str, + source_state_hash: str, + target_state_hash: str, + action: CrawlAction, +) -> str: + payload: dict[str, Any] = { + "session_id": session_id, + "source": source_state_hash, + "target": target_state_hash, + "action_type": action.action_type, + "selector": action.selector, + "value": action.value, + "metadata": action.metadata or {}, + } + raw = stable_json_dumps(payload) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def best_effort_action_value(action: CrawlAction) -> str: + if action.action_type in ("type", "select", "navigate", "press"): + return str(action.value or "") + return "" diff --git a/src/crawler/input_resolver.py b/src/crawler/input_resolver.py new file mode 100644 index 0000000..589e6a0 --- /dev/null +++ b/src/crawler/input_resolver.py @@ -0,0 +1,101 @@ +import json +from typing import Any + +from src import config +from src.crawler.semantic_engine.engine import SemanticEngine + + +class InputValueResolver: + def __init__( + self, + config_path: str | None = None, + input_defaults: dict[str, Any] | None = None, + confidence_threshold: float = 0.4, + semantic_engine: SemanticEngine | None = None, + ): + self._config = input_defaults if isinstance(input_defaults, dict) else self._load_config(config_path) + self._confidence_threshold = confidence_threshold + + field_patterns = self._config.get("field_patterns", {}) + if semantic_engine is not None: + semantic_engine.configure_input_defaults(field_patterns) + self._semantic_engine = semantic_engine + else: + self._semantic_engine = SemanticEngine( + input_defaults=field_patterns, + artifact_dir=config.SEMANTIC_ARTIFACT_DIR, + enabled=False, + ) + + def _load_config(self, path: str | None) -> dict[str, Any]: + if not path: + return {"field_patterns": {}, "type_fallbacks": {}} + + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + def resolve(self, element: dict) -> str: + resolved = self._semantic_engine.resolve_input_value(element) + + best_value = None + if ( + resolved + and not resolved.abstained + and resolved.value is not None + and resolved.confidence >= self._confidence_threshold + ): + best_value = str(resolved.value) + + if best_value is not None: + return self._apply_constraints(best_value, element) + + fallbacks = self._config.get("type_fallbacks", {}) + fallback = str(fallbacks.get(element.get("type", "text"), "test")) + + tag = str(element.get("tag", "") or element.get("type", "")).lower() + if tag == "select": + options = element.get("options", []) + if options: + for opt in options: + val = str(opt if isinstance(opt, str) else opt.get("value", "")) + if val and val.strip() and val.lower() not in ("none", "null", ""): + return val + + first = options[0] + return str(first if isinstance(first, str) else first.get("value", "")) + + return self._apply_constraints(fallback, element) + + def _apply_constraints(self, value: str, element: dict) -> str: + t = str(element.get("type", "") or "").lower() + maxlength = element.get("maxlength") + + if maxlength is not None: + try: + ml = int(maxlength) + if ml >= 0: + value = value[:ml] + except Exception: + pass + + if t in ("number", "range"): + chosen = None + min_v = element.get("min") + max_v = element.get("max") + try: + if min_v is not None: + chosen = str(int(float(min_v))) + except Exception: + chosen = None + + if chosen is None: + try: + if max_v is not None: + chosen = str(int(float(max_v))) + except Exception: + chosen = None + + if chosen is not None: + value = chosen + + return value diff --git a/src/crawler/replay/__init__.py b/src/crawler/replay/__init__.py new file mode 100644 index 0000000..531c540 --- /dev/null +++ b/src/crawler/replay/__init__.py @@ -0,0 +1,4 @@ +from src.crawler.replay.info import StateReplayInfo +from src.crawler.replay.replayer import StateReplayer + +__all__ = ["StateReplayInfo", "StateReplayer"] diff --git a/src/crawler/replay/info.py b/src/crawler/replay/info.py new file mode 100644 index 0000000..21a9439 --- /dev/null +++ b/src/crawler/replay/info.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from src.models import CrawlAction +from src.utils import stable_json_dumps + + +@dataclass +class StateReplayInfo: + checkpoint_url: str + checkpoint_state_hash: str = "" + checkpoint_kind: str = "url_change" + actions: list[CrawlAction] = field(default_factory=list) + storage_state: Any | None = None + fallback_checkpoint_url: str | None = None + fallback_checkpoint_state_hash: str | None = None + fallback_actions: list[CrawlAction] = field(default_factory=list) + fallback_storage_state: Any | None = None + + def to_neo4j_props(self, *, state_hash: str) -> dict[str, Any]: + def action_to_dict(action: CrawlAction) -> dict[str, Any]: + return { + "id": action.action_id, + "type": action.action_type, + "selector": action.selector, + "value": action.value, + "description": action.description, + "metadata": action.metadata, + } + + props: dict[str, Any] = { + "replay_recipe_version": 1, + "checkpoint_url": self.checkpoint_url, + "checkpoint_state_hash": self.checkpoint_state_hash, + "checkpoint_kind": self.checkpoint_kind, + "replay_actions_json": stable_json_dumps([action_to_dict(a) for a in self.actions]), + } + + if self.fallback_checkpoint_url: + props["fallback_checkpoint_url"] = self.fallback_checkpoint_url + if self.fallback_checkpoint_state_hash: + props["fallback_checkpoint_state_hash"] = self.fallback_checkpoint_state_hash + if self.fallback_actions: + props["fallback_actions_json"] = stable_json_dumps([action_to_dict(a) for a in self.fallback_actions]) + + props["is_checkpoint"] = bool(state_hash and self.checkpoint_state_hash == state_hash) + return props + + def score_for_state(self, state_hash: str) -> tuple[int, int, int, int, str]: + is_self_checkpoint = bool(state_hash and self.checkpoint_state_hash == state_hash) + kind_rank = 0 if self.checkpoint_kind == "initial" else 1 + checkpoint_url = str(self.checkpoint_url or "") + return ( + 0 if is_self_checkpoint else 1, + len(self.actions), + len(self.fallback_actions), + kind_rank, + checkpoint_url, + ) diff --git a/src/crawler/replay/replayer.py b/src/crawler/replay/replayer.py new file mode 100644 index 0000000..06ab87c --- /dev/null +++ b/src/crawler/replay/replayer.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from typing import Any + +from src.browser import BrowserEngine +from src.config import Config, config +from src.crawler.replay.info import StateReplayInfo +from src.models import CrawlAction + + +class StateReplayer: + def __init__(self, browser: BrowserEngine, executor, settings: Config = config): + self._browser = browser + self._executor = executor + self._settings = settings + self._replay_map: dict[str, StateReplayInfo] = {} + + def register(self, state_hash: str, info: StateReplayInfo) -> bool: + existing = self._replay_map.get(state_hash) + if existing is None: + self._replay_map[state_hash] = info + return True + + if info.score_for_state(state_hash) < existing.score_for_state(state_hash): + self._replay_map[state_hash] = info + return True + + return False + + def get_info(self, state_hash: str) -> StateReplayInfo | None: + return self._replay_map.get(state_hash) + + async def replay_to(self, state_hash: str) -> bool: + info = self._replay_map.get(state_hash) + if not info: + return False + + async def attempt(checkpoint_url: str, actions: list[CrawlAction], storage_state: Any | None = None) -> bool: + last_error: Exception | None = None + for _ in range(self._settings.REPLAY_RETRY_COUNT + 1): + try: + if storage_state is not None: + await self._browser.reset_context_from_storage_state(storage_state) + await self._browser.navigate(checkpoint_url) + await self._browser.wait_for_settle() + for action in actions: + await self._executor.execute_action(action) + await self._browser.wait_for_settle() + current_hash = await self._browser.get_state_hash() + if current_hash == state_hash: + return True + except Exception as e: + last_error = e + if last_error: + raise last_error + return False + + try: + if await attempt(info.checkpoint_url, info.actions, info.storage_state): + return True + except Exception: + pass + + if info.fallback_checkpoint_url: + return await attempt(info.fallback_checkpoint_url, info.fallback_actions, info.fallback_storage_state) + return False diff --git a/src/crawler/risk.py b/src/crawler/risk.py new file mode 100644 index 0000000..d4a3a18 --- /dev/null +++ b/src/crawler/risk.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass +from typing import Optional + +from src.config import Config, config +from src.models import CrawlAction + + +@dataclass(frozen=True) +class RiskClassifier: + keywords: tuple[str, ...] + + @staticmethod + def from_settings(settings: Config) -> "RiskClassifier": + raw = str(getattr(settings, "DESTRUCTIVE_KEYWORDS", "") or "") + parts = [p.strip().lower() for p in raw.split(",") if p.strip()] + return RiskClassifier(keywords=tuple(parts)) + + @staticmethod + def from_config() -> "RiskClassifier": + return RiskClassifier.from_settings(config) + + def is_risky(self, action: CrawlAction, *, element: Optional[dict] = None) -> bool: + text_bits: list[str] = [ + str(action.action_type or ""), + str(action.description or ""), + str(action.selector or ""), + str(action.value or ""), + ] + if action.metadata: + for k, v in action.metadata.items(): + text_bits.append(str(k)) + text_bits.append(str(v)) + if element: + for k in ("text", "href", "tag", "type", "name", "aria_label"): + if k in element: + text_bits.append(str(element.get(k) or "")) + + haystack = " ".join(text_bits).lower() + return any(kw and kw in haystack for kw in self.keywords) diff --git a/src/crawler/semantic_engine/__init__.py b/src/crawler/semantic_engine/__init__.py new file mode 100644 index 0000000..142048e --- /dev/null +++ b/src/crawler/semantic_engine/__init__.py @@ -0,0 +1,15 @@ +from src.crawler.semantic_engine.engine import SemanticEngine +from src.crawler.semantic_engine.extractor import DOMFeatureExtractor, FeatureExtractor +from src.crawler.semantic_engine.resolver import ResolvedInput +from src.crawler.semantic_engine.state import StateComparisonResult, StateSemanticProfile +from src.crawler.semantic_engine.topic import TopicPrediction + +__all__ = [ + "SemanticEngine", + "ResolvedInput", + "FeatureExtractor", + "DOMFeatureExtractor", + "StateSemanticProfile", + "StateComparisonResult", + "TopicPrediction", +] diff --git a/src/crawler/semantic_engine/artifacts.py b/src/crawler/semantic_engine/artifacts.py new file mode 100644 index 0000000..e611673 --- /dev/null +++ b/src/crawler/semantic_engine/artifacts.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import joblib + +ARTIFACT_SCHEMA_VERSION = 1 + + +class ArtifactError(RuntimeError): + pass + + +@dataclass(frozen=True) +class ModelBundle: + kind: str + model_version: str + dataset_hash: str + payload: dict[str, Any] + metrics: dict[str, Any] + + +class ModelArtifactLoader: + def __init__(self, artifact_dir: str | Path): + self._artifact_dir = Path(artifact_dir).expanduser().resolve() + + @property + def artifact_dir(self) -> Path: + return self._artifact_dir + + def load(self, filename: str, expected_kind: str) -> ModelBundle: + path = self._artifact_dir / filename + if not path.is_file(): + raise ArtifactError(f"Model artifact not found: {path}") + + try: + raw = joblib.load(path) + except Exception as exc: + raise ArtifactError(f"Could not load model artifact: {path}") from exc + + if not isinstance(raw, dict): + raise ArtifactError(f"Invalid artifact payload: {path}") + if raw.get("schema_version") != ARTIFACT_SCHEMA_VERSION: + raise ArtifactError(f"Incompatible artifact schema: {path}") + if raw.get("kind") != expected_kind: + raise ArtifactError(f"Expected {expected_kind} artifact: {path}") + if not isinstance(raw.get("payload"), dict): + raise ArtifactError(f"Artifact payload is missing: {path}") + + return ModelBundle( + kind=expected_kind, + model_version=str(raw.get("model_version", "")), + dataset_hash=str(raw.get("dataset_hash", "")), + payload=raw["payload"], + metrics=dict(raw.get("metrics") or {}), + ) + + +def save_model_bundle( + path: str | Path, + *, + kind: str, + model_version: str, + dataset_hash: str, + payload: dict[str, Any], + metrics: dict[str, Any] | None = None, +) -> None: + output = Path(path) + output.parent.mkdir(parents=True, exist_ok=True) + if output.exists(): + raise FileExistsError(f"Refusing to overwrite immutable artifact: {output}") + + joblib.dump( + { + "schema_version": ARTIFACT_SCHEMA_VERSION, + "kind": kind, + "model_version": model_version, + "dataset_hash": dataset_hash, + "payload": payload, + "metrics": metrics or {}, + }, + output, + ) diff --git a/src/crawler/semantic_engine/engine.py b/src/crawler/semantic_engine/engine.py new file mode 100644 index 0000000..536324f --- /dev/null +++ b/src/crawler/semantic_engine/engine.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from src.crawler.semantic_engine.artifacts import ArtifactError, ModelArtifactLoader +from src.crawler.semantic_engine.extractor import DOMFeatureExtractor +from src.crawler.semantic_engine.features import SklearnElementFeatureEncoder +from src.crawler.semantic_engine.resolver import InputResolver, ResolvedInput +from src.crawler.semantic_engine.state import ( + PairFeatureEquivalenceClassifier, + StateComparisonBank, + StateComparisonResult, + StateProfiler, + StateSemanticProfile, +) +from src.crawler.semantic_engine.topic import SklearnTopicClassifier, TopicClassifier + +logger = logging.getLogger(__name__) + + +class SemanticEngine: + def __init__( + self, + input_defaults: dict[str, Any], + *, + artifact_dir: str | Path, + enabled: bool = True, + similarity_threshold: float = 0.9, + uncertainty_margin: float = 0.05, + max_bank_size: int = 1000, + topic_classifier: TopicClassifier | None = None, + comparison_bank: StateComparisonBank | None = None, + ): + self.extractor = DOMFeatureExtractor() + self._enabled = enabled + self._load_error: str | None = None + self._topic_classifier = topic_classifier + self._comparison_bank = comparison_bank + + if topic_classifier is None or (enabled and comparison_bank is None): + self._load_artifacts( + artifact_dir=artifact_dir, + threshold=similarity_threshold, + uncertainty_margin=uncertainty_margin, + max_bank_size=max_bank_size, + ) + + self.resolver = InputResolver( + extractor=self.extractor, + classifier=self._topic_classifier, + input_defaults=input_defaults, + ) + + @property + def available(self) -> bool: + return self._enabled and self._comparison_bank is not None + + @property + def load_error(self) -> str | None: + return self._load_error + + @property + def profile_count(self) -> int: + return ( + self._comparison_bank.profile_count + if self._comparison_bank is not None + else 0 + ) + + def resolve_input_value( + self, + element: dict[str, Any], + ) -> ResolvedInput | None: + return self.resolver.resolve(element) + + def configure_input_defaults(self, input_defaults: dict[str, Any]) -> None: + self.resolver = InputResolver( + extractor=self.extractor, + classifier=self._topic_classifier, + input_defaults=input_defaults, + ) + + def register_state( + self, + state_hash: str, + elements: list[dict[str, Any]], + ) -> StateComparisonResult: + if not self.available: + return StateComparisonResult( + state_hash, + True, + False, + None, + 0.0, + {}, + "semantic_engine_unavailable", + ) + + try: + return self._comparison_bank.register(state_hash, elements) + except Exception: + logger.exception("Semantic state comparison failed open") + return StateComparisonResult( + state_hash, + True, + False, + None, + 0.0, + {}, + "comparison_error", + ) + + def get_state_profile( + self, + state_hash: str, + ) -> StateSemanticProfile | None: + if self._comparison_bank is None: + return None + return self._comparison_bank.get_profile(state_hash) + + def explain_comparison( + self, + comparison: StateComparisonResult, + ) -> dict[str, Any]: + if self._comparison_bank is None: + return {} + return self._comparison_bank.explain_result(comparison) + + def _load_artifacts( + self, + *, + artifact_dir: str | Path, + threshold: float, + uncertainty_margin: float, + max_bank_size: int, + ) -> None: + loader = ModelArtifactLoader(artifact_dir) + try: + topic_bundle = loader.load("topic_model.joblib", "topic_classifier") + topic_classifier = SklearnTopicClassifier( + topic_bundle.payload["pipeline"], + thresholds=topic_bundle.payload.get("thresholds"), + default_threshold=float( + topic_bundle.payload.get("default_threshold", 0.55) + ), + extractor=self.extractor, + ) + + self._topic_classifier = topic_classifier + except (ArtifactError, KeyError, TypeError, ValueError) as exc: + self._load_error = str(exc) + logger.warning("Topic classifier unavailable: %s", exc) + return + + if not self._enabled: + return + + try: + state_bundle = loader.load( + "state_equivalence.joblib", + "state_equivalence", + ) + encoder = SklearnElementFeatureEncoder( + state_bundle.payload["text_pipeline"], + topic_classifier=topic_classifier, + extractor=self.extractor, + ) + profiler = StateProfiler(encoder, topic_classifier) + classifier = PairFeatureEquivalenceClassifier( + state_bundle.payload["pair_classifier"] + ) + self._comparison_bank = StateComparisonBank( + profiler, + classifier, + threshold=threshold, + uncertainty_margin=uncertainty_margin, + max_size=max_bank_size, + ) + except (ArtifactError, KeyError, TypeError, ValueError) as exc: + self._load_error = str(exc) + logger.warning("State equivalence unavailable: %s", exc) diff --git a/src/crawler/semantic_engine/extractor.py b/src/crawler/semantic_engine/extractor.py new file mode 100644 index 0000000..7e9b6e8 --- /dev/null +++ b/src/crawler/semantic_engine/extractor.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import re +from abc import ABC, abstractmethod +from typing import Any + +_WHITESPACE_RE = re.compile(r"\s+") +_HTTP_SCHEME_RE = re.compile(r"https?://", re.IGNORECASE) + + +def normalize_semantic_text(value: Any) -> str: + text = _HTTP_SCHEME_RE.sub( + "//", + str(value or "").replace("_", " ").replace("-", " ").lower(), + ) + return _WHITESPACE_RE.sub(" ", text).strip() + + +class FeatureExtractor(ABC): + @abstractmethod + def extract(self, element: dict[str, Any]) -> str: + pass + + +class DOMFeatureExtractor(FeatureExtractor): + TEXT_KEYS = ( + "tag", + "type", + "id", + "name", + "text", + "value", + "placeholder", + "label", + "aria_label", + "aria-label", + "role", + "title", + "href", + "aria_invalid", + "aria_expanded", + ) + + def extract(self, element: dict[str, Any]) -> str: + features: list[str] = [] + for key in self.TEXT_KEYS: + value = normalize_semantic_text(element.get(key)) + if value: + features.append(value) + + options = element.get("options") or [] + for option in options[:20]: + if isinstance(option, dict): + option_text = normalize_semantic_text( + option.get("text") or option.get("value") + ) + else: + option_text = normalize_semantic_text(option) + if option_text: + features.append(option_text) + + return " ".join(dict.fromkeys(features)) diff --git a/src/crawler/semantic_engine/features.py b/src/crawler/semantic_engine/features.py new file mode 100644 index 0000000..3d5fda5 --- /dev/null +++ b/src/crawler/semantic_engine/features.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from typing import Any, Protocol + +import numpy as np + +from src.crawler.semantic_engine.extractor import DOMFeatureExtractor +from src.crawler.semantic_engine.topic import TopicClassifier + +STRUCTURAL_KEYS = ( + "disabled", + "readonly", + "required", + "checked", + "contenteditable", + "in_form", + "aria_invalid", + "aria_expanded", +) + + +class ElementFeatureEncoder(Protocol): + @property + def dimension(self) -> int: + ... + + def encode(self, elements: list[dict[str, Any]]) -> np.ndarray: + ... + + +class SklearnElementFeatureEncoder: + def __init__( + self, + text_pipeline: Any, + *, + topic_classifier: TopicClassifier | None = None, + extractor: DOMFeatureExtractor | None = None, + ): + self._text_pipeline = text_pipeline + self._topic_classifier = topic_classifier + self._extractor = extractor or DOMFeatureExtractor() + probe = self.encode([{"tag": "input", "type": "text", "name": "probe"}]) + self._dimension = int(probe.shape[1]) + + @property + def dimension(self) -> int: + return self._dimension + + def encode(self, elements: list[dict[str, Any]]) -> np.ndarray: + if not elements: + dimension = getattr(self, "_dimension", 0) + return np.empty((0, dimension), dtype=float) + + texts = [self._extractor.extract(element) for element in elements] + text_features = self._text_pipeline.transform(texts) + if hasattr(text_features, "toarray"): + text_features = text_features.toarray() + + structural = np.asarray( + [self._structural_vector(element) for element in elements], + dtype=float, + ) + topic_features = self._topic_vectors(elements) + combined = np.hstack([np.asarray(text_features), structural, topic_features]) + norms = np.linalg.norm(combined, axis=1, keepdims=True) + norms[norms == 0] = 1.0 + return combined / norms + + def _topic_vectors(self, elements: list[dict[str, Any]]) -> np.ndarray: + if self._topic_classifier is None: + return np.empty((len(elements), 0), dtype=float) + + classes = self._topic_classifier.classes + rows = [] + for element in elements: + prediction = self._topic_classifier.predict(element) + rows.append( + [prediction.probabilities.get(label, 0.0) for label in classes] + ) + return np.asarray(rows, dtype=float) + + def _structural_vector(self, element: dict[str, Any]) -> list[float]: + tag = str(element.get("tag", "") or "").lower() + input_type = str(element.get("type", "") or "").lower() + role = str(element.get("role", "") or "").lower() + options = element.get("options") or [] + + categories = [ + tag == "input", + tag == "textarea", + tag == "select", + tag == "button", + tag == "a", + input_type in {"text", "search", "email", "password", "tel", "url"}, + input_type in {"checkbox", "radio"}, + role == "button", + ] + flags = [bool(element.get(key)) for key in STRUCTURAL_KEYS] + return [float(value) for value in categories + flags] + [ + min(len(options), 50) / 50.0 + ] diff --git a/src/crawler/semantic_engine/resolver.py b/src/crawler/semantic_engine/resolver.py new file mode 100644 index 0000000..3c5e3cf --- /dev/null +++ b/src/crawler/semantic_engine/resolver.py @@ -0,0 +1,85 @@ +from dataclasses import dataclass +from typing import Any + +from src.crawler.semantic_engine.extractor import FeatureExtractor, normalize_semantic_text +from src.crawler.semantic_engine.topic import TopicClassifier, canonical_topic + + +@dataclass(frozen=True) +class ResolvedInput: + matched_key: str + value: Any + confidence: float + predicted_topic: str | None = None + abstained: bool = False + + +class InputResolver: + def __init__( + self, + extractor: FeatureExtractor, + classifier: TopicClassifier | None, + input_defaults: dict[str, Any], + ): + self._extractor = extractor + self._input_defaults = input_defaults + self._classifier = classifier + self._canonical_defaults = { + canonical_topic(key): value for key, value in input_defaults.items() + } + + def resolve(self, element: dict[str, Any]) -> ResolvedInput | None: + if not self._input_defaults: + return None + + deterministic = self._deterministic_match(element) + if deterministic is not None: + return deterministic + + if self._classifier is None: + return None + prediction = self._classifier.predict(element) + if prediction.abstained or prediction.topic is None: + return ResolvedInput( + matched_key="", + value=None, + confidence=prediction.confidence, + predicted_topic=None, + abstained=True, + ) + + topic = canonical_topic(prediction.topic) + if topic not in self._canonical_defaults: + return None + + return ResolvedInput( + matched_key=topic, + value=self._canonical_defaults[topic], + confidence=prediction.confidence, + predicted_topic=topic, + ) + + def _deterministic_match( + self, + element: dict[str, Any], + ) -> ResolvedInput | None: + hints = { + normalize_semantic_text(element.get(key)) + for key in ("type", "name", "id", "placeholder", "label", "aria_label") + } + hints.discard("") + + for key, value in self._input_defaults.items(): + normalized_key = normalize_semantic_text(key) + if normalized_key and any( + normalized_key == hint + or normalized_key in hint.split() + for hint in hints + ): + return ResolvedInput( + matched_key=key, + value=value, + confidence=1.0, + predicted_topic=canonical_topic(key), + ) + return None diff --git a/src/crawler/semantic_engine/state.py b/src/crawler/semantic_engine/state.py new file mode 100644 index 0000000..aad93e0 --- /dev/null +++ b/src/crawler/semantic_engine/state.py @@ -0,0 +1,413 @@ +from __future__ import annotations + +from collections import OrderedDict +from dataclasses import dataclass +from typing import Any, Protocol + +import numpy as np + +from src.crawler.semantic_engine.features import ElementFeatureEncoder +from src.crawler.semantic_engine.topic import TopicClassifier + +PAIR_FEATURE_NAMES = ( + "element_similarity", + "pooled_similarity", + "topic_similarity", + "structure_similarity", + "count_similarity", +) + +HIGH_SIMILARITY_EQUIVALENCE_MINIMUMS = { + "element_similarity": 0.995, + "pooled_similarity": 0.995, + "topic_similarity": 0.995, + "structure_similarity": 0.98, + "count_similarity": 0.95, +} + + +@dataclass(frozen=True, eq=False) +class StateSemanticProfile: + state_hash: str + element_vectors: np.ndarray + pooled_vector: np.ndarray + topic_distribution: np.ndarray + structural_distribution: np.ndarray + element_count: int + + def __eq__(self, other: object) -> bool: + return ( + isinstance(other, StateSemanticProfile) + and self.state_hash == other.state_hash + ) + + def __hash__(self) -> int: + return hash(self.state_hash) + + +@dataclass(frozen=True) +class StateComparisonResult: + state_hash: str + is_novel: bool + is_equivalent: bool + matched_state_hash: str | None + confidence: float + scores: dict[str, float] + reason: str + + +class StateProfiler: + def __init__( + self, + encoder: ElementFeatureEncoder, + topic_classifier: TopicClassifier | None = None, + ): + self._encoder = encoder + self._topic_classifier = topic_classifier + + def profile( + self, + state_hash: str, + elements: list[dict[str, Any]], + ) -> StateSemanticProfile: + vectors = self._encoder.encode(elements) + pooled = ( + vectors.mean(axis=0) + if vectors.shape[0] + else np.zeros(self._encoder.dimension, dtype=float) + ) + pooled_norm = np.linalg.norm(pooled) + if pooled_norm: + pooled = pooled / pooled_norm + + return StateSemanticProfile( + state_hash=state_hash, + element_vectors=vectors, + pooled_vector=pooled, + topic_distribution=self._topic_distribution(elements), + structural_distribution=self._structural_distribution(elements), + element_count=len(elements), + ) + + def _topic_distribution( + self, + elements: list[dict[str, Any]], + ) -> np.ndarray: + if self._topic_classifier is None: + return np.empty(0, dtype=float) + + classes = self._topic_classifier.classes + distribution = np.zeros(len(classes), dtype=float) + for element in elements: + prediction = self._topic_classifier.predict(element) + distribution += np.asarray( + [prediction.probabilities.get(label, 0.0) for label in classes] + ) + return _normalize(distribution) + + def _structural_distribution( + self, + elements: list[dict[str, Any]], + ) -> np.ndarray: + buckets = np.zeros(12, dtype=float) + for element in elements: + tag = str(element.get("tag", "") or "").lower() + input_type = str(element.get("type", "") or "").lower() + index = { + "input": 0, + "textarea": 1, + "select": 2, + "button": 3, + "a": 4, + }.get(tag, 5) + buckets[index] += 1 + buckets[6] += float(input_type in {"checkbox", "radio"}) + buckets[7] += float(bool(element.get("disabled"))) + buckets[8] += float(bool(element.get("required"))) + buckets[9] += float(bool(element.get("checked"))) + buckets[10] += float(bool(element.get("aria_invalid"))) + buckets[11] += float(bool(element.get("aria_expanded"))) + return _normalize(buckets) + + +class StateEquivalenceClassifier(Protocol): + def compare( + self, + left: StateSemanticProfile, + right: StateSemanticProfile, + ) -> tuple[float, dict[str, float]]: + ... + + +class PairFeatureEquivalenceClassifier: + def __init__( + self, + model: Any, + *, + equivalent_class: int | bool = 1, + ): + self._model = model + self._equivalent_class = equivalent_class + + def compare( + self, + left: StateSemanticProfile, + right: StateSemanticProfile, + ) -> tuple[float, dict[str, float]]: + scores = pair_features(left, right) + row = np.asarray([[scores[name] for name in PAIR_FEATURE_NAMES]]) + probabilities = self._model.predict_proba(row)[0] + classes = list(self._model.classes_) + try: + index = classes.index(self._equivalent_class) + except ValueError: + index = int(np.argmax(classes)) + return float(probabilities[index]), scores + + def explain_scores(self, scores: dict[str, float]) -> dict[str, Any]: + row = np.asarray([[scores[name] for name in PAIR_FEATURE_NAMES]]) + probabilities = self._model.predict_proba(row)[0] + classes = list(self._model.classes_) + try: + equivalent_index = classes.index(self._equivalent_class) + except ValueError: + equivalent_index = int(np.argmax(classes)) + + predicted_class = ( + self._model.predict(row)[0] + if hasattr(self._model, "predict") + else classes[int(np.argmax(probabilities))] + ) + + return { + "model_predicted_class": _plain_value(predicted_class), + "model_equivalent_class": _plain_value(self._equivalent_class), + "model_equivalent_probability": float(probabilities[equivalent_index]), + "model_probabilities": { + str(_plain_value(label)): float(probability) + for label, probability in zip(classes, probabilities, strict=True) + }, + } + + def predicts_equivalent(self, scores: dict[str, float]) -> bool: + row = np.asarray([[scores[name] for name in PAIR_FEATURE_NAMES]]) + if not hasattr(self._model, "predict"): + return False + predicted_class = _plain_value(self._model.predict(row)[0]) + return predicted_class == _plain_value(self._equivalent_class) + + +class StateComparisonBank: + def __init__( + self, + profiler: StateProfiler, + classifier: StateEquivalenceClassifier, + *, + threshold: float, + uncertainty_margin: float, + max_size: int, + ): + self._profiler = profiler + self._classifier = classifier + self._threshold = threshold + self._uncertainty_margin = uncertainty_margin + self._max_size = max(1, max_size) + self._profiles: OrderedDict[str, StateSemanticProfile] = OrderedDict() + + @property + def profile_count(self) -> int: + return len(self._profiles) + + @property + def threshold(self) -> float: + return self._threshold + + @property + def uncertainty_margin(self) -> float: + return self._uncertainty_margin + + @property + def equivalence_bar(self) -> float: + return self._threshold + self._uncertainty_margin + + def get_profile(self, state_hash: str) -> StateSemanticProfile | None: + return self._profiles.get(state_hash) + + def explain_result(self, result: StateComparisonResult) -> dict[str, Any]: + explanation: dict[str, Any] = { + "threshold": self._threshold, + "uncertainty_margin": self._uncertainty_margin, + "equivalence_bar": self.equivalence_bar, + "profile_count": self.profile_count, + "scores": result.scores, + } + if result.scores and hasattr(self._classifier, "explain_scores"): + explanation.update(self._classifier.explain_scores(result.scores)) + return explanation + + def register( + self, + state_hash: str, + elements: list[dict[str, Any]], + ) -> StateComparisonResult: + if state_hash in self._profiles: + return StateComparisonResult( + state_hash, + False, + True, + state_hash, + 1.0, + {}, + "already_registered", + ) + + candidate = self._profiler.profile(state_hash, elements) + if candidate.element_count == 0 or not np.any(candidate.pooled_vector): + return self._accept(candidate, "empty_or_unusable_profile") + + best_hash: str | None = None + best_confidence = 0.0 + best_scores: dict[str, float] = {} + for known_hash, known in self._profiles.items(): + confidence, scores = self._classifier.compare(candidate, known) + if confidence > best_confidence: + best_hash = known_hash + best_confidence = confidence + best_scores = scores + + confident_equivalence = ( + best_hash is not None + and best_confidence >= self._threshold + self._uncertainty_margin + ) + high_similarity_equivalence = ( + best_hash is not None + and self._is_high_similarity_equivalence(best_scores) + ) + if confident_equivalence or high_similarity_equivalence: + reason = ( + "confident_equivalence" + if confident_equivalence + else "high_similarity_equivalence" + ) + return StateComparisonResult( + state_hash, + False, + True, + best_hash, + best_confidence, + best_scores, + reason, + ) + + reason = ( + "uncertain_comparison" + if best_hash is not None + and best_confidence >= self._threshold - self._uncertainty_margin + else "novel_state" + ) + return self._accept( + candidate, + reason, + best_hash, + best_confidence, + best_scores, + ) + + def _is_high_similarity_equivalence(self, scores: dict[str, float]) -> bool: + if not scores: + return False + if hasattr(self._classifier, "predicts_equivalent"): + if not self._classifier.predicts_equivalent(scores): + return False + return all( + scores.get(name, 0.0) >= minimum + for name, minimum in HIGH_SIMILARITY_EQUIVALENCE_MINIMUMS.items() + ) + + def _accept( + self, + profile: StateSemanticProfile, + reason: str, + matched_hash: str | None = None, + confidence: float = 0.0, + scores: dict[str, float] | None = None, + ) -> StateComparisonResult: + self._profiles[profile.state_hash] = profile + self._profiles.move_to_end(profile.state_hash) + while len(self._profiles) > self._max_size: + self._profiles.popitem(last=False) + + return StateComparisonResult( + profile.state_hash, + True, + False, + matched_hash, + confidence, + scores or {}, + reason, + ) + + +def pair_features( + left: StateSemanticProfile, + right: StateSemanticProfile, +) -> dict[str, float]: + return { + "element_similarity": _bidirectional_element_similarity( + left.element_vectors, + right.element_vectors, + ), + "pooled_similarity": _cosine(left.pooled_vector, right.pooled_vector), + "topic_similarity": _cosine( + left.topic_distribution, + right.topic_distribution, + empty_value=1.0, + ), + "structure_similarity": _cosine( + left.structural_distribution, + right.structural_distribution, + empty_value=1.0, + ), + "count_similarity": ( + min(left.element_count, right.element_count) + / max(left.element_count, right.element_count, 1) + ), + } + + +def _bidirectional_element_similarity( + left: np.ndarray, + right: np.ndarray, +) -> float: + if not left.size or not right.size: + return 0.0 + similarities = np.clip(left @ right.T, -1.0, 1.0) + return float( + (similarities.max(axis=1).mean() + similarities.max(axis=0).mean()) + / 2.0 + ) + + +def _cosine( + left: np.ndarray, + right: np.ndarray, + *, + empty_value: float = 0.0, +) -> float: + if not left.size or not right.size: + return empty_value + denominator = np.linalg.norm(left) * np.linalg.norm(right) + if denominator == 0: + return empty_value + return float(np.clip(np.dot(left, right) / denominator, -1.0, 1.0)) + + +def _normalize(values: np.ndarray) -> np.ndarray: + total = float(values.sum()) + return values / total if total else values + + +def _plain_value(value: Any) -> Any: + if hasattr(value, "item"): + return value.item() + return value diff --git a/src/crawler/semantic_engine/topic.py b/src/crawler/semantic_engine/topic.py new file mode 100644 index 0000000..efeb8e7 --- /dev/null +++ b/src/crawler/semantic_engine/topic.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Protocol + +import numpy as np + +from src.crawler.semantic_engine.extractor import DOMFeatureExtractor + +TOPIC_ALIASES = { + "company": "organization", + "query": "search", +} + + +def canonical_topic(topic: str) -> str: + normalized = str(topic or "").strip().lower() + return TOPIC_ALIASES.get(normalized, normalized) + + +@dataclass(frozen=True) +class TopicPrediction: + topic: str | None + confidence: float + probabilities: dict[str, float] + abstained: bool + + +class TopicClassifier(Protocol): + @property + def classes(self) -> tuple[str, ...]: + ... + + def predict(self, element: dict[str, Any]) -> TopicPrediction: + ... + + +class SklearnTopicClassifier: + def __init__( + self, + pipeline: Any, + *, + thresholds: dict[str, float] | None = None, + default_threshold: float = 0.55, + extractor: DOMFeatureExtractor | None = None, + ): + self._pipeline = pipeline + self._thresholds = thresholds or {} + self._default_threshold = default_threshold + self._extractor = extractor or DOMFeatureExtractor() + self._classes = tuple(canonical_topic(label) for label in pipeline.classes_) + + @property + def classes(self) -> tuple[str, ...]: + return self._classes + + def predict(self, element: dict[str, Any]) -> TopicPrediction: + text = self._extractor.extract(element) + if not text: + return TopicPrediction(None, 0.0, {}, True) + + probabilities = self._pipeline.predict_proba([text])[0] + best_index = int(np.argmax(probabilities)) + topic = self._classes[best_index] + confidence = float(probabilities[best_index]) + threshold = float(self._thresholds.get(topic, self._default_threshold)) + probability_map = { + label: float(probability) + for label, probability in zip(self._classes, probabilities, strict=True) + } + + return TopicPrediction( + topic=None if confidence < threshold else topic, + confidence=confidence, + probabilities=probability_map, + abstained=confidence < threshold, + ) diff --git a/src/crawler/session/__init__.py b/src/crawler/session/__init__.py new file mode 100644 index 0000000..94ec2ee --- /dev/null +++ b/src/crawler/session/__init__.py @@ -0,0 +1,10 @@ +from src.crawler.session.base import CrawlSessionBase +from src.crawler.session.explore import CrawlSessionExploreMixin +from src.crawler.session.sequence import CrawlSessionSequenceMixin + + +class CrawlSession(CrawlSessionBase, CrawlSessionExploreMixin, CrawlSessionSequenceMixin): + pass + + +__all__ = ["CrawlSession"] diff --git a/src/crawler/session/base.py b/src/crawler/session/base.py new file mode 100644 index 0000000..ab51dc7 --- /dev/null +++ b/src/crawler/session/base.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import asyncio +import json +import logging +from collections import deque +from uuid import uuid4 + +from src import Config, config +from src.browser import BrowserEngine +from src.crawler.action_limits import ActionRepeatLimiter +from src.crawler.executor import EventExecutor +from src.crawler.replay import StateReplayer, StateReplayInfo +from src.crawler.risk import RiskClassifier +from src.crawler.semantic_engine import SemanticEngine +from src.crawler.session.types import DeferredWorkItem +from src.models import AbstractState, AbstractTransition, CrawlAction + +logger = logging.getLogger(__name__) + + +class CrawlSessionBase: + def __init__( + self, + base_url: str, + graph_builder, + config_path: str | None = None, + session_id: str | None = None, + headless: bool | None = None, + max_states: int | None = None, + max_transitions: int | None = None, + timeout_ms: int | None = None, + input_defaults: dict | None = None, + *, + browser: BrowserEngine | None = None, + settings: Config = config, + risk_classifier: RiskClassifier | None = None, + run_permission: asyncio.Event | None = None, + ): + self._settings = settings + self._run_permission = run_permission + self.base_url = base_url + self.graph_builder = graph_builder + self.headless = settings.HEADLESS if headless is None else headless + self.config_path = config_path + self._input_defaults = input_defaults + self.session_id = session_id or str(uuid4()) + + self.discovered_states: set[str] = set() + self.discovery_queue: deque[AbstractState] = deque() + self._deferred_work: deque[DeferredWorkItem] = deque() + self._tried_actions_by_state: dict[str, set[str]] = {} + self._seen_transition_ids: set[str] = set() + self._risk = risk_classifier or RiskClassifier.from_settings(settings) + self._login_attempts = 0 + self._action_repeat_limiter = ActionRepeatLimiter(max_repeats_per_scope=int(getattr(settings, "MAX_ACTION_REPEATS_PER_URL", 2))) + + self.browser = browser or BrowserEngine( + headless=self.headless, + timeout_ms=timeout_ms, + settings=settings, + ) + self.executor: EventExecutor | None = None + self.replayer: StateReplayer | None = None + self._semantic_engine: SemanticEngine | None = None + self._max_states: int = int(max_states if max_states is not None else getattr(settings, "MAX_STATES", 1000)) + self._max_transitions: int = int(max_transitions if max_transitions is not None else getattr(settings, "MAX_TRANSITIONS", 5000)) + self._state_count: int = 0 + self._transition_count: int = 0 + + @property + def state_count(self) -> int: + return self._state_count + + @property + def transition_count(self) -> int: + return self._transition_count + + async def _wait_permission(self) -> None: + if self._run_permission is not None: + await self._run_permission.wait() + + async def initialize(self) -> None: + await self.browser.start() + input_config = self._resolved_input_config() + field_patterns = input_config.get("field_patterns", {}) + + self._semantic_engine = SemanticEngine( + input_defaults=field_patterns, + artifact_dir=self._settings.SEMANTIC_ARTIFACT_DIR, + enabled=self._settings.USE_SEMANTIC_DIVERSITY, + similarity_threshold=self._settings.SEMANTIC_DIVERSITY_THRESHOLD, + uncertainty_margin=self._settings.SEMANTIC_UNCERTAINTY_MARGIN, + max_bank_size=self._settings.SEMANTIC_MAX_BANK_SIZE, + ) + self.executor = EventExecutor( + self.browser, + config_path=self.config_path, + input_defaults=input_config, + semantic_engine=self._semantic_engine, + ) + self.replayer = StateReplayer(self.browser, self.executor, self._settings) + + logger.info("Crawl session initialized") + + def _resolved_input_config(self) -> dict: + if isinstance(self._input_defaults, dict): + if "field_patterns" in self._input_defaults or "type_fallbacks" in self._input_defaults: + return self._input_defaults + return {"field_patterns": self._input_defaults, "type_fallbacks": {}} + + if self.config_path: + with open(self.config_path, "r", encoding="utf-8") as handle: + return json.load(handle) + + return {"field_patterns": {}, "type_fallbacks": {}} + + async def cleanup(self) -> None: + await self.browser.stop() + logger.info("Crawl session completed") + + async def add_to_queue(self, state: AbstractState, *, enqueue: bool = True) -> None: + if state.state_hash in self.discovered_states: + return + await self._wait_permission() + self._state_count += 1 + self.discovered_states.add(state.state_hash) + if enqueue: + self.discovery_queue.append(state) + await self.graph_builder.add_state(self.session_id, state) + + def get_next_state(self) -> AbstractState | None: + return self.discovery_queue.popleft() if self.discovery_queue else None + + async def add_transition(self, transition: AbstractTransition) -> None: + if transition.transition_id in self._seen_transition_ids: + return + await self._wait_permission() + self._seen_transition_ids.add(transition.transition_id) + self._transition_count += 1 + await self.graph_builder.add_transition(transition) + if self.executor: + self.executor.log_transition(transition) + + @property + def is_complete(self) -> bool: + return not self.discovery_queue + + def _within_limits(self) -> bool: + return self._state_count < self._max_states and self._transition_count < self._max_transitions + + async def run_crawl(self) -> None: + try: + await self.initialize() + await self._seed_initial_state() + + while self._within_limits(): + await self._wait_permission() + if not self.is_complete: + current = self.get_next_state() + if current: + logger.info( + f"Exploring state {current.state_hash} " + f"({self._state_count}/{self._max_states} states, " + f"{self._transition_count}/{self._max_transitions} transitions)" + ) + await self._explore_state(current) + continue + + if self._settings.DEFER_DESTRUCTIVE_ACTIONS and self._deferred_work: + item = self._deferred_work.popleft() + await self._run_deferred_item(item) + continue + + break + + logger.info(f"Crawl complete. States: {self._state_count}, Transitions: {self._transition_count}") + except Exception as e: + logger.error(f"Crawl failed: {e}", exc_info=True) + raise + finally: + await self.cleanup() + + async def _seed_initial_state(self) -> None: + await self._wait_permission() + await self.browser.navigate(self.base_url) + await self.browser.wait_for_settle() + + initial_state = await self.browser.capture_state() + if not self.replayer: + return + + login_actions = await self._plan_login_actions() + + if self._semantic_engine: + initial_elements = await self.browser.get_interactable_elements() + self._semantic_engine.register_state(initial_state.state_hash, initial_elements) + + await self.add_to_queue(initial_state, enqueue=not bool(login_actions)) + + info = StateReplayInfo( + checkpoint_url=initial_state.url, + checkpoint_state_hash=initial_state.state_hash, + checkpoint_kind="initial", + ) + updated = self.replayer.register(initial_state.state_hash, info) + if updated: + await self._persist_replay_artifacts( + state_hash=initial_state.state_hash, + info=info, + persist_storage_state=True, + ) + logger.info(f"Initial state: {initial_state.state_hash} at {initial_state.url}") + + if login_actions: + reached = await self._execute_action_sequence(initial_state, info, login_actions) + if not reached: + self.discovery_queue.append(initial_state) + + async def _plan_login_actions(self) -> list[CrawlAction] | None: + if self._login_attempts >= 2: + return None + if not self.executor: + return None + + await self._wait_permission() + + forms = await self.browser.get_forms() + login_form = self._select_login_form(forms) + if not login_form: + return None + + actions = self.executor.plan_form_submission(login_form) + if not actions: + return None + + self._login_attempts += 1 + return actions + + def _select_login_form(self, forms: list[dict]) -> dict | None: + for form in forms: + fields = form.get("fields", []) + if any(str(f.get("type", "")).lower() == "password" for f in fields): + submit = form.get("submit") + if submit and submit.get("selector"): + return form + return None diff --git a/src/crawler/session/explore.py b/src/crawler/session/explore.py new file mode 100644 index 0000000..8b7ba9e --- /dev/null +++ b/src/crawler/session/explore.py @@ -0,0 +1,387 @@ +from __future__ import annotations + +import logging + +from src.crawler import ActionType, HtmlTag, InputType +from src.crawler.replay import StateReplayInfo +from src.crawler.session.types import DeferredWorkItem +from src.models import AbstractState, CrawlAction +from src.utils import ( + element_label, + element_tag, + element_tag_hint, + element_type, + is_button, + is_non_http_href, + is_text_input, + supports_enter_submission, + text_input_label, +) + +logger = logging.getLogger(__name__) + + +class CrawlSessionExploreMixin: + async def _explore_state(self, current: AbstractState) -> None: + if not await self._replay_state(current): + return + + await self._prepare_state() + + current_info = self._get_current_state_info(current) + + if not current_info or not self.executor: + return + + state_elements = await self.browser.get_interactable_elements() + + if self._semantic_engine: + comparison = self._semantic_engine.register_state( + current.state_hash, + state_elements, + ) + diagnostics = self._semantic_engine.explain_comparison(comparison) + logger.debug("State %s: %s", current.state_hash, diagnostics) + if not comparison.is_novel and comparison.reason != "already_registered": + return + + await self._explore_forms(current, current_info) + await self._explore_elements(current, current_info, state_elements) + + async def _explore_forms( + self, + current: AbstractState, + current_info: StateReplayInfo, + ) -> None: + forms = await self.browser.get_forms() + + for form in forms: + if not self._within_limits(): + break + + await self._wait_permission() + await self._process_form(form, current, current_info) + + if not await self._replay_after_action(current): + return + + async def _explore_elements( + self, + current: AbstractState, + current_info: StateReplayInfo, + state_elements: list[dict] | None = None, + ) -> None: + elements = state_elements or await self.browser.get_interactable_elements() + processed = 0 + for element in elements: + if processed >= self._settings.MAX_ELEMENTS_PER_STATE: + break + + if not self._within_limits(): + break + + if not self._should_process_element(element, elements): + continue + + await self._wait_permission() + await self._process_element(element, current, current_info) + + processed += 1 + + async def _process_form( + self, + form: dict, + current: AbstractState, + current_info: StateReplayInfo, + ) -> None: + if not self.executor: + return + + actions = self.executor.plan_form_submission(form) + + if not actions: + return + + primary = actions[-1] + + if self._should_defer_action(primary, form.get("submit")): + self._defer_work(current, actions, form.get("submit")) + return + + await self._execute_action_sequence(current, current_info, actions) + + async def _process_element( + self, + element: dict, + current: AbstractState, + current_info: StateReplayInfo, + ) -> None: + selector = self.browser.get_selector_for_element(element) + + if not selector: + return + + sequences = self._plan_element_sequences(element, selector) + + for actions in sequences: + if not self._within_limits(): + return + + primary = actions[-1] + + if self._should_defer_action(primary, element): + self._defer_work(current, actions, element) + continue + + await self._execute_action_sequence(current, current_info, actions) + + if not await self._replay_after_action(current): + return + + def _should_process_element( + self, + element: dict, + state_elements: list[dict] | None = None, + ) -> bool: + if element.get("disabled"): + return False + + if element.get("in_form"): + return False + + if self._is_blocked_anchor(element): + return False + + return True + + def _plan_element_sequences( + self, + element: dict, + selector: str, + ) -> list[list[CrawlAction]]: + tag = element_tag(element) + input_type = element_type(element) + + if tag == HtmlTag.SELECT: + return self._build_select_sequences(element, selector) + + if tag == HtmlTag.INPUT and input_type in ( + InputType.CHECKBOX, + InputType.RADIO, + ): + return self._build_toggle_sequences(element, selector) + + if is_text_input(element): + return self._build_text_sequences(element, selector) + + if tag == HtmlTag.ANCHOR: + return self._build_anchor_sequences(element, selector) + + if is_button(element): + return self._build_button_sequences(element, selector) + + return self._build_generic_click_sequences(element, selector) + + def _build_select_sequences( + self, + element: dict, + selector: str, + ) -> list[list[CrawlAction]]: + sequences: list[list[CrawlAction]] = [] + + options = [option for option in element.get("options", []) if option.get("value")] + + for option in options[: self._settings.MAX_SELECT_OPTIONS_PER_ELEMENT]: + option_text = str(option.get("text") or option.get("value") or "").strip() + + sequences.append( + [ + CrawlAction( + action_type=ActionType.SELECT, + selector=selector, + value=str(option["value"]), + description=(f"Select '{option_text}' in {element_tag_hint(element)}{element_label(element, selector)}"), + metadata={ + "option": str(option.get("text", "")), + "frame": element.get("frame"), + }, + ) + ] + ) + + return sequences + + def _build_toggle_sequences( + self, + element: dict, + selector: str, + ) -> list[list[CrawlAction]]: + input_type = element_type(element) + + return [ + [ + CrawlAction( + action_type=ActionType.CLICK, + selector=selector, + description=(f"Toggle {input_type} {element_label(element, selector)}"), + metadata={ + "type": input_type, + "frame": element.get("frame"), + }, + ) + ] + ] + + def _build_text_sequences( + self, + element: dict, + selector: str, + ) -> list[list[CrawlAction]]: + if not self.executor: + return [] + + value = self.executor.resolve_value(element) + label = element_label(element, selector) + type_label = text_input_label(element) + + base_action = CrawlAction( + action_type=ActionType.TYPE, + selector=selector, + value=value, + description=f"Type into {type_label} {label}", + metadata={ + "type": element_type(element), + "frame": element.get("frame"), + }, + ) + + sequences = [[base_action]] + + if supports_enter_submission(element): + sequences.append( + [ + base_action, + CrawlAction( + action_type=ActionType.PRESS, + selector=selector, + value="Enter", + description=f"Press Enter in {label}", + metadata={"frame": element.get("frame")}, + ), + ] + ) + + return sequences + + def _build_anchor_sequences( + self, + element: dict, + selector: str, + ) -> list[list[CrawlAction]]: + href = str(element.get("href", "") or "") + href_part = f" ({href})" if href else "" + + return [ + [ + CrawlAction( + action_type=ActionType.CLICK, + selector=selector, + description=(f"Click link {element_label(element, selector)}{href_part}"), + metadata={"frame": element.get("frame")}, + ) + ] + ] + + def _build_button_sequences( + self, + element: dict, + selector: str, + ) -> list[list[CrawlAction]]: + return [ + [ + CrawlAction( + action_type=ActionType.CLICK, + selector=selector, + description=(f"Click button {element_label(element, selector)}"), + metadata={"frame": element.get("frame")}, + ) + ] + ] + + def _build_generic_click_sequences( + self, + element: dict, + selector: str, + ) -> list[list[CrawlAction]]: + return [ + [ + CrawlAction( + action_type=ActionType.CLICK, + selector=selector, + description=(f"Click {element_tag_hint(element)}{element_label(element, selector)}"), + metadata={"frame": element.get("frame")}, + ) + ] + ] + + async def _replay_state(self, current: AbstractState) -> bool: + if not self.replayer: + return False + + try: + return await self.replayer.replay_to(current.state_hash) + + except Exception as e: + logger.warning(f"Replay failed for state {current.state_hash}: {e}") + return False + + async def _prepare_state(self) -> None: + await self.browser.wait_for_settle() + + async def _replay_after_action(self, current: AbstractState) -> bool: + if not self.replayer: + return False + + try: + return await self.replayer.replay_to(current.state_hash) + + except Exception: + return False + + def _get_current_state_info( + self, + current: AbstractState, + ) -> StateReplayInfo | None: + if not self.replayer: + return None + + return self.replayer.get_info(current.state_hash) + + def _should_defer_action( + self, + action: CrawlAction, + element: dict | None, + ) -> bool: + return self._settings.DEFER_DESTRUCTIVE_ACTIONS and self._risk.is_risky(action, element=element) + + def _defer_work( + self, + current: AbstractState, + actions: list[CrawlAction], + element: dict | None, + ) -> None: + self._deferred_work.append( + DeferredWorkItem( + source_state=current, + actions=actions, + element=element, + ) + ) + + def _is_blocked_anchor(self, element: dict) -> bool: + if element_tag(element) != HtmlTag.ANCHOR or self._settings.CLICK_NON_HTTP_LINKS: + return False + + href = str(element.get("href", "") or "") + + return is_non_http_href(href) diff --git a/src/crawler/session/manual_crawl/action_recorder.js b/src/crawler/session/manual_crawl/action_recorder.js new file mode 100644 index 0000000..b939535 --- /dev/null +++ b/src/crawler/session/manual_crawl/action_recorder.js @@ -0,0 +1,65 @@ +if (!window.__manualCrawlInitialized) { + window.__manualCrawlInitialized = true; + + function getSelector(el) { + if (!el || el === document.body) return 'body'; + if (el.id) return `#${el.id}`; + const testId = el.getAttribute('data-testid') || el.getAttribute('data-test') || el.getAttribute('data-cy'); + if (testId) return `[data-testid="${testId}"]`; + const ariaLabel = el.getAttribute('aria-label'); + if (ariaLabel) return `${el.tagName.toLowerCase()}[aria-label="${ariaLabel}"]`; + + let path = []; + let current = el; + while (current && current !== document.body) { + let selector = current.tagName.toLowerCase(); + if (current.id) { + selector = `#${current.id}`; + path.unshift(selector); + break; + } + let sibling = current; + let nth = 1; + while ((sibling = sibling.previousElementSibling)) { + if (sibling.tagName === current.tagName) nth++; + } + if (nth > 1) selector += `:nth-of-type(${nth})`; + path.unshift(selector); + current = current.parentElement; + } + return path.join(' > '); + } + + document.addEventListener('click', e => { + if (typeof window.__reportStep === 'function') { + const el = e.target; + const tag = el.tagName.toLowerCase(); + const label = (el.innerText || el.getAttribute('aria-label') || el.getAttribute('value') || '').trim().slice(0, 80); + const href = tag === 'a' ? (el.href || el.getAttribute('href') || '') : ''; + window.__reportStep({ action: 'click', element: getSelector(el), tag, label, href }); + } + }, true); + + document.addEventListener('input', e => { + const el = e.target; + if (!['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName)) return; + if (typeof window.__reportStep === 'function') { + const label = el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.name || ''; + const inputType = el.type || el.tagName.toLowerCase(); + window.__reportStep({ + action: 'input', + element: getSelector(el), + value: el.value, + label, + inputType, + }); + } + }, true); + + document.addEventListener('keydown', e => { + if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return; + if (typeof window.__reportStep === 'function') { + window.__reportStep({ action: 'keypress', key: e.key, element: getSelector(e.target) }); + } + }, true); +} \ No newline at end of file diff --git a/src/crawler/session/manual_crawl/manual_crawl.py b/src/crawler/session/manual_crawl/manual_crawl.py new file mode 100644 index 0000000..c93d1fd --- /dev/null +++ b/src/crawler/session/manual_crawl/manual_crawl.py @@ -0,0 +1,145 @@ +import asyncio +import sys +import logging +from typing import List + +from src.crawler.session.base import CrawlSessionBase +from src.crawler.session.manual_crawl.recording_mapper import map_steps_to_actions +from src.crawler.session.sequence import CrawlSessionSequenceMixin +from src.models.graph import AbstractState +from src.workers.flow_worker import push_flows_to_api + +logger = logging.getLogger(__name__) + +with open("src/crawler/session/manual_crawl/action_recorder.js", "r", encoding="utf-8") as script_file: + RECORDING_JS = script_file.read() + +class ManualCrawlSession(CrawlSessionBase, CrawlSessionSequenceMixin): + async def run_crawl(self): + self._recording_state = False + self._exit_session = False + self._recorded_transitions = [] + self._recorded_steps = [] + self._recorded_storage_state_json = [] + self._recorded_states: List[AbstractState] = [] + self._recording_start_idx = 0 + + async def listen_for_input(): + loop = asyncio.get_event_loop() + print("\n--- Manual Crawl Controls ---") + print("Type 's' + Enter to Start recording for manual flow") + print("Type 'q' + Enter to stop recording and end session \n") + print("Type 'b' + Enter to Build Bug Graph\n") + while not self._exit_session: + cmd = await loop.run_in_executor(None, sys.stdin.readline) + cmd = cmd.strip().lower() + if cmd == 's': + if(self._recording_state): + print("Already recording") + else: + print("Recording started") + self._recording_state = True + elif cmd == 'q': + self._exit_session = True + break + elif cmd == 'b': + print("Building bug graph from recorded session...") + await self.build_bug_graph() + print("Bug graph build complete.") + + listener_task = asyncio.create_task(listen_for_input()) + + try: + self.headless = False + self.browser.headless = False + + await self.initialize() + context = self.browser._require_context() + + await context.expose_function("__reportStep", lambda step: self._recorded_steps.append(step)) + await context.add_init_script(RECORDING_JS) + + await self.browser.navigate(self.base_url) + await self.browser.wait_for_settle() + + current_state = await self.browser.capture_state() + self._recorded_states.append(current_state) + self._recorded_storage_state_json.append(await self.browser.export_storage_state()) + + print(f"\nManual Crawl Session live on {self.base_url}.\n") + + while not self._exit_session: + await asyncio.sleep(0.1) + + if not self.browser.context or len(self.browser.context.pages) == 0: + break + + new_hash = await self.browser.get_state_hash() + + if new_hash != current_state.state_hash: + steps_to_process = list(self._recorded_steps) + self._recorded_steps.clear() + + new_state = await self.browser.capture_state() + + if(new_state.url != current_state.url and not self._recording_state): + self._recording_start_idx = max(0, len(self._recorded_states) - 1) + + self._recorded_storage_state_json.append(await self.browser.export_storage_state()) + + actions = map_steps_to_actions(steps_to_process, fallback_url=current_state.url) + self._recorded_states.append(new_state) + + transition = self._build_transition(current_state, new_state, actions) + self._recorded_transitions.append(transition) + + logger.info(f"Generated Transition: {transition.action_description}") + current_state = new_state + + except KeyboardInterrupt: + print("\nManual session interrupted by user.") + finally: + listener_task.cancel() + print("Committing graph structure and cleaning up...") + await self.build_recorded_graph() + await self.cleanup() + + + async def build_recorded_graph(self) -> None: + if not self._recorded_states: + return + + start_idx = self._recording_start_idx if self._recording_start_idx is not None else 0 + + await self._build_graph_starting_from(start_idx, is_bug_graph=False) + + + async def build_bug_graph(self) -> None: + await self._build_graph_starting_from(0, is_bug_graph=True) + + + async def _build_graph_starting_from(self, start_idx: int, is_bug_graph: bool = False) -> dict: + if not self._recorded_states or start_idx >= len(self._recorded_states): + return {"final_state_hash": None,"transitions": [] } + + last_state_hash = self._recorded_states[-1].state_hash + transition_refs = [t.transition_id for t in self._recorded_transitions[start_idx:]] + + for i in range(start_idx, len(self._recorded_states)): + await self.add_to_queue(self._recorded_states[i]) + await self.graph_builder.set_state_properties( + self.session_id, + self._recorded_states[i].state_hash, + {"checkpoint_storage_state_json": self._recorded_storage_state_json[i]}, + ) + + for i in range(start_idx, len(self._recorded_transitions)): + await self.add_transition(self._recorded_transitions[i]) + + # await push_flows_to_api( + # self.session_id, + # { + # "final_state_hash": last_state_hash, + # "transition_refs": transition_refs + # } + # ) \ No newline at end of file diff --git a/src/crawler/session/manual_crawl/recording_mapper.py b/src/crawler/session/manual_crawl/recording_mapper.py new file mode 100644 index 0000000..f9fb9a6 --- /dev/null +++ b/src/crawler/session/manual_crawl/recording_mapper.py @@ -0,0 +1,115 @@ +from typing import Any + +from src.models import CrawlAction + + +def map_steps_to_actions( + raw_steps: list[dict[str, Any]], + fallback_url: str = "", +) -> list[CrawlAction]: + """ + Maps raw browser recording steps to CrawlActions""" + if not raw_steps: + return [CrawlAction( + action_type="navigate", + selector="", + value=fallback_url, + description=f"Navigate to {fallback_url}" if fallback_url else "System or URL redirect", + )] + + deduped: list[dict[str, Any]] = [] + for step in raw_steps: + if ( + step.get("action") == "input" + and deduped + and deduped[-1].get("action") == "input" + and deduped[-1].get("element") == step.get("element") + ): + deduped[-1] = step + else: + deduped.append(step) + + actions = [] + for step in deduped: + action = _map_step(step) + if action: + actions.append(action) + + if not actions: + return [CrawlAction( + action_type="navigate", + selector="", + value=fallback_url, + description=f"Navigate to {fallback_url}" if fallback_url else "System or URL redirect", + )] + + return actions + + +def _map_step(step: dict[str, Any]) -> CrawlAction | None: + action_type = step.get("action", "") + selector = step.get("element", "") + + if action_type == "click": + return _map_click(step, selector) + + if action_type == "input": + return _map_input(step, selector) + + if action_type == "keypress" and step.get("key"): + return CrawlAction( + action_type="press", + selector=selector, + value=step.get("key") or "", + description=f"Press {step.get('key')} in {selector}", + ) + + return None + + +def _map_click(step: dict[str, Any], selector: str) -> CrawlAction: + tag = step.get("tag", "") + label = (step.get("label") or "").strip() + href = (step.get("href") or "").strip() + + if label and tag: + playwright_selector = f'{tag}:has-text("{label}")' + else: + playwright_selector = selector + + if tag == "a": + element_hint = "link" + elif tag in ("button", "input", "submit"): + element_hint = "button" + else: + element_hint = tag or "element" + + label_part = f" {label}" if label else "" + selector_part = f" [{playwright_selector}]" + href_part = f" ({href})" if href else "" + description = f"Click {element_hint}{label_part}{selector_part}{href_part}" + + return CrawlAction( + action_type="click", + selector=playwright_selector, + value="", + description=description, + ) + + +def _map_input(step: dict[str, Any], selector: str) -> CrawlAction: + value = step.get("value", "") + label = (step.get("label") or "").strip() + input_type = (step.get("inputType") or "text").lower() + + label_hint = f" {label}" if label else f" {selector}" + + description = f"Type into {input_type}{label_hint}" + + return CrawlAction( + action_type="type", + selector=selector, + value=value, + description=description, + metadata={"type": input_type, "manual": True}, + ) \ No newline at end of file diff --git a/src/crawler/session/sequence.py b/src/crawler/session/sequence.py new file mode 100644 index 0000000..140f109 --- /dev/null +++ b/src/crawler/session/sequence.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import logging + +from src.crawler.fingerprints import ( + action_attempt_fingerprint, + action_key_fingerprint, + transition_fingerprint, +) +from src.crawler.replay import StateReplayInfo +from src.crawler.session.sequence_builders import ( + sequence_description, + sequence_digest, + sequence_value_for_graph, +) +from src.crawler.session.types import DeferredWorkItem +from src.models import AbstractState, AbstractTransition, CrawlAction +from src.utils import is_http_url, is_same_domain, normalize_checkpoint_url, normalize_url + +logger = logging.getLogger(__name__) + + +class CrawlSessionSequenceMixin: + async def _execute_action_sequence( + self, + source: AbstractState, + source_info: StateReplayInfo, + actions: list[CrawlAction], + ) -> AbstractState | None: + if not self.executor or not self.replayer or not actions: + return None + + await self._wait_permission() + + primary = actions[-1] + primary.metadata = dict(primary.metadata or {}) + primary.metadata["sequence_digest"] = sequence_digest(actions) + primary.metadata["sequence_len"] = len(actions) + + scope_url = normalize_url(getattr(source, "url", "") or source_info.checkpoint_url or "") + + action_key = action_key_fingerprint(primary) + + if not self._action_repeat_limiter.can_run(scope=scope_url, action_key=action_key): + return None + + attempt_fp = action_attempt_fingerprint(source.state_hash, primary) + tried = self._tried_actions_by_state.setdefault(source.state_hash, set()) + + if attempt_fp in tried: + return None + + tried.add(attempt_fp) + + initial_page_count = len(self.browser.context.pages) if self.browser.context else 0 + + try: + for action in actions: + await self._wait_permission() + await self.executor.execute_action(action) + + await self.browser.wait_for_settle() + + self._action_repeat_limiter.record(scope=scope_url, action_key=action_key) + + popup_urls = await self.browser.collect_and_close_pages_opened_since(initial_page_count) + + for url in popup_urls: + if not url or not is_http_url(url): + continue + if not is_same_domain(scope_url, url): + continue + + self._deferred_work.append( + DeferredWorkItem( + source_state=source, + actions=[CrawlAction(action_type="navigate", value=url, description="Navigate to popup URL")], + element=None, + ) + ) + + new_url = await self.browser.get_current_url() + + if not is_same_domain(scope_url, new_url): + logger.warning(f"Left domain: {new_url}") + await self.replayer.replay_to(source.state_hash) + return None + + new_state = await self.browser.capture_state() + source_url = normalize_url( + getattr(source, "url", "") or source_info.checkpoint_url or "" + ) + reached_url = normalize_url(new_url) + if new_state.state_hash == source.state_hash and reached_url == source_url: + return None + + target_state = new_state + is_semantic_duplicate = False + + if self._semantic_engine: + new_state_elements = await self.browser.get_interactable_elements() + comparison = self._semantic_engine.register_state( + new_state.state_hash, + new_state_elements, + ) + diagnostics = self._semantic_engine.explain_comparison(comparison) + logger.debug("State comparison diagnostics: %s", diagnostics) + if not comparison.is_novel and comparison.matched_state_hash: + logger.info( + "State %s is equivalent to %s (confidence %.3f). Recording transition to known state.", + new_state.state_hash, + comparison.matched_state_hash, + comparison.confidence, + ) + target_state = AbstractState( + state_hash=comparison.matched_state_hash, + url=new_state.url, + title=new_state.title, + html=new_state.html, + dom_snapshot=new_state.dom_snapshot, + metadata=new_state.metadata, + ) + is_semantic_duplicate = True + + info = self._build_replay_info(source_info, actions, reached_url=new_url) + + if not info.checkpoint_state_hash: + info.checkpoint_state_hash = new_state.state_hash + + updated = False + if not is_semantic_duplicate: + updated = self.replayer.register(new_state.state_hash, info) + + await self.add_to_queue(new_state) + + if updated: + await self._persist_replay_artifacts( + state_hash=new_state.state_hash, + info=info, + persist_storage_state=(info.checkpoint_state_hash == new_state.state_hash), + ) + + await self.add_transition(self._build_transition(source, target_state, actions)) + + if is_semantic_duplicate: + await self.replayer.replay_to(source.state_hash) + + return target_state + + except Exception as e: + logger.warning(f"Error on action sequence ({primary.action_type}): {e}") + try: + await self.replayer.replay_to(source.state_hash) + except Exception: + pass + return None + + def _build_transition( + self, + source: AbstractState, + target: AbstractState, + actions: list[CrawlAction], + ) -> AbstractTransition: + primary = actions[-1] + + fp = transition_fingerprint( + session_id=str(self.session_id), + source_state_hash=source.state_hash, + target_state_hash=target.state_hash, + action=primary, + ) + + locator_value = primary.selector or primary.value + + return AbstractTransition( + session_id=str(self.session_id), + transition_id=fp, + source_state_hash=source.state_hash, + target_state_hash=target.state_hash, + action_type=primary.action_type, + action_description=sequence_description(actions), + locator_id=primary.action_id, + locator_value=locator_value, + action_value=sequence_value_for_graph(actions), + action_fingerprint=fp, + ) + + def _build_replay_info( + self, + current_info: StateReplayInfo, + actions: list[CrawlAction], + *, + reached_url: str, + ) -> StateReplayInfo: + reached = normalize_checkpoint_url(reached_url) + parent = normalize_checkpoint_url(current_info.checkpoint_url) + + seq_actions = current_info.actions + list(actions) + + if reached and reached != parent: + return StateReplayInfo( + checkpoint_url=reached, + checkpoint_state_hash="", + checkpoint_kind="url_change", + actions=[], + fallback_checkpoint_url=current_info.checkpoint_url, + fallback_checkpoint_state_hash=current_info.checkpoint_state_hash, + fallback_actions=seq_actions, + fallback_storage_state=current_info.storage_state, + ) + + fallback_actions = list(getattr(current_info, "fallback_actions", [])) + + return StateReplayInfo( + checkpoint_url=current_info.checkpoint_url, + checkpoint_state_hash=current_info.checkpoint_state_hash, + checkpoint_kind=current_info.checkpoint_kind, + actions=seq_actions, + storage_state=current_info.storage_state, + fallback_checkpoint_url=getattr(current_info, "fallback_checkpoint_url", None), + fallback_checkpoint_state_hash=getattr(current_info, "fallback_checkpoint_state_hash", None), + fallback_actions=fallback_actions + seq_actions if getattr(current_info, "fallback_checkpoint_url", None) else fallback_actions, + fallback_storage_state=getattr(current_info, "fallback_storage_state", None), + ) + + async def _persist_replay_artifacts( + self, + *, + state_hash: str, + info: StateReplayInfo, + persist_storage_state: bool, + ) -> None: + props = info.to_neo4j_props(state_hash=state_hash) + + await self.graph_builder.set_state_properties(self.session_id, state_hash, props) + + if not persist_storage_state: + return + + storage_state = await self.browser.export_storage_state() + info.storage_state = storage_state + + await self.graph_builder.set_state_properties( + self.session_id, + state_hash, + {"checkpoint_storage_state_json": storage_state}, + ) + + async def _run_deferred_item(self, item: DeferredWorkItem) -> None: + if not self.replayer: + return + + await self._wait_permission() + + try: + ok = await self.replayer.replay_to(item.source_state.state_hash) + if not ok: + return + except Exception: + return + + source_info = self.replayer.get_info(item.source_state.state_hash) + if not source_info: + return + + await self._execute_action_sequence(item.source_state, source_info, item.actions) diff --git a/src/crawler/session/sequence_builders.py b/src/crawler/session/sequence_builders.py new file mode 100644 index 0000000..ea4957e --- /dev/null +++ b/src/crawler/session/sequence_builders.py @@ -0,0 +1,52 @@ +from typing import List + +from src.models import CrawlAction +from src.utils import stable_json_dumps + + +def sequence_description(actions: List[CrawlAction]) -> str: + if not actions: + return "" + + if len(actions) == 1: + return actions[0].description + + parts = [] + for a in actions[:6]: + d = str(a.description or a.action_type).strip() + if d: + parts.append(d) + + suffix = "" + if len(actions) > 6: + suffix = f" … (+{len(actions) - 6} more)" + + return f"Sequence ({len(actions)}): " + " -> ".join(parts) + suffix + + +def sequence_value_for_graph(actions: List[CrawlAction]) -> str: + payload = [ + { + "t": a.action_type, + "s": a.selector, + "v": _safe_value(a), + "d": a.description, + } + for a in actions + ] + return stable_json_dumps(payload) + + +def sequence_digest(actions: List[CrawlAction]) -> str: + import hashlib + + payload = [{"t": a.action_type, "s": a.selector, "v": a.value} for a in actions] + raw = stable_json_dumps(payload) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def _safe_value(action: CrawlAction) -> str: + if action.action_type in ("navigate", "select", "press","type"): + return str(action.value or "") + + return "" diff --git a/src/crawler/session/types.py b/src/crawler/session/types.py new file mode 100644 index 0000000..26b0872 --- /dev/null +++ b/src/crawler/session/types.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from src.models import AbstractState, CrawlAction + + +@dataclass +class DeferredWorkItem: + source_state: AbstractState + actions: list[CrawlAction] + element: dict | None = None diff --git a/src/db/__init__.py b/src/db/__init__.py new file mode 100644 index 0000000..d201f11 --- /dev/null +++ b/src/db/__init__.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any + +__all__ = [ + "Base", + "CrawlSession", + "CrawlStatus", + "TargetApplication", + "TargetApplicationVersion", + "crawl_status_enum", + "create_engine", + "create_sessionmaker", + "fetch_job_inputs", + "get_session_status", + "mark_completed_if_running", + "mark_failed_if_running", + "mark_finished_at_if_aborted", + "mark_queued_running", +] + +_EXPORTS: dict[str, tuple[str, str]] = { + "Base": ("src.db.base", "Base"), + "CrawlSession": ("src.db.schemas.crawl_sessions", "CrawlSession"), + "CrawlStatus": ("src.db.enums.crawl_status", "CrawlStatus"), + "TargetApplication": ("src.db.schemas.target_application", "TargetApplication"), + "TargetApplicationVersion": ("src.db.schemas.target_application_version", "TargetApplicationVersion"), + "crawl_status_enum": ("src.db.enums", "crawl_status_enum"), + "create_engine": ("src.db.database", "create_engine"), + "create_sessionmaker": ("src.db.database", "create_sessionmaker"), + "fetch_job_inputs": ("src.db.crawl_sessions", "fetch_job_inputs"), + "get_session_status": ("src.db.crawl_sessions", "get_session_status"), + "mark_completed_if_running": ("src.db.crawl_sessions", "mark_completed_if_running"), + "mark_failed_if_running": ("src.db.crawl_sessions", "mark_failed_if_running"), + "mark_finished_at_if_aborted": ("src.db.crawl_sessions", "mark_finished_at_if_aborted"), + "mark_queued_running": ("src.db.crawl_sessions", "mark_queued_running"), +} + + +def __getattr__(name: str) -> Any: + target = _EXPORTS.get(name) + if target is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module_name, attr = target + value = getattr(import_module(module_name), attr) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals().keys()) | set(_EXPORTS.keys())) + + +if TYPE_CHECKING: + from src.db.base import Base + from src.db.database import create_engine, create_sessionmaker + from src.db.enums import crawl_status_enum + from src.db.enums.crawl_status import CrawlStatus + from src.db.repositories.crawl_sessions import ( + fetch_job_inputs, + get_session_status, + mark_completed_if_running, + mark_failed_if_running, + mark_finished_at_if_aborted, + mark_queued_running, + ) + from src.db.schemas.crawl_sessions import CrawlSession + from src.db.schemas.target_application import TargetApplication + from src.db.schemas.target_application_version import TargetApplicationVersion diff --git a/src/db/base.py b/src/db/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/src/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/src/db/database.py b/src/db/database.py new file mode 100644 index 0000000..48da4d6 --- /dev/null +++ b/src/db/database.py @@ -0,0 +1,10 @@ +from sqlalchemy.engine.url import make_url +from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine + + +def create_engine(database_url: str) -> AsyncEngine: + return create_async_engine(make_url(database_url), pool_pre_ping=True) + + +def create_sessionmaker(engine: AsyncEngine) -> async_sessionmaker: + return async_sessionmaker(engine, expire_on_commit=False) diff --git a/src/db/enums/__init__.py b/src/db/enums/__init__.py new file mode 100644 index 0000000..893f798 --- /dev/null +++ b/src/db/enums/__init__.py @@ -0,0 +1,9 @@ +from sqlalchemy import Enum as SAEnum + +from src.db.enums.crawl_status import CrawlStatus + +crawl_status_enum = SAEnum( + CrawlStatus, + name="CrawlStatus", + create_type=False, +) diff --git a/src/db/enums/crawl_status.py b/src/db/enums/crawl_status.py new file mode 100644 index 0000000..8ac6fd6 --- /dev/null +++ b/src/db/enums/crawl_status.py @@ -0,0 +1,12 @@ +from enum import Enum + + +class CrawlStatus(str, Enum): + UNSPECIFIED = "UNSPECIFIED" + QUEUED = "QUEUED" + RUNNING = "RUNNING" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + ABORTED = "ABORTED" + PAUSED = "PAUSED" + NEW = "NEW" diff --git a/src/db/repositories/__init__.py b/src/db/repositories/__init__.py new file mode 100644 index 0000000..d474927 --- /dev/null +++ b/src/db/repositories/__init__.py @@ -0,0 +1,3 @@ +from . import crawl_sessions + +__all__ = ["crawl_sessions"] diff --git a/src/db/repositories/crawl_sessions.py b/src/db/repositories/crawl_sessions.py new file mode 100644 index 0000000..9d2cc09 --- /dev/null +++ b/src/db/repositories/crawl_sessions.py @@ -0,0 +1,125 @@ +import json +from typing import Any + +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload + +from src.db.enums import CrawlStatus +from src.db.schemas.crawl_sessions import CrawlSession +from src.db.schemas.target_application_version import TargetApplicationVersion + + +async def get_session_status(session: AsyncSession, session_id: str) -> str | None: + stmt = select(CrawlSession.status).where(CrawlSession.crawl_session_id == session_id) + result = await session.execute(stmt) + value = result.scalar_one_or_none() + return str(value) if value is not None else None + + +async def mark_queued_running(session: AsyncSession, session_id: str) -> bool: + stmt = ( + update(CrawlSession) + .where( + CrawlSession.crawl_session_id == session_id, + CrawlSession.status == CrawlStatus.QUEUED, + ) + .values(status=CrawlStatus.RUNNING, started_at=func.now()) + ) + result = await session.execute(stmt) + await session.commit() + return (result.rowcount or 0) == 1 + + +async def mark_completed_if_running( + session: AsyncSession, + session_id: str, + state_count: int, + transition_count: int, +) -> bool: + stmt = ( + update(CrawlSession) + .where( + CrawlSession.crawl_session_id == session_id, + CrawlSession.status.in_([CrawlStatus.RUNNING, CrawlStatus.PAUSED]), + ) + .values( + status=CrawlStatus.COMPLETED, + finished_at=func.now(), + error=None, + state_count=state_count, + transition_count=transition_count, + ) + ) + result = await session.execute(stmt) + await session.commit() + return (result.rowcount or 0) == 1 + + +async def mark_failed_if_running( + session: AsyncSession, + session_id: str, + error_message: str, +) -> bool: + stmt = ( + update(CrawlSession) + .where( + CrawlSession.crawl_session_id == session_id, + CrawlSession.status.in_([CrawlStatus.RUNNING, CrawlStatus.PAUSED]), + ) + .values( + status=CrawlStatus.FAILED, + finished_at=func.now(), + error=error_message, + ) + ) + result = await session.execute(stmt) + await session.commit() + return (result.rowcount or 0) == 1 + + +async def mark_finished_at_if_aborted(session: AsyncSession, session_id: str) -> None: + stmt = ( + update(CrawlSession) + .where( + CrawlSession.crawl_session_id == session_id, + CrawlSession.status == CrawlStatus.ABORTED, + CrawlSession.finished_at.is_(None), + ) + .values(finished_at=func.now()) + ) + await session.execute(stmt) + await session.commit() + + +async def fetch_job_inputs(session: AsyncSession, session_id: str) -> tuple[dict[str, Any], str]: + stmt = ( + select(CrawlSession) + .options(joinedload(CrawlSession.app_version).joinedload(TargetApplicationVersion.target_application)) + .where(CrawlSession.crawl_session_id == session_id) + ) + + result = await session.execute(stmt) + crawl_session = result.scalar_one_or_none() + if crawl_session is None: + raise RuntimeError(f"crawl session not found: {session_id}") + + config_json = crawl_session.config + if not isinstance(config_json, dict): + try: + config_json = json.loads(config_json) + except Exception: + config_json = {} + + base_url = str( + getattr( + getattr(getattr(crawl_session, "app_version", None), "target_application", None), + "base_url", + "", + ) + or "" + ).strip() + if not base_url: + raise RuntimeError(f"target application base_url missing for session: {session_id}") + + return config_json, base_url diff --git a/src/db/schemas/crawl_sessions.py b/src/db/schemas/crawl_sessions.py new file mode 100644 index 0000000..15a7ce2 --- /dev/null +++ b/src/db/schemas/crawl_sessions.py @@ -0,0 +1,38 @@ +from datetime import datetime +from typing import Any + +from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base +from src.db.enums import CrawlStatus, crawl_status_enum +from src.db.schemas.target_application_version import TargetApplicationVersion + + +class CrawlSession(Base): + __tablename__ = "crawl_sessions" + + crawl_session_id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True) + + app_version_id: Mapped[str | None] = mapped_column( + UUID(as_uuid=False), + ForeignKey("target_application_versions.id"), + ) + + status: Mapped[CrawlStatus | None] = mapped_column(crawl_status_enum) + + config: Mapped[dict[str, Any] | Any | None] = mapped_column(JSON) + + state_count: Mapped[int | None] = mapped_column(Integer) + transition_count: Mapped[int | None] = mapped_column(Integer) + + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + error: Mapped[str | None] = mapped_column(String) + + app_version: Mapped[TargetApplicationVersion | None] = relationship( + TargetApplicationVersion, + lazy="joined", + ) diff --git a/src/db/schemas/target_application.py b/src/db/schemas/target_application.py new file mode 100644 index 0000000..a16f164 --- /dev/null +++ b/src/db/schemas/target_application.py @@ -0,0 +1,12 @@ +from sqlalchemy import String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from src.db.base import Base + + +class TargetApplication(Base): + __tablename__ = "target_applications" + + id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True) + base_url: Mapped[str | None] = mapped_column(String) diff --git a/src/db/schemas/target_application_version.py b/src/db/schemas/target_application_version.py new file mode 100644 index 0000000..697f7eb --- /dev/null +++ b/src/db/schemas/target_application_version.py @@ -0,0 +1,22 @@ +from sqlalchemy import ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base +from src.db.schemas.target_application import TargetApplication + + +class TargetApplicationVersion(Base): + __tablename__ = "target_application_versions" + + id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True) + + target_application_id: Mapped[str | None] = mapped_column( + UUID(as_uuid=False), + ForeignKey("target_applications.id"), + ) + + target_application: Mapped[TargetApplication | None] = relationship( + TargetApplication, + lazy="joined", + ) diff --git a/src/db/services/__init__.py b/src/db/services/__init__.py new file mode 100644 index 0000000..d474927 --- /dev/null +++ b/src/db/services/__init__.py @@ -0,0 +1,3 @@ +from . import crawl_sessions + +__all__ = ["crawl_sessions"] diff --git a/src/db/services/crawl_sessions.py b/src/db/services/crawl_sessions.py new file mode 100644 index 0000000..f2585d0 --- /dev/null +++ b/src/db/services/crawl_sessions.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db.repositories.crawl_sessions import ( + get_session_status, + mark_finished_at_if_aborted, + mark_queued_running, +) + + +async def ensure_finished_at_if_aborted(session: AsyncSession, session_id: str) -> None: + await mark_finished_at_if_aborted(session, session_id) + + +async def ensure_started_or_skip_aborted(session: AsyncSession, session_id: str) -> bool: + status = await get_session_status(session, session_id) + if status == "ABORTED": + await mark_finished_at_if_aborted(session, session_id) + return False + + started = await mark_queued_running(session, session_id) + if started: + return True + + status = await get_session_status(session, session_id) + if status == "ABORTED": + await mark_finished_at_if_aborted(session, session_id) + return False + + if status in {"RUNNING", "PAUSED"}: + return True + + raise RuntimeError(f"Cannot start session {session_id} with status {status}") + + +__all__ = [ + "ensure_finished_at_if_aborted", + "ensure_started_or_skip_aborted", +] diff --git a/src/graph/__init__.py b/src/graph/__init__.py new file mode 100644 index 0000000..b2d5c83 --- /dev/null +++ b/src/graph/__init__.py @@ -0,0 +1,23 @@ +from importlib import import_module +from typing import Any + +__all__ = ["Neo4jClient", "GraphRepository", "create_graph", "init_schema"] + +_EXPORTS = { + "Neo4jClient": ("src.graph.client", "Neo4jClient"), + "GraphRepository": ("src.graph.repository", "GraphRepository"), + "create_graph": ("src.graph.factory", "create_graph"), + "init_schema": ("src.graph.schema", "init_schema"), +} + + +def __getattr__(name: str) -> Any: + module_name, attr = _EXPORTS[name] + module = import_module(module_name) + value = getattr(module, attr) + globals()[name] = value + return value + + +def __dir__(): + return sorted(__all__) diff --git a/src/graph/client.py b/src/graph/client.py new file mode 100644 index 0000000..f214847 --- /dev/null +++ b/src/graph/client.py @@ -0,0 +1,19 @@ +from neo4j import AsyncDriver, AsyncGraphDatabase + + +class Neo4jClient: + def __init__(self, uri: str, user: str, password: str): + self._driver: AsyncDriver = AsyncGraphDatabase.driver( + uri, + auth=(user, password), + ) + + @property + def driver(self) -> AsyncDriver: + return self._driver + + async def verify(self) -> None: + await self._driver.verify_connectivity() + + async def close(self) -> None: + await self._driver.close() diff --git a/src/graph/factory.py b/src/graph/factory.py new file mode 100644 index 0000000..ab3e1bb --- /dev/null +++ b/src/graph/factory.py @@ -0,0 +1,11 @@ +from src.graph.client import Neo4jClient +from src.graph.repository import GraphRepository +from src.graph.schema import init_schema + + +async def create_graph(uri: str, user: str, password: str): + client = Neo4jClient(uri, user, password) + await client.verify() + await init_schema(client.driver) + repo = GraphRepository(client.driver) + return client, repo diff --git a/src/graph/flow_finder.py b/src/graph/flow_finder.py new file mode 100644 index 0000000..40fc8bd --- /dev/null +++ b/src/graph/flow_finder.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import logging +from collections import deque +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + + +@dataclass +class FlowPath: + """Lightweight flow containing only transition IDs and its origin checkpoint.""" + + transition_refs: list[str] + checkpoint_hash: str + + +async def find_all_flows( + graph_repo, + *, + session_id: str, + max_paths_per_state: int = 3, + max_depth: int = 20, +) -> dict[str, list[FlowPath]]: + """ + Finds up to max_paths_per_state unique flows to each state, + starting from checkpoint states and the root. + """ + raw = await graph_repo.get_lightweight_flow_graph(session_id) + + states_info: dict[str, dict] = {s["state_hash"]: s for s in raw.get("states", [])} + + if not states_info: + logger.warning("No states found for session %s", session_id) + return {} + + checkpoint_states = {h for h, s in states_info.items() if s.get("is_checkpoint")} + root_hash = _find_root(states_info) + + starting_sources = set(checkpoint_states) + if root_hash: + starting_sources.add(root_hash) + logger.info("Root state for session %s: %s", session_id, root_hash) + + adjacency: dict[str, list[tuple[str, str]]] = {h: [] for h in states_info} + seen_edges: set[tuple[str, str]] = set() + + for t in raw.get("transitions", []): + src = t.get("source_hash") + tgt = t.get("target_hash") + trans_id = t.get("transition_id") + + if src and tgt and trans_id: + edge = (src, tgt) + if edge not in seen_edges: + seen_edges.add(edge) + adjacency[src].append((tgt, trans_id)) + + result: dict[str, list[FlowPath]] = {h: [] for h in states_info} + recorded_sources_per_state: dict[str, set[str]] = {h: set() for h in states_info} + + # Queue stores: (current_state_hash, origin_checkpoint_hash, path_of_trans_ids, visited_states_set) + queue = deque() + for source in starting_sources: + if source in states_info: + queue.append((source, source, [], {source})) + + while queue: + current, origin_cp, path, visited = queue.popleft() + # if this node is already a checkpoint, we should stop as we already flooded and started from it + if current in checkpoint_states and current != origin_cp: + continue + if len(path) >= max_depth: + continue + + for neighbor_hash, transition_id in adjacency.get(current, []): + if neighbor_hash in visited: + continue + arrival_path = path + [transition_id] + + if origin_cp not in recorded_sources_per_state[neighbor_hash]: + if len(result[neighbor_hash]) < max_paths_per_state: + result[neighbor_hash].append( + FlowPath( + transition_refs=arrival_path, + checkpoint_hash=origin_cp, + ) + ) + recorded_sources_per_state[neighbor_hash].add(origin_cp) + + new_visited = visited | {neighbor_hash} + queue.append((neighbor_hash, origin_cp, arrival_path, new_visited)) + + final = {h: flows for h, flows in result.items() if flows} + + logger.info( + "find_all_flows: %d states with flows (session %s)", + len(final), + session_id, + ) + return final + + +def _find_root(states: dict[str, dict]) -> str | None: + """ + The root is the state with the earliest first_seen timestamp. + Falls back to the first state with checkpoint_kind == 'initial'. + """ + + with_ts = [(h, s) for h, s in states.items() if s.get("first_seen") is not None] + if with_ts: + return min(with_ts, key=lambda x: x[1]["first_seen"])[0] + + return next(iter(states), None) + + +def _serialize_all_flows(all_flows: dict[str, list[FlowPath]]) -> dict[str, list[dict]]: + """ + Outputs highly compressed plain dicts for JSON transport. + Shape: { state_hash: [ { checkpoint_hash, transition_refs: [str] } ] } + """ + result: dict[str, list[dict]] = {} + for state_hash, flows in all_flows.items(): + result[state_hash] = [ + { + "checkpoint": flow.checkpoint_hash, + "transition_refs": flow.transition_refs, + } + for flow in flows + ] + return result diff --git a/src/graph/queries.py b/src/graph/queries.py new file mode 100644 index 0000000..d793bcd --- /dev/null +++ b/src/graph/queries.py @@ -0,0 +1,82 @@ +ADD_STATE = """ +MERGE (s:State {session_id: $session_id, state_hash: $state_hash}) +ON CREATE SET + s.url = $url, + s.title = $title, + s.html = $html, + s.first_seen = timestamp(), + s.last_seen = timestamp() +ON MATCH SET + s.url = $url, + s.title = $title, + s.html = $html, + s.last_seen = timestamp() +""" + +SET_STATE_PROPS = """ +MATCH (s:State {session_id: $session_id, state_hash: $state_hash}) +SET s += $props +""" + +ADD_TRANSITION = """ +MATCH (source:State {session_id: $session_id, state_hash: $source_hash}) +MATCH (target:State {session_id: $session_id, state_hash: $target_hash}) +MERGE (source)-[t:TRANSITION {session_id: $session_id, transition_id: $transition_id}]->(target) +ON CREATE SET t += $props, t.first_seen = timestamp(), t.last_seen = timestamp() +ON MATCH SET t += $props, t.last_seen = timestamp() +""" + +GET_GRAPH = """ +MATCH (s:State {session_id: $session_id}) +OPTIONAL MATCH (s)-[t:TRANSITION]->(target:State) +RETURN collect(DISTINCT s) AS states, +collect(DISTINCT {transition_id: t.transition_id, source_hash: s.state_hash, target_hash: target.state_hash, action_type: t.action_type, action_value: t.action_value, action_fingerprint: t.action_fingerprint}) AS transitions +""" + +GET_ACTIONS = """ +MATCH (s:State {session_id: $session_id, state_hash: $state_hash})-[t:TRANSITION]->(target:State) +RETURN t.transition_id AS transition_id, +target.state_hash AS target_state_hash, +t.action_type AS action_type, +t.action_description AS action_description, +t.locator_value AS locator_value, +t.action_value AS action_value, +t.action_fingerprint AS action_fingerprint +""" + +CLEAR_SESSION = """ +MATCH (s:State {session_id: $session_id}) +DETACH DELETE s +""" + +GET_LIGHTWEIGHT_FLOW_GRAPH = """ +MATCH (s:State {session_id: $session_id}) +OPTIONAL MATCH (s)-[t:TRANSITION]->(target:State) +RETURN + collect(DISTINCT { + state_hash: s.state_hash, + first_seen: s.first_seen + }) AS states, + collect(DISTINCT { + source_hash: s.state_hash, + target_hash: target.state_hash, + transition_id: t.transition_id + }) AS transitions +""" + +GET_DATA_FROM_FLOW_QUERY = """ +MATCH (s:State {state_hash: $checkpoint_hash}) +WITH s.checkpoint_url AS checkpoint_url, + s.checkpoint_storage_state_json AS checkpoint_storage_state_json +UNWIND $transition_refs AS ref +MATCH ()-[t:TRANSITION {transition_id: ref}]->() +RETURN checkpoint_url, + checkpoint_storage_state_json, + collect({ + transition_id: t.transition_id, + action_type: t.action_type, + selector: t.locator_value, + value: t.action_value, + description: t.action_description + }) AS transitions +""" diff --git a/src/graph/repository.py b/src/graph/repository.py new file mode 100644 index 0000000..290ea7e --- /dev/null +++ b/src/graph/repository.py @@ -0,0 +1,123 @@ +from typing import Any + +from neo4j import AsyncDriver + +from src.graph.queries import ( + ADD_STATE, + ADD_TRANSITION, + CLEAR_SESSION, + GET_ACTIONS, + GET_DATA_FROM_FLOW_QUERY, + GET_GRAPH, + GET_LIGHTWEIGHT_FLOW_GRAPH, + SET_STATE_PROPS, +) +from src.models import AbstractState, AbstractTransition +from src.utils.serialization import stable_json_dumps + + +class GraphRepository: + def __init__(self, driver: AsyncDriver): + self._driver = driver + + @staticmethod + def _normalize_prop_value(value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + + if isinstance(value, list): + if all(item is None or isinstance(item, (str, int, float, bool)) for item in value): + return value + return stable_json_dumps(value) + + return stable_json_dumps(value) + + def _normalize_props(self, props: dict) -> dict: + return {key: self._normalize_prop_value(value) for key, value in props.items()} + + async def add_state(self, session_id: str, state: AbstractState) -> None: + async with self._driver.session() as session: + await session.run( + ADD_STATE, + session_id=session_id, + state_hash=state.state_hash, + url=state.url, + title=state.title, + html=state.html, + ) + + async def set_state_properties(self, session_id: str, state_hash: str, props: dict) -> None: + async with self._driver.session() as session: + await session.run( + SET_STATE_PROPS, + session_id=session_id, + state_hash=state_hash, + props=self._normalize_props(props), + ) + + async def add_transition(self, t: AbstractTransition) -> None: + props = { + "action_type": t.action_type, + "action_description": t.action_description, + "locator_id": str(t.locator_id), + "locator_value": t.locator_value, + "action_value": t.action_value, + "action_fingerprint": t.action_fingerprint, + } + + async with self._driver.session() as session: + await session.run( + ADD_TRANSITION, + session_id=t.session_id, + source_hash=t.source_state_hash, + target_hash=t.target_state_hash, + transition_id=t.transition_id, + props=self._normalize_props(props), + ) + + async def get_state_graph(self, session_id: str) -> dict: + async with self._driver.session() as session: + result = await session.run(GET_GRAPH, session_id=session_id) + record = await result.single() + return record.data() if record else {"states": [], "transitions": []} + + async def get_available_actions(self, session_id: str, state_hash: str) -> list[dict]: + async with self._driver.session() as session: + result = await session.run( + GET_ACTIONS, + session_id=session_id, + state_hash=state_hash, + ) + return [r.data() for r in await result.fetch(100)] + + async def clear_session_data(self, session_id: str) -> None: + async with self._driver.session() as session: + await session.run(CLEAR_SESSION, session_id=session_id) + + + async def get_lightweight_flow_graph(self, session_id: str) -> dict: + async with self._driver.session() as session: + result = await session.run(GET_LIGHTWEIGHT_FLOW_GRAPH, session_id=session_id) + record = await result.single() + if not record: + return {"states": [], "transitions": []} + return record.data() + + async def get_data_from_flow_query( + self, + checkpoint_hash: str, + transition_refs: list[str], + ) -> tuple[str | None, Any, list[dict[str, Any]]]: + async with self._driver.session() as session: + result = await session.run( + GET_DATA_FROM_FLOW_QUERY, + checkpoint_hash=checkpoint_hash, + transition_refs=transition_refs, + ) + record = await result.single() + if not record: + return None, None, [] + checkpoint_url = record.get("checkpoint_url") + checkpoint_storage_state_json = record.get("checkpoint_storage_state_json") + transitions = record.get("transitions", []) + return checkpoint_url, checkpoint_storage_state_json, transitions diff --git a/src/graph/schema.py b/src/graph/schema.py new file mode 100644 index 0000000..dfa129c --- /dev/null +++ b/src/graph/schema.py @@ -0,0 +1,33 @@ +from neo4j import AsyncDriver + +STATE_CONSTRAINT = """ +CREATE CONSTRAINT state_unique IF NOT EXISTS +FOR (s:State) +REQUIRE (s.session_id, s.state_hash) IS UNIQUE +""" + +STATE_INDEX = """ +CREATE INDEX state_session IF NOT EXISTS +FOR (s:State) +ON (s.session_id) +""" + +TRANSITION_CONSTRAINT = """ +CREATE CONSTRAINT transition_unique IF NOT EXISTS +FOR ()-[t:TRANSITION]-() +REQUIRE (t.session_id, t.transition_id) IS UNIQUE +""" + +TRANSITION_INDEX = """ +CREATE INDEX transition_session IF NOT EXISTS +FOR ()-[t:TRANSITION]-() +ON (t.session_id) +""" + + +async def init_schema(driver: AsyncDriver) -> None: + async with driver.session() as session: + await session.run(STATE_CONSTRAINT) + await session.run(STATE_INDEX) + await session.run(TRANSITION_CONSTRAINT) + await session.run(TRANSITION_INDEX) diff --git a/src/graph/test_flow_generation/graph.py b/src/graph/test_flow_generation/graph.py new file mode 100644 index 0000000..82c6099 --- /dev/null +++ b/src/graph/test_flow_generation/graph.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +import logging + +logger = logging.getLogger(__name__) + +@dataclass(frozen=True, slots=True) +class Edge: + source: str + target: str + transition_id: str + +@dataclass(slots=True) +class FlowGraph: + adjacency: dict[str, list[Edge]] = field(default_factory=dict) + transition_count: int = 0 + checkpoints: set[str] = field(default_factory=set) + +@dataclass(slots=True) +class TestFlow: + transition_ids: list[str] = field(default_factory=list) + node_path: list[str] = field(default_factory=list) + visited_nodes: set[str] = field(default_factory=set) + + def __post_init__(self): + self.visited_nodes.update(self.node_path) + + def __len__(self) -> int: + return len(self.transition_ids) + + def add_step(self, transition_id: str, target_node: str) -> None: + self.transition_ids.append(transition_id) + self.node_path.append(target_node) + self.visited_nodes.add(target_node) + +def build_flow_graph(raw: dict) -> tuple[FlowGraph, str | None]: + states = [s for s in raw.get("states", []) if s.get("state_hash")] + graph = FlowGraph() + + graph.transition_count = sum( + 1 for t in raw.get("transitions", []) + if t.get("source_hash") and t.get("target_hash") and t.get("transition_id") + ) + + root_hash = None + if states: + with_ts = [s for s in states if s.get("first_seen") is not None] + root = min(with_ts, key=lambda x: x["first_seen"]) if with_ts else states[0] + root_hash = root["state_hash"] + + for t in raw.get("transitions", []): + src, tgt, tid = t.get("source_hash"), t.get("target_hash"), t.get("transition_id") + if not (src and tgt and tid): + continue + + graph.adjacency.setdefault(src, []).append(Edge(source=src, target=tgt, transition_id=tid)) + + graph.checkpoints = {s["state_hash"] for s in states if s.get("is_checkpoint")} + + logger.info("Built graph: %d real transitions", graph.transition_count) + return graph, root_hash \ No newline at end of file diff --git a/src/graph/test_flow_generation/stage1_preproccessing.py b/src/graph/test_flow_generation/stage1_preproccessing.py new file mode 100644 index 0000000..470214d --- /dev/null +++ b/src/graph/test_flow_generation/stage1_preproccessing.py @@ -0,0 +1,211 @@ +from __future__ import annotations +from collections import deque +import logging + +from graph import Edge, FlowGraph + +logger = logging.getLogger(__name__) + +from graph import TestFlow + +class CandidateTFGenerator: + def __init__(self, graph: FlowGraph, root_hash: str, *, max_num_of_states_per_tf: int = 15): + self.graph = graph + self.root_hash = root_hash + self.max_num_of_states_per_tf = max_num_of_states_per_tf + self.candidates: list[TestFlow] = [] + + def generate_candidate_tfs(self): + seeded_transitions: set[str] = set() + pending_starts: list[tuple[str, Edge | None]] = [(self.root_hash, None)] + + while pending_starts: + start_node, seed_edge = pending_starts.pop() + + tf = TestFlow( + transition_ids=[seed_edge.transition_id] if seed_edge else [], + node_path=[seed_edge.source, start_node] if seed_edge else [start_node] + ) + node = start_node + + while True: + out_edges = self.graph.adjacency.get(node, []) + if not out_edges: + break + + cycle_edges = [] + forward_edges = [] + + for e in out_edges: + if e.target in tf.visited_nodes: + cycle_edges.append(e) + else: + forward_edges.append(e) + + for ce in cycle_edges: + loop_tf = TestFlow( + transition_ids=list(tf.transition_ids) + [ce.transition_id], + node_path=list(tf.node_path) + [ce.target] + ) + self.candidates.append(loop_tf) + + if not forward_edges: + break + + continue_edge, *spawn_edges = forward_edges + + for e in reversed(spawn_edges): + if e.transition_id not in seeded_transitions: + seeded_transitions.add(e.transition_id) + pending_starts.append((e.target, e)) + + seeded_transitions.add(continue_edge.transition_id) + + tf.add_step(continue_edge.transition_id, continue_edge.target) + node = continue_edge.target + + if len(tf) >= self.max_num_of_states_per_tf: + self.candidates.append(tf) + tf = TestFlow(node_path=[node]) + + if len(tf.transition_ids) > 0: + self.candidates.append(tf) + + self._assert_full_coverage() + + def _assert_full_coverage(self) -> None: + claimed = {tid for tf in self.candidates for tid in tf.transition_ids} + missing = self.graph.transition_count - len(claimed) + if missing: + logger.error("Coverage failure: %d transitions missed", missing) + + def merge_short_tfs(self, *, min_num_of_states_per_tf: int): + clean = [tf for tf in self.candidates if len(tf) >= min_num_of_states_per_tf] + short = [tf for tf in self.candidates if len(tf) < min_num_of_states_per_tf] + + if not short: + return clean + + merged: list[TestFlow] = [] + for tf in short: + fixed = self._try_forward_merge(tf, clean, min_num_of_states_per_tf) or \ + self._try_backward_merge(tf, clean, min_num_of_states_per_tf) + if fixed: + merged.append(fixed) + + logger.info("Merged %d short TestFlows into %d longer ones", len(short), len(merged)) + logger.info("Total TestFlows after merging: %d", len(clean) + len(merged)) + logger.info("What thrown away: %d short TestFlows", len(short) - len(merged)) + self.candidates = clean + merged + + def _try_forward_merge(self, short_tf: TestFlow, clean_pool: list[TestFlow], min_len: int) -> TestFlow | None: + if not short_tf.transition_ids: + return None + + last_id = short_tf.transition_ids[-1] + valid_merges = [] + + for other in clean_pool: + try: + idx = other.transition_ids.index(last_id) + except ValueError: + continue + + continuation_ids = other.transition_ids[idx + 1:] + if not continuation_ids or len(short_tf) + len(continuation_ids) < min_len: + continue + + logger.debug("Merging short TF %s with continuation from %s", short_tf, other) + + valid_merges.append(TestFlow( + transition_ids=short_tf.transition_ids + continuation_ids, + node_path=short_tf.node_path + other.node_path[idx + 2:] + )) + + return min(valid_merges, key=len, default=None) + + def _try_backward_merge(self, short_tf: TestFlow, clean_pool: list[TestFlow], min_len: int) -> TestFlow | None: + if not short_tf.transition_ids: + return None + + first_id = short_tf.transition_ids[0] + valid_merges = [] + + for other in clean_pool: + try: + idx = other.transition_ids.index(first_id) + except ValueError: + continue + + prefix_ids = other.transition_ids[:idx] + if not prefix_ids or len(prefix_ids) + len(short_tf) < min_len: + continue + + logger.debug("Merging short TF %s with prefix from %s", short_tf, other) + + valid_merges.append(TestFlow( + transition_ids=prefix_ids + short_tf.transition_ids, + node_path=other.node_path[:idx + 1] + short_tf.node_path[1:] + )) + + return min(valid_merges, key=len, default=None) + + def append_checkpoints_to_tfs(self) -> None: + valid_starts = set(getattr(self.graph, 'checkpoints', [])) + valid_starts.add(self.root_hash) + targets = { + tf.node_path[0] for tf in self.candidates + if tf.node_path and tf.node_path[0] not in valid_starts + } + + if not targets: + return + + found_prefixes: dict[str, tuple[list[str], list[str]]] = {} + queue = deque() + + visited = set(valid_starts) + + for start_node in valid_starts: + queue.append((start_node, [], [start_node])) + + while queue and len(found_prefixes) < len(targets): + current_node, trans_path, node_path = queue.popleft() + + for edge in self.graph.adjacency.get(current_node, []): + neighbor = edge.target + + if neighbor in visited: + continue + + visited.add(neighbor) + + new_trans_path = trans_path + [edge.transition_id] + new_node_path = node_path + [neighbor] + + if neighbor in targets: + found_prefixes[neighbor] = (new_trans_path, new_node_path) + if len(found_prefixes) == len(targets): + break + + if neighbor not in valid_starts: + queue.append((neighbor, new_trans_path, new_node_path)) + + for tf in self.candidates: + if not tf.node_path: + continue + + start_node = tf.node_path[0] + + if start_node in found_prefixes: + prefix_trans, prefix_nodes = found_prefixes[start_node] + tf.transition_ids = prefix_trans + tf.transition_ids + tf.node_path = prefix_nodes[:-1] + tf.node_path + logger.info("Found prefix for %s and appended it to it",start_node) + + elif start_node in targets: + logger.warning("Could not find a path from any checkpoint to node %s", start_node) + + + def get_candidate_tfs(self) -> list[TestFlow]: + return self.candidates \ No newline at end of file diff --git a/src/graph/test_flow_generation/stage2_selecting_best_tf.py b/src/graph/test_flow_generation/stage2_selecting_best_tf.py new file mode 100644 index 0000000..713994c --- /dev/null +++ b/src/graph/test_flow_generation/stage2_selecting_best_tf.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import heapq +import logging + +from graph import TestFlow + +logger = logging.getLogger(__name__) + +MAX_TF_TAKEN = 10000 + +def select_tfs( + candidates: list[TestFlow], + *, + transition_count: int, + convergence_threshold: float | None = None, + min_num_of_tf: int | None = None, + min_num_of_states_per_tf: int | None = None, +) -> list[TestFlow]: + + if transition_count <= 0: + logger.warning("transition_count <= 0; nothing to cover") + return [] + + eligible: list[TestFlow] = [] + eligible_sets: list[set[str]] = [] + + for tf in candidates: + length = len(tf) + if (min_num_of_states_per_tf is None or length >= min_num_of_states_per_tf): + eligible.append(tf) + eligible_sets.append(set(tf.transition_ids)) + + if not eligible: + logger.warning("No eligible candidates after length filtering") + return [] + + selected: list[TestFlow] = [] + union_ids: set[str] = set() + + heap = [(-len(es), i) for i, es in enumerate(eligible_sets)] + heapq.heapify(heap) + + while heap and len(selected) < MAX_TF_TAKEN: + while heap: + neg_gain, idx = heapq.heappop(heap) + real_gain = len(eligible_sets[idx] - union_ids) + + if real_gain == -neg_gain: + candidate_idx, best_gain = idx, real_gain + break + heapq.heappush(heap, (-real_gain, idx)) + else: + break + + if best_gain == 0: + if min_num_of_tf is None or len(selected) >= min_num_of_tf: + break + + candidate = eligible[candidate_idx] + selected.append(candidate) + union_ids.update(candidate.transition_ids) + + current_coverage = len(union_ids) / transition_count + + if min_num_of_tf is not None: + if len(selected) >= min_num_of_tf: + if convergence_threshold is None: + break + elif current_coverage >= convergence_threshold: + logger.info( + "Target reached: within (%d) and coverage (%.2f%% >= %.2f%%)", + len(selected), current_coverage * 100, convergence_threshold * 100 + ) + break + else: + if convergence_threshold is not None and current_coverage >= convergence_threshold: + logger.info( + "Target reached: coverage %.2f%% >= %.2f%%", + current_coverage * 100, convergence_threshold * 100 + ) + break + + logger.info( + "Stage 2: selected %d/%d eligible TFs, coverage=%.2f%%", + len(selected), + len(eligible), + (len(union_ids) / transition_count) * 100, + ) + return selected \ No newline at end of file diff --git a/src/graph/test_flow_generation/test_flow_gen.py b/src/graph/test_flow_generation/test_flow_gen.py new file mode 100644 index 0000000..c1a4ca7 --- /dev/null +++ b/src/graph/test_flow_generation/test_flow_gen.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import logging + +from graph import build_flow_graph,TestFlow +from stage1_preproccessing import CandidateTFGenerator +from stage2_selecting_best_tf import select_tfs + +logger = logging.getLogger(__name__) + + +async def find_all_flows( + graph_repo, + *, + session_id: str, + min_num_of_states_per_tf: int = 3, + max_num_of_states_per_tf: int = 20, + convergence_threshold: float | None = None, + min_num_of_tf: int | None = None, +) -> list[TestFlow]: + """Fetch raw graph from repository, generate candidate flows, and select optimal subset.""" + raw = await graph_repo.get_lightweight_flow_graph(session_id) + + graph, root_hash = build_flow_graph(raw) + if root_hash is None: + logger.warning("No root found for session %s", session_id) + return [] + + candidate_generator = CandidateTFGenerator( + graph, + root_hash, + max_num_of_states_per_tf=max_num_of_states_per_tf, + ) + + candidate_generator.generate_candidate_tfs() + + + candidate_generator.merge_short_tfs( + min_num_of_states_per_tf=min_num_of_states_per_tf, + ) + + candidate_generator.append_checkpoints_to_tfs() + + candidates=candidate_generator.get_candidate_tfs() + + selected = select_tfs( + candidates, + transition_count=graph.transition_count, + convergence_threshold=convergence_threshold, + min_num_of_tf=min_num_of_tf, + min_num_of_states_per_tf=min_num_of_states_per_tf, + ) + + logger.info( + "find_all_flows: session=%s -> %d candidates -> %d selected", + session_id, + len(candidates), + len(selected), + ) + return selected \ No newline at end of file diff --git a/src/graph/test_flow_generation/test_runner.py b/src/graph/test_flow_generation/test_runner.py new file mode 100644 index 0000000..ce29f9d --- /dev/null +++ b/src/graph/test_flow_generation/test_runner.py @@ -0,0 +1,93 @@ +import asyncio +import random +import logging +import time +from test_flow_gen import find_all_flows + +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + +class MockGraphRepo: + def __init__(self, target_nodes: int = 20, max_branches: int = 3): + self.target_nodes = target_nodes + self.max_branches = max_branches + self.raw_data = {} + + async def get_lightweight_flow_graph(self, session_id: str) -> dict: + states = [{"state_hash": "node_0", "first_seen": 0, "is_checkpoint": True}] + transitions = [] + t_id = 1 + + # 1. Guarantee every node is reachable by connecting it to a previous node + for i in range(1, self.target_nodes): + node_name = f"node_{i}" + states.append({"state_hash": node_name, "first_seen": i, "is_checkpoint": random.random() < 0.3}) + + src = f"node_{random.randint(0, i-1)}" + transitions.append({ + "source_hash": src, + "target_hash": node_name, + "transition_id": f"t_{t_id}" + }) + t_id += 1 + + # 2. Add extra random branches/cycles to build complexity + node_pool = [f"node_{i}" for i in range(self.target_nodes)] + for src in node_pool: + extra_branches = random.randint(0, self.max_branches - 1) + for _ in range(extra_branches): + tgt = random.choice(node_pool) + transitions.append({ + "source_hash": src, + "target_hash": tgt, + "transition_id": f"t_{t_id}" + }) + t_id += 1 + + self.raw_data = {"states": states, "transitions": transitions} + return self.raw_data + +def print_graphviz_dot(raw: dict): + print("\n--- COPY AND PASTE THIS INTO: https://dreampuf.github.io/GraphvizOnline/ ---") + print("digraph G {") + print(' rankdir="LR";') + print(' node [style=filled, fillcolor=lightblue];') + + for t in raw.get("transitions", []): + src = t["source_hash"] + tgt = t["target_hash"] + tid = t["transition_id"] + print(f' "{src}" -> "{tgt}" [label="{tid}"];') + print("}\n-------------------------------------------------------------------------") + +async def main(): + repo = MockGraphRepo(target_nodes=1000, max_branches=5) + + + start_time = time.time() + selected_flows = await find_all_flows( + graph_repo=repo, + session_id="mock_session_123", + min_num_of_states_per_tf=4, + max_num_of_states_per_tf=20, + min_num_of_tf=800, + convergence_threshold=0.1, + ) + end_time = time.time() + print(f"\nTime taken to find all flows: {end_time - start_time:.4f} seconds") + + print_graphviz_dot(repo.raw_data) + + print(f"\nPipeline returned {len(selected_flows)} highly optimized TestFlows:") + for i, tf in enumerate(selected_flows): + # Build an alternating chain of: node -> [edge] -> node + path_elements = [tf.node_path[0]] + for edge_id, next_node in zip(tf.transition_ids, tf.node_path[1:]): + path_elements.append(f" -> [{edge_id}] -> {next_node}") + + full_visual_path = "".join(path_elements) + + print(f"\n Flow {i} (Edges: {len(tf.transition_ids)}, States: {len(tf.node_path)}):") + print(f" {full_visual_path}") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..a1db9d4 --- /dev/null +++ b/src/models/__init__.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any + +__all__ = ["AbstractState", "AbstractTransition", "CrawlAction", "CrawlJob"] + +_EXPORTS: dict[str, tuple[str, str]] = { + "AbstractState": ("src.models.graph", "AbstractState"), + "AbstractTransition": ("src.models.graph", "AbstractTransition"), + "CrawlAction": ("src.models.graph", "CrawlAction"), + "CrawlJob": ("src.models.crawl_job", "CrawlJob"), +} + + +def __getattr__(name: str) -> Any: + target = _EXPORTS.get(name) + if target is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module_name, attr = target + value = getattr(import_module(module_name), attr) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals().keys()) | set(_EXPORTS.keys())) + + +if TYPE_CHECKING: + from src.models.crawl_job import CrawlJob + from src.models.graph import AbstractState, AbstractTransition, CrawlAction diff --git a/src/models/crawl_job.py b/src/models/crawl_job.py new file mode 100644 index 0000000..a946e6c --- /dev/null +++ b/src/models/crawl_job.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from src.config import Config +from src.utils.coercion import coerce_bool, coerce_int, coerce_str + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _default_input_config_path() -> str | None: + candidate = _repo_root() / "src" / "configs" / "input_defaults.json" + return str(candidate) if candidate.exists() else None + + +@dataclass(frozen=True) +class CrawlJob: + base_url: str + session_id: str + headless: bool + timeout_ms: int + max_states: int + max_transitions: int + max_elements_per_state: int + max_select_options_per_element: int + max_action_repeats_per_url: int + action_retry_count: int + replay_retry_count: int + popup_timeout_ms: int + dom_quiet_ms: int + dom_settle_timeout_ms: int + use_dom_quiescence: bool + page_load_state: str + click_non_http_links: bool + defer_destructive_actions: bool + destructive_keywords: str + input_defaults: dict[str, Any] | None = None + input_defaults_path: str | None = None + + @staticmethod + def from_dict(payload: dict[str, Any], settings: Config) -> CrawlJob: + nested_settings = payload.get("settings") + if not isinstance(nested_settings, dict): + raise ValueError("settings must be an object") + + base_url = str(payload.get("base_url") or "").strip() + if not base_url: + raise ValueError("base_url is required") + + session_id = str(payload.get("session_id") or "").strip() or str(uuid4()) + headless = coerce_bool(nested_settings.get("headless"), bool(getattr(settings, "HEADLESS", True))) + timeout_ms = coerce_int(nested_settings.get("timeout_ms"), int(getattr(settings, "TIMEOUT_MS", 3000))) + max_states = coerce_int(nested_settings.get("max_states"), int(getattr(settings, "MAX_STATES", 1000))) + max_transitions = coerce_int( + nested_settings.get("max_transitions"), + int(getattr(settings, "MAX_TRANSITIONS", 5000)), + ) + max_elements_per_state = coerce_int( + nested_settings.get("max_elements_per_state"), + int(getattr(settings, "MAX_ELEMENTS_PER_STATE", 30)), + ) + max_select_options_per_element = coerce_int( + nested_settings.get("max_select_options_per_element"), + int(getattr(settings, "MAX_SELECT_OPTIONS_PER_ELEMENT", 3)), + ) + max_action_repeats_per_url = coerce_int( + nested_settings.get("max_action_repeats_per_url"), + int(getattr(settings, "MAX_ACTION_REPEATS_PER_URL", 2)), + ) + action_retry_count = coerce_int( + nested_settings.get("action_retry_count"), + int(getattr(settings, "ACTION_RETRY_COUNT", 1)), + ) + replay_retry_count = coerce_int( + nested_settings.get("replay_retry_count"), + int(getattr(settings, "REPLAY_RETRY_COUNT", 1)), + ) + popup_timeout_ms = coerce_int( + nested_settings.get("popup_timeout_ms"), + int(getattr(settings, "POPUP_TIMEOUT_MS", 3000)), + ) + dom_quiet_ms = coerce_int( + nested_settings.get("dom_quiet_ms"), + int(getattr(settings, "DOM_QUIET_MS", 400)), + ) + dom_settle_timeout_ms = coerce_int( + nested_settings.get("dom_settle_timeout_ms"), + int(getattr(settings, "DOM_SETTLE_TIMEOUT_MS", 3000)), + ) + use_dom_quiescence = coerce_bool( + nested_settings.get("use_dom_quiescence"), + bool(getattr(settings, "USE_DOM_QUIESCENCE", True)), + ) + page_load_state = coerce_str( + nested_settings.get("page_load_state"), + str(getattr(settings, "PAGE_LOAD_STATE", "networkidle")), + ) + click_non_http_links = coerce_bool( + nested_settings.get("click_non_http_links"), + bool(getattr(settings, "CLICK_NON_HTTP_LINKS", False)), + ) + defer_destructive_actions = coerce_bool( + nested_settings.get("defer_destructive_actions"), + bool(getattr(settings, "DEFER_DESTRUCTIVE_ACTIONS", True)), + ) + destructive_keywords = coerce_str( + nested_settings.get("destructive_keywords"), + str(getattr(settings, "DESTRUCTIVE_KEYWORDS", "")), + ) + input_defaults_path = _default_input_config_path() + + input_defaults = payload.get("input_defaults") + if not isinstance(input_defaults, dict): + input_defaults = None + + return CrawlJob( + base_url=base_url, + session_id=session_id, + headless=headless, + timeout_ms=timeout_ms, + max_states=max_states, + max_transitions=max_transitions, + max_elements_per_state=max_elements_per_state, + max_select_options_per_element=max_select_options_per_element, + max_action_repeats_per_url=max_action_repeats_per_url, + action_retry_count=action_retry_count, + replay_retry_count=replay_retry_count, + popup_timeout_ms=popup_timeout_ms, + dom_quiet_ms=dom_quiet_ms, + dom_settle_timeout_ms=dom_settle_timeout_ms, + use_dom_quiescence=use_dom_quiescence, + page_load_state=page_load_state, + click_non_http_links=click_non_http_links, + defer_destructive_actions=defer_destructive_actions, + destructive_keywords=destructive_keywords, + input_defaults=input_defaults, + input_defaults_path=input_defaults_path, + ) diff --git a/src/models/graph.py b/src/models/graph.py new file mode 100644 index 0000000..874fe45 --- /dev/null +++ b/src/models/graph.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any +from uuid import uuid4 + + +@dataclass +class AbstractState: + """Represents an abstract state in the application state graph.""" + + state_hash: str = "" + url: str = "" + title: str = "" + html: str = "" + dom_snapshot: dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + + def __hash__(self) -> int: + return hash(self.state_hash) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, AbstractState): + return False + return self.state_hash == other.state_hash + + +@dataclass +class AbstractTransition: + """Represents a transition between states.""" + + session_id: str = "" + transition_id: str = "" + source_state_hash: str = "" + target_state_hash: str = "" + action_type: str = "" + action_description: str = "" + locator_id: str = "" + locator_value: str = "" + action_value: str = "" + action_fingerprint: str = "" + + +@dataclass +class CrawlAction: + """Represents an executable action.""" + + action_id: str = field(default_factory=lambda: str(uuid4())) + action_type: str = "" + selector: str = "" + value: str = "" + description: str = "" + metadata: dict[str, Any] = field(default_factory=dict) diff --git a/src/queue/__init__.py b/src/queue/__init__.py new file mode 100644 index 0000000..e8f7965 --- /dev/null +++ b/src/queue/__init__.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any + +__all__ = [ + "CrawlStreamConfig", + "ack_and_delete", + "cancel_key", + "clear_cancel", + "crawl_stream_config", + "ensure_consumer_group", + "is_cancelled", + "parse_session_id", +] + +_EXPORTS: dict[str, tuple[str, str]] = { + "CrawlStreamConfig": ("src.queue.crawl_stream", "CrawlStreamConfig"), + "ack_and_delete": ("src.queue.crawl_stream", "ack_and_delete"), + "cancel_key": ("src.queue.crawl_stream", "cancel_key"), + "clear_cancel": ("src.queue.crawl_stream", "clear_cancel"), + "crawl_stream_config": ("src.queue.crawl_stream", "crawl_stream_config"), + "ensure_consumer_group": ("src.queue.crawl_stream", "ensure_consumer_group"), + "is_cancelled": ("src.queue.crawl_stream", "is_cancelled"), + "parse_session_id": ("src.queue.crawl_stream", "parse_session_id"), +} + + +def __getattr__(name: str) -> Any: + target = _EXPORTS.get(name) + if target is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module_name, attr = target + value = getattr(import_module(module_name), attr) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals().keys()) | set(_EXPORTS.keys())) + + +if TYPE_CHECKING: + from src.queue.crawl_stream import ( + CrawlStreamConfig, + ack_and_delete, + cancel_key, + clear_cancel, + crawl_stream_config, + ensure_consumer_group, + is_cancelled, + parse_session_id, + ) diff --git a/src/queue/crawl_stream.py b/src/queue/crawl_stream.py new file mode 100644 index 0000000..3bdec4f --- /dev/null +++ b/src/queue/crawl_stream.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass +from typing import Any, Mapping + +from redis.asyncio import Redis + + +@dataclass(frozen=True) +class CrawlStreamConfig: + stream_key: str + group_name: str + cancel_prefix: str + + +def crawl_stream_config() -> CrawlStreamConfig: + return CrawlStreamConfig( + stream_key="crawl:jobs", + group_name="CRAWL_GROUP", + cancel_prefix="crawl:cancelled:", + ) + + +async def ensure_consumer_group(redis: Redis, cfg: CrawlStreamConfig) -> None: + try: + await redis.xgroup_create(cfg.stream_key, cfg.group_name, id="0", mkstream=True) + except Exception as e: + if "BUSYGROUP" not in str(e): + raise + + +def parse_session_id(fields: Mapping[bytes, Any]) -> str: + raw = fields.get(b"sessionId") or fields.get(b"session_id") + if raw is None: + raise ValueError("Missing sessionId in job") + if isinstance(raw, (bytes, bytearray)): + return raw.decode("utf-8") + return str(raw) + + +def cancel_key(cfg: CrawlStreamConfig, session_id: str) -> str: + return f"{cfg.cancel_prefix}{session_id}" + + +async def is_cancelled(redis: Redis, cfg: CrawlStreamConfig, session_id: str) -> bool: + return await redis.get(cancel_key(cfg, session_id)) is not None + + +async def clear_cancel(redis: Redis, cfg: CrawlStreamConfig, session_id: str) -> None: + await redis.delete(cancel_key(cfg, session_id)) + + +async def ack_and_delete(redis: Redis, cfg: CrawlStreamConfig, message_id: bytes) -> None: + await redis.xack(cfg.stream_key, cfg.group_name, message_id) + await redis.xdel(cfg.stream_key, message_id) diff --git a/src/replay_assertions/__init__.py b/src/replay_assertions/__init__.py new file mode 100644 index 0000000..ae771fc --- /dev/null +++ b/src/replay_assertions/__init__.py @@ -0,0 +1,3 @@ +from src.replay_assertions.core import run_interactive_replay + +__all__ = ["run_interactive_replay"] diff --git a/src/replay_assertions/core.py b/src/replay_assertions/core.py new file mode 100644 index 0000000..42276fb --- /dev/null +++ b/src/replay_assertions/core.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import asyncio +import json +import logging +from pathlib import Path +from typing import Any + +from src.browser import BrowserEngine +from src.crawler.executor import EventExecutor +from src.models import CrawlAction + +logger = logging.getLogger(__name__) + +_START_ASSERTION_SCRIPT = """ +(() => { + if (window.__coveritAssertionRecording) { + return { recording: true, alreadyRunning: true, count: (window.__coveritAssertions || []).length }; + } + window.__coveritAssertionRecording = true; + window.__coveritAssertions = []; + + function getSelector(el) { + if (!el || el === document.body) return 'body'; + if (el.id) return `#${el.id}`; + const testId = el.getAttribute('data-testid') || el.getAttribute('data-test') || el.getAttribute('data-cy'); + if (testId) return `[data-testid="${testId}"]`; + const ariaLabel = el.getAttribute('aria-label'); + if (ariaLabel) return `${el.tagName.toLowerCase()}[aria-label="${ariaLabel}"]`; + const path = []; + let current = el; + while (current && current !== document.body) { + let selector = current.tagName.toLowerCase(); + if (current.id) { + selector = `#${current.id}`; + path.unshift(selector); + break; + } + let sibling = current; + let nth = 1; + while ((sibling = sibling.previousElementSibling)) { + if (sibling.tagName === current.tagName) nth += 1; + } + if (nth > 1) selector += `:nth-of-type(${nth})`; + path.unshift(selector); + current = current.parentElement; + } + return path.join(' > '); + } + + window.__coveritAssertionHandler = function (e) { + if (!window.__coveritAssertionRecording || !e.ctrlKey) return; + e.preventDefault(); + e.stopPropagation(); + const el = e.target; + window.__coveritAssertions.push({ + selector: getSelector(el), + tag: (el.tagName || '').toLowerCase(), + label: (el.innerText || '').trim().slice(0, 100) || el.getAttribute('aria-label') || '', + url: location.href, + timestamp: Date.now() + }); + }; + + document.addEventListener('click', window.__coveritAssertionHandler, true); + return { recording: true, alreadyRunning: false, count: 0 }; +})(); +""" + +_STOP_ASSERTION_SCRIPT = """ +(() => { + const assertions = Array.isArray(window.__coveritAssertions) ? window.__coveritAssertions : []; + if (window.__coveritAssertionHandler) { + document.removeEventListener('click', window.__coveritAssertionHandler, true); + } + window.__coveritAssertionRecording = false; + return assertions; +})(); +""" + + +async def _terminal_input(prompt: str) -> str: + return await asyncio.to_thread(input, prompt) + + +async def run_interactive_replay( + checkpoint_url: str, + storage_state: Any | None, + transitions: list[dict[str, Any]], + output_file: str = "artifacts/captured_assertions.json", +) -> None: + """ + Replays a specific flow with headless=False, stopping after each transition + to optionally capture assertions, and saves them mapped by transition_id. + """ + browser = BrowserEngine(headless=False) + executor = EventExecutor(browser) + + captured_data: dict[str, list[dict[str, Any]]] = {} + + try: + if storage_state: + await browser.start_with_storage_state(storage_state) + else: + await browser.start() + logger.info("Navigating to checkpoint: %s", checkpoint_url) + await browser.navigate(checkpoint_url) + await browser.wait_for_settle() + page = browser.page + if not page: + logger.error("Browser page failed to initialize.") + return + for step_idx, t in enumerate(transitions, start=1): + trans_id = t.get("transition_id") + if not trans_id: + logger.warning("Skipping step with missing transition_id") + continue + + action = CrawlAction( + action_type=t.get("action_type", ""), + selector=t.get("selector", ""), + value=t.get("value", ""), + description=t.get("description", ""), + ) + + logger.info("Step %d/%d: %s", step_idx, len(transitions), action.description) + await executor.execute_action(action) + await browser.wait_for_settle() + + captured_data[trans_id] = [] + + while True: + prompt = f"[Step {step_idx}] Assertions for {trans_id}? (Enter=skip, a=assert, q=quit) > " + command = (await _terminal_input(prompt)).strip().lower() + + if command in {"", "s", "skip"}: + break + elif command in {"q", "quit"}: + logger.info("Aborting replay early.") + _save_results(output_file, captured_data) + return + elif command in {"a", "assert"}: + await page.evaluate(_START_ASSERTION_SCRIPT) + print("Assertion mode ON: Ctrl+Click elements in Chrome.") + await _terminal_input("Press Enter when done capturing assertions > ") + + captured = await page.evaluate(_STOP_ASSERTION_SCRIPT) + if isinstance(captured, list): + captured_data[trans_id].extend(captured) + print(f"Captured {len(captured)} assertions for this step.") + break + else: + print("Unknown command. Use Enter, a, or q.") + + finally: + await browser.stop() + _save_results(output_file, captured_data) + + +def _save_results(output_file: str, data: dict[str, Any]) -> None: + out_path = Path(output_file) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(data, indent=2), encoding="utf-8") + logger.info("Saved captured assertions to %s", out_path.absolute()) diff --git a/src/replay_assertions/local_wrapper.py b/src/replay_assertions/local_wrapper.py new file mode 100644 index 0000000..5767f36 --- /dev/null +++ b/src/replay_assertions/local_wrapper.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import argparse +import asyncio +import logging +from typing import Any + +from src.config import config +from src.graph.factory import create_graph +from src.replay_assertions.core import run_interactive_replay + +logger = logging.getLogger(__name__) + + +async def fetch_flow_data( + repo: Any, + checkpoint_hash: str, + transition_refs: list[str], +) -> tuple[str | None, Any, list[dict[str, Any]]]: + """Fetches the starting checkpoint URL and all step details from Neo4j in one shot.""" + checkpoint_url, checkpoint_storage_state_json, raw_transitions = await repo.get_data_from_flow_query( + checkpoint_hash, + transition_refs, + ) + ref_map = {t["transition_id"]: t for t in raw_transitions} + ordered_transitions = [ref_map[ref] for ref in transition_refs if ref in ref_map] + + return checkpoint_url, checkpoint_storage_state_json, ordered_transitions + + +async def _run(args: argparse.Namespace) -> int: + client, repo = await create_graph(config.NEO4J_URI, config.NEO4J_USER, config.NEO4J_PASSWORD) + + try: + transition_refs = [ref.strip() for ref in args.transition_refs.split(",") if ref.strip()] + + logger.info("Fetching checkpoint URL and hydrating %d steps from Neo4j...", len(transition_refs)) + checkpoint_url, checkpoint_storage_state_json, hydrated_transitions = await fetch_flow_data( + repo, + args.checkpoint_hash, + transition_refs, + ) + + if not checkpoint_url: + logger.error("Could not find a checkpoint state or 'checkpoint_url' matching hash: %s", args.checkpoint_hash) + return 1 + + if not hydrated_transitions: + logger.error("Could not find action data for the provided transition references.") + return 1 + + logger.info("Flow resolved successfully. Starting browser replay...") + await run_interactive_replay( + checkpoint_url=checkpoint_url, + storage_state=checkpoint_storage_state_json, + transitions=hydrated_transitions, + output_file=args.output_file, + ) + + return 0 + finally: + await client.close() + + +def main() -> int: + logging.basicConfig(level=logging.INFO) + parser = argparse.ArgumentParser(description="Hydrate a flow fully from Neo4j using hashes and refs.") + parser.add_argument("--checkpoint-hash", required=True, help="The starting state hash from your Postgres database") + parser.add_argument("--transition-refs", required=True, help="Comma-separated list of transition IDs (e.g. 'id1,id2')") + parser.add_argument("--output-file", default="artifacts/assertions.json", help="Where to save the assertions") + + args = parser.parse_args() + return asyncio.run(_run(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/scripts/semantic_pipeline/__init__.py b/src/scripts/semantic_pipeline/__init__.py new file mode 100644 index 0000000..c9c2ef6 --- /dev/null +++ b/src/scripts/semantic_pipeline/__init__.py @@ -0,0 +1 @@ +__all__: list[str] = [] diff --git a/src/scripts/semantic_pipeline/__main__.py b/src/scripts/semantic_pipeline/__main__.py new file mode 100644 index 0000000..acce9d8 --- /dev/null +++ b/src/scripts/semantic_pipeline/__main__.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import argparse +import asyncio +import logging +from pathlib import Path + +from src.scripts.semantic_pipeline.pipeline import ( + PipelineSettings, + run_pipeline, +) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--max-websites", type=int, default=0) + parser.add_argument("--max-pages-per-domain", type=int, default=3) + parser.add_argument("--max-actions-per-page", type=int, default=5) + parser.add_argument("--max-pairs", type=int, default=1000) + parser.add_argument("--timeout-ms", type=int, default=15000) + parser.add_argument("--visible", action="store_true") + parser.add_argument("--model", default="all-mpnet-base-v2") + parser.add_argument("--reuse-collected", action="store_true") + args = parser.parse_args() + root = Path(__file__).resolve().parents[3] + logging.basicConfig(level=logging.INFO) + asyncio.run( + run_pipeline( + PipelineSettings( + workspace=root / "data" / "semantic_pipeline", + artifacts=root / "src" / "models" / "semantic", + input_config=root / "src" / "configs" / "input_defaults.json", + max_websites=args.max_websites, + max_pages_per_domain=args.max_pages_per_domain, + max_actions_per_page=args.max_actions_per_page, + max_pairs=args.max_pairs, + timeout_ms=args.timeout_ms, + headless=not args.visible, + labeling_model=args.model, + reuse_collected=args.reuse_collected, + ) + ) + ) + + +if __name__ == "__main__": + main() diff --git a/src/scripts/semantic_pipeline/collectors.py b/src/scripts/semantic_pipeline/collectors.py new file mode 100644 index 0000000..439d6d6 --- /dev/null +++ b/src/scripts/semantic_pipeline/collectors.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import csv +import hashlib +import json +import logging +import random +from pathlib import Path +from typing import Any + +from src.browser import BrowserEngine +from src.crawler.semantic_engine.extractor import DOMFeatureExtractor +from src.utils import is_same_domain, normalize_url, stable_json_dumps + +logger = logging.getLogger(__name__) + +RAW_FIELDS = ( + "flattened_text", + "tag", + "type", + "id", + "name", + "text", + "value", + "placeholder", + "label", + "aria_label", + "role", + "topic_label", +) + + +class SemanticDataCollector: + def __init__( + self, + raw_output: Path, + state_output: Path, + *, + max_pages_per_domain: int, + max_actions_per_page: int, + timeout_ms: int, + headless: bool, + ): + self.raw_output = raw_output + self.state_output = state_output + self.max_pages_per_domain = max_pages_per_domain + self.max_actions_per_page = max_actions_per_page + self.timeout_ms = timeout_ms + self.headless = headless + self._extractor = DOMFeatureExtractor() + self._seen_elements: set[str] = set() + self._seen_states: set[str] = set() + + async def collect(self, urls: list[str]) -> None: + self._initialize_outputs() + browser = BrowserEngine( + headless=self.headless, + timeout_ms=self.timeout_ms, + ) + await browser.start() + try: + for index, url in enumerate(urls, start=1): + logger.info("Collecting %s/%s: %s", index, len(urls), url) + await self._collect_domain(browser, url) + finally: + await browser.stop() + + def _initialize_outputs(self) -> None: + self.raw_output.parent.mkdir(parents=True, exist_ok=True) + self.state_output.parent.mkdir(parents=True, exist_ok=True) + with self.raw_output.open("w", encoding="utf-8", newline="") as handle: + csv.DictWriter(handle, fieldnames=RAW_FIELDS).writeheader() + self.state_output.write_text("", encoding="utf-8") + + async def _collect_domain( + self, + browser: BrowserEngine, + start_url: str, + ) -> None: + queue = [normalize_url(start_url)] + visited: set[str] = set() + while queue and len(visited) < self.max_pages_per_domain: + url = queue.pop(0) + if not url or url in visited: + continue + visited.add(url) + try: + await self._navigate(browser, url) + elements = await browser.get_interactable_elements() + except Exception as exc: + logger.warning("Skipping %s: %s", url, exc) + continue + + self._write_elements(elements) + await self._write_state(browser, elements, url, "initial") + + for href in self._same_domain_links(elements, start_url): + if href not in visited and href not in queue: + queue.append(href) + + actions = self._safe_actions(browser, elements) + for index, action in enumerate( + actions[: self.max_actions_per_page], + start=1, + ): + try: + await self._navigate(browser, url) + await self._execute_action(browser, action) + await browser.wait_for_settle(timeout_ms=self.timeout_ms) + changed = await browser.get_interactable_elements() + self._write_elements(changed) + await self._write_state( + browser, + changed, + url, + f"{index}:{action['kind']}", + ) + except Exception: + continue + + async def _navigate(self, browser: BrowserEngine, url: str) -> None: + try: + await browser.navigate(url) + except Exception: + if browser.page is None: + raise + await browser.page.goto( + url, + wait_until="domcontentloaded", + timeout=self.timeout_ms, + ) + await browser.wait_for_settle(timeout_ms=self.timeout_ms) + + def _write_elements(self, elements: list[dict[str, Any]]) -> None: + rows = [] + for element in elements: + flattened = self._extractor.extract(element) + if not flattened or flattened in self._seen_elements: + continue + self._seen_elements.add(flattened) + rows.append( + { + "flattened_text": flattened, + "tag": element.get("tag", ""), + "type": element.get("type", ""), + "id": element.get("id", ""), + "name": element.get("name", ""), + "text": element.get("text", ""), + "value": element.get("value", ""), + "placeholder": element.get("placeholder", ""), + "label": element.get("label", ""), + "aria_label": element.get("aria_label", ""), + "role": element.get("role", ""), + "topic_label": "", + } + ) + if not rows: + return + with self.raw_output.open("a", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=RAW_FIELDS) + writer.writerows(rows) + + async def _write_state( + self, + browser: BrowserEngine, + elements: list[dict[str, Any]], + source_url: str, + action: str, + ) -> None: + current_url = await browser.get_current_url() + state_id = hashlib.sha256( + stable_json_dumps( + {"url": current_url, "elements": elements} + ).encode("utf-8") + ).hexdigest() + if state_id in self._seen_states: + return + self._seen_states.add(state_id) + rows = [ + { + "state_id": state_id, + "url": current_url, + "source_url": source_url, + "action": action, + "elements": elements, + } + ] + if len(elements) > 1: + reordered = list(elements) + random.Random(state_id).shuffle(reordered) + augmented_id = f"{state_id}-order" + self._seen_states.add(augmented_id) + rows.append( + { + "state_id": augmented_id, + "url": current_url, + "source_url": source_url, + "action": f"{action}:order", + "augmentation_of": state_id, + "elements": reordered, + } + ) + with self.state_output.open("a", encoding="utf-8") as handle: + for row in rows: + handle.write( + json.dumps(row, ensure_ascii=True, default=str) + "\n" + ) + + def _same_domain_links( + self, + elements: list[dict[str, Any]], + start_url: str, + ) -> list[str]: + links = [] + for element in elements: + href = normalize_url(str(element.get("href", "") or "")) + if ( + href.startswith(("http://", "https://")) + and is_same_domain(start_url, href) + ): + links.append(href) + return list(dict.fromkeys(links)) + + def _safe_actions( + self, + browser: BrowserEngine, + elements: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + actions = [] + for element in elements: + selector = browser.get_selector_for_element(element) + if not selector or element.get("disabled"): + continue + tag = str(element.get("tag", "") or "").lower() + input_type = str(element.get("type", "") or "").lower() + if tag == "select": + current = str(element.get("value", "") or "") + option = next( + ( + item + for item in element.get("options", []) + if str(item.get("value", "") or "") + and str(item.get("value", "") or "") != current + ), + None, + ) + if option: + actions.append( + { + "kind": "select", + "selector": selector, + "value": str(option["value"]), + "frame": element.get("frame"), + } + ) + elif tag == "input" and input_type in {"checkbox", "radio"}: + actions.append( + { + "kind": "click", + "selector": selector, + "frame": element.get("frame"), + } + ) + elif self._is_safe_button(element): + actions.append( + { + "kind": "click", + "selector": selector, + "frame": element.get("frame"), + } + ) + return actions + + def _is_safe_button(self, element: dict[str, Any]) -> bool: + if str(element.get("tag", "") or "").lower() == "a": + return False + if str(element.get("type", "") or "").lower() == "submit": + return False + text = " ".join( + str(element.get(key, "") or "").lower() + for key in ("text", "label", "aria_label", "name", "id") + ) + blocked = { + "buy", + "cancel", + "checkout", + "delete", + "logout", + "pay", + "purchase", + "remove", + "submit", + } + return not any(word in text for word in blocked) and bool( + element.get("aria_expanded") + or str(element.get("role", "") or "").lower() == "button" + ) + + async def _execute_action( + self, + browser: BrowserEngine, + action: dict[str, Any], + ) -> None: + frame = action.get("frame") + frame_url = ( + frame.get("url") or frame.get("src") + if isinstance(frame, dict) + else None + ) + frame_name = frame.get("name") if isinstance(frame, dict) else None + if action["kind"] == "select": + await browser.select_option( + action["selector"], + action["value"], + frame_url=frame_url, + frame_name=frame_name, + ) + else: + await browser.click( + action["selector"], + frame_url=frame_url, + frame_name=frame_name, + ) diff --git a/src/scripts/semantic_pipeline/datasets.py b/src/scripts/semantic_pipeline/datasets.py new file mode 100644 index 0000000..eeb9f57 --- /dev/null +++ b/src/scripts/semantic_pipeline/datasets.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +import csv +import hashlib +import json +import random +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable + +from src.crawler.semantic_engine.extractor import ( + DOMFeatureExtractor, + normalize_semantic_text, +) +from src.crawler.semantic_engine.topic import canonical_topic + +VALUE_BEARING_TAGS = {"input", "select", "textarea"} +EXCLUDED_INPUT_TYPES = { + "button", + "checkbox", + "file", + "hidden", + "image", + "radio", + "reset", + "submit", +} +DATASET_FIELDS = ( + "flattened_text", + "topic_label", + "tag", + "type", + "id", + "name", + "text", + "value", + "placeholder", + "label", + "aria_label", + "role", + "provenance", + "review_status", +) + + +@dataclass(frozen=True) +class PreparedDatasets: + all_rows: list[dict[str, str]] + train_rows: list[dict[str, str]] + validation_rows: list[dict[str, str]] + test_rows: list[dict[str, str]] + + +def prepare_topic_dataset( + raw_path: str | Path, + labeled_path: str | Path, + *, + seed: int = 42, +) -> PreparedDatasets: + raw_rows = _read_csv(raw_path) + labeled_rows = _read_csv(labeled_path) + raw_by_text: dict[str, dict[str, str]] = {} + for row in raw_rows: + key = normalize_semantic_text(row.get("flattened_text")) + if key and key not in raw_by_text: + raw_by_text[key] = row + + extractor = DOMFeatureExtractor() + prepared_by_key: dict[tuple[str, str], dict[str, str]] = {} + for labeled in labeled_rows: + normalized = normalize_semantic_text(labeled.get("flattened_text")) + raw = raw_by_text.get(normalized) + if raw is None or not _is_value_bearing(raw): + continue + + topic = canonical_topic(labeled.get("topic_label", "")) + if not topic: + continue + + combined = {**raw, **labeled} + feature_text = extractor.extract(combined) + if not feature_text: + continue + + prepared = { + field: str(combined.get(field, "") or "") for field in DATASET_FIELDS + } + prepared["flattened_text"] = feature_text + prepared["topic_label"] = topic + prepared["provenance"] = "raw.csv+labeled.csv" + prepared["review_status"] = "proposed" + prepared_by_key[(feature_text, topic)] = prepared + + groups: dict[str, list[dict[str, str]]] = {} + for row in prepared_by_key.values(): + groups.setdefault(row["flattened_text"], []).append(row) + + train_keys, validation_keys, test_keys = _stratified_group_split( + groups, + seed, + ) + return PreparedDatasets( + all_rows=list(prepared_by_key.values()), + train_rows=_rows_for_keys(groups, train_keys), + validation_rows=_rows_for_keys(groups, validation_keys), + test_rows=_rows_for_keys(groups, test_keys), + ) + + +def write_prepared_datasets( + datasets: PreparedDatasets, + output_dir: str | Path, +) -> None: + output = Path(output_dir) + output.mkdir(parents=True, exist_ok=True) + _write_csv(output / "topic_all.csv", datasets.all_rows) + _write_csv(output / "topic_train.csv", datasets.train_rows) + _write_csv(output / "topic_validation.csv", datasets.validation_rows) + _write_csv(output / "topic_test.csv", datasets.test_rows) + + +def read_state_snapshots(path: str | Path) -> dict[str, list[dict[str, Any]]]: + snapshots: dict[str, list[dict[str, Any]]] = {} + with Path(path).open("r", encoding="utf-8") as handle: + for line_number, line in enumerate(handle, start=1): + if not line.strip(): + continue + row = json.loads(line) + state_id = str(row.get("state_id", "")).strip() + elements = row.get("elements") + if not state_id or not isinstance(elements, list): + raise ValueError(f"Invalid state snapshot on line {line_number}") + snapshots[state_id] = elements + return snapshots + + +def prepare_state_pairs( + snapshots_path: str | Path, + output_path: str | Path, + *, + max_pairs: int, +) -> None: + snapshots = read_state_snapshots(snapshots_path) + metadata = _read_snapshot_metadata(snapshots_path) + extractor = DOMFeatureExtractor() + tokens = { + state_id: set( + normalize_semantic_text( + " ".join(extractor.extract(item) for item in elements) + ).split() + ) + for state_id, elements in snapshots.items() + } + guaranteed = [] + candidate_ids = set() + state_ids = sorted(snapshots) + for index, left_id in enumerate(state_ids): + augmented = metadata.get(left_id, {}).get("augmentation_of") + if augmented and augmented in snapshots: + guaranteed.append((1.0, str(augmented), left_id)) + if index + 1 < len(state_ids): + candidate_ids.add((left_id, state_ids[index + 1])) + if index + 10 < len(state_ids): + candidate_ids.add((left_id, state_ids[index + 10])) + randomizer = random.Random(42) + sample_target = max_pairs * 50 + while len(candidate_ids) < min(sample_target, len(state_ids) * 20): + left_id, right_id = randomizer.sample(state_ids, 2) + if left_id == right_id: + continue + candidate_ids.add(tuple(sorted((left_id, right_id)))) + candidates = [ + (_jaccard(tokens[left_id], tokens[right_id]), left_id, right_id) + for left_id, right_id in candidate_ids + if not left_id.endswith("-order") + and not right_id.endswith("-order") + ] + guaranteed.sort(reverse=True) + candidates.sort(reverse=True) + positive_limit = min(len(guaranteed), max(1, max_pairs // 3)) + remaining = max(0, max_pairs - positive_limit) + high_limit = remaining // 2 + low_limit = remaining - high_limit + selected = ( + guaranteed[:positive_limit] + + candidates[:high_limit] + + list(reversed(candidates[-low_limit:] if low_limit else [])) + ) + output = Path(output_path) + output.parent.mkdir(parents=True, exist_ok=True) + with output.open("w", encoding="utf-8", newline="") as handle: + writer = csv.writer(handle) + writer.writerow( + [ + "left_state_id", + "right_state_id", + "equivalent", + "split", + "label_status", + "label_confidence", + "semantic_similarity", + "structural_similarity", + "count_similarity", + ] + ) + for score, left_id, right_id in selected: + writer.writerow( + [left_id, right_id, "", "", "", "", score, "", ""] + ) + + +def dataset_hash(paths: Iterable[str | Path]) -> str: + digest = hashlib.sha256() + for path in sorted(Path(item) for item in paths): + digest.update(path.name.encode()) + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _read_snapshot_metadata(path: str | Path) -> dict[str, dict[str, Any]]: + metadata = {} + with Path(path).open("r", encoding="utf-8") as handle: + for line in handle: + if line.strip(): + row = json.loads(line) + metadata[str(row["state_id"])] = row + return metadata + + +def _jaccard(left: set[str], right: set[str]) -> float: + union = left | right + return len(left & right) / len(union) if union else 0.0 + + +def _read_csv(path: str | Path) -> list[dict[str, str]]: + with Path(path).open("r", encoding="utf-8-sig", newline="") as handle: + return list(csv.DictReader(handle)) + + +def _write_csv(path: Path, rows: list[dict[str, str]]) -> None: + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=DATASET_FIELDS) + writer.writeheader() + writer.writerows(rows) + + +def _is_value_bearing(row: dict[str, str]) -> bool: + tag = str(row.get("tag", "") or "").lower() + input_type = str(row.get("type", "") or "").lower() + return tag in VALUE_BEARING_TAGS and input_type not in EXCLUDED_INPUT_TYPES + + +def _stratified_group_split( + groups: dict[str, list[dict[str, str]]], + seed: int, +) -> tuple[set[str], set[str], set[str]]: + keys_by_label: dict[str, list[str]] = {} + for key, rows in groups.items(): + label = rows[0]["topic_label"] + keys_by_label.setdefault(label, []).append(key) + + train: set[str] = set() + validation: set[str] = set() + test: set[str] = set() + randomizer = random.Random(seed) + for label in sorted(keys_by_label): + keys = sorted(keys_by_label[label]) + randomizer.shuffle(keys) + if len(keys) < 3: + train.update(keys) + continue + + validation_count = max(1, round(len(keys) * 0.15)) + test_count = max(1, round(len(keys) * 0.20)) + train_count = max(1, len(keys) - validation_count - test_count) + train.update(keys[:train_count]) + validation.update(keys[train_count : train_count + validation_count]) + test.update(keys[train_count + validation_count :]) + + return train, validation, test + + +def _rows_for_keys( + groups: dict[str, list[dict[str, str]]], + keys: set[str], +) -> list[dict[str, str]]: + return [row for key in keys for row in groups[key]] diff --git a/src/scripts/semantic_pipeline/labeling.py b/src/scripts/semantic_pipeline/labeling.py new file mode 100644 index 0000000..c17546f --- /dev/null +++ b/src/scripts/semantic_pipeline/labeling.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import csv +import json +import random +from pathlib import Path +from typing import Any + +import numpy as np +from sentence_transformers import SentenceTransformer + +from src.crawler.semantic_engine.extractor import DOMFeatureExtractor +from src.crawler.semantic_engine.topic import canonical_topic +from src.scripts.semantic_pipeline.collectors import RAW_FIELDS +from src.scripts.semantic_pipeline.datasets import read_state_snapshots + + +def label_topics( + raw_path: Path, + output_path: Path, + config_path: Path, + *, + model_name: str, + threshold: float, +) -> None: + with config_path.open("r", encoding="utf-8") as handle: + labels = list(json.load(handle).get("field_patterns", {})) + model = SentenceTransformer(model_name) + label_vectors = model.encode( + labels, + convert_to_numpy=True, + normalize_embeddings=True, + show_progress_bar=True, + ) + with raw_path.open("r", encoding="utf-8-sig", newline="") as handle: + rows = list(csv.DictReader(handle)) + texts = [row["flattened_text"] for row in rows] + vectors = model.encode( + texts, + convert_to_numpy=True, + normalize_embeddings=True, + show_progress_bar=True, + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=RAW_FIELDS) + writer.writeheader() + for row, vector in zip(rows, vectors, strict=True): + similarities = vector @ label_vectors.T + index = int(np.argmax(similarities)) + confidence = float(similarities[index]) + if confidence < threshold: + continue + row["topic_label"] = canonical_topic(labels[index]) + writer.writerow({field: row.get(field, "") for field in RAW_FIELDS}) + + +def label_state_pairs( + snapshots_path: Path, + pairs_path: Path, + output_path: Path, + *, + model_name: str, + equivalent_threshold: float, + structural_threshold: float, + count_threshold: float, + test_ratio: float, + seed: int, +) -> None: + snapshots = read_state_snapshots(snapshots_path) + with pairs_path.open("r", encoding="utf-8-sig", newline="") as handle: + pairs = list(csv.DictReader(handle)) + extractor = DOMFeatureExtractor() + needed_state_ids = { + state_id + for row in pairs + for state_id in (row["left_state_id"], row["right_state_id"]) + } + state_texts = { + state_id: [extractor.extract(element) for element in elements] + for state_id, elements in snapshots.items() + if state_id in needed_state_ids + } + unique_texts = list( + dict.fromkeys( + text + for texts in state_texts.values() + for text in texts + if text + ) + ) + model = SentenceTransformer(model_name) + vectors = model.encode( + unique_texts, + convert_to_numpy=True, + normalize_embeddings=True, + show_progress_bar=True, + ) + embeddings = { + text: np.asarray(vector, dtype=float) + for text, vector in zip(unique_texts, vectors, strict=True) + } + labeled = [] + for row in pairs: + left_id = row["left_state_id"] + right_id = row["right_state_id"] + is_order_augmentation = ( + right_id == f"{left_id}-order" + or left_id == f"{right_id}-order" + ) + semantic = _element_similarity( + state_texts[left_id], + state_texts[right_id], + embeddings, + ) + structural = _structural_similarity( + snapshots[left_id], + snapshots[right_id], + ) + count = min( + len(snapshots[left_id]), + len(snapshots[right_id]), + ) / max( + len(snapshots[left_id]), + len(snapshots[right_id]), + 1, + ) + equivalent = int( + is_order_augmentation + or ( + semantic >= equivalent_threshold + and structural >= structural_threshold + and count >= count_threshold + ) + ) + confidence = ( + min( + semantic / equivalent_threshold, + structural / structural_threshold, + count / count_threshold, + 1.0, + ) + if equivalent + else 1.0 + - min( + semantic / equivalent_threshold, + structural / structural_threshold, + count / count_threshold, + 1.0, + ) + ) + labeled.append( + { + **row, + "equivalent": equivalent, + "label_status": "auto_order" if is_order_augmentation else "auto", + "label_confidence": confidence, + "semantic_similarity": semantic, + "structural_similarity": structural, + "count_similarity": count, + } + ) + _assign_splits(labeled, test_ratio=test_ratio, seed=seed) + _ensure_two_classes(labeled) + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=list(labeled[0])) + writer.writeheader() + writer.writerows(labeled) + + +def _assign_splits( + rows: list[dict[str, Any]], + *, + test_ratio: float, + seed: int, +) -> None: + randomizer = random.Random(seed) + for label in (0, 1): + group = [row for row in rows if int(row["equivalent"]) == label] + randomizer.shuffle(group) + count = min(max(1, round(len(group) * test_ratio)), len(group) - 1) + for row in group[: max(0, count)]: + row["split"] = "test" + for row in group[max(0, count) :]: + row["split"] = "train" + + +def _ensure_two_classes(rows: list[dict[str, Any]]) -> None: + labels = {int(row["equivalent"]) for row in rows} + if labels == {0, 1}: + return + non_order = [ + row for row in rows if row.get("label_status") != "auto_order" + ] + if not non_order: + raise ValueError( + "State labeling only found order augmentations. Re-run pair generation " + "with more real state pairs." + ) + if labels == {1}: + selected = min( + non_order, + key=lambda row: _combined_state_score(row), + ) + selected["equivalent"] = 0 + selected["label_status"] = "auto_calibrated_negative" + else: + selected = max( + non_order, + key=lambda row: _combined_state_score(row), + ) + selected["equivalent"] = 1 + selected["label_status"] = "auto_calibrated_positive" + _assign_splits(rows, test_ratio=0.20, seed=42) + + +def _combined_state_score(row: dict[str, Any]) -> float: + return ( + float(row["semantic_similarity"]) + + float(row["structural_similarity"]) + + float(row["count_similarity"]) + ) / 3.0 + + +def _element_similarity( + left_texts: list[str], + right_texts: list[str], + embeddings: dict[str, np.ndarray], +) -> float: + left = np.asarray([embeddings[text] for text in left_texts if text]) + right = np.asarray([embeddings[text] for text in right_texts if text]) + if not left.size or not right.size: + return 0.0 + similarities = np.clip(left @ right.T, -1.0, 1.0) + return float( + ( + similarities.max(axis=1).mean() + + similarities.max(axis=0).mean() + ) + / 2.0 + ) + + +def _structural_similarity( + left: list[dict[str, Any]], + right: list[dict[str, Any]], +) -> float: + left_values = {_signature(element) for element in left} + right_values = {_signature(element) for element in right} + union = left_values | right_values + return len(left_values & right_values) / len(union) if union else 1.0 + + +def _signature(element: dict[str, Any]) -> tuple[str, ...]: + return ( + str(element.get("tag", "") or "").lower(), + str(element.get("type", "") or "").lower(), + str(element.get("role", "") or "").lower(), + str(bool(element.get("disabled"))), + str(bool(element.get("readonly"))), + str(bool(element.get("required"))), + str(bool(element.get("checked"))), + str(bool(element.get("aria_invalid"))), + str(bool(element.get("aria_expanded"))), + ) diff --git a/src/scripts/semantic_pipeline/pipeline.py b/src/scripts/semantic_pipeline/pipeline.py new file mode 100644 index 0000000..8623019 --- /dev/null +++ b/src/scripts/semantic_pipeline/pipeline.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import logging +import shutil +from dataclasses import dataclass +from pathlib import Path + +from src.scripts.semantic_pipeline.collectors import SemanticDataCollector +from src.scripts.semantic_pipeline.datasets import ( + prepare_state_pairs, + prepare_topic_dataset, + write_prepared_datasets, +) +from src.scripts.semantic_pipeline.labeling import ( + label_state_pairs, + label_topics, +) +from src.scripts.semantic_pipeline.training import ( + train_state_model, + train_topic_model, +) +from src.scripts.semantic_pipeline.websites import TARGET_URLS + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class PipelineSettings: + workspace: Path + artifacts: Path + input_config: Path + max_websites: int + max_pages_per_domain: int + max_actions_per_page: int + max_pairs: int + timeout_ms: int + headless: bool + labeling_model: str + reuse_collected: bool = False + + +async def run_pipeline(settings: PipelineSettings) -> None: + if not settings.reuse_collected: + _clean(settings.workspace) + raw = settings.workspace / "raw.csv" + labeled = settings.workspace / "labeled.csv" + snapshots = settings.workspace / "state_snapshots.jsonl" + pairs = settings.workspace / "state_pairs.csv" + labeled_pairs = settings.workspace / "state_pairs_labeled.csv" + topic_data = settings.workspace / "topic" + staged_artifacts = settings.workspace / "artifacts" + topic_artifact = staged_artifacts / "topic_model.joblib" + state_artifact = staged_artifacts / "state_equivalence.joblib" + urls = ( + TARGET_URLS[: settings.max_websites] + if settings.max_websites + else TARGET_URLS + ) + + if settings.reuse_collected: + _require_existing(raw, snapshots) + else: + await SemanticDataCollector( + raw, + snapshots, + max_pages_per_domain=settings.max_pages_per_domain, + max_actions_per_page=settings.max_actions_per_page, + timeout_ms=settings.timeout_ms, + headless=settings.headless, + ).collect(urls) + + if settings.reuse_collected and labeled.exists() and topic_artifact.exists(): + topic_metrics = {"macro_f1": 0.0} + else: + label_topics( + raw, + labeled, + settings.input_config, + model_name=settings.labeling_model, + threshold=0.45, + ) + prepared = prepare_topic_dataset(raw, labeled) + write_prepared_datasets(prepared, topic_data) + topic_metrics = train_topic_model( + topic_data / "topic_train.csv", + topic_data / "topic_validation.csv", + topic_data / "topic_test.csv", + topic_artifact, + default_threshold=0.55, + ) + + prepare_state_pairs( + snapshots, + pairs, + max_pairs=settings.max_pairs, + ) + label_state_pairs( + snapshots, + pairs, + labeled_pairs, + model_name=settings.labeling_model, + equivalent_threshold=0.82, + structural_threshold=0.90, + count_threshold=0.85, + test_ratio=0.20, + seed=42, + ) + state_metrics = train_state_model( + snapshots, + labeled_pairs, + topic_artifact, + state_artifact, + components=100, + ) + _publish(staged_artifacts, settings.artifacts) + logger.info( + "Completed topic_f1=%.3f state_f1=%.3f", + topic_metrics["macro_f1"], + state_metrics["macro_f1"], + ) + + +def _clean(workspace: Path) -> None: + resolved = workspace.resolve() + if resolved.exists(): + shutil.rmtree(resolved) + resolved.mkdir(parents=True, exist_ok=True) + + +def _require_existing(*paths: Path) -> None: + missing = [str(path) for path in paths if not path.exists()] + if missing: + raise FileNotFoundError( + "Cannot reuse collected data; missing " + ", ".join(missing) + ) + + +def _publish(staged: Path, destination: Path) -> None: + resolved = destination.resolve() + if resolved.exists(): + shutil.rmtree(resolved) + shutil.copytree(staged, resolved) diff --git a/src/scripts/semantic_pipeline/training.py b/src/scripts/semantic_pipeline/training.py new file mode 100644 index 0000000..c8b3470 --- /dev/null +++ b/src/scripts/semantic_pipeline/training.py @@ -0,0 +1,290 @@ +from __future__ import annotations + +import csv +from datetime import UTC, datetime +from pathlib import Path + +import numpy as np +from sklearn.decomposition import TruncatedSVD +from sklearn.feature_extraction.text import TfidfVectorizer +from sklearn.linear_model import LogisticRegression +from sklearn.metrics import classification_report, f1_score +from sklearn.pipeline import FeatureUnion, Pipeline + +from src.crawler.semantic_engine.artifacts import ( + ModelArtifactLoader, + save_model_bundle, +) +from src.crawler.semantic_engine.extractor import DOMFeatureExtractor +from src.crawler.semantic_engine.features import SklearnElementFeatureEncoder +from src.crawler.semantic_engine.state import ( + PAIR_FEATURE_NAMES, + StateProfiler, + pair_features, +) +from src.crawler.semantic_engine.topic import SklearnTopicClassifier +from src.scripts.semantic_pipeline.datasets import ( + dataset_hash, + read_state_snapshots, +) + + +def train_topic_model( + train_path: Path, + validation_path: Path, + test_path: Path, + output_path: Path, + *, + default_threshold: float, +) -> dict: + train_texts, train_labels = _read_topic_rows(train_path) + validation_texts, validation_labels = _read_topic_rows(validation_path) + test_texts, test_labels = _read_topic_rows(test_path) + pipeline = Pipeline( + [ + ( + "features", + FeatureUnion( + [ + ( + "word", + TfidfVectorizer( + ngram_range=(1, 2), + min_df=2, + max_features=20_000, + sublinear_tf=True, + ), + ), + ( + "character", + TfidfVectorizer( + analyzer="char_wb", + ngram_range=(3, 5), + min_df=2, + max_features=30_000, + sublinear_tf=True, + ), + ), + ] + ), + ), + ( + "classifier", + LogisticRegression( + class_weight="balanced", + max_iter=2000, + random_state=42, + ), + ), + ] + ) + pipeline.fit(train_texts, train_labels) + thresholds = _topic_thresholds( + pipeline, + validation_texts, + validation_labels, + default_threshold, + ) + predictions = pipeline.predict(test_texts) + metrics = { + "macro_f1": float( + f1_score(test_labels, predictions, average="macro") + ), + "classification_report": classification_report( + test_labels, + predictions, + output_dict=True, + zero_division=0, + ), + } + save_model_bundle( + output_path, + kind="topic_classifier", + model_version=datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ"), + dataset_hash=dataset_hash( + [train_path, validation_path, test_path] + ), + payload={ + "pipeline": pipeline, + "thresholds": thresholds, + "default_threshold": default_threshold, + }, + metrics=metrics, + ) + return metrics + + +def train_state_model( + snapshots_path: Path, + pairs_path: Path, + topic_artifact: Path, + output_path: Path, + *, + components: int, +) -> dict: + pairs = _read_state_pairs(pairs_path) + needed_state_ids = { + state_id + for left_id, right_id, _, _ in pairs + for state_id in (left_id, right_id) + } + snapshots = { + state_id: elements + for state_id, elements in read_state_snapshots(snapshots_path).items() + if state_id in needed_state_ids + } + topic_bundle = ModelArtifactLoader(topic_artifact.parent).load( + topic_artifact.name, + "topic_classifier", + ) + topic_classifier = SklearnTopicClassifier( + topic_bundle.payload["pipeline"], + thresholds=topic_bundle.payload.get("thresholds"), + default_threshold=float( + topic_bundle.payload.get("default_threshold", 0.55) + ), + ) + extractor = DOMFeatureExtractor() + corpus = [ + extractor.extract(element) + for elements in snapshots.values() + for element in elements + ] + corpus = [text for text in corpus if text] + dimension = min(components, max(2, len(corpus) - 1)) + text_pipeline = Pipeline( + [ + ( + "tfidf", + TfidfVectorizer( + ngram_range=(1, 2), + min_df=1, + sublinear_tf=True, + ), + ), + ( + "svd", + TruncatedSVD( + n_components=dimension, + random_state=42, + ), + ), + ] + ) + text_pipeline.fit(corpus) + profiler = StateProfiler( + SklearnElementFeatureEncoder( + text_pipeline, + topic_classifier=topic_classifier, + ), + topic_classifier, + ) + profiles = { + state_id: profiler.profile(state_id, elements) + for state_id, elements in snapshots.items() + } + features = [] + labels = [] + splits = [] + for left_id, right_id, label, split in pairs: + scores = pair_features(profiles[left_id], profiles[right_id]) + features.append([scores[name] for name in PAIR_FEATURE_NAMES]) + labels.append(label) + splits.append(split) + train_indices = [ + index for index, split in enumerate(splits) if split != "test" + ] + test_indices = [ + index for index, split in enumerate(splits) if split == "test" + ] + if not test_indices: + test_indices = train_indices + classifier = LogisticRegression( + class_weight="balanced", + max_iter=1000, + random_state=42, + ) + classifier.fit( + np.asarray(features)[train_indices], + np.asarray(labels)[train_indices], + ) + predictions = classifier.predict(np.asarray(features)[test_indices]) + actual = np.asarray(labels)[test_indices] + metrics = { + "macro_f1": float(f1_score(actual, predictions, average="macro")), + "classification_report": classification_report( + actual, + predictions, + output_dict=True, + zero_division=0, + ), + } + save_model_bundle( + output_path, + kind="state_equivalence", + model_version=datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ"), + dataset_hash=dataset_hash([snapshots_path, pairs_path]), + payload={ + "text_pipeline": text_pipeline, + "pair_classifier": classifier, + "pair_feature_names": PAIR_FEATURE_NAMES, + }, + metrics=metrics, + ) + return metrics + + +def _read_topic_rows(path: Path) -> tuple[list[str], list[str]]: + with path.open("r", encoding="utf-8-sig", newline="") as handle: + rows = [ + row + for row in csv.DictReader(handle) + if row.get("flattened_text") and row.get("topic_label") + ] + return ( + [row["flattened_text"] for row in rows], + [row["topic_label"] for row in rows], + ) + + +def _topic_thresholds( + pipeline: Pipeline, + texts: list[str], + labels: list[str], + default_threshold: float, +) -> dict[str, float]: + if not texts: + return {str(label): default_threshold for label in pipeline.classes_} + probabilities = pipeline.predict_proba(texts) + thresholds = {} + for index, label in enumerate(pipeline.classes_): + actual = np.asarray([item == label for item in labels], dtype=int) + best_threshold = default_threshold + best_score = -1.0 + for threshold in np.linspace(0.35, 0.9, 23): + score = f1_score( + actual, + (probabilities[:, index] >= threshold).astype(int), + zero_division=0, + ) + if score > best_score: + best_score = float(score) + best_threshold = float(threshold) + thresholds[str(label)] = best_threshold + return thresholds + + +def _read_state_pairs( + path: Path, +) -> list[tuple[str, str, int, str]]: + with path.open("r", encoding="utf-8-sig", newline="") as handle: + rows = list(csv.DictReader(handle)) + return [ + ( + row["left_state_id"], + row["right_state_id"], + int(row["equivalent"]), + row["split"], + ) + for row in rows + ] diff --git a/src/scripts/semantic_pipeline/websites.py b/src/scripts/semantic_pipeline/websites.py new file mode 100644 index 0000000..05485d6 --- /dev/null +++ b/src/scripts/semantic_pipeline/websites.py @@ -0,0 +1,348 @@ +TARGET_URLS = [ + "https://demo.nopcommerce.com/register", + "https://demo.nopcommerce.com/login", + "https://demo.nopcommerce.com/contactus", + "https://ecommerce-playground.lambdatest.io/index.php?route=account/register", + "https://parabank.parasoft.com/parabank/register.htm", + "https://practice.expandtesting.com/login", + "https://practice.expandtesting.com/register", + "https://demoqa.com/automation-practice-form", + "https://demoqa.com/text-box", + "https://magento.softwaretestingboard.com/customer/account/create/", + "https://automationexercise.com/login", + "https://automationexercise.com/contact_us", + "https://www.saucedemo.com/", + "https://juice-shop.herokuapp.com/#/register", + "https://opensource-demo.orangehrmlive.com/web/index.php/auth/login", + "https://petstore.octoperf.com/actions/Account.action?newAccountForm=", + "https://jpetstore.aspectran.com/account/signonForm", + "https://katalon-demo-cura.herokuapp.com/profile.php#login", + "https://the-internet.herokuapp.com/login", + "https://computer-database.gatling.io/computers/new", + "https://blazedemo.com/register", + "https://demo.guru99.com/test/newtours/register.php", + "https://phptravels.com/demo/", + "https://demo.automationtesting.in/Register.html", + "https://ultimateqa.com/filling-out-forms/", + "https://formy-project.herokuapp.com/form", + "https://compendiumdev.co.uk/apps/html/", + "https://automationteststore.com/index.php?rt=account/login", + "https://automationteststore.com/index.php?rt=account/create", + "https://toolsqa.com/selenium-training/", + "https://way2automation.com/way2auto_jquery/registration.php", + "https://way2automation.com/way2auto_jquery/index.php", + "https://globalsqa.com/angularJs-protractor/registration-login-example/", + "https://rahulshettyacademy.com/angularpractice/", + "https://rahulshettyacademy.com/client/auth/register", + "https://react-shopping-cart-67744.firebaseapp.com/", + "https://magento.softwaretestingboard.com/customer/account/login/", + "http://zero.webappsecurity.com/login.html", + "https://practice.cybertekschool.com/registration_form", + "https://practice.cybertekschool.com/login", + + "https://colorlib.com/etc/regform/colorlib-regform-1/", + "https://colorlib.com/etc/regform/colorlib-regform-2/", + "https://colorlib.com/etc/regform/colorlib-regform-3/", + "https://colorlib.com/etc/regform/colorlib-regform-4/", + "https://colorlib.com/etc/regform/colorlib-regform-5/", + "https://colorlib.com/etc/regform/colorlib-regform-6/", + "https://colorlib.com/etc/regform/colorlib-regform-7/", + "https://colorlib.com/etc/regform/colorlib-regform-8/", + "https://colorlib.com/etc/lf/Login_v1/index.html", + "https://colorlib.com/etc/lf/Login_v2/index.html", + "https://bootsnipp.com/forms", + "https://getbootstrap.com/docs/5.3/examples/sign-in/", + "https://getbootstrap.com/docs/5.3/examples/checkout/", + + "https://developer.salesforce.com/signup", + "https://signup.heroku.com/", + "https://github.com/signup", + "https://github.com/login", + "https://gitlab.com/users/sign_up", + "https://gitlab.com/users/sign_in", + "https://bitbucket.org/account/signup/", + "https://hub.docker.com/signup", + "https://www.npmjs.com/signup", + "https://pypi.org/account/register/", + "https://rubygems.org/sign_up", + "https://vercel.com/signup", + "https://app.netlify.com/signup", + "https://dash.cloudflare.com/sign-up", + "https://www.digitalocean.com/sign-up", + "https://login.linode.com/signup", + "https://my.vultr.com/register/", + "https://console.aws.amazon.com/console/home", + "https://azure.microsoft.com/en-us/free/", + "https://cloud.google.com/free", + "https://postman.com/sign-up", + "https://insomnia.rest/get-started", + "https://swagger.io/tools/swaggerhub/free-trial/", + + "https://trello.com/signup", + "https://slack.com/get-started", + "https://asana.com/create-account", + "https://zoom.us/signup", + "https://www.wrike.com/free-trial/", + "https://monday.com/pricing", + "https://clickup.com/signup", + "https://airtable.com/signup", + "https://notion.so/signup", + "https://evernote.com/register", + "https://todoist.com/auth/signup", + "https://basecamp.com/signup", + "https://smartsheet.com/try-it", + "https://coda.io/signup", + "https://www.meistertask.com/signup", + "https://ticktick.com/signup", + "https://app.clockify.me/signup", + "https://toggl.com/track/signup/", + "https://www.lucidchart.com/pages/landing/flowchart-maker", + + "https://app.hubspot.com/signup/crm", + "https://www.zoho.com/crm/signup.html", + "https://www.pipedrive.com/en/register", + "https://www.freshworks.com/crm/signup/", + "https://www.zendesk.com/register/", + "https://www.intercom.com/early-stage", + "https://keap.com/pricing", + "https://mailchimp.com/signup/", + "https://www.constantcontact.com/signup.jsp", + "https://www.sendinblue.com/users/signup/", + "https://www.campaignmonitor.com/signup/", + "https://www.activecampaign.com/free", + "https://www.typeform.com/signup/", + "https://jotform.com/signup/", + "https://www.surveymonkey.com/user/sign-up/", + "https://stripe.com/register", + "https://www.paypal.com/welcome/signup/", + "https://squareup.com/signup", + + "https://www.reddit.com/register/", + "https://www.pinterest.com/", + "https://wordpress.com/start/user", + "https://www.tumblr.com/register", + "https://imgur.com/register", + "https://giphy.com/join", + "https://www.twitch.tv/signup", + "https://discord.com/register", + "https://quora.com/", + "https://stackexchange.com/users/signup", + "https://stackoverflow.com/users/signup", + "https://news.ycombinator.com/login", + "https://medium.com/m/signin", + "https://vsco.co/store/signup", + "https://vimeo.com/join", + "https://soundcloud.com/signup", + "https://x.com/i/flow/signup", + "https://www.instagram.com/accounts/emailsignup/", + "https://www.tiktok.com/signup", + "https://www.snapchat.com/add", + "https://www.linkedin.com/signup", + "https://mastodon.social/auth/sign_up", + "https://bsky.app/", + + "https://www.fiverr.com/join", + "https://www.upwork.com/nx/signup/", + "https://www.freelancer.com/signup", + "https://dribbble.com/signup", + "https://www.behance.net/signup", + "https://500px.com/signup", + "https://unsplash.com/join", + "https://pixabay.com/accounts/register/", + "https://www.pexels.com/join-us/", + "https://canva.com/signup", + "https://miro.com/signup/", + "https://figma.com/signup", + "https://invisionapp.com/signup", + "https://marvelapp.com/signup", + "https://99designs.com/register", + "https://www.shutterstock.com/register", + "https://elements.envato.com/sign-up", + + "https://www.coursera.org/?authMode=signup", + "https://www.udemy.com/join/signup-popup/", + "https://www.khanacademy.org/signup", + "https://quizlet.com/sign-up", + "https://www.duolingo.com/register", + "https://www.codecademy.com/register", + "https://leetcode.com/accounts/signup/", + "https://hackerrank.com/auth/signup", + "https://www.freecodecamp.org/signin", + "https://auth.edx.org/register", + "https://www.skillshare.com/en/signup", + "https://www.pluralsight.com/buy", + "https://teamtreehouse.com/subscribe/new", + "https://www.datacamp.com/users/sign_up", + "https://www.udacity.com/catalog", + "https://brilliant.org/sign-up/", + "https://brainly.com/register", + + "https://www.dropbox.com/register", + "https://app.box.com/signup/personal", + "https://www.mediafire.com/upgrade/", + "https://mega.io/register", + "https://pcloud.com/register", + "https://wetransfer.com/sign-up", + "https://sync.com/register/", + "https://icedrive.net/register", + "https://proton.me/drive/pricing", + + "https://www.pandora.com/account/register", + "https://www.deezer.com/us/register", + "https://tidal.com/tiers/free", + "https://www.last.fm/join", + "https://www.goodreads.com/user/sign_up", + "https://www.wattpad.com/signup", + "https://myanimelist.net/register.php", + "https://anilist.co/signup", + "https://letterboxd.com/create-account/", + "https://www.imdb.com/registration/signin", + "https://open.spotify.com/signup", + "https://music.apple.com/us/browse", + + "https://telegram.org/", + "https://line.me/en/", + "https://www.viber.com/en/", + "https://signal.org/", + "https://web.whatsapp.com/", + "https://www.skype.com/en/", + + "https://www.booking.com/index.html?aid=304142&label=gen173nr-1FCAEoggI46AdIM1gEaKQCiAEBmAEJuAEXyAEM2AEB6AEB-AECiAIBqAIDuALM", + "https://www.airbnb.com/", + "https://www.expedia.com/", + "https://www.kayak.com/", + "https://www.skyscanner.net/", + "https://www.agoda.com/", + "https://www.tripadvisor.com/RegistrationController", + "https://www.vrbo.com/", + "https://www.orbitz.com/", + "https://www.priceline.com/", + "https://www.hotels.com/", + + "https://www.ubereats.com/", + "https://www.doordash.com/consumer/login/", + "https://www.grubhub.com/", + "https://www.postmates.com/", + "https://www.instacart.com/", + "https://www.seamless.com/", + + "https://www.indeed.com/account/register", + "https://www.glassdoor.com/profile/joinNow_input.htm", + "https://www.monster.com/account/sign-up", + "https://wellfound.com/join", + "https://dribbble.com/jobs", + "https://remote.co/", + "https://weworkremotely.com/", + + "https://www.zillow.com/", + "https://www.realtor.com/", + "https://www.redfin.com/", + "https://www.trulia.com/", + "https://www.apartments.com/", + + "https://www.coinbase.com/signup", + "https://accounts.binance.com/en/register", + "https://kraken.com/sign-up", + "https://robinhood.com/signup", + "https://www.sofi.com/register/", + "https://app.empower.com/registration", + "https://www.etrade.com/register", + "https://www.nerdwallet.com/register", + + "https://www.myfitnesspal.com/account/create", + "https://www.strava.com/register/free", + "https://accounts.fitbit.com/signup", + "https://www.headspace.com/login", + "https://www.calm.com/signup", + "https://www.webmd.com/register", + "https://www.peloton.com/register", + + "https://www.eventbrite.com/signin/signup", + "https://my.ticketmaster.com/account/generate/register", + "https://www.stubhub.com/login", + "https://www.meetup.com/register/", + "https://www.fandango.com/account/join", + "https://www.viagogo.com/secure/login", + + "https://my.asos.com/identity/register", + "https://www.sephora.com/profile/create", + "https://www.nike.com/register", + "https://www.adidas.com/us/account-register", + "https://www.zara.com/us/en/logon", + "https://www.macys.com/account/create", + "https://us.shein.com/user/auth/login", + + "https://store.steampowered.com/join/", + "https://www.epicgames.com/id/register", + "https://www.roblox.com/", + "https://account.battle.net/creation/tos.html", + "https://playvalorant.com/en-us/", + "https://www.ign.com/register", + + "https://auth.uber.com/login/", + "https://www.lyft.com/rider/signup", + "https://turo.com/us/en/signup", + "https://www.cars.com/profile/secure/register/", + "https://www.carvana.com/account/register", + "https://www.autotrader.com/myatc/registration.xhtml", + + "https://myaccount.nytimes.com/register", + "https://subscribe.washingtonpost.com/register", + "https://profile.theguardian.com/register", + "https://www.wsj.com/", + "https://www.forbes.com/connect/sign-up/", + "https://www.wired.com/account/sign-up", + "https://www.theatlantic.com/register/", + + "https://www.patreon.com/signup", + "https://www.kickstarter.com/signup", + "https://www.gofundme.com/sign-up", + "https://www.indiegogo.com/register", + "https://www.change.org/login", + "https://ko-fi.com/signup", + + + "https://www.delta.com/eu/en/login", + "https://www.united.com/en/us/account/enroll/profile", + "https://www.southwest.com/air/enroll/index.html", + "https://www.ryanair.com/gb/en", + "https://www.emirates.com/english/skywards/registration/", + "https://www.amtrak.com/profile/registration.html", + + "https://www.att.com/my/", + "https://secure.verizon.com/vzauth/UI/Login", + "https://account.t-mobile.com/oauth2/v1/auth", + "https://www.vodafone.co.uk/my-vodafone/registration", + "https://www.comcast.com/", + + "https://signup.microsoft.com/", + "https://www.ibm.com/account/reg/us-en/signup", + "https://login.oracle.com/mysso/signon.jsp", + "https://www.sap.com/registration", + "https://www.workday.com/en-us/signin.html", + "https://cisco.com/c/en/us/about/account.html", + + "https://opensea.io/login", + "https://magiceden.io/", + "https://rarible.com/connect", + "https://portfolio.metamask.io/", + "https://dydx.exchange/", + + "https://tinder.com/", + "https://bumble.com/get-started", + "https://www.okcupid.com/login", + "https://www.match.com/cpx/en-us/match/registration/start/", + "https://badoo.com/signup", + + "https://www.redcross.org/donate/donation.html/", + "https://support.wwf.org.uk/donate", + "https://donate.doctorswithoutborders.org/", + "https://give.unicef.org/", + "https://www.savethechildren.org/donate", + + "https://en.wikipedia.org/w/index.php?title=Special:CreateAccount", + "https://www.fandom.com/register", + "https://forum.xda-developers.com/register/", + "https://users.nexusmods.com/register", + "https://www.resetera.com/register" +] diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..289217d --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any + +__all__ = [ + "attach_selectors_to_forms", + "build_selector", + "coerce_bool", + "coerce_int", + "coerce_str", + "element_display_hint", + "element_label", + "element_tag", + "element_tag_hint", + "element_type", + "is_button", + "is_http_url", + "is_non_http_href", + "is_same_domain", + "is_text_input", + "normalize_checkpoint_url", + "normalize_url", + "read_file", + "stable_json_dumps", + "supports_enter_submission", + "text_input_label", + "to_ms", +] + +_EXPORTS: dict[str, tuple[str, str]] = { + "attach_selectors_to_forms": ("src.utils.dom", "attach_selectors_to_forms"), + "build_selector": ("src.utils.dom", "build_selector"), + "coerce_bool": ("src.utils.coercion", "coerce_bool"), + "coerce_int": ("src.utils.coercion", "coerce_int"), + "coerce_str": ("src.utils.coercion", "coerce_str"), + "element_display_hint": ("src.utils.dom", "element_display_hint"), + "element_label": ("src.utils.dom", "element_label"), + "element_tag": ("src.utils.dom", "element_tag"), + "element_tag_hint": ("src.utils.dom", "element_tag_hint"), + "element_type": ("src.utils.dom", "element_type"), + "is_button": ("src.utils.dom", "is_button"), + "is_http_url": ("src.utils.url", "is_http_url"), + "is_non_http_href": ("src.utils.url", "is_non_http_href"), + "is_same_domain": ("src.utils.url", "is_same_domain"), + "is_text_input": ("src.utils.dom", "is_text_input"), + "normalize_checkpoint_url": ("src.utils.url", "normalize_checkpoint_url"), + "normalize_url": ("src.utils.url", "normalize_url"), + "read_file": ("src.utils.common", "read_file"), + "stable_json_dumps": ("src.utils.serialization", "stable_json_dumps"), + "supports_enter_submission": ("src.utils.dom", "supports_enter_submission"), + "text_input_label": ("src.utils.dom", "text_input_label"), + "to_ms": ("src.utils.common", "to_ms"), +} + + +def __getattr__(name: str) -> Any: + target = _EXPORTS.get(name) + if target is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module_name, attr = target + value = getattr(import_module(module_name), attr) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals().keys()) | set(_EXPORTS.keys())) + + +if TYPE_CHECKING: + from src.utils.coercion import coerce_bool, coerce_int, coerce_str + from src.utils.common import read_file, to_ms + from src.utils.dom import ( + attach_selectors_to_forms, + build_selector, + element_display_hint, + element_label, + element_tag, + element_tag_hint, + element_type, + is_button, + is_text_input, + supports_enter_submission, + text_input_label, + ) + from src.utils.serialization import stable_json_dumps + from src.utils.url import ( + is_http_url, + is_non_http_href, + is_same_domain, + normalize_checkpoint_url, + normalize_url, + ) diff --git a/src/utils/coercion.py b/src/utils/coercion.py new file mode 100644 index 0000000..796e470 --- /dev/null +++ b/src/utils/coercion.py @@ -0,0 +1,55 @@ +from typing import Any + + +def coerce_int(value: Any, default: int) -> int: + if value is None or isinstance(value, bool): + return default + + if isinstance(value, int): + return value + + if isinstance(value, float): + return int(value) + + if isinstance(value, str): + stripped = value.strip() + + if not stripped: + return default + + return int(stripped) + + return default + + +def coerce_bool(value: Any, default: bool) -> bool: + if value is None: + return default + + if isinstance(value, bool): + return value + + if isinstance(value, (int, float)): + return bool(value) + + if isinstance(value, str): + stripped = value.strip().lower() + + if stripped in {"true", "1", "yes", "y", "on"}: + return True + + if stripped in {"false", "0", "no", "n", "off"}: + return False + + return default + + +def coerce_str(value: Any, default: str) -> str: + if value is None: + return default + + if isinstance(value, str): + stripped = value.strip() + return stripped or default + + return str(value) diff --git a/src/utils/common.py b/src/utils/common.py new file mode 100644 index 0000000..945f745 --- /dev/null +++ b/src/utils/common.py @@ -0,0 +1,9 @@ +from pathlib import Path + + +def read_file(file_path: str) -> str: + return Path(file_path).read_text(encoding="utf-8") + + +def to_ms(seconds: float) -> int: + return int(float(seconds) * 1000) diff --git a/src/utils/dom.py b/src/utils/dom.py new file mode 100644 index 0000000..0e64f18 --- /dev/null +++ b/src/utils/dom.py @@ -0,0 +1,156 @@ +import re +from typing import Optional + +from src.crawler import HtmlTag, InputType + + +def css_escape(value: str) -> str: + if not value: + return value + + value = value.replace("\\", "\\\\") + value = value.replace('"', '\\"') + value = value.replace("'", "\\'") + + return re.sub(r"([#.;:[\]()=+>*~|^$ ])", r"\\\1", value) + +def element_tag(element: dict) -> str: + return str(element.get("tag", "") or "").lower() + +def element_type(element: dict) -> str: + return str(element.get("type", "") or "").lower() + +def is_text_input(element: dict) -> bool: + tag = element_tag(element) + + return tag in (HtmlTag.INPUT, HtmlTag.TEXTAREA) or element.get("contenteditable") + +def is_button(element: dict) -> bool: + tag = element_tag(element) + input_type = element_type(element) + + return tag == HtmlTag.BUTTON or element.get("role") == "button" or (tag == HtmlTag.INPUT and input_type in (InputType.SUBMIT, InputType.BUTTON)) + + +def supports_enter_submission(element: dict) -> bool: + return element_tag(element) == HtmlTag.INPUT and element_type(element) in ( + "text", + "search", + "email", + "tel", + "url", + "number", + ) + +def element_tag_hint(element: dict) -> str: + tag = element_tag(element) + input_type = element_type(element) + + if tag == HtmlTag.INPUT and input_type and input_type not in ("text", "search"): + return f"{tag}[{input_type}] " + + return f"{tag} " if tag else "" + +def text_input_label(element: dict) -> str: + input_type = element_type(element) + + if input_type: + return input_type + + if element.get("contenteditable"): + return "contenteditable" + + return "field" + + +def element_label(element: dict, selector: str | None = None) -> str: + parts: list[str] = [] + + label = element.get("aria-label") or element.get("label") or element.get("name") or element.get("title") + + text = element.get("innerText") or element.get("text") + + if label: + label_str = str(label).strip() + if label_str: + parts.append(label_str) + + if text: + text_str = str(text).strip() + if text_str and text_str != label: + parts.append(text_str) + + if selector: + parts.append(f"[{selector}]") + + return " ".join(parts).strip() + + +def build_selector(element: dict) -> str | None: + selector_candidates = element.get("selector_candidates") or [] + + if selector_candidates: + return selector_candidates[0] + + tag = element_tag(element) + element_id = element.get("id") + name = element.get("name") + text = str(element.get("text") or "").strip() + value = element.get("value") + input_type = element_type(element) + + if element_id and not str(element_id).isdigit(): + return f"#{css_escape(str(element_id))}" + + if name: + return f'[name="{css_escape(str(name))}"]' + + if tag == HtmlTag.INPUT and input_type in (InputType.SUBMIT, InputType.BUTTON) and value: + return f'input[type="{input_type}"][value="{css_escape(str(value))}"]' + + if tag in (HtmlTag.BUTTON, HtmlTag.ANCHOR) and text: + safe_text = css_escape(text[:80]) + return f'{tag}:has-text("{safe_text}")' + + return None + + +def attach_selectors_to_forms(forms: list[dict]) -> list[dict]: + for form in forms: + for field in form.get("fields", []): + field["selector"] = build_selector(field) + + if form.get("submit"): + form["submit"]["selector"] = build_selector(form["submit"]) + return forms + + +def element_display_hint( + element: dict, + *, + label_keys: tuple[str, ...] = ("label", "aria_label"), + max_len: int = 80, +) -> str: + def pick(*values: Optional[str]) -> str: + for v in values: + s = str(v or "").strip() + if s: + return s + return "" + + text = pick(element.get("text")) + if text: + text = " ".join(text.split()) + return f"'{text[:max_len]}'" + + for key in label_keys: + v = pick(element.get(key)) + if v: + v = " ".join(v.split()) + return f"'{v[:max_len]}'" + + placeholder = pick(element.get("placeholder")) + name = pick(element.get("name")) + el_id = pick(element.get("id")) + hint = placeholder or name or el_id + return f"[{hint[:max_len]}]" if hint else "" diff --git a/src/utils/serialization.py b/src/utils/serialization.py new file mode 100644 index 0000000..6def32c --- /dev/null +++ b/src/utils/serialization.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +import json +from typing import Any + + +def stable_json_dumps(value: Any) -> str: + try: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + except TypeError: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str) diff --git a/src/utils/url.py b/src/utils/url.py new file mode 100644 index 0000000..cc6981d --- /dev/null +++ b/src/utils/url.py @@ -0,0 +1,35 @@ +from urllib.parse import urlparse + + +def is_http_url(url: str) -> bool: + normalized = (url or "").strip().lower() + + return normalized.startswith("http://") or normalized.startswith("https://") + + +def is_non_http_href(href: str) -> bool: + normalized = (href or "").strip().lower() + + if not normalized: + return False + + return not (normalized.startswith("http://") or normalized.startswith("https://") or normalized.startswith("/")) + + +def is_same_domain(url1: str, url2: str) -> bool: + return urlparse(url1).netloc == urlparse(url2).netloc + + +def normalize_url(url: str) -> str: + u = str(url or "") + if u.endswith("?"): + u = u[:-1] + u = u.split("#", 1)[0] + return u + + +def normalize_checkpoint_url(url: str) -> str: + u = str(url or "") + if u.endswith("?"): + u = u[:-1] + return u diff --git a/src/workers/__init__.py b/src/workers/__init__.py new file mode 100644 index 0000000..dc3d7ad --- /dev/null +++ b/src/workers/__init__.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any + +__all__ = ["CrawlerWorker"] + +_EXPORTS: dict[str, tuple[str, str]] = { + "CrawlerWorker": ("src.workers.crawler_worker", "CrawlerWorker"), +} + + +def __getattr__(name: str) -> Any: + target = _EXPORTS.get(name) + if target is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module_name, attr = target + value = getattr(import_module(module_name), attr) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals().keys()) | set(_EXPORTS.keys())) + + +if TYPE_CHECKING: + from src.workers.crawler_worker import CrawlerWorker diff --git a/src/workers/consumers/__init__.py b/src/workers/consumers/__init__.py new file mode 100644 index 0000000..16583ea --- /dev/null +++ b/src/workers/consumers/__init__.py @@ -0,0 +1 @@ +__all__ = ["stream_to_arq"] diff --git a/src/workers/consumers/stream_to_arq.py b/src/workers/consumers/stream_to_arq.py new file mode 100644 index 0000000..5e78edc --- /dev/null +++ b/src/workers/consumers/stream_to_arq.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import asyncio +import logging +import os +import socket + +from arq.connections import create_pool + +from src import config +from src.db import create_engine, create_sessionmaker, mark_finished_at_if_aborted +from src.queue import ( + ack_and_delete, + clear_cancel, + crawl_stream_config, + ensure_consumer_group, + is_cancelled, + parse_session_id, +) +from src.workers.main import _redis_settings_from_url + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def main() -> int: + redis_url = config.REDIS_URL + db_url = config.DATABASE_URL + + if not redis_url: + raise ValueError("REDIS_URL is required") + if not db_url: + raise ValueError("DATABASE_URL is required") + + consumer_name = f"{socket.gethostname()}-{os.getpid()}" + + redis = await create_pool(_redis_settings_from_url(redis_url)) + engine = create_engine(db_url) + db = create_sessionmaker(engine) + stream_cfg = crawl_stream_config() + + try: + await ensure_consumer_group(redis, stream_cfg) + logger.info("Stream→ARQ consumer started (%s)", consumer_name) + + while True: + resp = await redis.xreadgroup( + groupname=stream_cfg.group_name, + consumername=consumer_name, + streams={stream_cfg.stream_key: ">"}, + count=1, + block=5000, + ) + + if not resp: + continue + + for _, messages in resp: + for message_id, fields in messages: + session_id = None + try: + session_id = parse_session_id(fields) + + if await is_cancelled(redis, stream_cfg, session_id): + await clear_cancel(redis, stream_cfg, session_id) + async with db() as s: + await mark_finished_at_if_aborted(s, session_id) + await ack_and_delete(redis, stream_cfg, message_id) + continue + + await redis.enqueue_job( + "crawl_session", + session_id, + _job_id=session_id, + ) + await ack_and_delete(redis, stream_cfg, message_id) + + except Exception as e: + logger.error( + "Stream message %s (session %s) failed: %s", + message_id, + session_id, + e, + exc_info=True, + ) + + finally: + await engine.dispose() + await redis.aclose() + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/src/workers/crawler_worker.py b/src/workers/crawler_worker.py new file mode 100644 index 0000000..bd932a1 --- /dev/null +++ b/src/workers/crawler_worker.py @@ -0,0 +1,125 @@ +import argparse +import asyncio +import json +import logging +import sys +from dataclasses import replace +from typing import Optional + +from src import Config, config +from src.crawler import CrawlSession +from src.graph import Neo4jGraphBuilder +from src.models import CrawlJob + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def _job_settings(base: Config, job: CrawlJob) -> Config: + return replace( + base, + HEADLESS=job.headless, + TIMEOUT_MS=job.timeout_ms, + MAX_STATES=job.max_states, + MAX_TRANSITIONS=job.max_transitions, + MAX_ELEMENTS_PER_STATE=job.max_elements_per_state, + MAX_SELECT_OPTIONS_PER_ELEMENT=job.max_select_options_per_element, + MAX_ACTION_REPEATS_PER_URL=job.max_action_repeats_per_url, + ACTION_RETRY_COUNT=job.action_retry_count, + REPLAY_RETRY_COUNT=job.replay_retry_count, + POPUP_TIMEOUT_MS=job.popup_timeout_ms, + DOM_QUIET_MS=job.dom_quiet_ms, + DOM_SETTLE_TIMEOUT_MS=job.dom_settle_timeout_ms, + USE_DOM_QUIESCENCE=job.use_dom_quiescence, + PAGE_LOAD_STATE=job.page_load_state, + CLICK_NON_HTTP_LINKS=job.click_non_http_links, + DEFER_DESTRUCTIVE_ACTIONS=job.defer_destructive_actions, + DESTRUCTIVE_KEYWORDS=job.destructive_keywords, + ) + + +class CrawlerWorker: + def __init__(self, settings: Config = config): + self._settings = settings + self._graph_builder = Neo4jGraphBuilder(settings.NEO4J_URI, settings.NEO4J_USER, settings.NEO4J_PASSWORD) + self._started = False + + async def start(self) -> None: + if self._started: + return + await self._graph_builder.connect() + self._started = True + + async def stop(self) -> None: + if not self._started: + return + await self._graph_builder.disconnect() + self._started = False + + async def process(self, job: CrawlJob, run_permission: Optional[asyncio.Event] = None) -> tuple[int, int]: + job_settings = _job_settings(self._settings, job) + session = CrawlSession( + base_url=job.base_url, + graph_builder=self._graph_builder, + config_path=job.input_defaults_path, + session_id=job.session_id, + headless=job.headless, + max_states=job.max_states, + max_transitions=job.max_transitions, + timeout_ms=job.timeout_ms, + input_defaults=job.input_defaults, + settings=job_settings, + run_permission=run_permission, + ) + await session.run_crawl() + return session.state_count, session.transition_count + + +async def _run_once(args: argparse.Namespace, settings: Config) -> int: + worker = CrawlerWorker(settings) + await worker.start() + try: + raw = sys.stdin.read() if args.payload_stdin else args.payload_json + if not raw or not str(raw).strip(): + raise ValueError("payload is required") + payload = json.loads(raw) + if not isinstance(payload, dict): + raise ValueError("payload must be a JSON object") + job = CrawlJob.from_dict(payload, settings) + state_count, transition_count = await worker.process(job) + print( + json.dumps( + { + "status": "ok", + "session_id": job.session_id, + "state_count": state_count, + "transition_count": transition_count, + } + ), + flush=True, + ) + return 0 + finally: + await worker.stop() + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + parser.add_argument("--payload-stdin", action="store_true") + parser.add_argument("--payload-json", type=str) + return parser + + +async def main() -> int: + args = _build_parser().parse_args() + + settings = config + + if not (args.payload_stdin or args.payload_json): + raise ValueError("--payload-stdin or --payload-json is required") + + return await _run_once(args, settings) + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/src/workers/cron/__init__.py b/src/workers/cron/__init__.py new file mode 100644 index 0000000..d0b39ad --- /dev/null +++ b/src/workers/cron/__init__.py @@ -0,0 +1,2 @@ +__all__ = [] +__all__ = [] diff --git a/src/workers/flow_worker.py b/src/workers/flow_worker.py new file mode 100644 index 0000000..7407c0d --- /dev/null +++ b/src/workers/flow_worker.py @@ -0,0 +1,85 @@ +""" +ARQ job that runs after a crawl session completes. +Single pass: + 1. find_all_flows() for all states + 2. POST all flows to the TypeScript API +""" + +from __future__ import annotations + +import logging +from logging import config +import os +from src.config import config +import aiohttp + +from src.graph.factory import create_graph +from src.graph.flow_finder import _serialize_all_flows, find_all_flows + +logger = logging.getLogger(__name__) + +_API_BASE_URL = os.environ["COVERIT_API_INTERNAL_URL"].rstrip("/") + + + +async def push_flows_to_api(session_id: str, serialized_flows: dict) -> None: + async with aiohttp.ClientSession() as http: + resp = await http.post( + f"{_API_BASE_URL}/internal/sessions/{session_id}/flows", + json={"flows": serialized_flows}, + ) + + if not resp.ok: + body = await resp.text() + raise RuntimeError(f"API rejected flows for session {session_id}: {resp.status} {body}") + + +async def generate_flows_for_session(ctx: dict, session_id: str) -> None: + _, graph = await create_graph( + config.NEO4J_URI, + config.NEO4J_USER, + config.NEO4J_PASSWORD, + ) + + all_flows = await find_all_flows(graph, session_id=session_id) + + if not all_flows: + logger.info("No flows found for session %s — skipping", session_id) + return + + serialized = _serialize_all_flows(all_flows) + + await push_flows_to_api(session_id, serialized) + + logger.info( + "Flow generation complete for session %s (%d states)", + session_id, + len(all_flows), + ) + + +async def generate_manual_crawl_flow(ctx: dict, session_id: str) -> None: + """ + Generates flows for a manual crawl session. + """ + _, graph = await create_graph( + config.NEO4J_URI, + config.NEO4J_USER, + config.NEO4J_PASSWORD, + ) + + all_flows = await find_all_flows(graph, session_id=session_id) + + if not all_flows: + logger.info("No flows found for manual crawl session %s — skipping", session_id) + return + + serialized = _serialize_all_flows(all_flows) + + await push_flows_to_api(session_id, serialized) + + logger.info( + "Flow generation complete for manual crawl session %s (%d states)", + session_id, + len(all_flows), + ) \ No newline at end of file diff --git a/src/workers/jobs/__init__.py b/src/workers/jobs/__init__.py new file mode 100644 index 0000000..376dcc4 --- /dev/null +++ b/src/workers/jobs/__init__.py @@ -0,0 +1,3 @@ +from src.workers.jobs.crawl_session import crawl_session + +__all__ = ["crawl_session"] diff --git a/src/workers/jobs/crawl_session.py b/src/workers/jobs/crawl_session.py new file mode 100644 index 0000000..8fd5253 --- /dev/null +++ b/src/workers/jobs/crawl_session.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from src.db.repositories.crawl_sessions import ( + fetch_job_inputs, + get_session_status, + mark_completed_if_running, + mark_failed_if_running, + mark_finished_at_if_aborted, +) +from src.db.services.crawl_sessions import ensure_started_or_skip_aborted +from src.models import CrawlJob +from src.queue import clear_cancel, crawl_stream_config, is_cancelled + +logger = logging.getLogger(__name__) + + +def _pick(source: dict[str, Any], *keys: str) -> Any: + for key in keys: + if key in source: + return source.get(key) + return None + + +def _build_payload_from_db(config_json: dict[str, Any], base_url: str, session_id: str) -> dict[str, Any]: + crawler_settings = config_json.get("crawlerSettings") + if not isinstance(crawler_settings, dict): + crawler_settings = {} + + settings = { + "headless": _pick(crawler_settings, "headless"), + "timeout_ms": _pick(crawler_settings, "timeout_ms", "timeoutMs"), + "max_states": _pick(crawler_settings, "max_states", "maxStates"), + "max_transitions": _pick(crawler_settings, "max_transitions", "maxTransitions"), + "max_elements_per_state": _pick(crawler_settings, "max_elements_per_state", "maxElementsPerState"), + "max_select_options_per_element": _pick( + crawler_settings, + "max_select_options_per_element", + "maxSelectOptionsPerElement", + ), + "max_action_repeats_per_url": _pick(crawler_settings, "max_action_repeats_per_url", "maxActionRepeatsPerUrl"), + "action_retry_count": _pick(crawler_settings, "action_retry_count", "actionRetryCount"), + "replay_retry_count": _pick(crawler_settings, "replay_retry_count", "replayRetryCount"), + "popup_timeout_ms": _pick(crawler_settings, "popup_timeout_ms", "popupTimeoutMs"), + "dom_quiet_ms": _pick(crawler_settings, "dom_quiet_ms", "domQuietMs"), + "dom_settle_timeout_ms": _pick(crawler_settings, "dom_settle_timeout_ms", "domSettleTimeoutMs"), + "use_dom_quiescence": _pick(crawler_settings, "use_dom_quiescence", "useDomQuiescence"), + "page_load_state": _pick(crawler_settings, "page_load_state", "pageLoadState"), + "click_non_http_links": _pick(crawler_settings, "click_non_http_links", "clickNonHttpLinks"), + "defer_destructive_actions": _pick(crawler_settings, "defer_destructive_actions", "deferDestructiveActions"), + "destructive_keywords": ( + ",".join(_pick(crawler_settings, "destructive_keywords", "destructiveKeywords")) + if isinstance(_pick(crawler_settings, "destructive_keywords", "destructiveKeywords"), list) + else _pick(crawler_settings, "destructive_keywords", "destructiveKeywords") + ), + } + + return { + "base_url": base_url, + "session_id": session_id, + "settings": settings, + "input_defaults": config_json.get("inputDefaults"), + } + + +async def crawl_session(ctx: dict, session_id: str) -> dict[str, Any]: + redis = ctx.get("redis") + db = ctx["db"] + worker = ctx["crawler_worker"] + + stream_cfg = crawl_stream_config() + + if redis is not None and await is_cancelled(redis, stream_cfg, session_id): + await clear_cancel(redis, stream_cfg, session_id) + async with db() as s: + await mark_finished_at_if_aborted(s, session_id) + return {"status": "cancelled", "session_id": session_id} + + async with db() as s: + started = await ensure_started_or_skip_aborted(s, session_id) + if not started: + return {"status": "aborted", "session_id": session_id} + + config_json, base_url = await fetch_job_inputs(s, session_id) + + payload = _build_payload_from_db(config_json, base_url, session_id) + job = CrawlJob.from_dict(payload, worker._settings) + + abort_event = asyncio.Event() + run_permission = asyncio.Event() + run_permission.set() + + async def abort_poller() -> None: + while True: + await asyncio.sleep(1) + async with db() as poll_s: + current = await get_session_status(poll_s, session_id) + + if current == "ABORTED": + abort_event.set() + return + + if current == "PAUSED": + run_permission.clear() + else: + run_permission.set() + + crawl_task = asyncio.create_task(worker.process(job, run_permission=run_permission)) + poll_task = asyncio.create_task(abort_poller()) + + try: + while True: + done, _ = await asyncio.wait( + {crawl_task, poll_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + if poll_task in done and abort_event.is_set(): + crawl_task.cancel() + break + if crawl_task in done: + break + + if abort_event.is_set(): + try: + await crawl_task + except asyncio.CancelledError: + pass + async with db() as s: + await mark_finished_at_if_aborted(s, session_id) + return {"status": "aborted", "session_id": session_id} + + state_count, transition_count = await crawl_task + async with db() as s: + updated = await mark_completed_if_running(s, session_id, state_count, transition_count) + if not updated: + await mark_finished_at_if_aborted(s, session_id) + + if updated: + await ctx["redis"].enqueue_job("generate_flows_for_session", session_id) + + return { + "status": "completed", + "session_id": session_id, + "state_count": state_count, + "transition_count": transition_count, + } + + except asyncio.CancelledError: + async with db() as s: + await mark_finished_at_if_aborted(s, session_id) + raise + + except Exception as e: + message = str(e) + async with db() as s: + updated = await mark_failed_if_running(s, session_id, message) + if not updated: + await mark_finished_at_if_aborted(s, session_id) + logger.error("Session %s failed: %s", session_id, message, exc_info=True) + raise + + finally: + poll_task.cancel() + try: + await poll_task + except asyncio.CancelledError: + pass + except Exception: + pass diff --git a/src/workers/main.py b/src/workers/main.py new file mode 100644 index 0000000..a381843 --- /dev/null +++ b/src/workers/main.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from urllib.parse import urlparse + +from arq.connections import RedisSettings +from arq.worker import WorkerSettings as ArqWorkerSettings + +from src import config +from src.db import create_engine, create_sessionmaker +from src.workers.crawler_worker import CrawlerWorker +from src.workers.flow_worker import generate_flows_for_session +from src.workers.jobs.crawl_session import crawl_session + + +def _redis_settings_from_url(url: str) -> RedisSettings: + parsed = urlparse(url) + + if parsed.scheme not in {"redis", "rediss"}: + raise ValueError("REDIS_URL must start with redis:// or rediss://") + + path = (parsed.path or "/").lstrip("/") + database = int(path) if path else 0 + + return RedisSettings( + host=parsed.hostname or "localhost", + port=int(parsed.port or 6379), + database=database, + username=parsed.username, + password=parsed.password, + ssl=parsed.scheme == "rediss", + ) + + +async def startup(ctx: dict) -> None: + db_url = config.DATABASE_URL + if not db_url: + raise ValueError("DATABASE_URL is required") + + engine = create_engine(db_url) + db = create_sessionmaker(engine) + + crawler_worker = CrawlerWorker(config) + await crawler_worker.start() + + ctx["engine"] = engine + ctx["db"] = db + ctx["crawler_worker"] = crawler_worker + + +async def shutdown(ctx: dict) -> None: + crawler_worker = ctx.get("crawler_worker") + if crawler_worker is not None: + await crawler_worker.stop() + + engine = ctx.get("engine") + if engine is not None: + await engine.dispose() + + +class WorkerSettings(ArqWorkerSettings): + redis_settings = _redis_settings_from_url(config.REDIS_URL or "redis://localhost:6379/0") + functions = [crawl_session, generate_flows_for_session] + on_startup = startup + on_shutdown = shutdown + cron_jobs = [] + + max_jobs = 10 + job_timeout = 60 * 30 + keep_result = 0 diff --git a/src/workers/queue_consumer.py b/src/workers/queue_consumer.py new file mode 100644 index 0000000..68e3b2d --- /dev/null +++ b/src/workers/queue_consumer.py @@ -0,0 +1,11 @@ +import asyncio + +from src.workers.consumers.stream_to_arq import main as _main + + +async def main() -> int: + return await _main() + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..67b1883 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2179 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +] + +[[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-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" }, +] + +[[package]] +name = "arq" +version = "0.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "redis", extra = ["hiredis"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/81/7f9db65a89c29ba374000309b9dd95509500045df5c7e22f26c3731b7380/arq-0.28.0.tar.gz", hash = "sha256:a458188aefc2d7ee17d136f80d8fa8df1d6eba4ceebdead87e9f172d027dc311", size = 416141, upload-time = "2026-04-16T10:50:23.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/32/66b616976c5058d434ca2017979bfffd784888177b16b5038abcec93954a/arq-0.28.0-py3-none-any.whl", hash = "sha256:b1696bf5614d60f4172a2c0cbdc177e23ba03a5eb9acc29bd8181f4ea71fff94", size = 26061, upload-time = "2026-04-16T10:50:22.321Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[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 = "coverit-contracts" +version = "1.10.0" +source = { registry = "https://coveritlabs.github.io/coverit-contracts/simple/" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://coveritlabs.github.io/coverit-contracts/packages/coverit_contracts-1.10.0.tar.gz" } +wheels = [ + { url = "https://coveritlabs.github.io/coverit-contracts/packages/coverit_contracts-1.10.0-py3-none-any.whl" }, +] + +[[package]] +name = "coverit-crawler" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiohttp" }, + { name = "arq" }, + { name = "asyncpg" }, + { name = "coverit-contracts" }, + { name = "greenlet" }, + { name = "neo4j" }, + { name = "numpy" }, + { name = "playwright" }, + { name = "python-dotenv" }, + { name = "redis" }, + { name = "ruff" }, + { name = "scikit-learn" }, + { name = "sentence-transformers" }, + { name = "sqlalchemy" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pre-commit" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, + { name = "uv" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.8.0" }, + { name = "arq", specifier = ">=0.26.0" }, + { name = "asyncpg", specifier = ">=0.30.0,<1.0" }, + { name = "coverit-contracts" }, + { name = "greenlet", specifier = ">=3.1.0" }, + { name = "neo4j", specifier = ">=5.18.0" }, + { name = "numpy", specifier = ">=1.26.0" }, + { name = "playwright", specifier = ">=1.45.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.7.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "redis", specifier = ">=5.0.0" }, + { name = "ruff", specifier = ">=0.15.17" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.17" }, + { name = "scikit-learn", specifier = ">=1.4.0" }, + { name = "sentence-transformers", specifier = ">=3.0.0" }, + { name = "sqlalchemy", specifier = ">=2.0.0" }, +] +provides-extras = ["dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "pre-commit", specifier = ">=3.7.0" }, + { name = "pyright", specifier = ">=1.1.410" }, + { name = "pytest", specifier = ">=8.3.0" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, + { name = "ruff", specifier = ">=0.15.17" }, + { name = "uv", specifier = ">=0.11.21" }, +] + +[[package]] +name = "cuda-bindings" +version = "13.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/6d2e9047d1fb243dbaa364b01e0297534b9ed7fd27dba1c9f361519cf69b/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e32d08f71ebcdf00f0f41eab2eb37e8da94c8ed411cc9f7f7a019ce6b34abe3a", size = 6657965, upload-time = "2026-05-29T23:11:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/2394f8163360f8391f8f1b7e72d300a82724edb81a7b7084c799fbd4c91f/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9efb21c1ee64981e184b9e0ba5eb3179e5ba3d4b51665a6cb52b8ef3d01a7cbf", size = 5920504, upload-time = "2026-05-29T23:11:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/34/c2/ef9b6a63f7dc432712a462c816662e662e00d38caa9b861c8c2588195d03/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2732904099e0a4d4db774a5fc6d91ee95fae065b4d2ecabb4968c5fe2406c9d7", size = 6476660, upload-time = "2026-05-29T23:11:59.188Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/bff68ce829999c1e4209c761bbf903b1c06ec570416ddb25020864ad5907/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ab2f74ed65bfef4163ba07a8db16f1085e0729291db12a2423aff84ee8278b8", size = 6013639, upload-time = "2026-05-29T23:12:03.509Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e0/c8a1f0c8f9ffdea4f5fe6dbab89b326cef4d85caf489dad39e209da89416/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd4c814d311ec08c981f6dded1dbe7d4b371067ee4f6c14cccec4bde9590f80", size = 6534419, upload-time = "2026-05-29T23:12:05.633Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/83b1f563925b290f2d11a01a77a84013ba56052fe3653a5bef3ccfbb43d6/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3c772dfff49681541d59630c90f858e173ac926b9c593a2b7123f2a1043cc76", size = 5809771, upload-time = "2026-05-29T23:12:10.422Z" }, + { url = "https://files.pythonhosted.org/packages/12/20/e79b4bfe98f075195afb6343d41c498f9dbd2d161d7021d4d28bceb83581/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36febb7c1079d68a981dbbd8d5a67235b399802b82075c9388624719607e52b9", size = 6358584, upload-time = "2026-05-29T23:12:12.767Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.5.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/c8/26f2e4aae92f11522a96043892ba39a90eac610d5242523aa863212bc1c7/cuda_pathfinder-1.5.5-py3-none-any.whl", hash = "sha256:0228c023f95d1480f143ef5c8922d27a2ab052087a942e81dc289c9eb8f91689", size = 51671, upload-time = "2026-05-27T01:21:25.413Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" }, +] + +[package.optional-dependencies] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +curand = [ + { name = "nvidia-curand", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +cusolver = [ + { name = "nvidia-cusolver", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +nvtx = [ + { name = "nvidia-nvtx", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, +] + +[[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/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 = "fsspec" +version = "2026.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646, upload-time = "2026-06-16T01:57:28.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, + { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, + { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, + { url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" }, + { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, + { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" }, + { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, + { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, + { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, + { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, + { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, + { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, + { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, + { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, + { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, + { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, + { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, + { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" }, +] + +[[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 = "hf-xet" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/2d/57fd21d84d93efb4bd0b962383790e19dd1bc053501b4264c97903b4e83e/hf_xet-1.5.1.tar.gz", hash = "sha256:51ef4500dab3764b41135ee1381a4b62ce56fc54d4c92b719b59e597d6df5bf6", size = 876636, upload-time = "2026-06-08T23:02:53.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/ee/dd9ba7beae1005e54131b7d45263cc74c8a066d47d354e6d58ae9445a388/hf_xet-1.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:dbf48c0d02cf0b2e568944330c60d9120c272dabe013bd892d48e25bc6797577", size = 4069485, upload-time = "2026-06-08T23:02:13.193Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/9cae6cfeb4e03070874e73e5c97c66eb90369d3206b6a2b1ef5f96520888/hf_xet-1.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78e4e5192ad2b674c2e1160b651cb9134db974f8ae1835bdfbfb0166b894a43", size = 3838493, upload-time = "2026-06-08T23:02:15.282Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b4/d5c01e0eb6d9f2ca2dacd84d0d1b71e6cfbb2ef3208c968528e010e9b3d7/hf_xet-1.5.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6f7a04a8ad962422e225bc49fbbac99dc1806764b1f3e54dbd154bffa7593947", size = 4505658, upload-time = "2026-06-08T23:02:17.196Z" }, + { url = "https://files.pythonhosted.org/packages/76/c5/29a7598c0c6383c523dc22186d577f4e04267a626cd95ae60f67c00bfe66/hf_xet-1.5.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d48199c2bf4f8df0adc55d31d1368b6ec0e4d4f45bc86b08038089c23db0bed8", size = 4292822, upload-time = "2026-06-08T23:02:18.608Z" }, + { url = "https://files.pythonhosted.org/packages/04/9a/dceaf6ca69390126b86ea825fb354b93d01163199070b7bd849225de9468/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:97f212a88d14bbf573619a74b7fecb238de77d08fc702e54dec6f78276ca3283", size = 4491255, upload-time = "2026-06-08T23:02:20.124Z" }, + { url = "https://files.pythonhosted.org/packages/48/a7/e5a7afaacf6c1791fdbeeac42951fb81c3d2bc482992b115dedcc86d963e/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f61e3665892a6c8c5e765395838b8ddf36185da835253d4bc4509a81e49fb342", size = 4711062, upload-time = "2026-06-08T23:02:21.863Z" }, + { url = "https://files.pythonhosted.org/packages/53/49/2802f8433c9742ce281bddc1e65c02c32268ca3098d66828b05e12e45ee2/hf_xet-1.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f4ad3ebd4c32dd2b27099d69dc7b2df821e30767e46fb6ee6a0713778243b8ff", size = 4017205, upload-time = "2026-06-08T23:02:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5a/50c71195b9fb883659f596e7252faf4c18c58e753a9013bdbf9bac5d2250/hf_xet-1.5.1-cp313-cp313t-win_arm64.whl", hash = "sha256:8298485c1e36e7e67cbd01eeb1376619b7af43d4f1ec245caae306f890a8a32d", size = 3845426, upload-time = "2026-06-08T23:02:25.124Z" }, + { url = "https://files.pythonhosted.org/packages/05/24/5e0c28f80371c17d49fed004597d9d132cb75c1f6f53db2cb95f459d2312/hf_xet-1.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3474760d10e3bb6f92ff3f024fcb00c0b3e4001e9b035c7483e49a5dd17aa70f", size = 4069676, upload-time = "2026-06-08T23:02:26.759Z" }, + { url = "https://files.pythonhosted.org/packages/d2/17/261ba565b6a4d960fb478f61fdf919c0be5824645aaf1c319eca660c1611/hf_xet-1.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6762d89b9e3267dfd502b29b2a327b4525f33b17e7b509a78d94e2151a30ce30", size = 3838509, upload-time = "2026-06-08T23:02:28.573Z" }, + { url = "https://files.pythonhosted.org/packages/4e/44/7ffdc2e184b0d41fc0f683ba3936ef669ab63cf242cf36ef50e57d683668/hf_xet-1.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf67e6ed10260cef62e852789dc91ebb03f382d5bdc4b1dbeb64763ea275e7d6", size = 4505881, upload-time = "2026-06-08T23:02:30.257Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/788060d5aa4d5e671f1a31bf69624c314eb2d8babab3aa562f9e5d53444e/hf_xet-1.5.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c6b6cd08ca095058780b50b8ce4d6cbf6787bcf27841705d58a9d32246e3e47a", size = 4292995, upload-time = "2026-06-08T23:02:31.993Z" }, + { url = "https://files.pythonhosted.org/packages/22/93/c5540cbd6b55529b7dc42f6734e88cebee21aefbea34128b66229df56c57/hf_xet-1.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e1af0de8ca6f190d4294a28b88023db64a1e2d1d719cab044baf75bec569e7a9", size = 4491570, upload-time = "2026-06-08T23:02:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/03/f3/9d8ceab30f44f36c1679b1b8683054c71a0dadc787dbf07421891742d3ca/hf_xet-1.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4f561cbbb92f80960772059864b7fb07eae879adde1b2e781ec6f86f6ac26c59", size = 4711565, upload-time = "2026-06-08T23:02:35.454Z" }, + { url = "https://files.pythonhosted.org/packages/cd/54/27ed9a5e2cc583b4df82f75a03a4df8dbf55f5a9fa1f47f1fadfb20dbeac/hf_xet-1.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:e7dbb40617410f432182d918e37c12303fe6700fd6aa6c5964e30a535a4461d6", size = 4017343, upload-time = "2026-06-08T23:02:37.14Z" }, + { url = "https://files.pythonhosted.org/packages/ae/12/ecb2fc8d45e767580e3a37faa97cb895608b614965567efb4f18cff67e27/hf_xet-1.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6071d5ccb4d8d2cbd5fea5cc798da4f0ba3f44e25369591c4e89a4987050e61d", size = 3845716, upload-time = "2026-06-08T23:02:39.073Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d8/5e54cf37434759d1f4f2ba9b66077ff9d4c4e1f37b6bd7975da5c40d94ab/hf_xet-1.5.1-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6abd35c3221eff63836618ddfb954dcf84798603f71d8e33e3ed7b04acfdbe6e", size = 4077794, upload-time = "2026-06-08T23:02:40.656Z" }, + { url = "https://files.pythonhosted.org/packages/35/94/4b2ecfbad8f8b04701a23aefb62f540b9137d058b7e1dbef16a32676f0e9/hf_xet-1.5.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:94e761bbd266bf4c03cee73753916062665ce8365aa40ed321f45afcb934b41e", size = 3845354, upload-time = "2026-06-08T23:02:42.702Z" }, + { url = "https://files.pythonhosted.org/packages/de/cc/f99f4bc7295023d7bd9ebbfd51f75cc530ca262c1227666268b8208f4b77/hf_xet-1.5.1-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:892e3a3a3aecc12aded8b93cf4f9cd059282c7de0732f7d55026f3abdf474350", size = 4514864, upload-time = "2026-06-08T23:02:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/cd/6e/21f7e5a2381278bd3b7b7a5a4d90038518bb6308a0c1daf5d9f8268bb178/hf_xet-1.5.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a93df2039190502835b1db8cd7e178b0b7b889fe9ab51299d5ced26e0dd879a4", size = 4303784, upload-time = "2026-06-08T23:02:46.203Z" }, + { url = "https://files.pythonhosted.org/packages/35/0e/f992bb6927ac1cb30ef74e62268f551f338bc32b2191f7c96a44c6f7283e/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0c97106032ef70467b4f6bc2d0ccc266d7613ee076afc56516c502f87ce1c4a6", size = 4500703, upload-time = "2026-06-08T23:02:47.628Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d1/90a498d05447980b977b1669246eeeeae4cfb0ea3e7a286eaba627f91bf9/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6208adb15d192b90e4c2ad2a27ed864359b2cb0f2494eb6d7c7f3699ac02e2bf", size = 4719498, upload-time = "2026-06-08T23:02:49.268Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b6/20f99cfe97cc663a711f7b33cc21d4793e51968e9a26125b4afcd77315ba/hf_xet-1.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:f7b3002f95d1c13e24bcb4537baa8f0eb3838957067c91bb4959bc004a6435f5", size = 4026419, upload-time = "2026-06-08T23:02:50.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/fa/77453694888f03e5a8c8852d1514a0894d8e81c622d39edbaf308ea0dcf4/hf_xet-1.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:93d090b57b211133f6c0dab0205ef5cb6d89162979ba75a74845045cc3063b8e", size = 3855178, upload-time = "2026-06-08T23:02:52.452Z" }, +] + +[[package]] +name = "hiredis" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/e2/1654d65851f39fd94e91a77a5655d09d4b64901fdc594020d8348db697b2/hiredis-3.4.0.tar.gz", hash = "sha256:da19331354433af6a2c54c21f2d70ba084933c0d7d2c43578ec5c5b446674ad5", size = 137169, upload-time = "2026-06-03T16:23:46.226Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/84/f74deb132d238a0d5a3eb1618bf7558c65230b279421f909a9753231c516/hiredis-3.4.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:9e88048a66dfffec7a3f578f2a2a0fd907c75b5bd85b3c9184f76f0149ea399f", size = 138679, upload-time = "2026-06-03T16:22:17.598Z" }, + { url = "https://files.pythonhosted.org/packages/a2/13/399fe51d399b8d4f5717aa68cb1dafcb8c244b19b1b9b0afaaa526c1be94/hiredis-3.4.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:8b3f1d03046765c0a83558bf1756811101e3947649c7ca22a71d9dc3c92929d1", size = 74657, upload-time = "2026-06-03T16:22:18.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cf/6a0bcf454b1642997c4dd007bd89beada43f38b22781afdf475060e427ac/hiredis-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:24751054bb11353016d242d09a4a902ecf8f25e3b56fe396cccb6f056fdda016", size = 70115, upload-time = "2026-06-03T16:22:19.649Z" }, + { url = "https://files.pythonhosted.org/packages/98/99/62340215f80e59680c79ae5080c5422311da105870c57bbefc5d87487025/hiredis-3.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:258f820cdd6ee6be39ae6a8ea94a76b8856d34113de6604f63bc81327ef06240", size = 306481, upload-time = "2026-06-03T16:22:20.608Z" }, + { url = "https://files.pythonhosted.org/packages/f1/be/97f349e5bb0dcab0ef28b15523443d9bbe81f8ccbd3dadff56594dfa82fe/hiredis-3.4.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3774461209688790734b5db8934400a4456493fc1a172fb5298cc5d72201aceb", size = 339560, upload-time = "2026-06-03T16:22:21.861Z" }, + { url = "https://files.pythonhosted.org/packages/1e/3f/eb6a9632bcc13a3fbefce5de90090052fb1ae1cd3d57faf687f20149d592/hiredis-3.4.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccdb63363c82ea9cea2d48126bc8e9241437b8b3b36413e967647a17add59643", size = 351549, upload-time = "2026-06-03T16:22:22.969Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/440369f727dcb856f3eeda238d6e67781b180feaa831bd28997d8af10c3b/hiredis-3.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:452cff764acb30c106d1e33f1bdf03fa9d4a9b0a9c995d722d4d39c998b40582", size = 313066, upload-time = "2026-06-03T16:22:23.987Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/3d76c4d5c46cd2e7b38641f7c8b325e0cab7d49d565ea573256eb3837d0c/hiredis-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fb0a139cd52535f3e5a532816b5c36b3aea95817410fbf28ca4a676026347a5", size = 300827, upload-time = "2026-06-03T16:22:25.287Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bc/d112dd9704ae47243a515fb021ec4d0b5a1b8d83a7a3eff3284c0248412d/hiredis-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:163d8c43e2706d23490532ea0de8736fc1493cfa52f0ee65f85b0f074f2fe017", size = 331284, upload-time = "2026-06-03T16:22:26.385Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7b/8a4dc0a15e4658c81a9e79b2c167fbfbf750e0c1c7ef13e00e69d4273ced/hiredis-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4b8f52844cd260d7805eca55c834e3e06b4c0d5b53a4178143b92242c2517c0d", size = 332962, upload-time = "2026-06-03T16:22:27.392Z" }, + { url = "https://files.pythonhosted.org/packages/1d/52/d3d0bb234de8deb4cbd432cdc63d001a6cad1f9c05fe07d2fa652f8cf412/hiredis-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03374d663b0e025e4039757ef5fad02e3ff714f7a01e5b34c88de2a9c91359dc", size = 311698, upload-time = "2026-06-03T16:22:28.442Z" }, + { url = "https://files.pythonhosted.org/packages/04/5b/54a052eccaf901703b57d7c28509e74341fa0da08d770f485345397ea1e5/hiredis-3.4.0-cp312-cp312-win32.whl", hash = "sha256:696e0a2118e1df5ccacf8ecf8abe528cf0c4f1f1d867f64c34579bef77778cdb", size = 38921, upload-time = "2026-06-03T16:22:29.39Z" }, + { url = "https://files.pythonhosted.org/packages/a7/64/6508236eda66765fbe873d1d0a0722e38059302e96dc9915b162ff17b35a/hiredis-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:ee6b4beb79a71df67af15a8451366babc2687fcac674d5c6eacec4197e4ce8c1", size = 40090, upload-time = "2026-06-03T16:22:30.204Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1c/7333aba1b4b7cef2591b244140aec0f1aad903397bbaa31c1858722b2fe4/hiredis-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:14524fdc751e3960d78d848872576b5442b40baae3cac14fbab1ba7ac523891f", size = 36875, upload-time = "2026-06-03T16:22:31.087Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e5/9e47dda8f1d55e77293c6cdf4169182b7f2f55b56913d1fb16a0ddf63a3d/hiredis-3.4.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:4f0e3536eea76c03435d411099d165850bc3c9d873efe62843b995027135a763", size = 138688, upload-time = "2026-06-03T16:22:31.825Z" }, + { url = "https://files.pythonhosted.org/packages/1e/07/039bcf7ce8262ed66db736349c121486874826248ccd70c98c2f830ec9da/hiredis-3.4.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:82860f050aabd08c046f304eb57c105bb3d5a7370f79a4a0b74d2b771767cc13", size = 74666, upload-time = "2026-06-03T16:22:32.758Z" }, + { url = "https://files.pythonhosted.org/packages/29/6d/692c50d846a0a36578e9ef0c62c6193ce01a48f353f6961de9de88a30b37/hiredis-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:74bcfb26189939daba2a0eb4bad05a6a30773bb2461f3d9967b8ced224bd0de9", size = 70119, upload-time = "2026-06-03T16:22:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/28/5d/c8b9ca711b4d6b7637eae744d6b45ea47f6bded61bac0232bb42ed8c583e/hiredis-3.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d95b602ab022f3505288ce51feaa48c072a62e57da55d6a7a38ecb8c5ad67d81", size = 306364, upload-time = "2026-06-03T16:22:34.62Z" }, + { url = "https://files.pythonhosted.org/packages/c4/7e/e940eea3c2ee1aa5947f2e6224f03a1dfd38a5813307259a25f580411820/hiredis-3.4.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de3e2297a182253dfa4400883a9a4fb46d44946aed3157ea2da873b93e2525c4", size = 339454, upload-time = "2026-06-03T16:22:35.87Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ea/b8147da5c270a2a5b85090c97d0ff7e2fae6e7c5f7749f8c3c2decadd3ac/hiredis-3.4.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:454236d2a5bd917daf38914ce363e71aeef41240e6800f4799e04ee82689bfd2", size = 351457, upload-time = "2026-06-03T16:22:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/33/b5/ff8fe4f812348f09d2943b109cb64c5301af4f601e1cf026518e93a72fff/hiredis-3.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:35ab3653569b9867b8d8a3b4c0684a20dc769fe45d4666bedfe9a3391a61b30b", size = 312970, upload-time = "2026-06-03T16:22:38.004Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2a/c90dff527cb2521ee1687e9e30bdf1156f2f4acfd47833b44dc52fec3ec6/hiredis-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:afff0876dafad6d3bb446c907da2836954876243f6bb9d5e44915d175e424aa4", size = 300850, upload-time = "2026-06-03T16:22:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/90/0b/c48e93a1e524198b10ccc26d770368547c0c29d126a992fd4b4aa533f1ac/hiredis-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d5c33eb2da5c9ccd281c396e1c618cfe6a91eb841e957f17d2fa520383b3111d", size = 331430, upload-time = "2026-06-03T16:22:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/95/12/ed5bdc482d5c98930ffa264dd707dfb04b83118b2f7f760760c5dfbe6782/hiredis-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:04e54fc3bcecf8c7cb2846947b84baf7ce1507caba641bd23590c52fefade865", size = 333021, upload-time = "2026-06-03T16:22:41.363Z" }, + { url = "https://files.pythonhosted.org/packages/e6/42/d4a2e7be82f2b2db7b67ec622806ba099d8fe09d218568f71197922cbe79/hiredis-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5f1ddfe6429f9adc0a8d705afbcd40530fddeafa919873ffbb11f59eda44dbb9", size = 311747, upload-time = "2026-06-03T16:22:42.374Z" }, + { url = "https://files.pythonhosted.org/packages/d6/33/b5ac3420bd803ca9affd68a4a2a6111812bd26bfb9d6b41a721e009d79d9/hiredis-3.4.0-cp313-cp313-win32.whl", hash = "sha256:165e6405b48f9bd66ddb4ad52ce28b0c0041a0308654d7a0cb4357a1939134dc", size = 38921, upload-time = "2026-06-03T16:22:43.513Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/76e68122b1cf680b93b951a82953fff5b5883dc08ec93f63677eb3653591/hiredis-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:306aae11a52e495aaf0a14e3efcd7b51029e632c74b847bc03159e1e1f6db591", size = 40095, upload-time = "2026-06-03T16:22:44.296Z" }, + { url = "https://files.pythonhosted.org/packages/20/05/9313dc27ed159512dc22b4ecf8a62a84d0aa5fbd500ffdad955b361cb2a8/hiredis-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:975a8e75a10425442037dd9c7abbaae31941c34328d9f01b1ca42d9db44ac31d", size = 36884, upload-time = "2026-06-03T16:22:45.134Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ea/cbc922aeaa5af11f1c1235d8b2b04ff8cdf6e3e95c785a500521f32d8d70/hiredis-3.4.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d3a12ae5685e9621a988af07b5af0ad685c7d19d6a7246ac852e35060178cff4", size = 138762, upload-time = "2026-06-03T16:22:45.927Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e9/e004067ffad9f707174cde04d117c985d5f22dd4d9409f0983892738cb44/hiredis-3.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a70df45cf167b5af99b9fe3e2044716919e30580a869dfa766f2a6467c0c320", size = 74696, upload-time = "2026-06-03T16:22:46.924Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d1/5fe5b6d05e59116d78f9d228d9cc0022efbb84d234333c5fbe6a0c6e13fe/hiredis-3.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0a68b0e48509e6e66f4c212e53d98f29178addf83b0701a71bf0fce792954419", size = 70163, upload-time = "2026-06-03T16:22:47.798Z" }, + { url = "https://files.pythonhosted.org/packages/db/93/c86f0a7ae2cd10b72e30476f87aafd1af22992e080feb4b5d2ec1cbdf4e4/hiredis-3.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a45822bc8487da8151fe67c788de74b834582b1d510c67b888fcda64bf6ba4bb", size = 306631, upload-time = "2026-06-03T16:22:48.671Z" }, + { url = "https://files.pythonhosted.org/packages/e8/10/3746b028d9c43fab1fa4126fe69c6967df89ab9819140092930322b0550c/hiredis-3.4.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b82cab9ad7a1574ab273a78942f780c1b1496101eb342b630c46c3e918ca21b", size = 339758, upload-time = "2026-06-03T16:22:49.662Z" }, + { url = "https://files.pythonhosted.org/packages/59/f3/c6fb383854237891039a4d94d3e66dc5eec8a2993fed6020c983d63c5393/hiredis-3.4.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db13f8039ad8229f77f0e242be14e53bd67e8f3aadeb16f3af30944287cca092", size = 351360, upload-time = "2026-06-03T16:22:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/70/b7/32110aa458690722a1069c7349b8ebe374a6ba0bdf9ef8925a9f37a74978/hiredis-3.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54b6267918c66d8ba4a3cf519db1235a4bd56d2a0969ca5b2ae3c6b6b7d9ed79", size = 313070, upload-time = "2026-06-03T16:22:51.966Z" }, + { url = "https://files.pythonhosted.org/packages/bb/23/bccfa0fb7b1b529cff35c8725cfd99a2d18fa4123f52f52bf03e84210855/hiredis-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:88396e6a24b80c86f4dc180964d9cc467ba3aa3c886af6532fe077c5a5dc0c3c", size = 300927, upload-time = "2026-06-03T16:22:53.085Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0f/e1e2295ee863efc7ce8c88ec10bcc4b1504352373998cb493f10e900dbe5/hiredis-3.4.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:73dd607b47863633d8070f1eb3bab1b3b097ee747783fe69c0dd0f93ec673d8b", size = 331764, upload-time = "2026-06-03T16:22:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/11b1de2ac85dfd7a8713d72a6ed7ac0f1a6e28d906bd362e0df3a27f5c86/hiredis-3.4.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e6e8d5fa63ec2a0738d188488e828818cbe4cb4d37c0c706836cf3888d82c53d", size = 333144, upload-time = "2026-06-03T16:22:55.277Z" }, + { url = "https://files.pythonhosted.org/packages/6f/10/4b104565c936d51b4b02597352ec068937c9d6a73a3c4c9609c08ae3923e/hiredis-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d77901d058923a09ed25063ea6fb2842c153bbe75060a46e3949e73ad12ce352", size = 311593, upload-time = "2026-06-03T16:22:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/70/ae/c9eda3c116bef50fcf0dc7e44379e3577f3627caca4ffd7af04675b02d98/hiredis-3.4.0-cp314-cp314-win32.whl", hash = "sha256:05384fcfe5851b5af868bf24265c14ab86f38562679f9c6f712895b67a98163c", size = 39662, upload-time = "2026-06-03T16:22:57.683Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c8/cedb336a0386a97271761ace460a362cb2433c6cdf1d1ba760ad99225734/hiredis-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:53233656e4fecf9f8ec654f1f4c5d445bf1c2957d7f63ffdedbba2682c9d1584", size = 40682, upload-time = "2026-06-03T16:22:58.526Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ea/3a05247ce4e2afe56f59d24b73ba38e37f2b324dba8290beba56fbd9fd1f/hiredis-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3348ba4e101f3a96c927447ff2edcb3e0026dc6df375ba117485a43edcbb6980", size = 37541, upload-time = "2026-06-03T16:22:59.307Z" }, + { url = "https://files.pythonhosted.org/packages/35/14/caeaa1be1205ebdc1cf6760c5f6882afbdb3b82a6bdf0559d01205b1c857/hiredis-3.4.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:3159c54fe560aa30bf1ab76e65c4c23dc45ad79d7cf4aecc25ec9942f5ea4cea", size = 139787, upload-time = "2026-06-03T16:23:00.139Z" }, + { url = "https://files.pythonhosted.org/packages/49/85/8f52b485b9d835e0f8da063a635290d916a6f5ab60c18db5411ecea344d1/hiredis-3.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:be4a41496a0a48c3abf57ef1bbeb11980060ce9c7a1dd8b92caa028a813a9c59", size = 75136, upload-time = "2026-06-03T16:23:01.705Z" }, + { url = "https://files.pythonhosted.org/packages/9f/09/ee568562f36f481395d5cea3ab75fd9350cd77d98d55ee5f9b395f3fc358/hiredis-3.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2f9a9a591b3eaade523f3e778dfcd8684965ee6e954ae25cd2fd6d8c75e881d", size = 70772, upload-time = "2026-06-03T16:23:02.765Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0d/3cb03fbbe72f86541f42ee49dba95ff428c87908815152970fbf24bdcf4c/hiredis-3.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c2852eaa26c0a73be4a30118cd5ad6a77c095d224ccb5ac38e40cb865747d22", size = 315571, upload-time = "2026-06-03T16:23:03.826Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/c8667282e41153bc20930aeba8ba0dff989cbaa9eb7594f8bcac02558dea/hiredis-3.4.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18ff3d9b23ebe6c8248c3debca2402ad209d60c48495e7ed76407c2fe54cb9b4", size = 348131, upload-time = "2026-06-03T16:23:05.077Z" }, + { url = "https://files.pythonhosted.org/packages/99/13/5431ace8330904b2b9d9ce5425c13b7a8fa2b443ff272a92f248c07e6400/hiredis-3.4.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:94f83352295bf3d332678689ecd4ce190a4d233a20ad2f432724efd3ce03e49a", size = 359915, upload-time = "2026-06-03T16:23:06.293Z" }, + { url = "https://files.pythonhosted.org/packages/be/57/30dab05cf2a70905e5d2807edd4afa30a4747599070faf80f18e61375e11/hiredis-3.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:393d5e7c8c67cdddf7109a8e925d885e788f3f43e5b1043f84390df40c59944b", size = 321426, upload-time = "2026-06-03T16:23:07.447Z" }, + { url = "https://files.pythonhosted.org/packages/33/6f/0a6e030d96d927000735b39aa8b8fef03b43fafdf4a79c80755be351a0f5/hiredis-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7e7ab4c1c8c4d365b02d9e82cdf25b01a065edf2ededd7b5acb043201ff80203", size = 309862, upload-time = "2026-06-03T16:23:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/11/48/26b2771d2b2403124c1f97c2a6d45df0ba3fa59f0c2d4d244e90543722fb/hiredis-3.4.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:cfe23f8dcf2c0f4e03d107ff68a9ee9707f9d76abeddbe59633e5de1564a650c", size = 339568, upload-time = "2026-06-03T16:23:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/07/b1/01c18f676d5dea65e894c01ffae8da2f15df1fceed1c69b16877ba57be60/hiredis-3.4.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a7e76904148c229549db7240a4f9963deb8bb328c0c0844fc9f2320aca05b530", size = 341424, upload-time = "2026-06-03T16:23:10.964Z" }, + { url = "https://files.pythonhosted.org/packages/fb/58/ab3a5672e506f282e1dd6dfb1c0c3f7e17f02398280c2a2994f8d7b478ba/hiredis-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:92b570225f6097430615a82543c3eb7974ca354738a6cef38053138f7d983151", size = 320386, upload-time = "2026-06-03T16:23:12.174Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/3f26324cca720f56ace408883c1c7311ce71b571e82e6434515f7ba4eb59/hiredis-3.4.0-cp314-cp314t-win32.whl", hash = "sha256:decc176d86127c620b5d280b3fe5f97a788be58ca945971f3852c3bf54f4d5ad", size = 40516, upload-time = "2026-06-03T16:23:13.179Z" }, + { url = "https://files.pythonhosted.org/packages/8b/18/e011a424a9608ff152ebeb7bbae2be3163e5716e92cf75baddcb5a8fc312/hiredis-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:05c852c58fec65d4c9fb861372dd7391d8b2ce96c960ba8714145f8cd85cd0ec", size = 41453, upload-time = "2026-06-03T16:23:14.091Z" }, + { url = "https://files.pythonhosted.org/packages/43/5f/829287555ce7286be8d6c87c69f93aa1f38fe67c46740806416142231cf3/hiredis-3.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7ff29c9f5d3c91fda948c2fde58f457b3244550781d3bc0891b1b9d93c10f47f", size = 37968, upload-time = "2026-06-03T16:23:14.948Z" }, +] + +[[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 = "huggingface-hub" +version = "1.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/27/629cfe58c582f92ded066c4a07d1a057ff617118ab7973200f770bd853cb/huggingface_hub-1.19.0.tar.gz", hash = "sha256:fd771622182d40977272a923953ee3b1b13538f9f8a7f5d78398f10af0f1c0bd", size = 824721, upload-time = "2026-06-11T12:33:18.665Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a5/558da89f66464d8d0229ff497e8b8666977de2d8cf48c28a2862ecf1250f/huggingface_hub-1.19.0-py3-none-any.whl", hash = "sha256:1dc72e1f6b4d6df6b30eb72e57d00514ef453d660f04af2b87f0e67267f31ee0", size = 693398, upload-time = "2026-06-11T12:33:16.695Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[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 = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +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/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 = "narwhals" +version = "2.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/3c/c4ef2164a71c1a63d7f1ae411c4082c5fa872405106db60a4b7114989ad7/narwhals-2.22.1.tar.gz", hash = "sha256:d62920805a0a43b7ff8b54b0c0d3142d796f8a9301836ada37e573d6a33cbcd9", size = 647493, upload-time = "2026-06-05T12:34:34.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl", hash = "sha256:60567d774edf77db53906f89d9fbd164e66e56d66d388e1e6990f17ac33cfb53", size = 454815, upload-time = "2026-06-05T12:34:32.289Z" }, +] + +[[package]] +name = "neo4j" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/aaa4ac19adae4b01bc742b63afd2672a77e7351566f02721e713e4b863ee/neo4j-6.2.0.tar.gz", hash = "sha256:e1e246b65b572bd8ea97f9e0e721b7d40a5ce53e53d0007c29aef63e4f9124d9", size = 241459, upload-time = "2026-05-04T07:35:41.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/cf/1c3795866cefaac6e648d4e98c373cafd97810f6e317c307371007ab4abb/neo4j-6.2.0-py3-none-any.whl", hash = "sha256:b87abdd13a5cc2e3bd51026926c2f20ac38fa3febe98c340520dce19e97388d0", size = 327824, upload-time = "2026-05-04T07:35:39.604Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, +] + +[[package]] +name = "nvidia-cublas" +version = "13.1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cuda-nvrtc" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.20.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/edb9c0ae051602c3ccaffe424256463636d639e27d7f302dde9975ef9e7a/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304", size = 366173588, upload-time = "2026-03-09T19:29:34.474Z" }, +] + +[[package]] +name = "nvidia-cufft" +version = "12.0.0.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, +] + +[[package]] +name = "nvidia-cufile" +version = "1.15.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, +] + +[[package]] +name = "nvidia-curand" +version = "10.4.0.35" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, +] + +[[package]] +name = "nvidia-cusolver" +version = "12.0.4.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, +] + +[[package]] +name = "nvidia-cusparse" +version = "12.6.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.29.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/0d/daf50d44177ee0cbc7ff0a0c91eb5ff676c82be42f9a970bc7597f440c3a/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5", size = 206014712, upload-time = "2026-03-03T05:34:20.843Z" }, + { url = "https://files.pythonhosted.org/packages/67/f4/58e4e91b6919367c7aafb8e36fce9aad1a3047e536bf7e2fd560927d3a4c/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d", size = 205976000, upload-time = "2026-03-03T05:36:24.472Z" }, +] + +[[package]] +name = "nvidia-nvjitlink" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933, upload-time = "2025-09-04T08:35:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748, upload-time = "2025-09-04T08:35:20.008Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu13" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, +] + +[[package]] +name = "nvidia-nvtx" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "playwright" +version = "1.60.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/f0/832bd9677194908da118064eef20082f2791e3d18215cc6d9391ee2c5a67/playwright-1.60.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:6a8cd0fec171fb3089e95e898c8bc8a6f35dea0b78b399e12fcc19427e91b1d7", size = 43474635, upload-time = "2026-05-18T12:00:31.969Z" }, + { url = "https://files.pythonhosted.org/packages/59/7b/e1d32ae8a3ed937ec2be3721c5f728b13d731a0b7c6442e0b3bec5094ac0/playwright-1.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:39b5420ba6145045b69ced4c5c47d4d9fe5bddfc8ff816c518913afcb25ec7a5", size = 42261327, upload-time = "2026-05-18T12:00:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/d7/bc/23de499ded6411c188a20c5a0dea6f0cd4ed5d2b3cc6042a5dbd3ed609aa/playwright-1.60.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:2581d0e6a3392c71f91b27460c7fd093356818dc430f48153896c8aeeaef7705", size = 43474636, upload-time = "2026-05-18T12:00:39.294Z" }, + { url = "https://files.pythonhosted.org/packages/22/7b/1d679f4fced4ea94efadd17103856d8c565384f68382a1681264e46f5925/playwright-1.60.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:1c2bfae7884fb3fb05b853290eab8f343d524e5016f2f1def702acbbdf14c93e", size = 47467220, upload-time = "2026-05-18T12:00:43.179Z" }, + { url = "https://files.pythonhosted.org/packages/84/c2/1528d267d4442bd2c6b8eaeab819dd52c2030bf80e89293f0ba1f687473b/playwright-1.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43e66564125ee31b07a58cefb21e256d62d67d8d1713e6858df7a3019d8ed353", size = 47154856, upload-time = "2026-05-18T12:00:46.715Z" }, + { url = "https://files.pythonhosted.org/packages/bb/4e/b008b6440a7a1624378041da94829956d4b8f7ab9ef5aad22d0dc3f2e26d/playwright-1.60.0-py3-none-win32.whl", hash = "sha256:ec94e416ea320711e0ad4bf185dcbf41833672961e90773e1885255d7db7b7e7", size = 37902157, upload-time = "2026-05-18T12:00:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/55/f0/0541524133104f9cc20bf900870ff4a736b76a23483f3a55295ddfa58409/playwright-1.60.0-py3-none-win_amd64.whl", hash = "sha256:9566821ce6030a1f9e7146a24e19355ab0d98805fd0f9be50bb3d8fef1750c02", size = 37902159, upload-time = "2026-05-18T12:00:53.728Z" }, + { url = "https://files.pythonhosted.org/packages/80/c8/210f282d278e4709cdd71b12a31af45a30a22ab3207b387e29b37e478713/playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537", size = 34037981, upload-time = "2026-05-18T12:00:57.584Z" }, +] + +[[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 = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[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.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.410" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/53/e4d8ea1391bd4355231be6f91bf239479aa0014260ed3fb5526eeb12a1f2/pyright-1.1.410.tar.gz", hash = "sha256:07a073b8ba6749826773c1269773efa11b93440d9a6aa60419d9a3172d6dc488", size = 4062013, upload-time = "2026-06-01T17:35:48.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/33/288b5868fa00846dacf249633719d747893e54aebd196b9968ac1878a5d3/pyright-1.1.410-py3-none-any.whl", hash = "sha256:5e961bed37cacf96b3f7cd7b1da39b350a9239aa2e69138d0e88f728cfaf296c", size = 6082448, upload-time = "2026-06-01T17:35:46.387Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" }, +] + +[[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 = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, +] + +[[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/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 = "redis" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyjwt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/cf/128b1b6d7086200c9f387bd4be9b2572a30b90745ef078bd8b235042dc9f/redis-5.3.1.tar.gz", hash = "sha256:ca49577a531ea64039b5a36db3d6cd1a0c7a60c34124d46924a45b956e8cf14c", size = 4626200, upload-time = "2025-07-25T08:06:27.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/26/5c5fa0e83c3621db835cfc1f1d789b37e7fa99ed54423b5f519beb931aa7/redis-5.3.1-py3-none-any.whl", hash = "sha256:dc1909bd24669cc31b5f67a039700b16ec30571096c5f1f0d9d2324bff31af97", size = 272833, upload-time = "2025-07-25T08:06:26.317Z" }, +] + +[package.optional-dependencies] +hiredis = [ + { name = "hiredis" }, +] + +[[package]] +name = "regex" +version = "2026.5.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451, upload-time = "2026-05-09T23:12:34.72Z" }, + { url = "https://files.pythonhosted.org/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6", size = 292112, upload-time = "2026-05-09T23:12:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225", size = 289599, upload-time = "2026-05-09T23:12:38.089Z" }, + { url = "https://files.pythonhosted.org/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0", size = 796732, upload-time = "2026-05-09T23:12:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107", size = 865440, upload-time = "2026-05-09T23:12:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309", size = 912329, upload-time = "2026-05-09T23:12:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8", size = 801239, upload-time = "2026-05-09T23:12:46.268Z" }, + { url = "https://files.pythonhosted.org/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66", size = 777054, upload-time = "2026-05-09T23:12:48.051Z" }, + { url = "https://files.pythonhosted.org/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026", size = 785098, upload-time = "2026-05-09T23:12:49.851Z" }, + { url = "https://files.pythonhosted.org/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962", size = 860095, upload-time = "2026-05-09T23:12:51.666Z" }, + { url = "https://files.pythonhosted.org/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621", size = 765762, upload-time = "2026-05-09T23:12:53.413Z" }, + { url = "https://files.pythonhosted.org/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d", size = 852100, upload-time = "2026-05-09T23:12:55.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce", size = 789479, upload-time = "2026-05-09T23:12:57.573Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699, upload-time = "2026-05-09T23:12:59.14Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783, upload-time = "2026-05-09T23:13:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513, upload-time = "2026-05-09T23:13:02.426Z" }, + { url = "https://files.pythonhosted.org/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb", size = 490303, upload-time = "2026-05-09T23:13:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f", size = 292019, upload-time = "2026-05-09T23:13:06.022Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c", size = 289468, upload-time = "2026-05-09T23:13:08.214Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed", size = 796749, upload-time = "2026-05-09T23:13:10.573Z" }, + { url = "https://files.pythonhosted.org/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020", size = 865445, upload-time = "2026-05-09T23:13:12.523Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2", size = 912322, upload-time = "2026-05-09T23:13:14.281Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2", size = 801269, upload-time = "2026-05-09T23:13:16.569Z" }, + { url = "https://files.pythonhosted.org/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04", size = 777085, upload-time = "2026-05-09T23:13:18.675Z" }, + { url = "https://files.pythonhosted.org/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c", size = 785153, upload-time = "2026-05-09T23:13:20.525Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f", size = 860164, upload-time = "2026-05-09T23:13:22.263Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8", size = 765731, upload-time = "2026-05-09T23:13:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6", size = 852062, upload-time = "2026-05-09T23:13:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21", size = 789577, upload-time = "2026-05-09T23:13:27.814Z" }, + { url = "https://files.pythonhosted.org/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127", size = 266691, upload-time = "2026-05-09T23:13:29.549Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca", size = 277747, upload-time = "2026-05-09T23:13:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6", size = 270500, upload-time = "2026-05-09T23:13:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3", size = 494172, upload-time = "2026-05-09T23:13:35.935Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6", size = 293952, upload-time = "2026-05-09T23:13:38.307Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff", size = 292314, upload-time = "2026-05-09T23:13:40.353Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88", size = 811681, upload-time = "2026-05-09T23:13:42.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178", size = 871135, upload-time = "2026-05-09T23:13:44.326Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100", size = 917265, upload-time = "2026-05-09T23:13:47.295Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e", size = 816311, upload-time = "2026-05-09T23:13:49.885Z" }, + { url = "https://files.pythonhosted.org/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2", size = 785498, upload-time = "2026-05-09T23:13:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b", size = 801348, upload-time = "2026-05-09T23:13:54.127Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e", size = 866493, upload-time = "2026-05-09T23:13:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041", size = 772811, upload-time = "2026-05-09T23:13:57.867Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0", size = 856584, upload-time = "2026-05-09T23:13:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081", size = 803453, upload-time = "2026-05-09T23:14:01.978Z" }, + { url = "https://files.pythonhosted.org/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" }, + { url = "https://files.pythonhosted.org/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a", size = 490376, upload-time = "2026-05-09T23:14:09.64Z" }, + { url = "https://files.pythonhosted.org/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4", size = 291964, upload-time = "2026-05-09T23:14:11.424Z" }, + { url = "https://files.pythonhosted.org/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c", size = 289682, upload-time = "2026-05-09T23:14:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9", size = 796996, upload-time = "2026-05-09T23:14:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af", size = 866089, upload-time = "2026-05-09T23:14:17.757Z" }, + { url = "https://files.pythonhosted.org/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0", size = 911530, upload-time = "2026-05-09T23:14:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4", size = 800643, upload-time = "2026-05-09T23:14:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf", size = 777223, upload-time = "2026-05-09T23:14:24.179Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346", size = 785760, upload-time = "2026-05-09T23:14:26.065Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676", size = 860891, upload-time = "2026-05-09T23:14:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14", size = 765891, upload-time = "2026-05-09T23:14:30.06Z" }, + { url = "https://files.pythonhosted.org/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd", size = 851380, upload-time = "2026-05-09T23:14:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e", size = 789350, upload-time = "2026-05-09T23:14:34.799Z" }, + { url = "https://files.pythonhosted.org/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad", size = 272130, upload-time = "2026-05-09T23:14:36.729Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763", size = 280999, upload-time = "2026-05-09T23:14:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372", size = 273500, upload-time = "2026-05-09T23:14:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499", size = 494269, upload-time = "2026-05-09T23:14:42.869Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1", size = 293954, upload-time = "2026-05-09T23:14:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d", size = 292405, upload-time = "2026-05-09T23:14:47.211Z" }, + { url = "https://files.pythonhosted.org/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c", size = 811855, upload-time = "2026-05-09T23:14:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5", size = 871189, upload-time = "2026-05-09T23:14:51.555Z" }, + { url = "https://files.pythonhosted.org/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20", size = 917485, upload-time = "2026-05-09T23:14:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0", size = 816369, upload-time = "2026-05-09T23:14:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d", size = 785869, upload-time = "2026-05-09T23:14:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b", size = 801427, upload-time = "2026-05-09T23:15:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a", size = 866482, upload-time = "2026-05-09T23:15:03.384Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415", size = 773022, upload-time = "2026-05-09T23:15:05.625Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2", size = 856642, upload-time = "2026-05-09T23:15:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41", size = 803552, upload-time = "2026-05-09T23:15:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58", size = 275746, upload-time = "2026-05-09T23:15:12.609Z" }, + { url = "https://files.pythonhosted.org/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77", size = 285685, upload-time = "2026-05-09T23:15:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, + { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, + { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, +] + +[[package]] +name = "safetensors" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568, upload-time = "2026-06-09T07:52:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844, upload-time = "2026-06-09T07:52:07.624Z" }, + { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823, upload-time = "2026-06-09T07:52:09.565Z" }, + { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461, upload-time = "2026-06-09T07:52:11.128Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148, upload-time = "2026-06-09T07:52:13.603Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040, upload-time = "2026-06-09T07:52:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832, upload-time = "2026-06-09T07:52:12.37Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930, upload-time = "2026-06-09T07:52:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670, upload-time = "2026-06-09T07:52:20.032Z" }, + { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679, upload-time = "2026-06-09T07:52:21.322Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683, upload-time = "2026-06-09T07:52:22.594Z" }, + { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361, upload-time = "2026-06-09T07:52:23.924Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401, upload-time = "2026-06-09T07:52:28.895Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540, upload-time = "2026-06-09T07:52:27.832Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122, upload-time = "2026-06-02T11:53:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512, upload-time = "2026-06-02T11:53:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603, upload-time = "2026-06-02T11:53:30.328Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097, upload-time = "2026-06-02T11:53:33.456Z" }, + { url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173, upload-time = "2026-06-02T11:53:36.602Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451, upload-time = "2026-06-02T11:53:39.075Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/c9a35cf59b20a86fec24d306f1547b78dec194b08d367ce2a3e4854169d9/scikit_learn-1.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162", size = 8713289, upload-time = "2026-06-02T11:53:58.788Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2", size = 8245141, upload-time = "2026-06-02T11:54:01.694Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/f4a0c4fe9711154cddabf913471153af79056382ddc612cfe5ee0ff4b72e/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913", size = 8847671, upload-time = "2026-06-02T11:54:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb", size = 9118104, upload-time = "2026-06-02T11:54:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673", size = 8290674, upload-time = "2026-06-02T11:54:10.087Z" }, + { url = "https://files.pythonhosted.org/packages/65/5b/d4c879cf358f1187141cf90ced473f087183489090244f50c124a2ee478b/scikit_learn-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42", size = 7978807, upload-time = "2026-06-02T11:54:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/8a/43/bfae3121ec67ae09150d453c442c7c1cc166e9aefe056e6ab3b7728a5cfc/scikit_learn-1.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949", size = 9031941, upload-time = "2026-06-02T11:54:15.436Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/20a4546eb17f3b25d3c66df15810411c14ed5065bcfab50b53c96fb627b2/scikit_learn-1.9.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96", size = 8613528, upload-time = "2026-06-02T11:54:18.842Z" }, + { url = "https://files.pythonhosted.org/packages/18/3c/e440e039bb82cd19004edaaad00acbde0fb9b461083c3ecf37941c557312/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b", size = 8855050, upload-time = "2026-06-02T11:54:21.699Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/b341b8dab5998da6270a3a42c2152c578501354d36f944b5856757035ef8/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a", size = 9097190, upload-time = "2026-06-02T11:54:24.454Z" }, + { url = "https://files.pythonhosted.org/packages/fb/de/b650b4d69b84468cfa2e28a3ff7b8103743029e6446ce1a97fe060ef688c/scikit_learn-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666", size = 8963204, upload-time = "2026-06-02T11:54:27.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/ff83d76d7418112e5a61326443cdda87be3545dd8d6599c95b2481a4419e/scikit_learn-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa", size = 8222661, upload-time = "2026-06-02T11:54:30.192Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "sentence-transformers" +version = "5.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "torch" }, + { name = "tqdm" }, + { name = "transformers" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/56/d2cb00765a6b15c994a7fccf20f9032f16e8193ca49147cb5155166ad744/sentence_transformers-5.6.0.tar.gz", hash = "sha256:0e7164d051e416c1853ade7c274ff52af3f9da0f4be7f0b83d734c27699e1057", size = 453194, upload-time = "2026-06-16T14:01:56.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c1/dc1582b79e9a2eb0cddf9559cd9bcdff084f541d6fe881fdd9d98630dba7/sentence_transformers-5.6.0-py3-none-any.whl", hash = "sha256:d2075b5e687a1611005e20ab04a6846994d51adfcf39610aed066af3c0c0b81f", size = 596411, upload-time = "2026-06-16T14:01:55.103Z" }, +] + +[[package]] +name = "setuptools" +version = "81.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, +] + +[[package]] +name = "torch" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/bb/285d643f254731294c9b595a007eac39db4600a98682d7bca688f42ca164/torch-2.12.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b41339df93d491435e790ff8bcbae1c0ce777175889bfd1281d119862793e6a2", size = 88010197, upload-time = "2026-05-13T14:55:35.414Z" }, + { url = "https://files.pythonhosted.org/packages/79/81/76debf1db1343bd929bbb5d74c89fb437c2ed88eb144712557e7bd3eea45/torch-2.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8fbef9f108a863e7722a73740998967e3b074742a834fc5be3a535a2befa7057", size = 426376751, upload-time = "2026-05-13T14:55:03.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/f0/80026028b603c4650ff270fc3785bdef4bd6738765a9cc5a0f5a637d65a2/torch-2.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4b4f64c2c2b11f7510d93dd6412b87025ff6eddd6bb61c3b5a3d892ea20c4756", size = 532261691, upload-time = "2026-05-13T14:52:54.453Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c2/64b06cbb7830fb3cd9be13e1158b31a3f36b68e6a209105ee3c9d9480be0/torch-2.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:8b958caff4a14d3a3b0b2dfc6a378f64dda9728a9dad28c08a0db9ce4dafb549", size = 122988114, upload-time = "2026-05-13T14:54:42.153Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/01896c80ba921676aa45886b2c5b8d774912de2a1f719de48169c6f755cd/torch-2.12.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:90dd587a5f61bfe1307148b581e2084fc5bc4a06e2b90a20e9a36b81087ff16b", size = 88009511, upload-time = "2026-05-13T14:54:47.411Z" }, + { url = "https://files.pythonhosted.org/packages/a5/04/52bdaf4787eab6ac7d7f5851dff934e4def0bc8ead9c8fd2b69b3e529699/torch-2.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:864392c73b7654f4d2b3ae712f607937d0dbb1101c4555fbb41848106b297f39", size = 426383231, upload-time = "2026-05-13T14:53:32.129Z" }, + { url = "https://files.pythonhosted.org/packages/49/8a/94bdecd13f5aaa90d45920b89789d9fe7c6f4af8c3cdd7ce01fcb59908fc/torch-2.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5d6b560dfa7d56291c07d615c3bb73e8d9943d9b6d87f76cd0d9d570c4797fa6", size = 532269288, upload-time = "2026-05-13T14:53:49.423Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2f/bdbaaa267de519ef1b73054bf590d8c93c37a266c9a4e24a01bd38b6918f/torch-2.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:3fee918902090ade827643e758e98363278815de583c75d111fdd665ebffde9f", size = 122987706, upload-time = "2026-05-13T14:54:00.335Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ad/e95e822f3538171e22640a7fbe839a1fdb666600bf6487025de2ff03b11a/torch-2.12.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:10ee1448a9f304d3b987eb4656f664ba6e4d7b410ca7a5a7c642199777a2cf88", size = 88319556, upload-time = "2026-05-13T14:54:05.574Z" }, + { url = "https://files.pythonhosted.org/packages/b7/07/055d06d985b445d67422d25b033c11cf55bbb81785d4c4e68e28bca5820e/torch-2.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:af68dbf403439cae9ceaeaaf92f8352b460787dcd27b92aa05c40dd4a19c0f1e", size = 426397656, upload-time = "2026-05-13T14:52:38.84Z" }, + { url = "https://files.pythonhosted.org/packages/43/94/b0b4fdc3014122e0a7302fb90086d352aa48f2576f0b252561ebb38c01a8/torch-2.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:a6a2eebb237d3b1d9ad3b378e86d9b9e0782afdea8b1e0eba6a13646b9b49c07", size = 532183124, upload-time = "2026-05-13T14:53:16.178Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c8/052405e6ad05d3237bfe5a4df78f917773956f8e17813a2d44c059068b74/torch-2.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2140e373e9a51a3e22ef62e8d14366d0b470d18f0adf19fdc757368077133a34", size = 123232462, upload-time = "2026-05-13T14:52:27.26Z" }, + { url = "https://files.pythonhosted.org/packages/67/dc/ac069f8d6e8be701535921141055293b0d4819d3d7f224a4612cf157c7f9/torch-2.12.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7dfae4a519197dfa050e98d8e36378a0fb5899625a875c2b54445005a2e404e", size = 88027282, upload-time = "2026-05-13T14:53:05.258Z" }, + { url = "https://files.pythonhosted.org/packages/33/c3/1c1eb00e34555b536dddf792676026a988d710ed36981aa00499b36b0620/torch-2.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:891c769072637c74e9a5a77a3bc782894696d8ffec83b938df8536dee7f0ba78", size = 426386961, upload-time = "2026-05-13T14:51:28.406Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d4/7e730dba0c7032a4154dc9056b76cf9625515e030e269cfbf8098fcfee7d/torch-2.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e2ad3eb85d39c3cab62dfa93ed5a73516e6a53c6713cb97d004004fe089f0f1f", size = 532272265, upload-time = "2026-05-13T14:51:59.308Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b4/92c80d1bbfee1c0036c06d1d2155a3065bd2423134c83bf8a47e65cd6b9b/torch-2.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:c66696857e987efb8bc1777a37357ec4f60ab5e8af6250b83d6034437fa2d8f3", size = 122987138, upload-time = "2026-05-13T14:51:45.942Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/2e12b37ce50a19a037d7bc62d652a5a8f27385a7b05859d6bc9204f20cfe/torch-2.12.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:b4556715c8572758625d62b6e0ae3b1f76c440221913a6fb5e100f321fb4fb02", size = 88320100, upload-time = "2026-05-13T14:51:39.955Z" }, + { url = "https://files.pythonhosted.org/packages/56/5e/83c450ec7b0bb40a7b74611c1b5440f9260e33c54c90d556fd4a1f0fd955/torch-2.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a43ac605a5e13116c72b64c359644cce0229f213dde48d2ae0ae5eb5becf7feb", size = 426391871, upload-time = "2026-05-13T14:52:14.989Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e9/1a0b575d98d0afedd8f157d23fa3d2759421483660448e60d0a4b10b6daa/torch-2.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6a7512adfdd7f6732e40de1c620831e3c75b39b98cef60b11d0c5f0a76473ec5", size = 532192241, upload-time = "2026-05-13T14:51:07.795Z" }, + { url = "https://files.pythonhosted.org/packages/88/21/afadd25ecd81b3cea1e11c73cf1ab41a983a50271548c3ec7ec3b9efc3e9/torch-2.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5f96b63f8287f66a005dd1b5a6abba2920f11156c5e5c4d815f3e2050fd1aa16", size = 123231092, upload-time = "2026-05-13T14:51:18.854Z" }, +] + +[[package]] +name = "tqdm" +version = "4.68.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/05/0d5260f1f1ca784f4a4a0def9cbe6affe587f5b4025328d446c3d67765f4/tqdm-4.68.2.tar.gz", hash = "sha256:89c230e8dbc67c7615c142487111222f878c77427ea09549960f62389e258add", size = 171923, upload-time = "2026-06-09T13:26:42.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/75/1a0392bcc21c44dcdf87b3cf2d137e7829be2c083a1e38d44efca3d57a16/tqdm-4.68.2-py3-none-any.whl", hash = "sha256:d4240441fb5353290b87d6a85968c9decc131a99b8c7faa28269d829de669ede", size = 78578, upload-time = "2026-06-09T13:26:40.731Z" }, +] + +[[package]] +name = "transformers" +version = "5.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/7c/8240f612819718100a9346dc28dea6a11370c3ca9c8c6eabadd3dea4ef29/transformers-5.12.1.tar.gz", hash = "sha256:679ee731c8225347889ad4fb3b2c926a62e9da3b7d284e9d12c791da7272466b", size = 8924054, upload-time = "2026-06-15T17:27:50.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/56/bbd60dd8668055803bf8ba55a81f9b8a8b31497f620109a9671d26a2076d/transformers-5.12.1-py3-none-any.whl", hash = "sha256:2a5e109d2021265df7098ffbb738295acaf5ad256f12cbc586db2ea4dcbb1a8a", size = 11150587, upload-time = "2026-06-15T17:27:46.679Z" }, +] + +[[package]] +name = "triton" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/13/ec05adfcd87311d532ba61e3af143e8be59fcd26675884c4682841406a20/triton-3.7.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4bf49b00a7a377a68a6da603a876e797614e6455a80e9021669c476a953ad9a", size = 188505104, upload-time = "2026-05-07T19:05:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/62/7b/468a576e35beef1426e0828e28e9ba9e65f5474d496f16ee126c15646324/triton-3.7.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f111161d49bf903c0eaedde3962353a3d841c08a836839b7cc1025b8426efcf", size = 201457567, upload-time = "2026-05-07T18:46:13.505Z" }, + { url = "https://files.pythonhosted.org/packages/01/e1/a59a583de59b8f62c495d67c80ee3ea97d09e91ac80c4c6e76456ed8d8ac/triton-3.7.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abdf6beaa89b1bcfb9a43cd990536ce66091a997841a4814b260b7bee4c88c3c", size = 188503209, upload-time = "2026-05-07T19:05:17.935Z" }, + { url = "https://files.pythonhosted.org/packages/30/b1/b7507bb9815d403927c8dd51d4158ed2e11751a92dbc118a044f247b6848/triton-3.7.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a35d7afe3f3f058e7ec49fcce09794049e0ffc5c59019ac25ec3413741b8c4e7", size = 201453566, upload-time = "2026-05-07T18:46:20.427Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8f/0bea7a6a0c989315c9135a1d7fb37e41905cfb3a17cbc1f10044ebd4cc3a/triton-3.7.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc1d61c172d257db80ddf42595131fb196ad2e9bdd751e90fe2ef13531734e8b", size = 188612899, upload-time = "2026-05-07T19:05:24.955Z" }, + { url = "https://files.pythonhosted.org/packages/e1/02/d96f57828d0912aec733b9bc7e0e7dbfd2c6f079a8fa433ac25cb93d1a30/triton-3.7.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70fb9bbdc9f400afc54bbf6eb2670af28829a6ae3996863317964783141daf56", size = 201553816, upload-time = "2026-05-07T18:46:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/40/fb/82a802dac4689f2a2fb2e69302e6a138eecc3e175bbe976ba3cfc717683a/triton-3.7.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a44a8476d0d3571eac4e4d1048e1ff75aad81a09ff4602ccfc56c6dea1672e", size = 188507879, upload-time = "2026-05-07T19:05:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/8f/af/9904ec6d3c93d9b24e5ec360445bbdf758b7f00bfbeedb89cb0eb64eb8bb/triton-3.7.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9b85e72968a9d8bba5ddb24e9b64aaabaf48affb042f2755cb7cfa92b7531ce", size = 201460637, upload-time = "2026-05-07T18:46:34.749Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/4835a8ea746b88727d8899f4e3ccce4f9cacb38abfc3bb0a638266c53111/triton-3.7.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a160de426fd99f92b0baf509045360afbd3bfaa0b4a5171dde800ec9f09684", size = 188608706, upload-time = "2026-05-07T19:05:39.218Z" }, + { url = "https://files.pythonhosted.org/packages/c1/68/fa86e5a39608000f645535b2c124920126327ab731f8c4fafd5b07ff8d4b/triton-3.7.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce061073102714b725f3660ec6939d94a1da7984b3aa99c921417cae273672f5", size = 201546766, upload-time = "2026-05-07T18:46:42.088Z" }, +] + +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, +] + +[[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 = "uv" +version = "0.11.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/f9/f45bb1c251962ee614afd58ccd3dc06ada7869d04987efc2858a81cc4e0f/uv-0.11.21.tar.gz", hash = "sha256:083882c73373a16de4c136d54e3386a52388dead5048a07505e25578b157182f", size = 4259001, upload-time = "2026-06-11T18:18:26.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/a5/1c863b931f3aba6e07547929b8cb45875038de00678bfd2fbabcd76faeef/uv-0.11.21-py3-none-linux_armv6l.whl", hash = "sha256:48c36eb170a5e7a668c1d13d2c8edeb017a3e6484c224f1521b540a6bda9e50b", size = 23747368, upload-time = "2026-06-11T18:19:21.724Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8c/66d22f9152a014fbb17b1308394efe274e860b8beb4933f051396f96dd9f/uv-0.11.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:88d8283f6ea9f0cdbb7717e6e08e916c32a8b8b7e11c72fcc6426a4c4eeb89e0", size = 22992460, upload-time = "2026-06-11T18:18:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/a5/f7/31d62c17837c9ae79cc6d5351fc5d54e8926e78b0315b4b6c187e0d1d50d/uv-0.11.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9c11169a049ec8bf9ddc6a9f55fba9a240942ec8005faaaf4393f00ff7a4c16e", size = 21762931, upload-time = "2026-06-11T18:18:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/3c/04/c5503fc1015095db71c280526f45537f3bb06855ce281ff1761b85d149bf/uv-0.11.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:00193e4e077c27ee3d66da356744dbf0b3aa59356dfbd9a9efb1dc8469af8ad7", size = 23716032, upload-time = "2026-06-11T18:19:17.03Z" }, + { url = "https://files.pythonhosted.org/packages/13/ac/46132335772fcdc38e5b5ec76701a8df8e3707605909b5fed46783689501/uv-0.11.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:870f48082df673016f465b068f40ad5aa7d2d3cfbcfb4e73724630684003a2ab", size = 23330010, upload-time = "2026-06-11T18:19:00.825Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d4/cfa1ea36706c32006dea9bf0a819b56c22af8270ea3a2b57562ce96c2d45/uv-0.11.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:af08e0d8f43da43bc68930aee56ca5f38ccfbc79d45b6e8a7d5051f1e975684f", size = 23339731, upload-time = "2026-06-11T18:18:52.395Z" }, + { url = "https://files.pythonhosted.org/packages/96/c5/b34d3cdf05a069c583ef368e6db90242f842d7eb26b246981b3ca8799c27/uv-0.11.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4530761c565f3a519a68f36628ee51f2b467b66573e2023e9073641219b60d23", size = 24657820, upload-time = "2026-06-11T18:19:25.62Z" }, + { url = "https://files.pythonhosted.org/packages/be/b9/89b4e3909111c14311d4a1551afb37f0669587dc1f4ae7e26ec5baea6c09/uv-0.11.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66906cfa7c29c2cf4ea5117cf5614b0b83078ff669e664e2187071fcb24c85c1", size = 25744586, upload-time = "2026-06-11T18:19:09.311Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7b/51d53d9fb1aaf38a613c2d20b40583ee2aa47fc000724a00aecbd5e61431/uv-0.11.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:525ef0eb56ff982357a321eca953307d824ab6f58473630c69521e8085f12b0a", size = 24990030, upload-time = "2026-06-11T18:18:29.618Z" }, + { url = "https://files.pythonhosted.org/packages/de/70/3347f736911b73df1f31c0823d6502891f3c49fdeb157fe8060b18c08d1c/uv-0.11.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9ecdefa81db7e966d1655988cad6f840316228381dd69131ebc4ae9362bbccd", size = 25110133, upload-time = "2026-06-11T18:19:13.307Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/b92538042d78550626ec7ac98b525bcb81ded8605c7ca9d6e35a1454ba71/uv-0.11.21-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4ed98ff3165bf7b339692d0df918b87e6d36eb0bed5183466330d27d5730d57b", size = 23755172, upload-time = "2026-06-11T18:18:19.189Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1a/5c8993f95d4384baeaf00b96df0111af3c941a34e4466cde0d52b0b6ad99/uv-0.11.21-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:0e7916874f125a6f6af4cddd95f892ef19a4bb65c146afea7e544b0f98c63d02", size = 24468447, upload-time = "2026-06-11T18:19:04.572Z" }, + { url = "https://files.pythonhosted.org/packages/66/2c/d4db24f9aeab8fce106633cd0388df4c0cf9f0991a2b5d9f58d061a031f7/uv-0.11.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:05e2f2e0fbf7c423f8287011ba0d2d69464f26a5f13b33df05cd491fbe5a910a", size = 24564716, upload-time = "2026-06-11T18:19:29.559Z" }, + { url = "https://files.pythonhosted.org/packages/f6/53/c61711e81f9f8d34dd020340ace968499b2539d3bb4ac09d39339df54a9d/uv-0.11.21-py3-none-musllinux_1_1_i686.whl", hash = "sha256:b756dd2b368d7cc4aeb48249d06e1250bfcf81f0313ff7d7ec2ccafcd3ee4c93", size = 23917742, upload-time = "2026-06-11T18:18:57.187Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/210a5562a6a0eddfbe4890eb48e67f167be0307e75f029ca46b8f6386e5d/uv-0.11.21-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:88668a27959df9188ff72b0314f6b14f6acf6090964bb0748974239183ecb51c", size = 25330418, upload-time = "2026-06-11T18:18:37.383Z" }, + { url = "https://files.pythonhosted.org/packages/f8/3c/81979463de0278facaa59ed3940b9c62f25a68d737d1a6f11cc3f922fba3/uv-0.11.21-py3-none-win32.whl", hash = "sha256:a00c78f3eea6db7967d98a505b01b7d80354517c7ff34f51701949f39c7b53e6", size = 22633520, upload-time = "2026-06-11T18:18:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/3d/51/e682e060813424467f14ae964dd7022f8fc537fea5803b5aab0ba1eca9cc/uv-0.11.21-py3-none-win_amd64.whl", hash = "sha256:d956ba9470d5267cc0ea3d7572cac3bf045bc78adad5b031b5558c6df13d2e19", size = 25291878, upload-time = "2026-06-11T18:18:23.832Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ef/8b1d92f9501963ef8694bb17ad80ba9926d049240d2da0a4f879aa37f3e2/uv-0.11.21-py3-none-win_arm64.whl", hash = "sha256:f64a851e429e6afb96f3a0b688995757ed3697bf1078509e2da8220ffc9805cd", size = 23715885, upload-time = "2026-06-11T18:18:48.596Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, + { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +]