From 8f1745f3971ca398406d78387caa840f029483f9 Mon Sep 17 00:00:00 2001 From: ar0x4 Date: Sun, 28 Jun 2026 14:48:05 +0200 Subject: [PATCH] Init Public Commit --- README.md | 87 ++ azprox/__init__.py | 3 + azprox/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 209 bytes azprox/__pycache__/cli.cpython-314.pyc | Bin 0 -> 17040 bytes azprox/cli.py | 331 ++++++++ azprox/core/__init__.py | 0 .../core/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 122 bytes azprox/core/__pycache__/auth.cpython-314.pyc | Bin 0 -> 7999 bytes .../core/__pycache__/config.cpython-314.pyc | Bin 0 -> 6799 bytes .../core/__pycache__/deployer.cpython-314.pyc | Bin 0 -> 14420 bytes .../core/__pycache__/health.cpython-314.pyc | Bin 0 -> 6033 bytes .../core/__pycache__/regions.cpython-314.pyc | Bin 0 -> 5340 bytes azprox/core/auth.py | 136 ++++ azprox/core/config.py | 107 +++ azprox/core/deployer.py | 227 ++++++ azprox/core/health.py | 73 ++ azprox/core/regions.py | 160 ++++ .../__pycache__/function_app.cpython-314.pyc | Bin 0 -> 7015 bytes azprox/function_template/function_app.py | 196 +++++ azprox/function_template/host.json | 24 + azprox/function_template/requirements.txt | 2 + azprox/proxy/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 123 bytes .../__pycache__/interceptor.cpython-314.pyc | Bin 0 -> 3423 bytes .../proxy/__pycache__/rotator.cpython-314.pyc | Bin 0 -> 6878 bytes .../proxy/__pycache__/server.cpython-314.pyc | Bin 0 -> 38044 bytes azprox/proxy/interceptor.py | 93 +++ azprox/proxy/rotator.py | 116 +++ azprox/proxy/server.py | 526 +++++++++++++ pyproject.toml | 45 ++ requirements.txt | 8 + uv.lock | 742 ++++++++++++++++++ 32 files changed, 2876 insertions(+) create mode 100644 README.md create mode 100644 azprox/__init__.py create mode 100644 azprox/__pycache__/__init__.cpython-314.pyc create mode 100644 azprox/__pycache__/cli.cpython-314.pyc create mode 100644 azprox/cli.py create mode 100644 azprox/core/__init__.py create mode 100644 azprox/core/__pycache__/__init__.cpython-314.pyc create mode 100644 azprox/core/__pycache__/auth.cpython-314.pyc create mode 100644 azprox/core/__pycache__/config.cpython-314.pyc create mode 100644 azprox/core/__pycache__/deployer.cpython-314.pyc create mode 100644 azprox/core/__pycache__/health.cpython-314.pyc create mode 100644 azprox/core/__pycache__/regions.cpython-314.pyc create mode 100644 azprox/core/auth.py create mode 100644 azprox/core/config.py create mode 100644 azprox/core/deployer.py create mode 100644 azprox/core/health.py create mode 100644 azprox/core/regions.py create mode 100644 azprox/function_template/__pycache__/function_app.cpython-314.pyc create mode 100644 azprox/function_template/function_app.py create mode 100644 azprox/function_template/host.json create mode 100644 azprox/function_template/requirements.txt create mode 100644 azprox/proxy/__init__.py create mode 100644 azprox/proxy/__pycache__/__init__.cpython-314.pyc create mode 100644 azprox/proxy/__pycache__/interceptor.cpython-314.pyc create mode 100644 azprox/proxy/__pycache__/rotator.cpython-314.pyc create mode 100644 azprox/proxy/__pycache__/server.cpython-314.pyc create mode 100644 azprox/proxy/interceptor.py create mode 100644 azprox/proxy/rotator.py create mode 100644 azprox/proxy/server.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 uv.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..90d3a8c --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# AzProx + +**IP rotation through Azure Functions. Like proxychains, but through the cloud.** + +Deploy lightweight HTTP proxy functions across Azure regions. Each Function App +gets several outbound IPs, so prefixing a command with `azprox` routes its +traffic through a rotating pool of Azure IPs. + +```bash +azprox curl https://ifconfig.me # different Azure IP per request +azprox python3 spray.py # any tool, all HTTP(S) traffic rotated +azprox nuclei -u https://target.com +``` + +## Install + +```bash +git clone azprox && cd azprox +pip install -e . +``` + +Requires Python 3.10+ and the Azure CLI (`az`). + +## Quickstart + +```bash +azprox init # use your current `az login` session +azprox deploy # 5 random EU regions (~3-5 min, remote build) +azprox status # show endpoints + live outbound IPs +azprox curl https://ifconfig.me # go +azprox nuke # tear everything down when done +``` + +## Commands + +| Command | What it does | +|---------|--------------| +| `azprox init` | Authenticate (az CLI session, or `--client-id/--secret/--tenant/--subscription` for a service principal) | +| `azprox deploy` | Deploy proxies. `-n 10`, `--regions eu\|us\|apac\|all`, or `--regions westeurope,uksouth` | +| `azprox status` | Health-check each endpoint, print outbound IP + latency | +| `azprox serve` | Run a persistent local proxy (`-p 8080`, `--random`) | +| `azprox regions` | List available regions | +| `azprox nuke` | Delete the resource group and clear local state (`--force` to skip the prompt) | +| `azprox ` | Run that command through the proxy | + +One active deployment at a time. State lives in `~/.azprox/`. + +## How it works + +1. `azprox deploy` creates a Function App per region — each a small HTTP relay + that strips identifying headers and forwards to your target. +2. `azprox ` starts a local proxy, sets `HTTP_PROXY`/`HTTPS_PROXY`, and + runs your command. +3. The local proxy picks an endpoint per request; the function forwards from its + own Azure outbound IP. The target sees a rotating Azure IP, no proxy headers. + +``` +your machine Azure Functions target +┌──────────┐ ┌───────────────────────────┐ ┌────────┐ +│ azprox │ │ westeurope (N IPs) │ │ │ +│ curl ... │──► │ northeurope (N IPs) │──► │ target │ +│ │ │ uksouth (N IPs) │ │ .com │ +└──────────┘ └───────────────────────────┘ └────────┘ + HTTP_PROXY random / round-robin sees rotating IPs +``` + +**HTTPS:** an `HTTP_PROXY` can't see inside a `CONNECT` tunnel, so `azprox` +terminates TLS locally with a leaf cert from a local CA (`~/.azprox/ca/`), +rewrites the decrypted request through the rotating function, and re-encrypts the +reply. In `azprox ` mode the child's CA-bundle env vars are pointed at +that CA, so curl, requests, httpx, wget, node, and git trust it automatically. +The CA never leaves your machine. + +## Cost + +Azure Functions Consumption plan: 1M executions/month free (permanent). Typical +engagement volume is effectively free; idle costs nothing. + +## OPSEC + +Resource names are randomized, App Insights is disabled, and the TLS fingerprint +to targets is Azure's. Use a dedicated subscription and always `azprox nuke` +afterward. See [docs/OPSEC.md](docs/OPSEC.md). + +## License + +GPL-3.0 diff --git a/azprox/__init__.py b/azprox/__init__.py new file mode 100644 index 0000000..2609a09 --- /dev/null +++ b/azprox/__init__.py @@ -0,0 +1,3 @@ +"""AzProx — Azure Functions IP rotation proxy.""" + +__version__ = "0.1.0" diff --git a/azprox/__pycache__/__init__.cpython-314.pyc b/azprox/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..433553bab246437bdacd6678228457210d05c297 GIT binary patch literal 209 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 run command through proxy") + console.print(" azprox curl https://ifconfig.me example: curl through rotating IPs") + console.print(" azprox python3 spray.py example: any tool, any args") + console.print() + console.print(" [bold]Setup:[/bold]") + console.print(" azprox init authenticate to Azure") + console.print(" azprox deploy deploy proxy functions (5 random EU regions)") + console.print(" azprox deploy -n 10 deploy to 10 regions") + console.print(" azprox deploy --regions eu deploy to all EU regions") + console.print() + console.print(" [bold]Management:[/bold]") + console.print(" azprox status show deployed endpoints & health") + console.print(" azprox serve start persistent local proxy") + console.print(" azprox regions list available Azure regions") + console.print(" azprox nuke tear down everything") + console.print(" azprox nuke --force skip confirmation") + console.print() + + +# ── Subcommand handlers ─────────────────────────────────────────────────── + +def _cmd_init(args: list[str]): + """Authenticate to Azure (az login, or service-principal flags).""" + import argparse + parser = argparse.ArgumentParser(prog="azprox init") + parser.add_argument("--client-id", help="Service principal client ID") + parser.add_argument("--secret", help="Service principal secret") + parser.add_argument("--tenant", help="Azure AD tenant ID") + parser.add_argument("--subscription", help="Azure subscription ID") + parsed = parser.parse_args(args) + + from azprox.core.auth import authenticate + + try: + session = authenticate( + client_id=parsed.client_id, + client_secret=parsed.secret, + tenant_id=parsed.tenant, + subscription_id=parsed.subscription, + ) + except Exception as exc: # noqa: BLE001 + console.print(f"[red]Authentication failed:[/red] {exc}") + sys.exit(1) + + console.print("[green]✓ Authenticated to Azure[/green]") + console.print(f" Subscription: [cyan]{session.subscription_id}[/cyan]") + if session.tenant_id: + console.print(f" Tenant: [dim]{session.tenant_id}[/dim]") + + +def _cmd_deploy(args: list[str]): + """Deploy Function App proxies to the resolved regions.""" + import argparse + parser = argparse.ArgumentParser(prog="azprox deploy") + parser.add_argument("-n", "--count", type=int, default=5, help="Number of regions (default: 5)") + parser.add_argument("--regions", default=None, help="'eu', 'us', 'apac', 'all', or comma-separated list") + parser.add_argument("--stealth", action="store_true", help="Use bland resource names (default: True)") + parsed = parser.parse_args(args) + + from azprox.core.config import has_deployment + from azprox.core.regions import ( + ALL_REGIONS, + APAC_REGIONS, + EU_REGIONS, + US_REGIONS, + resolve_regions, + ) + + if has_deployment(): + console.print("[yellow]An active deployment already exists.[/yellow] Run [bold]azprox nuke[/bold] first.") + sys.exit(1) + + arg = parsed.regions + if arg is None: + regions = resolve_regions(count=parsed.count) + else: + keyword = arg.strip().lower() + pools = {"eu": EU_REGIONS, "us": US_REGIONS, "apac": APAC_REGIONS, "all": ALL_REGIONS} + if keyword in pools: + regions = pools[keyword].copy() + else: + regions = resolve_regions(regions_csv=arg) + + if not regions: + console.print("[red]No regions resolved.[/red]") + sys.exit(1) + + from azprox.core.deployer import Deployer + + try: + state = Deployer().deploy(regions) + except Exception as exc: # noqa: BLE001 + console.print(f"[red]Deploy failed:[/red] {exc}") + sys.exit(1) + + active = len(state.active_endpoints) + console.print( + f"\n[green]Deployment complete.[/green] {active}/{len(state.endpoints)} endpoints healthy. " + f"Use: [bold]azprox curl https://ifconfig.me[/bold]" + ) + + +def _cmd_status(args: list[str]): + """Health-check every endpoint and print a status table.""" + from azprox.core.config import load_state, save_state + + try: + state = load_state() + except FileNotFoundError: + console.print("[yellow]No active deployment.[/yellow] Run: [bold]azprox deploy[/bold]") + return + + import asyncio + + from azprox.core.health import check_deployment_health + + console.print("[dim]checking endpoint health...[/dim]") + try: + results = asyncio.run(check_deployment_health(state)) + except Exception as exc: # noqa: BLE001 + console.print(f"[red]Health check failed:[/red] {exc}") + results = [] + + by_url = {r.endpoint.url: r for r in results} + + table = Table(title="AzProx Endpoints") + table.add_column("Region", style="cyan") + table.add_column("Endpoint", style="dim") + table.add_column("Status") + table.add_column("Outbound IP", style="magenta") + table.add_column("ms", justify="right", style="dim") + + healthy = 0 + for ep in state.endpoints: + r = by_url.get(ep.url) + if r and r.healthy: + healthy += 1 + status = "[green]healthy[/green]" + ip = r.outbound_ip + ms = f"{r.response_time_ms:.0f}" + elif r: + status = "[red]unhealthy[/red]" + ip = r.error[:30] + ms = f"{r.response_time_ms:.0f}" + else: + status = f"[yellow]{ep.status}[/yellow]" + ip = "" + ms = "" + table.add_row(ep.region, ep.url, status, ip, ms) + + console.print(table) + save_state(state) + console.print( + f"\n [dim]{healthy}/{len(state.endpoints)} healthy | rg: {state.resource_group} " + f"| key: {state.auth_key[:8]}...[/dim]" + ) + + +def _cmd_nuke(args: list[str]): + """Delete the resource group and clear local state (--force skips the prompt).""" + force = "--force" in args or "-f" in args + + from azprox.core.config import load_state + try: + state = load_state() + except FileNotFoundError: + console.print("[dim]Nothing to nuke — no active deployment.[/dim]") + return + + if not force: + console.print(f"[yellow]This will destroy {len(state.endpoints)} endpoints in [bold]{state.resource_group}[/bold][/yellow]") + confirm = input("Type 'yes' to confirm: ") + if confirm.strip().lower() != "yes": + console.print("[dim]Aborted.[/dim]") + return + + from azprox.core.deployer import Deployer + + try: + Deployer().destroy() + except Exception as exc: # noqa: BLE001 + console.print(f"[red]Nuke failed:[/red] {exc}") + sys.exit(1) + + +def _cmd_serve(args: list[str]): + """Run a persistent local proxy in the foreground.""" + import argparse + parser = argparse.ArgumentParser(prog="azprox serve") + parser.add_argument("-p", "--port", type=int, default=8080) + parser.add_argument("--random", action="store_true", help="Random endpoint selection") + parsed = parser.parse_args(args) + + from azprox.core.config import load_state + from azprox.proxy.server import run_proxy_server + + try: + state = load_state() + except FileNotFoundError: + console.print("[red]No active deployment.[/red] Run: [bold]azprox deploy[/bold]") + sys.exit(1) + + strategy = "random" if parsed.random else "round-robin" + + run_proxy_server( + endpoints=state.endpoint_urls, + auth_key=state.auth_key, + strategy=strategy, + port=parsed.port, + ) + + +def _cmd_regions(): + """List available Azure regions.""" + from azprox.core.regions import EU_REGIONS, US_REGIONS, APAC_REGIONS + + table = Table(title="Available Azure Regions") + table.add_column("Region", style="cyan") + table.add_column("Pool", style="dim") + + for r in EU_REGIONS: + table.add_row(r, "eu") + for r in US_REGIONS: + table.add_row(r, "us") + for r in APAC_REGIONS: + table.add_row(r, "apac") + + console.print(table) + + +# ── Proxy-exec mode ─────────────────────────────────────────────────────── + +def _cmd_proxy_exec(command: list[str]): + """Run a command with its HTTP(S) traffic routed through the proxy.""" + from azprox.core.config import load_state + from azprox.proxy.server import exec_with_proxy + + try: + state = load_state() + except FileNotFoundError: + console.print("[red]No active deployment.[/red] Run: [bold]azprox deploy[/bold] first.") + sys.exit(1) + + if not state.endpoint_urls: + console.print("[red]No healthy endpoints.[/red] Run: [bold]azprox status[/bold] to check.") + sys.exit(1) + + exit_code = exec_with_proxy( + endpoints=state.endpoint_urls, + auth_key=state.auth_key, + command=command, + ) + sys.exit(exit_code) + + +# ── Entry point ─────────────────────────────────────────────────────────── + +if __name__ == "__main__": + main() diff --git a/azprox/core/__init__.py b/azprox/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/azprox/core/__pycache__/__init__.cpython-314.pyc b/azprox/core/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..62029047a20ec4af29d001ac7c7acf8540829d96 GIT binary patch literal 122 zcmdPq_I|p@<2{{|u76C7xJSP?TSx xpPXNmsvjSpnU`4-AFo$Xd5gm)H$SB`C)KWq6{rqmQZb0}iJ6g+v4|PS0s!2?7e@d9 literal 0 HcmV?d00001 diff --git a/azprox/core/__pycache__/auth.cpython-314.pyc b/azprox/core/__pycache__/auth.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..30027996df7af7b537fd991324cf5d8e42408104 GIT binary patch literal 7999 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 literal 0 HcmV?d00001 diff --git a/azprox/core/__pycache__/config.cpython-314.pyc b/azprox/core/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a6dd6f0c0f6991bb4fdb91fd9d101f36e9bc8274 GIT binary patch literal 6799 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 RIpB#O!qB&Vux=*y{{qq#9y0&{ literal 0 HcmV?d00001 diff --git a/azprox/core/__pycache__/deployer.cpython-314.pyc b/azprox/core/__pycache__/deployer.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5829e29e53949aecd858487a224aaef2073373fb GIT binary patch literal 14420 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 literal 0 HcmV?d00001 diff --git a/azprox/core/__pycache__/regions.cpython-314.pyc b/azprox/core/__pycache__/regions.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ada1fee6e5b7e2762dc1ab302f98d4b3b6c2663e GIT binary patch literal 5340 zcmai1+fN+V9X_+WvzJ*G*aeL7b-*@eeF5Ju#16*b3$X)ah;dRIZ-(6g#vXRoXJ$-s zABxl{A*m{pwo)7^5AI8qsI8iZKIAcx`Ulu0shw$}x@oH_Z)=mtO`rPx&g=q-T=huv z&3CT9bNznj?5s^G4B_M2-m71v8T&g$szDBjwDgh0n9kx zZ)V`3fyi0el?4wEM9;?DnBb9txEmjnn^-H;Qnm|H7K?xK=iOp9r|$0aSUbpjJw z7f{jm0F&BYV2!p9n9}wGYqbNwI_)5^UONO#Ylndu?Fg_zI|^*nx`9pFGr%?4F<^^! zd|ZN^b=tFtt=D>h8?+O^jan~olXeogS?dF;T0gK=I|baLod#~z&H%S*XMye70C2nZ z9I!(>2h3{cfjhM4$E7~Wtt%I;HbM-Yyu0YF*28Tu0O@%og=&GhfXpm`>ilVenb?hN)NVjr5=ERI-wv z)Qd&i6$Twd69wJX^Oo*VBtBFk)4Da7N2ixVIAGG!A&@yXhMkDr7`l>j!`PS7SVRkn zxgi!_W)Z=#KeZ_4A?7E=xPyuC!;Z`S2s&-}HRx^0(r0rbmY6i`Nv@ZsX5VLn*{C1S ziS%5~S8};&yWm*{cug*M-P0|qfmmHGH(_$ewalVXw1b3IqeMbeF6Ypg^EushxjF8+ zhLg+jB*wEugY(zuH%r|9VOQSfMpv*jPG@PBN0BKY1>$4?ud|0r>L&vaGEIw-rU!MI z*9SQzi$cCKV>qtial2&r$)e5OsY)c~U2|;Do${mJH7et$Cb(YA8+oJXa^3QiK{P0n znKbycUYwBLHdk7Yv?dm;elai#wZvt9T-lP zY-*$$RZLg6W`ibcoRV%<3;riTyX_K|ZOm6rUC3#t&I}9(Ev!W}9BW8?2m+tzl5xP5ugJ;x z$;#o$<((ThQ&l|)_Klpky`qaD1ERWRkr0e&ulE>~jN2CO)SdFvp&-P12!91P{`i%3=H;CTq(w{^cl3rOPp5aaO;v zoFJyKXx*bEm^j1v2->KI1PDegFZ~*}X(qN;atZ~Si%~XQowI5U{59!7CAFSih^u88QW!mR3!hbu1sX?cU8b8U*0WKp+GuDwwTV(M$E&RklQw=YRQrp1VFM*G3t=sE z2b!N)UPgQ)Pqq??QFiMs^OKed7q`3WxMicIj^KhaZB9

