diff --git a/README.md b/README.md index c3540c2..6f2e60d 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,46 @@ All configuration is read from environment variables (see `.env.example`). Secre USPS's default tier for new developer accounts is **60 req/hr per application** across all endpoints on `apis.usps.com` (addresses + tracking + labels). Request a tier upgrade to 300 req/hr by emailing `emailus.usps.com` with your CRID, app name, and mailer-use justification. IV-MTR tracking calls (`iv.usps.com`) are on a separate bucket. +## Backup address resolver (when the Addresses API is unavailable) + +Address standardization normally goes through the official `apis.usps.com` +Addresses API (`NewApiClient`). If that API is down — or after USPS's +2026-07-12 move of the Addresses API to a paid, licensed tier — a standby +resolver can keep `/generate` producing full ZIP+4 + 11-digit-routing barcodes +for **$0**. + +It drives USPS's free consumer ZIP-lookup (`tools.usps.com`) with a real +browser. That endpoint sits behind Akamai Bot Manager, which requires a live +JS-sensor session, so plain HTTP clients are blocked; the resolver uses +`nodriver` + Chromium, headful under `xvfb`. It writes results straight into +the `address_cache` table — keyed identically to what `/generate` looks up — so +**the service and its request path are unchanged**; they simply get a cache hit +and skip the API. + +Linux-only (needs Chromium + Xvfb), in the optional `browser` dependency group: + +```sh +# On the mailwatch host. Pass the SAME recipient fields you'd enter on /generate +# (the cache key is the hash of that raw input): +nix run .#resolve-address -- "475 L'Enfant Plaza SW" \ + --city Washington --state DC --zip 20260 +# --company / --address2 if the recipient has them +# --print-only resolve without writing the cache +# --db target DB (default: $DB_PATH) +``` + +Verify the resolver still beats Akamai (live canary, opt-in): + +```sh +nix run .#test -- -m integration +``` + +Caveats: this automates a free public tool, which is a gray area in USPS's +terms of use — it's a low-volume fallback, not the default path. Keep volume +low; results are cached for a year. The browser engine is isolated behind +`mailwatch/web_lookup.py`, so it can be swapped (e.g. for Camoufox) without +touching callers. + ## License MIT. See [LICENSE](LICENSE). diff --git a/flake.nix b/flake.nix index 5b822e9..ce7ab0e 100644 --- a/flake.nix +++ b/flake.nix @@ -154,6 +154,24 @@ meta.description = "Run mailwatch's pytest suite with WeasyPrint libs on the path"; }; } + # `nix run .#resolve-address -- "" --city C --state ST --zip 12345` + # Backup USPS address resolver (mailwatch.cli_resolve): drives the + # nix-provided Chromium headful under xvfb to seed address_cache when + # apis.usps.com is unavailable. Linux-only — needs Chromium + Xvfb. + // nixpkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { + resolve-address = { + type = "app"; + program = toString ( + pkgs.writeShellScript "mailwatch-resolve-address" '' + ${libEnv} + export CHROME_BIN=${pkgs.chromium}/bin/chromium + exec ${pkgs.xvfb-run}/bin/xvfb-run -a \ + ${devEnv}/bin/python -m mailwatch.cli_resolve "$@" + '' + ); + meta.description = "Backup USPS address resolver (Chromium+xvfb) — seeds address_cache"; + }; + } ); checks = forAllSystems ( @@ -177,7 +195,9 @@ export HOME=$TMPDIR export PYTEST_CACHE_DIR=$TMPDIR/.pytest_cache ${libEnv} - pytest + # Integration tests hit live USPS through a real browser — excluded + # from the sandboxed check (no network/display/Chromium here). + pytest -m "not integration" touch $out ''; lint = pkgs.runCommand "mailwatch-lint" { nativeBuildInputs = [ devEnv ]; } '' diff --git a/mailwatch/cli_resolve.py b/mailwatch/cli_resolve.py new file mode 100644 index 0000000..e7bf350 --- /dev/null +++ b/mailwatch/cli_resolve.py @@ -0,0 +1,120 @@ +"""Standby CLI: resolve a USPS address via the backup browser lookup and seed +``address_cache`` so mailwatch's ``/generate`` path serves it without the API. + +Use when ``apis.usps.com`` is unavailable. Pass the **same recipient fields you +enter on the generate form** — the cache key is the hash of that raw input +(``mailwatch.usps_api.NewApiClient.validate_address``), so the fields must match +for the service to get a hit. The cached value is a standardized response +identical in shape to the API's, so nothing else in mailwatch changes. + + python -m mailwatch.cli_resolve "475 L'Enfant Plaza SW" \ + --city Washington --state DC --zip 20260 + +Runs Chromium headful; on a display-less server wrap it in ``xvfb-run`` (the +``resolve-address`` flake app does this and points it at the nix Chromium). +""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +from pathlib import Path + +from mailwatch import db +from mailwatch.config import get_settings +from mailwatch.models import AddressRequest, StandardizedAddressResponse +from mailwatch.web_lookup import WebLookupError, resolve + +_ZIP5_LEN = 5 + + +def _clean_zip(raw: str) -> str: + """Digits-only, first 5 — mirrors ``routes._clean_zip(...)[:5]`` so the + cache key matches what ``/generate`` computes for the same input.""" + return "".join(ch for ch in raw if ch.isdigit())[:_ZIP5_LEN] + + +def _build_request(args: argparse.Namespace) -> AddressRequest: + """Construct the request identically to ``routes._standardize_for_generate`` + so ``hash_address_dict`` produces the same cache key the service looks up.""" + return AddressRequest( + firm=args.company or None, + streetAddress=args.street, + secondaryAddress=args.address2 or None, + city=args.city, + state=args.state.upper(), + ZIPCode=_clean_zip(args.zip), + ) + + +def _format_human(std: StandardizedAddressResponse) -> str: + dp = std.additionalInfo.deliveryPoint if std.additionalInfo else None + line2 = f"{std.address.city} {std.address.state} {std.full_zip}" + return ( + f" {std.address.streetAddress}\n" + f" {line2}\n" + f" ZIP+4: {std.full_zip} delivery point: {dp or '(none)'}" + ) + + +async def _run(args: argparse.Namespace) -> int: + req = _build_request(args) + try: + candidates = await resolve(req, chrome_path=args.chrome) + except WebLookupError as exc: + print(f"lookup failed: {exc}", file=sys.stderr) + return 2 + + if not candidates: + print("no USPS match for that address", file=sys.stderr) + return 1 + if len(candidates) > 1: + print(f"warning: {len(candidates)} candidates returned; using the first", file=sys.stderr) + + chosen = candidates[0] + if args.json: + print(chosen.model_dump_json(indent=2)) + else: + print(_format_human(chosen)) + + if args.print_only: + return 0 + + db_path = args.db or get_settings().DB_PATH + conn = db.connect(Path(db_path)) + try: + db.init_db(conn) + key = db.hash_address_dict(req.model_dump(exclude_none=True)) + db.cache_put(conn, key, chosen.model_dump_json()) + finally: + conn.close() + print(f"seeded address_cache ({key[:12]}…) in {db_path}", file=sys.stderr) + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="mailwatch.cli_resolve", + description="Backup USPS address resolver — seeds mailwatch's address_cache.", + ) + parser.add_argument("street", help="Street address line (as entered on /generate)") + parser.add_argument("--city", required=True) + parser.add_argument("--state", required=True, help="2-letter state code") + parser.add_argument("--zip", required=True, help="5-digit ZIP (as entered on /generate)") + parser.add_argument("--company", default=None, help="Firm/company line, if any") + parser.add_argument("--address2", default=None, help="Secondary line (apt/suite), if any") + parser.add_argument("--db", default=None, help="SQLite path (default: settings DB_PATH)") + parser.add_argument("--chrome", default=None, help="Chromium binary (default: $CHROME_BIN)") + parser.add_argument( + "--json", action="store_true", help="Emit the standardized response as JSON" + ) + parser.add_argument( + "--print-only", action="store_true", help="Resolve and print without writing the cache" + ) + args = parser.parse_args(argv) + return asyncio.run(_run(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/mailwatch/web_lookup.py b/mailwatch/web_lookup.py new file mode 100644 index 0000000..c828224 --- /dev/null +++ b/mailwatch/web_lookup.py @@ -0,0 +1,174 @@ +"""Backup address resolver: USPS's free consumer ZIP-lookup via a real browser. + +Standby for when the paid/official Addresses API (``apis.usps.com``) is +unavailable. Returns the same :class:`StandardizedAddressResponse` shape the +API does, so a cached entry produced here is indistinguishable to the rest of +mailwatch (see :mod:`mailwatch.cli_resolve`, which seeds ``address_cache``). + +Why a browser at all: the data endpoint +``tools.usps.com/.../ziplookup/zipByAddress`` sits behind Akamai Bot Manager. +A live browser running Akamai's JS sensor in-request is mandatory — plain HTTP +clients (including TLS-fingerprint-impersonating ones carrying a +browser-minted ``_abck`` cookie) are blocked on the data XHR. We drive Chromium +via ``nodriver``, headful under a virtual display (``xvfb`` on servers). + +``nodriver`` is imported lazily inside the driver so this module — and its pure +:func:`parse_address_list` core — imports without the optional ``browser`` +dependency group installed. The engine is isolated to :func:`_capture_lookup`; +swapping it (e.g. for Camoufox) touches nothing else. +""" + +from __future__ import annotations + +import contextlib +import json +import logging +import os +from typing import Any + +from mailwatch.models import ( + AdditionalInfo, + AddressInfo, + AddressRequest, + StandardizedAddressResponse, +) + +logger = logging.getLogger(__name__) + +LOOKUP_URL = "https://tools.usps.com/zip-code-lookup.htm?byaddress" +_DELIVERY_POINT_LEN = 2 + + +class WebLookupError(RuntimeError): + """The USPS web lookup failed, was blocked by Akamai, or found no match.""" + + +def _norm_dp(value: Any) -> str | None: + """Keep a delivery point only if it's the expected 2-digit form.""" + if isinstance(value, str) and len(value) == _DELIVERY_POINT_LEN and value.isdigit(): + return value + return None + + +def parse_address_list(payload: dict[str, Any]) -> list[StandardizedAddressResponse]: + """Parse a ``zipByAddress`` JSON body into standardized-response models. + + Pure function — the unit-testable core. Maps USPS's consumer wire fields + (``addressLine1``/``zip5``/``zip4``/``deliveryPoint``/...) onto the same + models :class:`~mailwatch.usps_api.NewApiClient` produces. Returns ``[]`` + when the lookup reports anything other than success or carries no usable + entries (callers treat empty as "no match"). + """ + if payload.get("resultStatus") != "SUCCESS": + return [] + + results: list[StandardizedAddressResponse] = [] + for entry in payload.get("addressList") or []: + zip5 = entry.get("zip5") + street = entry.get("addressLine1") + if not zip5 or not street: + continue + firm = entry.get("firm") or None + address = AddressInfo( + firm=firm, + streetAddress=street, + secondaryAddress=entry.get("addressLine2") or None, + city=entry.get("city", ""), + state=entry.get("state", ""), + ZIPCode=zip5, + ZIPPlus4=entry.get("zip4") or None, + ) + additional = AdditionalInfo( + deliveryPoint=_norm_dp(entry.get("deliveryPoint")), + carrierRoute=entry.get("carrierRoute") or None, + DPVConfirmation=entry.get("dpvConfirmation") or None, + ) + results.append( + StandardizedAddressResponse(firm=firm, address=address, additionalInfo=additional) + ) + return results + + +async def _capture_lookup( # pragma: no cover - browser I/O, exercised by integration test + req: AddressRequest, + *, + chrome_path: str | None, + settle_s: float, +) -> dict[str, Any]: + """Drive Chromium through the lookup form and return the captured JSON. + + Raises :class:`WebLookupError` if Akamai serves the block page or no + ``zipByAddress`` response is observed. + """ + # Lazy: nodriver lives in the optional `browser` group, so importing this + # module (and its pure parser) must not require it. + import nodriver as uc + + captured: list[dict[str, Any]] = [] + browser = await uc.start( + headless=False, + sandbox=False, + browser_executable_path=chrome_path, + browser_args=["--window-size=1280,900", "--disable-gpu"], + ) + try: + tab = await browser.get(LOOKUP_URL) + + async def on_response(event: Any) -> None: + if "ziplookup" not in event.response.url.lower(): + return + with contextlib.suppress(Exception): + body, _ = await tab.send(uc.cdp.network.get_response_body(event.request_id)) + captured.append(json.loads(body)) + + tab.add_handler(uc.cdp.network.ResponseReceived, on_response) + await tab.send(uc.cdp.network.enable()) + await tab.sleep(3) + + el = await tab.select("#tAddress") + await el.send_keys(req.streetAddress) + el = await tab.select("#tCity") + await el.send_keys(req.city) + await tab.evaluate( + "(() => { const s = document.querySelector('#tState');" + f" if (s) {{ s.value = {req.state!r};" + " s.dispatchEvent(new Event('change', {bubbles: true})); } })()" + ) + button = await tab.select("#zip-by-address") + await button.click() + await tab.sleep(settle_s) + + body_text = str(await tab.evaluate("document.body.innerText") or "") + finally: + browser.stop() + + if captured: + return captured[-1] + if "unavailable" in body_text.lower(): + raise WebLookupError("USPS returned the Akamai bot-block page") + raise WebLookupError("no zipByAddress response was captured") + + +async def resolve( # pragma: no cover - thin orchestration over browser I/O + req: AddressRequest, + *, + chrome_path: str | None = None, + settle_s: float = 7.0, + retries: int = 1, +) -> list[StandardizedAddressResponse]: + """Resolve ``req`` against USPS's free web lookup; retry once on a block. + + ``chrome_path`` defaults to ``$CHROME_BIN`` (set by the ``resolve-address`` + flake app to the nix-provided Chromium). Returns the standardized + candidates (usually one for a deliverable address). + """ + chrome = chrome_path or os.environ.get("CHROME_BIN") + last_exc: WebLookupError | None = None + for attempt in range(retries + 1): + try: + payload = await _capture_lookup(req, chrome_path=chrome, settle_s=settle_s) + return parse_address_list(payload) + except WebLookupError as exc: + last_exc = exc + logger.warning("USPS web lookup attempt %d failed: %s", attempt + 1, exc) + raise last_exc if last_exc else WebLookupError("lookup failed") diff --git a/pyproject.toml b/pyproject.toml index 8357392..3ce30a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,14 @@ dev = [ "pypdf>=5.0,<7", ] +# Optional: the backup browser-driven address resolver (mailwatch.web_lookup / +# mailwatch.cli_resolve). Heavy + downloads a browser at runtime, so it is kept +# out of the main service deps — only the `resolve-address` flake app and the +# integration test need it. Drives the nix-provided Chromium. +browser = [ + "nodriver>=0.50,<1", +] + [build-system] requires = ["uv_build>=0.11.0,<0.12.0"] build-backend = "uv_build" @@ -122,6 +130,10 @@ warn_required_dynamic_aliases = true module = ["weasyprint.*"] ignore_missing_imports = true +[[tool.mypy.overrides]] +module = ["nodriver.*"] +ignore_missing_imports = true + [tool.pytest.ini_options] minversion = "8.0" testpaths = ["tests"] @@ -134,6 +146,10 @@ addopts = [ "--cov=mailwatch", "--cov-report=term-missing:skip-covered", "--cov-report=xml", + # Integration tests (real browser → live USPS) are opt-in: run them with + # `nix run .#test -- -m integration` on a host with Chromium+xvfb. + "-m", + "not integration", ] filterwarnings = [ "error", @@ -147,7 +163,9 @@ markers = [ [tool.coverage.run] source = ["mailwatch"] branch = true -omit = ["tests/*", "mailwatch/_generated/*", "mailwatch/__main__.py"] +# cli_resolve is a thin argparse entrypoint over the (browser-driven) resolver, +# exercised manually / by the integration test, like __main__.py. +omit = ["tests/*", "mailwatch/_generated/*", "mailwatch/__main__.py", "mailwatch/cli_resolve.py"] [tool.coverage.report] precision = 1 diff --git a/tests/test_web_lookup.py b/tests/test_web_lookup.py new file mode 100644 index 0000000..b7b0a4a --- /dev/null +++ b/tests/test_web_lookup.py @@ -0,0 +1,152 @@ +"""Tests for the backup browser-driven address resolver. + +The browser/CDP layer (`web_lookup._capture_lookup`, `web_lookup.resolve`) is +exercised only by the network-marked integration test below; the default suite +covers the pure parser and the cache-seam contract that lets a resolver-seeded +entry be served by the unchanged `/generate` path. +""" + +from __future__ import annotations + +import argparse + +import pytest + +from mailwatch import db +from mailwatch.cli_resolve import _build_request +from mailwatch.models import AddressRequest, StandardizedAddressResponse +from mailwatch.web_lookup import parse_address_list + +# A real `zipByAddress` body captured from tools.usps.com (475 L'Enfant Plaza). +SAMPLE_PAYLOAD = { + "resultStatus": "SUCCESS", + "addressList": [ + { + "addressLine1": "475 LENFANT PLZ SW", + "city": "WASHINGTON", + "state": "DC", + "zip5": "20260", + "zip4": "0001", + "carrierRoute": "C000", + "countyName": "DISTRICT OF COLUMBIA", + "deliveryPoint": "75", + "checkDigit": "7", + "dpvConfirmation": "Y", + } + ], +} + + +def test_parse_maps_all_fields() -> None: + [std] = parse_address_list(SAMPLE_PAYLOAD) + assert std.address.streetAddress == "475 LENFANT PLZ SW" + assert std.address.city == "WASHINGTON" + assert std.address.state == "DC" + assert std.address.ZIPCode == "20260" + assert std.address.ZIPPlus4 == "0001" + assert std.full_zip == "20260-0001" + assert std.additionalInfo is not None + assert std.additionalInfo.deliveryPoint == "75" + assert std.additionalInfo.carrierRoute == "C000" + assert std.additionalInfo.DPVConfirmation == "Y" + + +def test_parse_non_success_returns_empty() -> None: + assert parse_address_list({"resultStatus": "ADDRESS_NOT_FOUND", "addressList": []}) == [] + assert parse_address_list({}) == [] + + +def test_parse_skips_entries_without_zip_or_street() -> None: + payload = { + "resultStatus": "SUCCESS", + "addressList": [ + {"addressLine1": "1 NOWHERE ST", "city": "X", "state": "DC"}, # no zip5 + {"zip5": "20260", "city": "X", "state": "DC"}, # no street + ], + } + assert parse_address_list(payload) == [] + + +def test_parse_drops_malformed_delivery_point() -> None: + payload = { + "resultStatus": "SUCCESS", + "addressList": [{**SAMPLE_PAYLOAD["addressList"][0], "deliveryPoint": "7"}], + } + [std] = parse_address_list(payload) + assert std.additionalInfo is not None + assert std.additionalInfo.deliveryPoint is None # 1 digit -> not kept + + +def test_seeded_value_round_trips_as_service_reads_it() -> None: + """The stored cache value must be consumable by the unchanged service path, + which does ``StandardizedAddressResponse.model_validate_json(cached)``.""" + [std] = parse_address_list(SAMPLE_PAYLOAD) + stored = std.model_dump_json() + reloaded = StandardizedAddressResponse.model_validate_json(stored) + assert reloaded.full_zip == "20260-0001" + assert reloaded.additionalInfo is not None + assert reloaded.additionalInfo.deliveryPoint == "75" + + +def _args(**kw: str | None) -> argparse.Namespace: + base: dict[str, str | None] = { + "street": "475 L'Enfant Plaza SW", + "city": "Washington", + "state": "DC", + "zip": "20260", + "company": None, + "address2": None, + } + base.update(kw) + return argparse.Namespace(**base) + + +def test_cli_cache_key_matches_service_construction() -> None: + """The CLI's request must hash to the key the service computes, or a seeded + entry is never found. The service builds the request exactly as below + (routes._standardize_for_generate) and keys on + ``model_dump(exclude_none=True)``.""" + cli_req = _build_request(_args()) + service_req = AddressRequest( + firm=None, + streetAddress="475 L'Enfant Plaza SW", + secondaryAddress=None, + city="Washington", + state="DC", + ZIPCode="20260", + ) + cli_key = db.hash_address_dict(cli_req.model_dump(exclude_none=True)) + service_key = db.hash_address_dict(service_req.model_dump(exclude_none=True)) + assert cli_key == service_key + + +def test_cli_zip_is_cleaned_to_five_digits() -> None: + req = _build_request(_args(zip="20260-0001")) + assert req.ZIPCode == "20260" + + +def test_cli_optional_fields_change_the_key() -> None: + """firm/secondaryAddress presence is part of the key — they must be supplied + iff they were entered on /generate.""" + bare = db.hash_address_dict(_build_request(_args()).model_dump(exclude_none=True)) + with_company = db.hash_address_dict( + _build_request(_args(company="ACME")).model_dump(exclude_none=True) + ) + assert bare != with_company + + +@pytest.mark.integration +async def test_live_resolve_beats_akamai() -> None: + """Live canary — run with ``-m integration`` on a host with Chromium+xvfb + (or CHROME_BIN set). Confirms the resolver still defeats USPS's Akamai.""" + pytest.importorskip("nodriver") + from mailwatch.web_lookup import resolve + + req = AddressRequest( + streetAddress="475 L'Enfant Plaza SW", city="Washington", state="DC", ZIPCode="20260" + ) + results = await resolve(req) + assert results, "no results — Akamai may have blocked, or selectors drifted" + assert results[0].address.ZIPCode == "20260" + assert results[0].additionalInfo is not None + assert results[0].additionalInfo.deliveryPoint is not None diff --git a/uv.lock b/uv.lock index 20119b2..f4a98bb 100644 --- a/uv.lock +++ b/uv.lock @@ -375,6 +375,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" }, ] +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -633,6 +645,9 @@ dependencies = [ ] [package.dev-dependencies] +browser = [ + { name = "nodriver" }, +] dev = [ { name = "asgi-lifespan" }, { name = "mypy" }, @@ -661,6 +676,7 @@ requires-dist = [ ] [package.metadata.requires-dev] +browser = [{ name = "nodriver", specifier = ">=0.50,<1" }] dev = [ { name = "asgi-lifespan", specifier = ">=2.1,<3" }, { name = "mypy", specifier = ">=1.13,<2" }, @@ -736,6 +752,15 @@ wheels = [ { 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 = "mss" +version = "10.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/5d/eee782a6d674f562c946ae6a026f4c595ea2b7b031f290bf9fbf60da09b5/mss-10.2.0.tar.gz", hash = "sha256:ab271860775545e62f29d7b11f82f279ac1048f5bbdd26cfad84830208dbd393", size = 200317, upload-time = "2026-04-23T10:44:57.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/c3/313e14f245c79b4c05bd0f3a84a4813aa26fa10f8993aebd91d04c5fad3f/mss-10.2.0-py3-none-any.whl", hash = "sha256:e79f428899280e7e64e38365b5bfed683851ebea807eeaeadaf06eb8e0d67197", size = 67106, upload-time = "2026-04-23T10:44:56.266Z" }, +] + [[package]] name = "mypy" version = "1.20.2" @@ -797,6 +822,20 @@ 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 = "nodriver" +version = "0.50.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "mss" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/ad/b8b7472ddbf8c28e4ae37ef46d863400ea2688569f3178a083dda8531f3f/nodriver-0.50.3.tar.gz", hash = "sha256:24ca688d8646ef8ffad5c8ce65804e5e7671a779ad26a24d76f6465d5666c631", size = 354439, upload-time = "2026-05-13T13:57:55.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/23/b2dd278ad941c720f217c5264d7cbfc60dd09d882b5f7fb6c365013a8ce0/nodriver-0.50.3-py3-none-any.whl", hash = "sha256:a053533108c14c309a7613445e6c7fb460b6b165c144a062e1b2f39e4f7b396b", size = 378085, upload-time = "2026-05-13T13:57:51.877Z" }, +] + [[package]] name = "packaging" version = "26.1" @@ -1356,6 +1395,115 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, ] +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "wrapt" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" }, + { url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" }, + { url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" }, + { url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" }, + { url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" }, + { url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" }, + { url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" }, + { url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" }, + { url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" }, + { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" }, + { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" }, + { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" }, + { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" }, + { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, +] + [[package]] name = "zopfli" version = "0.4.1"