UglwPyunf z;vR|!G58e-#9%9nH$3R-zTb7?Ue}3_8b0Ydwb*d_Ue~GjTHikN)|p=nygl^R(4QN( zA!R;(x8d|>o7xvMJ?~82PXFPVdpn+ed+);Dcc$iJcQZYIO=cd})IX?ep0E2?EcJO^ z%dH>3@#Doh^=@2!*wXf=mL2bzA0GImv*%~sf4nr;|9LWf>-d|;Z#TZ{+&TE$E$|=U@>{{Zsg#%kDFQJ8R=U;)8o*lf499a5&1Y?(^nh$xFrhwjvSL9;YdqMsuqIsgf1hy)aHd!$UiJ0l_% z2qaF|av;${-{hr0c`q+#JWc8@LK=6Gg-WO%X!JUC^Y|H@EN2SWC6(^CDl}i!E8A1; z@aUwPMffk9>ytQmd2vuwy73k#vz^_8U!fbgEQa_c{NZ#B7lWMv7lUmNHcLH{D;p&z zZ1EqVgcx}<$Q=7ea^wAE>%C;_o%RnhzuOTk*UGS~Lnj4-J!xr!sP*(E>5?$8uviYU z0yop*_O$8-JobMV8E)f^6=H=8Kyn-y-@W6bPG6KX$!t)PvC2y$ryV@oh6fHW&IYWPC0G3 z&?o(dZd&@dRek2ZC~v;?G=NERd1=}WK~?Cr_?^Y$-HUt=uz}f))Z28@`@^4l3VBu!&$ZflAOy&_=KYkWFxUv2wZ*@eYD4!486*1au|ly9scP z7(5uZJmY%__7UtSpwqz*63}Cr9|mM2gI`gL zKbFfC>^umbyAvMn!*t1|x%QLGFLlG=bng79J6nPT@(3b)nm{~Pw-Ku+=pr~mAQqM6 zQTRw9eVmpDLirkgLMTrJLK#06JhPwWKSd77p0foV_;)!hNz&J$h!puoVbYd=veqxy z`oFLgF4m#slTcV{z(Pq4OJPLI6n&gYw}j@D`GF-USLkuOQjIBytxc>C&DG4CODrLU z%4^uVt#__|+Op?^W1qJ4%q8bvUSvJxXj^;eL8N|RTR9B&Fp^$4SeC(HQLdf$$}!?` zCa30&a)P+R>yxkDgxnbc{IZZsnp`(EfVj8v?$O_@ dict: + try: + out = subprocess.run( + ["az", "account", "show", "-o", "json"], + capture_output=True, text=True, timeout=30, + ) + if out.returncode == 0 and out.stdout.strip(): + return json.loads(out.stdout) + except (subprocess.SubprocessError, json.JSONDecodeError, FileNotFoundError): + pass + return {} + + +class AzureSession: + def __init__(self, credential, subscription_id: str, tenant_id: str = ""): + self.credential = credential + self.subscription_id = subscription_id + self.tenant_id = tenant_id + self._resource_client = None + self._web_client = None + self._storage_client = None + + @classmethod + def from_config(cls) -> "AzureSession": + auth = load_auth() + + if auth.method == "sp" and auth.client_id and auth.client_secret and auth.tenant_id: + from azure.identity import ClientSecretCredential + + credential = ClientSecretCredential( + tenant_id=auth.tenant_id, + client_id=auth.client_id, + client_secret=auth.client_secret, + ) + if not auth.subscription_id: + raise RuntimeError("Service-principal auth needs a subscription_id. Re-run: azprox init") + return cls(credential, auth.subscription_id, auth.tenant_id) + + from azure.identity import AzureCliCredential + + sub, tenant = auth.subscription_id, auth.tenant_id + if not sub or not tenant: + acct = _az_account_show() + if not acct: + raise RuntimeError("Not authenticated. Run `az login` (or `azprox init`) first.") + sub = sub or acct.get("id", "") + tenant = tenant or acct.get("tenantId", "") + + return cls(AzureCliCredential(), sub, tenant) + + @classmethod + def interactive_login( + cls, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + tenant_id: Optional[str] = None, + subscription_id: Optional[str] = None, + ) -> "AzureSession": + if client_id and client_secret and tenant_id: + from azure.identity import ClientSecretCredential + + if not subscription_id: + raise RuntimeError("--subscription is required with service-principal credentials.") + credential = ClientSecretCredential( + tenant_id=tenant_id, client_id=client_id, client_secret=client_secret, + ) + session = cls(credential, subscription_id, tenant_id) + session.verify() + save_auth(AuthConfig( + method="sp", subscription_id=subscription_id, + client_id=client_id, client_secret=client_secret, tenant_id=tenant_id, + )) + return session + + acct = _az_account_show() + if not acct: + subprocess.run(["az", "login"], check=True) + acct = _az_account_show() + if not acct: + raise RuntimeError("az login did not produce an active account.") + + sub = subscription_id or acct.get("id", "") + tenant = tenant_id or acct.get("tenantId", "") + if subscription_id and subscription_id != acct.get("id"): + subprocess.run(["az", "account", "set", "--subscription", subscription_id], check=True) + + from azure.identity import AzureCliCredential + + session = cls(AzureCliCredential(), sub, tenant) + session.verify() + save_auth(AuthConfig(method="cli", subscription_id=sub, tenant_id=tenant)) + return session + + def verify(self) -> None: + next(iter(self.resource_client().resource_groups.list()), None) + + def resource_client(self): + if self._resource_client is None: + try: + from azure.mgmt.resource import ResourceManagementClient + except ImportError: + # azure-mgmt-resource >= 26 dropped the top-level re-export. + from azure.mgmt.resource.resources import ResourceManagementClient + self._resource_client = ResourceManagementClient(self.credential, self.subscription_id) + return self._resource_client + + def web_client(self): + if self._web_client is None: + from azure.mgmt.web import WebSiteManagementClient + + self._web_client = WebSiteManagementClient(self.credential, self.subscription_id) + return self._web_client + + def storage_client(self): + if self._storage_client is None: + from azure.mgmt.storage import StorageManagementClient + + self._storage_client = StorageManagementClient(self.credential, self.subscription_id) + return self._storage_client + + def mgmt_token(self) -> str: + return self.credential.get_token("https://management.azure.com/.default").token + + +def authenticate(**kwargs) -> AzureSession: + return AzureSession.interactive_login(**kwargs) diff --git a/azprox/core/config.py b/azprox/core/config.py new file mode 100644 index 0000000..12627c9 --- /dev/null +++ b/azprox/core/config.py @@ -0,0 +1,107 @@ +""" +Config — single deployment state management. + +No named deployments, no complexity. One active deployment at a time. + +State file: ~/.azprox/state.json +Config file: ~/.azprox/config.json (auth creds) +""" +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Optional + +CONFIG_DIR = Path.home() / ".azprox" +STATE_FILE = CONFIG_DIR / "state.json" +CONFIG_FILE = CONFIG_DIR / "config.json" + + +@dataclass +class EndpointState: + """Single deployed Azure Function endpoint.""" + + region: str + function_app_name: str + url: str + storage_account: str + status: str = "active" # active | unhealthy | deploying + + +@dataclass +class DeploymentState: + """The one active deployment.""" + + resource_group: str + subscription_id: str + auth_key: str # shared secret for function auth + created_at: str = "" + endpoints: list[EndpointState] = field(default_factory=list) + + @property + def active_endpoints(self) -> list[EndpointState]: + return [e for e in self.endpoints if e.status == "active"] + + @property + def endpoint_urls(self) -> list[str]: + return [e.url for e in self.active_endpoints] + + +@dataclass +class AuthConfig: + """Stored Azure auth configuration.""" + + method: str = "cli" # "cli" or "sp" + subscription_id: str = "" + client_id: str = "" + client_secret: str = "" + tenant_id: str = "" + + +# ── File operations ─────────────────────────────────────────────────────── + +def ensure_dirs() -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + + +def save_state(state: DeploymentState) -> None: + ensure_dirs() + data = { + "resource_group": state.resource_group, + "subscription_id": state.subscription_id, + "auth_key": state.auth_key, + "created_at": state.created_at, + "endpoints": [asdict(e) for e in state.endpoints], + } + STATE_FILE.write_text(json.dumps(data, indent=2)) + + +def load_state() -> DeploymentState: + """Load active deployment. Raises FileNotFoundError if none.""" + if not STATE_FILE.exists(): + raise FileNotFoundError("No active deployment") + data = json.loads(STATE_FILE.read_text()) + endpoints = [EndpointState(**e) for e in data.pop("endpoints", [])] + return DeploymentState(**data, endpoints=endpoints) + + +def has_deployment() -> bool: + return STATE_FILE.exists() + + +def clear_state() -> None: + if STATE_FILE.exists(): + STATE_FILE.unlink() + + +def save_auth(auth: AuthConfig) -> None: + ensure_dirs() + CONFIG_FILE.write_text(json.dumps(asdict(auth), indent=2)) + + +def load_auth() -> AuthConfig: + if not CONFIG_FILE.exists(): + return AuthConfig() + data = json.loads(CONFIG_FILE.read_text()) + return AuthConfig(**data) diff --git a/azprox/core/deployer.py b/azprox/core/deployer.py new file mode 100644 index 0000000..17e6cd4 --- /dev/null +++ b/azprox/core/deployer.py @@ -0,0 +1,227 @@ +"""Create and destroy the Azure Function proxy infrastructure (one deployment at a time).""" +from __future__ import annotations + +import asyncio +import io +import secrets +import string +import time +import traceback +import zipfile +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timezone +from pathlib import Path + +import requests +from rich.console import Console + +from azprox.core.auth import AzureSession +from azprox.core.config import ( + CONFIG_DIR, + DeploymentState, + EndpointState, + clear_state, + load_state, + save_state, +) +from azprox.core.regions import get_region_short + +console = Console() + +RG_LOCATION = "westeurope" +HEALTH_TIMEOUT = 420.0 +ZIPDEPLOY_RETRIES = 4 + + +def _random_string(length: int, charset: str = string.ascii_lowercase + string.digits) -> str: + return "".join(secrets.choice(charset) for _ in range(length)) + + +class Deployer: + def __init__(self): + self.session: AzureSession | None = None + + def _ensure_auth(self): + if not self.session: + self.session = AzureSession.from_config() + + def deploy(self, regions: list[str]) -> DeploymentState: + self._ensure_auth() + assert self.session is not None + + auth_key = secrets.token_urlsafe(32) + run_id = _random_string(8) + rg_name = f"azp-{run_id}-rg" + + console.print(f"\n[bold]Deploying to {len(regions)} region(s)...[/bold]") + console.print(f" Resource group: [cyan]{rg_name}[/cyan]") + console.print(f" Auth key: [dim]{auth_key[:12]}...[/dim]\n") + + console.print(f" [dim]creating resource group {rg_name}...[/dim]") + self.session.resource_client().resource_groups.create_or_update(rg_name, {"location": RG_LOCATION}) + + endpoints: list[EndpointState] = [] + with ThreadPoolExecutor(max_workers=min(len(regions), 8)) as pool: + futures = { + pool.submit(self._deploy_region, region, rg_name, run_id, auth_key): region + for region in regions + } + for fut in as_completed(futures): + region = futures[fut] + try: + ep = fut.result() + endpoints.append(ep) + console.print(f" [green]✓[/green] {region:<20} {ep.url}") + except Exception as exc: # noqa: BLE001 + console.print(f" [red]✗[/red] {region:<20} {type(exc).__name__}: {exc}") + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + (CONFIG_DIR / "last-deploy-error.log").write_text(traceback.format_exc()) + + if not endpoints: + raise RuntimeError("All region deployments failed. Resource group left in place for inspection.") + + state = DeploymentState( + resource_group=rg_name, + subscription_id=self.session.subscription_id, + auth_key=auth_key, + created_at=datetime.now(timezone.utc).isoformat(), + endpoints=endpoints, + ) + save_state(state) + + console.print("\n [dim]running health checks...[/dim]") + from azprox.core.health import check_deployment_health + + for r in asyncio.run(check_deployment_health(state)): + tag = "[green]healthy[/green]" if r.healthy else f"[red]unhealthy[/red] ({r.error})" + ip = f" → {r.outbound_ip}" if r.outbound_ip else "" + console.print(f" {r.endpoint.region:<20} {tag}{ip}") + save_state(state) + return state + + def _deploy_region(self, region: str, rg_name: str, run_id: str, auth_key: str) -> EndpointState: + assert self.session is not None + short = get_region_short(region) + storage_name = f"azpst{_random_string(12)}" + plan_name = f"azp-{run_id}-{short}-plan" + app_name = f"azp-{run_id}-{short}-{_random_string(4)}" + + sc = self.session.storage_client() + wc = self.session.web_client() + + sc.storage_accounts.begin_create(rg_name, storage_name, { + "location": region, + "sku": {"name": "Standard_LRS"}, + "kind": "StorageV2", + "properties": {"minimumTlsVersion": "TLS1_2", "allowBlobPublicAccess": False}, + }).result() + keys = sc.storage_accounts.list_keys(rg_name, storage_name) + # SDK model is a Mapping, so `.keys` is the dict method — index it. + account_key = keys["keys"][0].value + conn_str = ( + f"DefaultEndpointsProtocol=https;AccountName={storage_name};" + f"AccountKey={account_key};EndpointSuffix=core.windows.net" + ) + + plan = wc.app_service_plans.begin_create_or_update(rg_name, plan_name, { + "location": region, + "kind": "functionapp", + "sku": {"name": "Y1", "tier": "Dynamic"}, + "properties": {"reserved": True}, + }).result() + + app_settings = [ + {"name": "FUNCTIONS_EXTENSION_VERSION", "value": "~4"}, + {"name": "FUNCTIONS_WORKER_RUNTIME", "value": "python"}, + {"name": "AzureWebJobsStorage", "value": conn_str}, + {"name": "AzureWebJobsFeatureFlags", "value": "EnableWorkerIndexing"}, + {"name": "AZUREPROX_KEY", "value": auth_key}, + {"name": "SCM_DO_BUILD_DURING_DEPLOYMENT", "value": "true"}, + {"name": "ENABLE_ORYX_BUILD", "value": "true"}, + {"name": "APPINSIGHTS_INSTRUMENTATIONKEY", "value": ""}, + {"name": "APPLICATIONINSIGHTS_CONNECTION_STRING", "value": ""}, + ] + wc.web_apps.begin_create_or_update(rg_name, app_name, { + "location": region, + "kind": "functionapp,linux", + "properties": { + "serverFarmId": plan.id, + "reserved": True, + "httpsOnly": True, + "siteConfig": { + "linuxFxVersion": "Python|3.11", + "appSettings": app_settings, + "ftpsState": "Disabled", + "minTlsVersion": "1.2", + }, + }, + }).result() + + self._zip_deploy(app_name) + self._wait_for_health(app_name) + + return EndpointState( + region=region, + function_app_name=app_name, + url=f"https://{app_name}.azurewebsites.net/api/proxy", + storage_account=storage_name, + status="active", + ) + + def _zip_deploy(self, app_name: str) -> None: + assert self.session is not None + zip_bytes = self._build_zip() + scm_url = f"https://{app_name}.scm.azurewebsites.net/api/zipdeploy" + last_err = "" + for attempt in range(1, ZIPDEPLOY_RETRIES + 1): + try: + resp = requests.post( + scm_url, + headers={ + "Authorization": f"Bearer {self.session.mgmt_token()}", + "Content-Type": "application/zip", + }, + data=zip_bytes, + timeout=600, + ) + if resp.status_code in (200, 202): + return + last_err = f"HTTP {resp.status_code}: {resp.text[:200]}" + except requests.RequestException as exc: + last_err = f"{type(exc).__name__}: {exc}" + time.sleep(15 * attempt) # SCM may not be ready right after app creation + raise RuntimeError(f"zip deploy failed for {app_name}: {last_err}") + + def _build_zip(self) -> bytes: + template_dir = Path(__file__).parent.parent / "function_template" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for f in template_dir.iterdir(): + if f.is_file() and not f.name.startswith("."): + zf.write(f, f.name) + return buf.getvalue() + + def _wait_for_health(self, app_name: str) -> None: + health_url = f"https://{app_name}.azurewebsites.net/api/health" + deadline = time.time() + HEALTH_TIMEOUT + last = "" + while time.time() < deadline: + try: + resp = requests.get(health_url, timeout=20) + if resp.status_code == 200: + return + last = f"HTTP {resp.status_code}" + except requests.RequestException as exc: + last = type(exc).__name__ + time.sleep(10) + raise RuntimeError(f"{app_name} not healthy within {HEALTH_TIMEOUT:.0f}s (last: {last})") + + def destroy(self) -> None: + self._ensure_auth() + assert self.session is not None + state = load_state() + + console.print(f"[yellow]Deleting resource group [bold]{state.resource_group}[/bold] (cascading)...[/yellow]") + self.session.resource_client().resource_groups.begin_delete(state.resource_group).wait() + clear_state() + console.print("[green]Done. All infrastructure destroyed.[/green]") diff --git a/azprox/core/health.py b/azprox/core/health.py new file mode 100644 index 0000000..43b27f4 --- /dev/null +++ b/azprox/core/health.py @@ -0,0 +1,73 @@ +"""Endpoint health checks — confirm forwarding works and capture the outbound IP.""" +from __future__ import annotations + +import asyncio +import json +from dataclasses import dataclass + +import httpx + +from azprox.core.config import DeploymentState, EndpointState + +IP_ECHO_TARGET = "https://api.ipify.org?format=json" + + +@dataclass +class HealthResult: + endpoint: EndpointState + healthy: bool + response_time_ms: float + error: str = "" + outbound_ip: str = "" + + +def _parse_ip(body: str) -> str: + body = body.strip() + try: + data = json.loads(body) + for key in ("ip", "origin", "ipAddress"): + if key in data: + return str(data[key]).split(",")[0].strip() + except (json.JSONDecodeError, TypeError): + pass + return body.split()[0] if body else "" + + +async def check_endpoint( + endpoint: EndpointState, + auth_key: str, + timeout: float = 15.0, + client: httpx.AsyncClient | None = None, +) -> HealthResult: + headers = {"X-AzureProx-Key": auth_key, "X-AzureProx-Target": IP_ECHO_TARGET} + owns_client = client is None + if owns_client: + client = httpx.AsyncClient(timeout=timeout) + loop = asyncio.get_event_loop() + start = loop.time() + try: + resp = await client.get(endpoint.url, headers=headers) + elapsed_ms = (loop.time() - start) * 1000.0 + if resp.status_code == 200: + return HealthResult(endpoint, True, elapsed_ms, outbound_ip=_parse_ip(resp.text)) + return HealthResult(endpoint, False, elapsed_ms, error=f"HTTP {resp.status_code}") + except httpx.HTTPError as exc: + return HealthResult(endpoint, False, (loop.time() - start) * 1000.0, error=f"{type(exc).__name__}: {exc}") + finally: + if owns_client: + await client.aclose() + + +async def check_deployment_health(deployment: DeploymentState, concurrency: int = 10) -> list[HealthResult]: + sem = asyncio.Semaphore(concurrency) + + async with httpx.AsyncClient(timeout=15.0) as client: + async def _bounded(ep: EndpointState) -> HealthResult: + async with sem: + return await check_endpoint(ep, deployment.auth_key, client=client) + + results = await asyncio.gather(*[_bounded(ep) for ep in deployment.endpoints]) + + for result in results: + result.endpoint.status = "active" if result.healthy else "unhealthy" + return results diff --git a/azprox/core/regions.py b/azprox/core/regions.py new file mode 100644 index 0000000..971af60 --- /dev/null +++ b/azprox/core/regions.py @@ -0,0 +1,160 @@ +""" +Regions — Azure region enumeration and selection logic. + +Provides hardcoded region lists and dynamic region discovery via Azure SDK. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional +import random + + +@dataclass +class AzureRegion: + name: str # e.g. "westeurope" + display_name: str # e.g. "West Europe" + geography: str # e.g. "Europe" + + +# Regions known to support Azure Functions Consumption plan (Python, Linux) +# Grouped by geography for easy selection + +EU_REGIONS: list[str] = [ + "westeurope", # Netherlands + "northeurope", # Ireland + "uksouth", # London + "ukwest", # Cardiff + "francecentral", # Paris + "francesouth", # Marseille + "germanywestcentral", # Frankfurt + "germanynorth", # Berlin + "switzerlandnorth", # Zurich + "switzerlandwest", # Geneva + "norwayeast", # Oslo + "norwaywest", # Stavanger + "swedencentral", # Stockholm + "polandcentral", # Warsaw + "italynorth", # Milan + "spaincentral", # Madrid +] + +US_REGIONS: list[str] = [ + "eastus", + "eastus2", + "centralus", + "westus", + "westus2", + "westus3", + "northcentralus", + "southcentralus", + "westcentralus", +] + +APAC_REGIONS: list[str] = [ + "eastasia", # Hong Kong + "southeastasia", # Singapore + "australiaeast", # Sydney + "australiasoutheast", # Melbourne + "japaneast", # Tokyo + "japanwest", # Osaka + "koreacentral", # Seoul + "centralindia", # Pune +] + +ALL_REGIONS: list[str] = EU_REGIONS + US_REGIONS + APAC_REGIONS + +# Short codes for storage account naming (max 24 chars, no hyphens) +REGION_SHORT_CODES: dict[str, str] = { + "westeurope": "weu", + "northeurope": "neu", + "uksouth": "uks", + "ukwest": "ukw", + "francecentral": "frc", + "francesouth": "frs", + "germanywestcentral": "gwc", + "germanynorth": "gno", + "switzerlandnorth": "chn", + "switzerlandwest": "chw", + "norwayeast": "noe", + "norwaywest": "now", + "swedencentral": "sec", + "polandcentral": "plc", + "italynorth": "itn", + "spaincentral": "esc", + "eastus": "eus", + "eastus2": "eu2", + "centralus": "cus", + "westus": "wus", + "westus2": "wu2", + "westus3": "wu3", + "northcentralus": "ncu", + "southcentralus": "scu", + "westcentralus": "wcu", + "eastasia": "eas", + "southeastasia": "sea", + "australiaeast": "aue", + "australiasoutheast": "aus", + "japaneast": "jpe", + "japanwest": "jpw", + "koreacentral": "krc", + "centralindia": "cin", +} + + +def resolve_regions( + regions_csv: Optional[str] = None, + count: int = 5, + all_eu: bool = False, + geography: Optional[str] = None, +) -> list[str]: + """ + Resolve which regions to deploy to based on CLI flags. + + Priority: + 1. If regions_csv is set → parse and validate + 2. If all_eu → return EU_REGIONS + 3. Otherwise → randomly select `count` regions from EU_REGIONS (default) + + Validation: + - Check each region against ALL_REGIONS + - Warn on unknown regions but don't block (Azure might have new ones) + """ + if regions_csv: + selected = [r.strip().lower() for r in regions_csv.split(",")] + unknown = [r for r in selected if r not in ALL_REGIONS] + if unknown: + # Warn but continue — region might be new/valid + pass + return selected + + if all_eu: + return EU_REGIONS.copy() + + pool = EU_REGIONS if geography is None or geography == "eu" else ALL_REGIONS + return random.sample(pool, min(count, len(pool))) + + +def get_region_short(region: str) -> str: + """Get short code for a region (used in storage account naming).""" + return REGION_SHORT_CODES.get(region, region[:3]) + + +def get_available_regions(auth=None) -> list[AzureRegion]: + """ + Fetch available regions from Azure SDK. + + If auth is provided, use it to query Azure for regions that support + Microsoft.Web/sites (Function Apps) with Consumption plan. + + Falls back to hardcoded list if no auth or API call fails. + + TODO: Implement with: + subscription_client = SubscriptionClient(auth.credential) + regions = subscription_client.subscriptions.list_locations(auth.subscription_id) + """ + # Fallback to hardcoded + return [ + AzureRegion(name=r, display_name=r.replace("_", " ").title(), geography="") + for r in ALL_REGIONS + ] diff --git a/azprox/function_template/__pycache__/function_app.cpython-314.pyc b/azprox/function_template/__pycache__/function_app.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7897f95e7fef4cf9386cef2dccae24e7fa8dee2e GIT binary patch literal 7015 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 func.HttpResponse: + """ + HTTP proxy relay handler. + + Protocol: + - Caller sets X-AzureProx-Target header with the destination URL + - Caller sets X-AzureProx-Key header with the shared auth secret + - All other headers are forwarded to the target (after stripping Azure headers) + - Target's response is returned to the caller + """ + # ── 1. Authenticate ────────────────────────────────────────────── + if AZUREPROX_KEY: + caller_key = req.headers.get("X-AzureProx-Key", "") + if caller_key != AZUREPROX_KEY: + return func.HttpResponse( + "Unauthorized", + status_code=401, + ) + + # ── 2. Extract target URL ──────────────────────────────────────── + target = ( + req.headers.get("X-AzureProx-Target") + or req.params.get("target") + ) + if not target: + return func.HttpResponse( + '{"error": "Missing X-AzureProx-Target header or ?target= param"}', + status_code=400, + mimetype="application/json", + ) + + # ── 3. Build clean headers for forwarding ──────────────────────── + forward_headers: dict[str, str] = {} + for key, value in req.headers.items(): + k_lower = key.lower() + + # Skip our control headers + if k_lower.startswith(CONTROL_PREFIX): + continue + + # Skip Azure / proxy-revealing headers + if k_lower in STRIP_REQUEST_HEADERS: + continue + + # Skip Host — httpx will set it based on the target URL + if k_lower == "host": + continue + + forward_headers[key] = value + + # ── 4. Forward request to target ───────────────────────────────── + body = req.get_body() + + try: + async with httpx.AsyncClient( + timeout=30.0, + follow_redirects=False, # Let the caller handle redirects + verify=True, + ) as client: + response = await client.request( + method=req.method, + url=target, + headers=forward_headers, + content=body if body else None, + ) + except httpx.TimeoutException: + return func.HttpResponse( + '{"error": "Upstream timeout"}', + status_code=504, + mimetype="application/json", + ) + except httpx.RequestError as exc: + logger.warning(f"Upstream error: {exc}") + return func.HttpResponse( + f'{{"error": "Upstream error: {type(exc).__name__}"}}', + status_code=502, + mimetype="application/json", + ) + + # ── 5. Build response ──────────────────────────────────────────── + response_headers: dict[str, str] = {} + for key, value in response.headers.items(): + k_lower = key.lower() + + # Skip transport-level headers + if k_lower in SKIP_RESPONSE_HEADERS: + continue + + # Strip Azure-revealing response headers + if k_lower in STRIP_RESPONSE_HEADERS: + continue + + response_headers[key] = value + + return func.HttpResponse( + body=response.content, + status_code=response.status_code, + headers=response_headers, + ) + + +@app.function_name(name="health") +@app.route(route="health", methods=["GET"], auth_level=func.AuthLevel.ANONYMOUS) +def health_check(req: func.HttpRequest) -> func.HttpResponse: + """Simple health check endpoint — no auth required.""" + return func.HttpResponse( + '{"status": "ok", "service": "azureprox"}', + status_code=200, + mimetype="application/json", + ) diff --git a/azprox/function_template/host.json b/azprox/function_template/host.json new file mode 100644 index 0000000..68961d8 --- /dev/null +++ b/azprox/function_template/host.json @@ -0,0 +1,24 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": false + } + }, + "logLevel": { + "default": "Warning", + "Host.Results": "Error", + "Function": "Warning", + "Host.Aggregator": "Error" + } + }, + "extensions": { + "http": { + "routePrefix": "api", + "maxConcurrentRequests": 100, + "maxOutstandingRequests": 200 + } + }, + "functionTimeout": "00:00:30" +} diff --git a/azprox/function_template/requirements.txt b/azprox/function_template/requirements.txt new file mode 100644 index 0000000..f3a8f33 --- /dev/null +++ b/azprox/function_template/requirements.txt @@ -0,0 +1,2 @@ +azure-functions>=1.17.0 +httpx>=0.27.0 diff --git a/azprox/proxy/__init__.py b/azprox/proxy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/azprox/proxy/__pycache__/__init__.cpython-314.pyc b/azprox/proxy/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cd4bec52a3d46402023149a3823b612293d7fca9 GIT binary patch literal 123 zcmdPq_I|p@<2{{|u76C6QQFP?TSx v4>qu!8)kz^ZcBQCQhIGCJiqT>`2L4L{S7D4qXvX@SO#L37=)8`Vn|EUqRi#fNXtvH zMTIMZjxDK+aUMTSZYjfLf*8suG1OT(*kd-_lP8Qg@Drn?I>3`g66lnmQ-*eiwX-1aH;)G60p+u@+jM<| z7EGkW*MQh1w+NTO@A7&dk9`Gv&lN+yr5dqGSQV5aC>3~dK_!4n3M$3pflvJbL->tT z#|RwRdU@f<4}ni%O@~7UPI=Dh@_x+Y%#YV7uefDD250zpf7vea?*aR~4#O=!QGw%A z=K1Qz4yT^4Y`Eo}Uy+qu!cUr}W7Vi>`kHCh+;XFebjCE_YFO29C2N|SHuHGZcBtdR za>6vrZV3oj>G3BphE3B$@0Lu9b7pTeK%Hr_esInq`q3Y=S{N1u5gP@#ohYpG^&J)m z2{H5m;e82)cgbIrOOGuA|`m)S@pT#|so7a!CFHzploeD9JX zS(oSDA*2kvljN#YkqpU@jTpR&0XJkMj3m%0cr_zkNI=AA{)HLBk~kz_@G~y6w;&Fy zCis@6k&hY5&`6d+iNVb<wKP)CNLzbrrNs)1KeZ-J) ze!Upv)y)w1;9q4nw5zTawbkBJgzAfNLuBC1RLFoBC@RHxF=nXWPy!y@a>CjM^R*xk zDdyLcSoFW)MMN;5+8beXMEN_f>F|8}@|s@Zyzb2x3PBF%0rLu0-7YjdNJXKpa3Oy| zNH4k`pAT~#b{+Pd5QYX@j|S zI*nnP7pqOW;n|Rzd53aglsL_^jQT$}`(n${D>tSEMz5l6YXa`)pX(*DTklWHbZ1__ z80FThP*W=OY24=(>db2w^11=2=RHFw;_7Y3ZR*Vmg?a@uNXMqd%G!&0y=YZ!C<;(z zp3E0dTcMsmrOp3fEnO3Q%>M zlrc;MKZ!;l#tUr9r2`cRyPvCIV>msD;)6$p$&Bhr3;|Ak+p0FGpSW#`z5TeTp=A$| za#B2a^?Cqv* zpG@6Nbkak6n;)l7{wZ_pQF^F7vhZD;<6Em)T!rLDAmkL*>-p(_@|Na3^!BGceKV8~ik-X5w9< zsv%e+LaHfS9jf=@iw3oq~Dm7;n_8^r%7yWm;6poWuYk zNl#@EwLlE3xPbS81))zmcYwo#nJ#|XQ{NKP0No>FL6(FDtB1c8e-_E}ko{R$Zb<8L z)HT+n9xRq3EMAYzH382Du*TlRY9by(E|PgULtd04xr$Vj*2#H{u<(Jo^weDLozKL; ziLuu2v{mfOj$k@KtEdS@=K>HTszAFLG*q4rFiN5I)2$89twJ9%ea%?HM6W#wm`~z! z-GZFON^k_tm)=-exqNA@2b||;XJvYn)x;WJv72O&6o7n z@$XtyR8O&y4Z3GEd|uBfz6x!cU58J-YI8qUrH-!w5H;v!#$1+@85Bn1tB_dS^RPXc z4Abej$Td^=BT6Y#1lvm>B?9J6ARw=fk-owAUw`lQAARSKnem62@%!F`h5gLr?&VJs z+TEA#wDzvP^MgPOq{ z^x3v@Rs`BgPGc{iY0x}wHoyWH~&wyX5_C{VC8XK#GlCoz=fQCEXSpRE`i}N2GcH1 QKO2IU=P{EYMa0(s0f=~n4FCWD literal 0 HcmV?d00001 diff --git a/azprox/proxy/__pycache__/rotator.cpython-314.pyc b/azprox/proxy/__pycache__/rotator.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0ecf095812b910e6221ebbba16c79cdd386e595e GIT binary patch literal 6878 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 JAJa@{_g^#wh1>uD literal 0 HcmV?d00001 diff --git a/azprox/proxy/__pycache__/server.cpython-314.pyc b/azprox/proxy/__pycache__/server.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..06a8b2b266c9717247d2c826a53e991ad00965f1 GIT binary patch literal 38044 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

    iH}u-M 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 RewrittenRequest: + """ + Rewrite an HTTP request to route through an Azure Function proxy. + + Original request: + GET https://target.com/api/users HTTP/1.1 + Host: target.com + User-Agent: curl/8.0 + + Rewritten request: + GET https://azureprox-xxx-westeurope-func.azurewebsites.net/api/proxy HTTP/1.1 + Host: azureprox-xxx-westeurope-func.azurewebsites.net + User-Agent: curl/8.0 + X-AzureProx-Target: https://target.com/api/users + X-AzureProx-Key: + + The Azure Function then: + 1. Reads X-AzureProx-Target to know where to forward + 2. Validates X-AzureProx-Key + 3. Strips proxy headers + 4. Forwards the request with the remaining headers + """ + rewritten_headers = {} + + # Copy original headers, except Host (will be set by httpx on the function side) + for key, value in headers.items(): + k_lower = key.lower() + if k_lower == "host": + continue + # Don't forward proxy-specific headers + if k_lower in ("proxy-connection", "proxy-authorization"): + continue + rewritten_headers[key] = value + + # Add our control headers + rewritten_headers["X-AzureProx-Target"] = original_url + rewritten_headers["X-AzureProx-Key"] = auth_key + + # Set correct Host for the function endpoint + parsed = urlparse(function_endpoint) + rewritten_headers["Host"] = parsed.hostname + + return RewrittenRequest( + method=method, + url=function_endpoint, + headers=rewritten_headers, + body=body, + ) + + +def parse_proxy_request_line(line: str) -> tuple[str, str, str]: + """ + Parse an HTTP proxy request line. + + Proxy requests come in absolute-URI form: + GET http://target.com/path HTTP/1.1 + CONNECT target.com:443 HTTP/1.1 + + Returns (method, url, http_version) + """ + parts = line.strip().split(" ", 2) + if len(parts) != 3: + raise ValueError(f"Malformed request line: {line!r}") + return parts[0], parts[1], parts[2] diff --git a/azprox/proxy/rotator.py b/azprox/proxy/rotator.py new file mode 100644 index 0000000..dbed382 --- /dev/null +++ b/azprox/proxy/rotator.py @@ -0,0 +1,116 @@ +""" +Rotator — endpoint selection strategies for the local proxy. + +Manages the pool of active Azure Function endpoints and selects which +one to use for each outgoing request. +""" +from __future__ import annotations + +import asyncio +import random +import time +from collections import defaultdict +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class EndpointStats: + """Per-endpoint tracking.""" + + url: str + total_requests: int = 0 + total_errors: int = 0 + last_used: float = 0.0 + last_error: float = 0.0 + is_healthy: bool = True + consecutive_errors: int = 0 + + # Mark unhealthy after this many consecutive errors + ERROR_THRESHOLD: int = 3 + # Re-check unhealthy endpoints after this many seconds + RECOVERY_INTERVAL: float = 60.0 + + +class EndpointRotator: + """ + Manages endpoint selection with health tracking. + + Strategies: + - round-robin: Cycle through endpoints sequentially + - random: Pick a random endpoint per request + + Health tracking: + - After ERROR_THRESHOLD consecutive errors, mark endpoint unhealthy + - Unhealthy endpoints are skipped + - Periodically re-try unhealthy endpoints (every RECOVERY_INTERVAL seconds) + - If ALL endpoints are unhealthy, use them anyway (best-effort) + """ + + def __init__( + self, + endpoints: list[str], + strategy: str = "round-robin", + auth_key: str = "", + ): + self.strategy = strategy + self.auth_key = auth_key + self._stats: dict[str, EndpointStats] = { + url: EndpointStats(url=url) for url in endpoints + } + self._index = 0 + self._lock = asyncio.Lock() + + @property + def all_endpoints(self) -> list[str]: + return list(self._stats.keys()) + + @property + def healthy_endpoints(self) -> list[str]: + now = time.monotonic() + healthy = [] + for url, stats in self._stats.items(): + if stats.is_healthy: + healthy.append(url) + elif (now - stats.last_error) > EndpointStats.RECOVERY_INTERVAL: + # Give unhealthy endpoint another chance + healthy.append(url) + return healthy or self.all_endpoints # fallback: use all if none healthy + + async def next(self) -> str: + """Select the next endpoint based on strategy.""" + async with self._lock: + pool = self.healthy_endpoints + + if self.strategy == "round-robin": + endpoint = pool[self._index % len(pool)] + self._index += 1 + elif self.strategy == "random": + endpoint = random.choice(pool) + else: + endpoint = pool[0] + + self._stats[endpoint].total_requests += 1 + self._stats[endpoint].last_used = time.monotonic() + return endpoint + + def report_success(self, endpoint: str) -> None: + """Mark a successful request to an endpoint.""" + if endpoint in self._stats: + stats = self._stats[endpoint] + stats.consecutive_errors = 0 + stats.is_healthy = True + + def report_error(self, endpoint: str) -> None: + """Mark a failed request to an endpoint.""" + if endpoint in self._stats: + stats = self._stats[endpoint] + stats.total_errors += 1 + stats.consecutive_errors += 1 + stats.last_error = time.monotonic() + if stats.consecutive_errors >= EndpointStats.ERROR_THRESHOLD: + stats.is_healthy = False + + def get_stats(self) -> dict[str, EndpointStats]: + """Return stats for all endpoints.""" + return dict(self._stats) diff --git a/azprox/proxy/server.py b/azprox/proxy/server.py new file mode 100644 index 0000000..1811246 --- /dev/null +++ b/azprox/proxy/server.py @@ -0,0 +1,526 @@ +""" +Local proxy that routes traffic through rotating Azure Function endpoints. + +Plain HTTP requests (absolute-URI) are rewritten and forwarded directly. HTTPS +arrives as CONNECT, which we can't rewrite inside the tunnel — so we terminate +TLS locally with a leaf cert signed by a local CA (~/.azprox/ca), read the +decrypted request, and forward it the same way. `exec_with_proxy` points the +child's CA-bundle env vars at that CA so the interception is transparent. +""" +from __future__ import annotations + +import asyncio +import logging +import os +import ssl +import subprocess +import threading +from pathlib import Path +from typing import Optional + +import httpx +from rich.console import Console + +from azprox.proxy.interceptor import parse_proxy_request_line, rewrite_request +from azprox.proxy.rotator import EndpointRotator + +console = Console() +err_console = Console(stderr=True) +logger = logging.getLogger("azprox.proxy") + +CONFIG_DIR = Path.home() / ".azprox" +CA_DIR = CONFIG_DIR / "ca" + +# Hop-by-hop headers (RFC 7230) plus framing headers we recompute. +_HOP_BY_HOP = { + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailers", "transfer-encoding", "upgrade", + "content-encoding", "content-length", +} + + +def _ci_get(headers: dict[str, str], name: str) -> Optional[str]: + name = name.lower() + for k, v in headers.items(): + if k.lower() == name: + return v + return None + + +class CAStore: + """Local CA + per-host leaf certs for HTTPS interception.""" + + def __init__(self, directory: Path = CA_DIR): + self.dir = directory + self.leaf_dir = directory / "leaf" + self.ca_cert_path = directory / "ca-cert.pem" + self.ca_key_path = directory / "ca-key.pem" + self._ca_cert = None + self._ca_key = None + self._contexts: dict[str, ssl.SSLContext] = {} + self._lock = threading.Lock() + + def ensure_ca(self) -> None: + self.dir.mkdir(parents=True, exist_ok=True) + self.leaf_dir.mkdir(parents=True, exist_ok=True) + if self.ca_cert_path.exists() and self.ca_key_path.exists(): + self._load_ca() + else: + self._generate_ca() + + def _generate_ca(self) -> None: + import datetime as dt + + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "AzProx Local CA")]) + now = dt.datetime.now(dt.timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(name).issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - dt.timedelta(days=1)) + .not_valid_after(now + dt.timedelta(days=3650)) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .add_extension(x509.KeyUsage( + digital_signature=True, key_cert_sign=True, crl_sign=True, + content_commitment=False, key_encipherment=False, data_encipherment=False, + key_agreement=False, encipher_only=False, decipher_only=False, + ), critical=True) + .sign(key, hashes.SHA256()) + ) + self.ca_key_path.write_bytes(key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + )) + self.ca_cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + self.ca_key_path.chmod(0o600) + self._ca_cert, self._ca_key = cert, key + + def _load_ca(self) -> None: + from cryptography import x509 + from cryptography.hazmat.primitives import serialization + + self._ca_cert = x509.load_pem_x509_certificate(self.ca_cert_path.read_bytes()) + self._ca_key = serialization.load_pem_private_key(self.ca_key_path.read_bytes(), password=None) + + def context_for(self, host: str) -> ssl.SSLContext: + with self._lock: + if host not in self._contexts: + cert_path, key_path = self._leaf_for(host) + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(certfile=str(cert_path), keyfile=str(key_path)) + self._contexts[host] = ctx + return self._contexts[host] + + def _leaf_for(self, host: str) -> tuple[Path, Path]: + import datetime as dt + import ipaddress + + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + + safe = host.replace(":", "_") + cert_path = self.leaf_dir / f"{safe}-cert.pem" + key_path = self.leaf_dir / f"{safe}-key.pem" + if cert_path.exists() and key_path.exists(): + return cert_path, key_path + + try: + san = x509.IPAddress(ipaddress.ip_address(host)) + except ValueError: + san = x509.DNSName(host) + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + now = dt.datetime.now(dt.timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, host)])) + .issuer_name(self._ca_cert.subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - dt.timedelta(days=1)) + .not_valid_after(now + dt.timedelta(days=825)) + .add_extension(x509.SubjectAlternativeName([san]), critical=False) + .sign(self._ca_key, hashes.SHA256()) + ) + key_path.write_bytes(key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + )) + cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + return cert_path, key_path + + +class ProxyServer: + def __init__( + self, + rotator: EndpointRotator, + auth_key: str, + host: str = "127.0.0.1", + port: int = 8080, + verbose: bool = False, + ca: Optional[CAStore] = None, + ): + self.rotator = rotator + self.auth_key = auth_key + self.host = host + self.port = port + self.verbose = verbose + self.ca = ca or CAStore() + self.actual_port = port + self._server: Optional[asyncio.Server] = None + self._client: Optional[httpx.AsyncClient] = None + self._stop_event: Optional[asyncio.Event] = None + + @property + def proxy_url(self) -> str: + return f"http://{self.host}:{self.actual_port}" + + async def start(self) -> None: + self.ca.ensure_ca() + self._client = httpx.AsyncClient(timeout=60.0, verify=True, follow_redirects=False) + self._stop_event = asyncio.Event() + self._server = await asyncio.start_server(self._handle_client, self.host, self.port) + self.actual_port = self._server.sockets[0].getsockname()[1] + + async def serve_forever(self) -> None: + assert self._server is not None + async with self._server: + await self._server.serve_forever() + + async def stop(self) -> None: + if self._server: + self._server.close() + try: + await self._server.wait_closed() + except Exception: # noqa: BLE001 + pass + if self._client: + await self._client.aclose() + + async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + req = await self._read_request(reader) + if req is None: + return + method, target, _version, headers, body = req + + if method.upper() == "CONNECT": + await self._handle_connect(target, reader, writer) + return + + await self._handle_http_proxy(method, target, headers, body, writer) + while not writer.is_closing(): + nxt = await self._read_request(reader) + if nxt is None: + break + m, t, _v, h, b = nxt + if m.upper() == "CONNECT": + break + await self._handle_http_proxy(m, t, h, b, writer) + except (ConnectionError, asyncio.IncompleteReadError): + pass + except Exception as exc: # noqa: BLE001 + logger.warning("client handler error: %s", exc) + finally: + try: + writer.close() + await writer.wait_closed() + except Exception: # noqa: BLE001 + pass + + async def _read_request(self, reader: asyncio.StreamReader): + line = b"" + while line in (b"", b"\r\n", b"\n"): + try: + line = await reader.readuntil(b"\r\n") + except (asyncio.IncompleteReadError, asyncio.LimitOverrunError, ConnectionError): + return None + if not line: + return None + + method, target, version = parse_proxy_request_line(line.decode("latin-1")) + headers = await self._read_headers(reader) + body = await self._read_body(reader, headers) + return method, target, version, headers, body + + async def _read_headers(self, reader: asyncio.StreamReader) -> dict[str, str]: + headers: dict[str, str] = {} + while True: + line = await reader.readuntil(b"\r\n") + if line in (b"\r\n", b"\n", b""): + break + decoded = line.decode("latin-1").rstrip("\r\n") + if ":" in decoded: + k, _, v = decoded.partition(":") + headers[k.strip()] = v.strip() + return headers + + async def _read_body(self, reader: asyncio.StreamReader, headers: dict[str, str]) -> bytes: + if "chunked" in (_ci_get(headers, "transfer-encoding") or "").lower(): + return await self._read_chunked(reader) + cl = _ci_get(headers, "content-length") + if cl: + try: + n = int(cl) + except ValueError: + return b"" + if n > 0: + return await reader.readexactly(n) + return b"" + + async def _read_chunked(self, reader: asyncio.StreamReader) -> bytes: + out = bytearray() + while True: + size_line = await reader.readuntil(b"\r\n") + try: + size = int(size_line.strip().split(b";")[0], 16) + except ValueError: + break + if size == 0: + await reader.readuntil(b"\r\n") + break + out += await reader.readexactly(size) + await reader.readexactly(2) + return bytes(out) + + async def _handle_connect(self, authority: str, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + host, _, port_s = authority.partition(":") + port = int(port_s) if port_s else 443 + + writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n") + await writer.drain() + + try: + treader, twriter = await _upgrade_to_tls(reader, writer, self.ca.context_for(host)) + except (ssl.SSLError, ConnectionError, asyncio.IncompleteReadError) as exc: + logger.warning("TLS upgrade failed for %s: %s", host, exc) + return + + authority_host = host if port == 443 else f"{host}:{port}" + try: + while True: + req = await self._read_request(treader) + if req is None: + break + method, path, _v, headers, body = req + if method.upper() == "CONNECT": + break + await self._handle_http_proxy(method, f"https://{authority_host}{path}", headers, body, twriter) + if (_ci_get(headers, "connection") or "").lower() == "close": + break + except (ConnectionError, asyncio.IncompleteReadError): + pass + finally: + try: + twriter.close() + await twriter.wait_closed() + except Exception: # noqa: BLE001 + pass + + async def _handle_http_proxy( + self, + method: str, + url: str, + headers: dict[str, str], + body: bytes, + writer: asyncio.StreamWriter, + ) -> None: + assert self._client is not None + endpoint = await self.rotator.next() + rewritten = rewrite_request(method, url, headers, body, endpoint, self.auth_key) + + try: + resp = await self._client.request( + rewritten.method, rewritten.url, + headers=rewritten.headers, content=rewritten.body or None, + ) + self.rotator.report_success(endpoint) + except httpx.HTTPError as exc: + self.rotator.report_error(endpoint) + await self._write_error(writer, 502, f"Bad Gateway: {type(exc).__name__}") + return + + if self.verbose: + err_console.print(f"[dim]{method} {url} via {endpoint.split('//')[1].split('.')[0]} → {resp.status_code}[/dim]") + await self._write_response(writer, resp) + + async def _write_response(self, writer: asyncio.StreamWriter, resp: httpx.Response) -> None: + body = resp.content + out = bytearray(f"HTTP/1.1 {resp.status_code} {resp.reason_phrase or ''}\r\n".encode("latin-1")) + for k, v in resp.headers.items(): + if k.lower() not in _HOP_BY_HOP: + out += f"{k}: {v}\r\n".encode("latin-1") + out += f"Content-Length: {len(body)}\r\n".encode("latin-1") + out += b"Connection: keep-alive\r\n\r\n" + body + writer.write(out) + await writer.drain() + + async def _write_error(self, writer: asyncio.StreamWriter, code: int, message: str) -> None: + body = message.encode("latin-1", "replace") + out = ( + f"HTTP/1.1 {code} {message}\r\nContent-Length: {len(body)}\r\nConnection: keep-alive\r\n\r\n" + ).encode("latin-1") + body + try: + writer.write(out) + await writer.drain() + except (ConnectionError, RuntimeError): + pass + + +async def _upgrade_to_tls( + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ctx: ssl.SSLContext, +) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + loop = asyncio.get_event_loop() + transport = writer.transport + protocol = transport.get_protocol() + new_transport = await loop.start_tls(transport, protocol, ctx, server_side=True) + # Re-point the existing protocol/writer at the new TLS transport. + protocol._transport = new_transport # type: ignore[attr-defined] + writer._transport = new_transport # type: ignore[attr-defined] + return reader, writer + + +def run_proxy_server( + endpoints: list[str], + auth_key: str, + strategy: str = "round-robin", + host: str = "127.0.0.1", + port: int = 8080, + verbose: bool = False, +) -> None: + logging.getLogger("asyncio").setLevel(logging.ERROR) + ca = CAStore() + ca.ensure_ca() + rotator = EndpointRotator(endpoints, strategy=strategy, auth_key=auth_key) + server = ProxyServer(rotator, auth_key, host, port, verbose, ca) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + async def _run() -> None: + await server.start() + console.print("\n[green]AzProx proxy started[/green]") + console.print(f" HTTP proxy: {server.proxy_url}") + console.print(f" Endpoints: {len(endpoints)} active") + console.print(f" Strategy: {strategy}") + console.print(f" CA bundle: {ca.ca_cert_path}") + console.print("\n Usage:") + console.print(f" export HTTP_PROXY={server.proxy_url}") + console.print(f" export HTTPS_PROXY={server.proxy_url}") + console.print(f" export CURL_CA_BUNDLE={ca.ca_cert_path}") + console.print(" curl http://ifconfig.me\n") + console.print(" Press Ctrl+C to stop\n") + await server.serve_forever() + + try: + loop.run_until_complete(_run()) + except KeyboardInterrupt: + console.print("\n[yellow]Shutting down proxy...[/yellow]") + finally: + loop.run_until_complete(server.stop()) + loop.close() + + +def start_proxy_background( + endpoints: list[str], + auth_key: str, + strategy: str = "round-robin", + host: str = "127.0.0.1", + port: int = 0, + verbose: bool = False, + ca: Optional[CAStore] = None, +) -> tuple[threading.Thread, "ProxyServer"]: + rotator = EndpointRotator(endpoints, strategy=strategy, auth_key=auth_key) + server = ProxyServer(rotator, auth_key, host, port, verbose, ca) + ready = threading.Event() + + def _run() -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + server._loop = loop # type: ignore[attr-defined] + + async def _boot() -> None: + await server.start() + ready.set() + assert server._stop_event is not None + await server._stop_event.wait() + await server.stop() + + try: + loop.run_until_complete(_boot()) + except Exception as exc: # noqa: BLE001 + logger.warning("proxy loop error: %s", exc) + ready.set() + finally: + loop.close() + + thread = threading.Thread(target=_run, daemon=True, name="azprox-proxy") + thread.start() + if not ready.wait(timeout=15): + raise RuntimeError("Proxy server failed to start within 15s") + return thread, server + + +def stop_background(thread: threading.Thread, server: "ProxyServer") -> None: + loop = getattr(server, "_loop", None) + if loop and server._stop_event is not None: + loop.call_soon_threadsafe(server._stop_event.set) + thread.join(timeout=5) + + +def exec_with_proxy( + endpoints: list[str], + auth_key: str, + command: list[str], + strategy: str = "random", + verbose: bool = False, +) -> int: + # Default to random: most invocations issue one request, and round-robin + # would always start at index 0 (same endpoint every time). + logging.getLogger("asyncio").setLevel(logging.ERROR) + ca = CAStore() + ca.ensure_ca() + + thread, server = start_proxy_background(endpoints, auth_key, strategy, verbose=verbose, ca=ca) + proxy = server.proxy_url + ca_path = str(ca.ca_cert_path) + + env = os.environ.copy() + env.update({ + "HTTP_PROXY": proxy, "HTTPS_PROXY": proxy, + "http_proxy": proxy, "https_proxy": proxy, + "ALL_PROXY": proxy, "all_proxy": proxy, + "NO_PROXY": "localhost,127.0.0.1,::1", + "no_proxy": "localhost,127.0.0.1,::1", + # Trust the local MITM CA so HTTPS interception is transparent. + "CURL_CA_BUNDLE": ca_path, + "REQUESTS_CA_BUNDLE": ca_path, + "SSL_CERT_FILE": ca_path, + "NODE_EXTRA_CA_CERTS": ca_path, + "GIT_SSL_CAINFO": ca_path, + }) + + err_console.print(f"[dim]azprox: proxying via {len(endpoints)} endpoint(s) on {proxy} ({strategy})[/dim]") + + try: + return subprocess.run(command, env=env).returncode + except FileNotFoundError: + err_console.print(f"[red]command not found:[/red] {command[0]}") + return 127 + except KeyboardInterrupt: + return 130 + finally: + stop_background(thread, server) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c3e23bc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "azprox" +version = "0.1.0" +description = "Azure Functions IP rotation proxy — like proxychains but through Azure" +readme = "README.md" +license = { text = "GPL-3.0" } +requires-python = ">=3.10" +authors = [{ name = "AzProx" }] +keywords = ["azure", "proxy", "ip-rotation", "red-team"] + +dependencies = [ + "rich>=13.0.0", + "azure-identity>=1.15.0", + "azure-mgmt-resource>=23.0.0", + "azure-mgmt-web>=7.0.0", + "azure-mgmt-storage>=21.0.0", + "httpx>=0.27.0", + "requests>=2.31.0", + "cryptography>=42.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-asyncio>=0.21", + "ruff>=0.4.0", +] + +[project.scripts] +azprox = "azprox.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["azprox"] + +[tool.ruff] +target-version = "py310" +line-length = 120 + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a1ef1e1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +rich>=13.0.0 +azure-identity>=1.15.0 +azure-mgmt-resource>=23.0.0 +azure-mgmt-web>=7.0.0 +azure-mgmt-storage>=21.0.0 +httpx>=0.27.0 +requests>=2.31.0 +cryptography>=42.0.0 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..585523e --- /dev/null +++ b/uv.lock @@ -0,0 +1,742 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + +[[package]] +name = "azprox" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "azure-identity" }, + { name = "azure-mgmt-resource" }, + { name = "azure-mgmt-storage" }, + { name = "azure-mgmt-web" }, + { name = "cryptography" }, + { name = "httpx" }, + { name = "requests" }, + { name = "rich" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "azure-identity", specifier = ">=1.15.0" }, + { name = "azure-mgmt-resource", specifier = ">=23.0.0" }, + { name = "azure-mgmt-storage", specifier = ">=21.0.0" }, + { name = "azure-mgmt-web", specifier = ">=7.0.0" }, + { name = "cryptography", specifier = ">=42.0.0" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21" }, + { name = "requests", specifier = ">=2.31.0" }, + { name = "rich", specifier = ">=13.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "azure-core" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042, upload-time = "2026-05-07T23:30:54.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/db/325c6d7312d2200251c52323878281045aaffcb5586612296484e4280eaa/azure_core-1.41.0-py3-none-any.whl", hash = "sha256:522b4011e8180b1a3dcd2024396a4e7fe9ac37fb8597db47163d230b5efe892d", size = 220920, upload-time = "2026-05-07T23:30:56.357Z" }, +] + +[[package]] +name = "azure-identity" +version = "1.25.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" }, +] + +[[package]] +name = "azure-mgmt-core" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/99/fa9e7551313d8c7099c89ebf3b03cd31beb12e1b498d575aa19bb59a5d04/azure_mgmt_core-1.6.0.tar.gz", hash = "sha256:b26232af857b021e61d813d9f4ae530465255cb10b3dde945ad3743f7a58e79c", size = 30818, upload-time = "2025-07-03T02:02:24.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/26/c79f962fd3172b577b6f38685724de58b6b4337a51d3aad316a43a4558c6/azure_mgmt_core-1.6.0-py3-none-any.whl", hash = "sha256:0460d11e85c408b71c727ee1981f74432bc641bb25dfcf1bb4e90a49e776dbc4", size = 29310, upload-time = "2025-07-03T02:02:25.203Z" }, +] + +[[package]] +name = "azure-mgmt-resource" +version = "26.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/e1/c6196381cc6bb3eff9e24f5bf31b7fd962651ddbd6120bc2a4c482a7af16/azure_mgmt_resource-26.0.0.tar.gz", hash = "sha256:54227c5db06d3be4dd4b77ad885b273e9eb2606d1de653c5b534066cfbb60c0c", size = 117372, upload-time = "2026-06-24T09:48:29.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/6e/3e776a0817ebe3558fbd8d3f98b1bd8fa75a46596e5cf9bb4377960c4611/azure_mgmt_resource-26.0.0-py3-none-any.whl", hash = "sha256:ef36559a4c0843b4272aa584efb455c686e4c3f6357421b38c82f405c01a9a9f", size = 102492, upload-time = "2026-06-24T09:48:30.854Z" }, +] + +[[package]] +name = "azure-mgmt-storage" +version = "25.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/6a/62d21f86b62cf990cb7d63f470dd4a766277a68a71543ef9331e0ee16f7d/azure_mgmt_storage-25.0.0.tar.gz", hash = "sha256:52c4bb1fb395fcfa7a2e8fb024c1dabc5a67bd6fa23c0d2d5a7fb29314f172d2", size = 228272, upload-time = "2026-05-20T08:37:01.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ca/92239e71a92cd3a60c5c6175539defcec5ffa086bd9507817330cf7f4662/azure_mgmt_storage-25.0.0-py3-none-any.whl", hash = "sha256:7824840e8251aa6b2c27abf29cbd0ab86fc6685aa69e44f61c2f53168e71ff2d", size = 215194, upload-time = "2026-05-20T08:37:02.555Z" }, +] + +[[package]] +name = "azure-mgmt-web" +version = "11.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/dd/581afc7b4e6e2a86859e1323d654f58a7e3f63cfaa69ddbdb0baa8419d0a/azure_mgmt_web-11.0.0.tar.gz", hash = "sha256:1f98b29283ecb9c36ede7309c0da8d26db0455d77ae37e1cb6cdcd244044d6de", size = 520401, upload-time = "2026-05-06T18:42:03.281Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/5e/0e4922a0c17464a821271ae2df4e49e351364d2062e131b96fd3d70f7a97/azure_mgmt_web-11.0.0-py3-none-any.whl", hash = "sha256:ccaeeaa757de94a6deb62e7692c0274190fe9e252e67984baf43d7fa3913df7b", size = 499178, upload-time = "2026-05-06T18:42:05.228Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.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 = "isodate" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, +] + +[[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 = "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 = "msal" +version = "1.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/99/d840198ecf6e8057bbc937f129ae940404485d736cda73253bbff9537f01/msal-1.37.0.tar.gz", hash = "sha256:1b1672a33ee467c1d70b341bb16cafd51bb3c817147a95b93263794b03971bec", size = 182444, upload-time = "2026-05-29T19:49:05.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/b0/d807279f4b55d16d1f120d5ac4344c6e39b56732e2a224d40bded7fd67ad/msal-1.37.0-py3-none-any.whl", hash = "sha256:dd17e95a7c71bce75e8108113438ba7c4a086b3bcad4f57a8c09b7af3d753c2d", size = 123725, upload-time = "2026-05-29T19:49:04.335Z" }, +] + +[[package]] +name = "msal-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, +] + +[[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 = "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 = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "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" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +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.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/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 = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[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.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[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 = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +]