diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 52d700cd6..4149d49f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,10 +51,10 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.11", "3.12", "3.13"] + python-version: ["3.13"] os: ["ubuntu-latest", "macos-latest", "windows-latest"] nats-server-version: ["latest"] - project: ["nats-server"] + project: ["nats-server", "nats-client"] steps: - name: Checkout repository uses: actions/checkout@v5 diff --git a/nats-client/README.md b/nats-client/README.md new file mode 100644 index 000000000..5ee13456c --- /dev/null +++ b/nats-client/README.md @@ -0,0 +1,40 @@ +# NATS Client + +A Python client for the NATS messaging system. + +## Features + +- Support for publish/subscribe +- Support for request/reply +- Support for queue groups +- Support for multi-value message headers + +## Installation + +```bash +pip install nats-client +``` + +## Usage + +```python +import asyncio +from nats.client import connect + +async def main(): + client = await connect("nats://localhost:4222") + + # Subscribe + async with await client.subscribe("foo") as subscription: + # Publish + await client.publish("foo", "Hello World!") + + # Receive message + message = await subscription.next() + print(f"Received: {message.data}") + + await client.close() + +if __name__ == "__main__": + asyncio.run(main()) +``` diff --git a/nats-client/benches/__init__.py b/nats-client/benches/__init__.py new file mode 100644 index 000000000..f4082c50c --- /dev/null +++ b/nats-client/benches/__init__.py @@ -0,0 +1 @@ +"""Benchmarks for nats-client package.""" diff --git a/nats-client/benches/bench_client.py b/nats-client/benches/bench_client.py new file mode 100644 index 000000000..2255aee16 --- /dev/null +++ b/nats-client/benches/bench_client.py @@ -0,0 +1,65 @@ +"""Benchmarks for NATS client operations.""" + +import asyncio + +import pytest +from nats.client import connect +from nats.server import run + + +@pytest.mark.parametrize( + "size", + [ + 1, + 2, + 4, + 8, + 16, + 32, + 64, + 128, + 256, + 512, + 1024, + 2048, + 4096, + 8192, + 16384, + 32768, + ], +) +def test_bench_publish(benchmark, size): + """Benchmark publish with various payload sizes.""" + subject = "bench.publish" + payload = b"x" * size + + # Adjust count based on message size to keep total data volume consistent + # Target ~10MB total per benchmark run + target_bytes = 10 * 1024 * 1024 + count = max(1, target_bytes // max(1, size)) + + def setup(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + server = loop.run_until_complete(run(port=0)) + client = loop.run_until_complete(connect(server.client_url)) + return ((loop, server, client), {}) + + def execute(loop, server, client): + async def publish_n(): + for _ in range(count): + await client.publish(subject, payload) + + loop.run_until_complete(publish_n()) + + def teardown(loop, server, client): + loop.run_until_complete(client.close()) + loop.run_until_complete(server.shutdown()) + loop.close() + asyncio.set_event_loop(None) + + benchmark.extra_info["message_size"] = size + benchmark.extra_info["message_count"] = count + + result = benchmark.pedantic(execute, setup=setup, teardown=teardown, iterations=1, rounds=1) + return result diff --git a/nats-client/benches/bench_protocol.py b/nats-client/benches/bench_protocol.py new file mode 100644 index 000000000..8b20c3574 --- /dev/null +++ b/nats-client/benches/bench_protocol.py @@ -0,0 +1,125 @@ +"""Benchmarks for NATS protocol encoding operations.""" + +import pytest +from nats.client.protocol import command + + +def test_bench_encode_connect(benchmark): + """Benchmark encoding CONNECT command with basic connection info.""" + connect_info = { + "verbose": False, + "pedantic": False, + "tls_required": False, + "name": "test-client", + "lang": "python", + "version": "1.0.0", + "protocol": 1, + } + + benchmark(command.encode_connect, connect_info) + + +@pytest.mark.parametrize("size", [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192]) +def test_bench_encode_pub_with_payload(benchmark, size): + """Benchmark encoding PUB command with various payload sizes.""" + subject = "test.subject" + payload = b"x" * size + + benchmark(command.encode_pub, subject, payload) + + +def test_bench_encode_pub_with_reply(benchmark): + """Benchmark encoding PUB command with reply subject.""" + subject = "test.subject" + payload = b"hello world" + reply = "reply.subject" + + benchmark(command.encode_pub, subject, payload, reply=reply) + + +def test_bench_encode_hpub_single_header(benchmark): + """Benchmark encoding HPUB command with single header.""" + subject = "test.subject" + payload = b"hello world" + headers = {"X-Custom": "value"} + + benchmark(command.encode_hpub, subject, payload, headers=headers) + + +def test_bench_encode_hpub_multiple_headers(benchmark): + """Benchmark encoding HPUB command with multiple headers.""" + subject = "test.subject" + payload = b"hello world" + headers = { + "X-Custom-1": "value1", + "X-Custom-2": "value2", + "X-Custom-3": "value3", + "Content-Type": "application/json", + "X-Request-ID": "12345-67890-abcdef", + } + + benchmark(command.encode_hpub, subject, payload, headers=headers) + + +def test_bench_encode_hpub_multivalue_headers(benchmark): + """Benchmark encoding HPUB command with multi-value headers.""" + subject = "test.subject" + payload = b"hello world" + headers = { + "X-Custom": ["value1", "value2", "value3"], + "X-Tags": ["tag1", "tag2", "tag3", "tag4"], + } + + benchmark(command.encode_hpub, subject, payload, headers=headers) + + +def test_bench_encode_hpub_with_reply(benchmark): + """Benchmark encoding HPUB command with reply subject and headers.""" + subject = "test.subject" + payload = b"hello world" + reply = "reply.subject" + headers = {"X-Custom": "value"} + + benchmark(command.encode_hpub, subject, payload, reply=reply, headers=headers) + + +def test_bench_encode_sub(benchmark): + """Benchmark encoding SUB command.""" + subject = "test.subject" + sid = "1" + + benchmark(command.encode_sub, subject, sid) + + +def test_bench_encode_sub_with_queue(benchmark): + """Benchmark encoding SUB command with queue group.""" + subject = "test.subject" + sid = "1" + queue = "test-queue" + + benchmark(command.encode_sub, subject, sid, queue) + + +def test_bench_encode_unsub(benchmark): + """Benchmark encoding UNSUB command.""" + sid = "1" + + benchmark(command.encode_unsub, sid) + + +def test_bench_encode_unsub_with_max(benchmark): + """Benchmark encoding UNSUB command with max_msgs.""" + sid = "1" + max_msgs = 100 + + benchmark(command.encode_unsub, sid, max_msgs) + + +def test_bench_encode_ping(benchmark): + """Benchmark encoding PING command.""" + benchmark(command.encode_ping) + + +def test_bench_encode_pong(benchmark): + """Benchmark encoding PONG command.""" + benchmark(command.encode_pong) diff --git a/nats-client/examples/nats-echo.py b/nats-client/examples/nats-echo.py new file mode 100755 index 000000000..f4bb6a475 --- /dev/null +++ b/nats-client/examples/nats-echo.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""NATS Echo Service Example. + +Implements an echo service that replies to requests with the same message content. +Also provides a status endpoint that returns service information. + +Usage: + python nats-echo.py [-s server] [-creds file] [-nkey file] [-t] [-id service_id] + +Examples: + python nats-echo.py echo + python nats-echo.py -s nats://demo.nats.io:4222 echo + python nats-echo.py -id my-echo-1 -t echo +""" + +import argparse +import asyncio +import json +import platform +import signal +import sys +from datetime import datetime + +from nats.client import connect + +# Global flag for graceful shutdown +shutdown_event = asyncio.Event() + + +def signal_handler(sig, frame): + """Handle interrupt signal for graceful shutdown.""" + print("\nShutting down...") + shutdown_event.set() + + +async def main(): + """Run the echo service.""" + parser = argparse.ArgumentParser( + description="NATS Echo Service", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "-s", + "--server", + default="nats://localhost:4222", + help="NATS server URL (default: nats://localhost:4222)", + ) + parser.add_argument( + "-creds", + "--credentials", + help="User credentials file", + ) + parser.add_argument( + "-nkey", + "--nkey", + help="NKey seed file", + ) + parser.add_argument( + "-t", + "--timestamp", + action="store_true", + help="Display timestamps", + ) + parser.add_argument( + "-id", + "--service-id", + default="nats-echo", + help="Service identifier (default: nats-echo)", + ) + parser.add_argument( + "subject", + help="Subject to listen on for echo requests", + ) + + args = parser.parse_args() + + # Load credentials if provided + token = None + user = None + password = None + nkey = None + + if args.credentials: + with open(args.credentials) as f: + token = f.read().strip() + + if args.nkey: + with open(args.nkey) as f: + nkey = f.read().strip() + + # Setup signal handler + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + # Service info + service_info = { + "id": args.service_id, + "subject": args.subject, + "platform": platform.platform(), + "python_version": platform.python_version(), + } + + try: + # Connect to NATS + client = await connect( + args.server, + token=token, + user=user, + password=password, + nkey=nkey, + ) + + print(f"Echo service '{args.service_id}' listening on [{args.subject}]") + print(f"Status available on [{args.subject}.status]") + + # Subscribe to the echo subject (with queue group for load balancing) + echo_subscription = await client.subscribe(args.subject, queue="echo-service") + + # Subscribe to the status subject (without queue group, all instances respond) + status_subject = f"{args.subject}.status" + status_subscription = await client.subscribe(status_subject) + + # Message counters + echo_count = 0 + status_count = 0 + + async def handle_echo(): + """Handle echo requests.""" + nonlocal echo_count + async with echo_subscription: + while not shutdown_event.is_set(): + try: + message = await asyncio.wait_for(echo_subscription.next(), timeout=0.5) + echo_count += 1 + + if args.timestamp: + timestamp = datetime.now().strftime("%H:%M:%S") + print(f"[#{echo_count} {timestamp}] Echo request: {message.data.decode()}") + else: + print(f"[#{echo_count}] Echo request: {message.data.decode()}") + + # Echo back the message + if message.reply: + await client.publish(message.reply, message.data) + + except asyncio.TimeoutError: + continue + except Exception as e: + print(f"Error handling echo request: {e}", file=sys.stderr) + break + + async def handle_status(): + """Handle status requests.""" + nonlocal status_count + async with status_subscription: + while not shutdown_event.is_set(): + try: + message = await asyncio.wait_for(status_subscription.next(), timeout=0.5) + status_count += 1 + + if args.timestamp: + timestamp = datetime.now().strftime("%H:%M:%S") + print(f"[#{status_count} {timestamp}] Status request") + else: + print(f"[#{status_count}] Status request") + + # Send status information + if message.reply: + status_response = { + **service_info, + "echo_count": echo_count, + "status_count": status_count, + } + await client.publish(message.reply, json.dumps(status_response).encode()) + + except asyncio.TimeoutError: + continue + except Exception as e: + print(f"Error handling status request: {e}", file=sys.stderr) + break + + # Run both handlers concurrently + await asyncio.gather( + handle_echo(), + handle_status(), + ) + + # Close the connection + await client.close() + print("Echo service stopped") + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/nats-client/examples/nats-pub.py b/nats-client/examples/nats-pub.py new file mode 100755 index 000000000..78cab9be6 --- /dev/null +++ b/nats-client/examples/nats-pub.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""NATS Publisher Example. + +Publishes a message to a specified subject on a NATS server. + +Usage: + python nats-pub.py [-s server] [-creds file] [-nkey file] + +Examples: + python nats-pub.py hello "world" + python nats-pub.py -s nats://demo.nats.io:4222 hello "world" + python nats-pub.py -creds ~/.nats/creds hello "world" +""" + +import argparse +import asyncio +import sys + +from nats.client import connect + + +async def main(): + """Publish a message to NATS.""" + parser = argparse.ArgumentParser( + description="NATS Publisher", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "-s", + "--server", + default="nats://localhost:4222", + help="NATS server URL (default: nats://localhost:4222)", + ) + parser.add_argument( + "-creds", + "--credentials", + help="User credentials file", + ) + parser.add_argument( + "-nkey", + "--nkey", + help="NKey seed file", + ) + parser.add_argument( + "subject", + help="Subject to publish to", + ) + parser.add_argument( + "message", + help="Message to publish", + ) + + args = parser.parse_args() + + # Load credentials if provided + token = None + user = None + password = None + nkey = None + + if args.credentials: + # For simplicity, we'll just support token in credentials file + # A full implementation would parse JWT credentials + with open(args.credentials) as f: + token = f.read().strip() + + if args.nkey: + with open(args.nkey) as f: + nkey = f.read().strip() + + try: + # Connect to NATS + client = await connect( + args.server, + token=token, + user=user, + password=password, + nkey=nkey, + ) + + # Publish the message + await client.publish(args.subject, args.message.encode()) + await client.flush() + + print(f"Published [{args.subject}] : '{args.message}'") + + # Close the connection + await client.close() + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/nats-client/examples/nats-qsub.py b/nats-client/examples/nats-qsub.py new file mode 100755 index 000000000..12b45e6d0 --- /dev/null +++ b/nats-client/examples/nats-qsub.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""NATS Queue Subscriber Example. + +Subscribes to a subject with a queue group for load-balanced message processing. +Multiple instances with the same queue group will share messages. + +Usage: + python nats-qsub.py [-s server] [-creds file] [-nkey file] [-t] + +Examples: + python nats-qsub.py hello workers + python nats-qsub.py -s nats://demo.nats.io:4222 hello workers + python nats-qsub.py -t hello workers # with timestamps +""" + +import argparse +import asyncio +import os +import signal +import sys +from datetime import datetime + +from nats.client import connect + +# Global flag for graceful shutdown +shutdown_event = asyncio.Event() + + +def signal_handler(sig, frame): + """Handle interrupt signal for graceful shutdown.""" + print("\nShutting down...") + shutdown_event.set() + + +async def main(): + """Subscribe to messages from NATS using a queue group.""" + parser = argparse.ArgumentParser( + description="NATS Queue Subscriber", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "-s", + "--server", + default="nats://localhost:4222", + help="NATS server URL (default: nats://localhost:4222)", + ) + parser.add_argument( + "-creds", + "--credentials", + help="User credentials file", + ) + parser.add_argument( + "-nkey", + "--nkey", + help="NKey seed file", + ) + parser.add_argument( + "-t", + "--timestamp", + action="store_true", + help="Display timestamps", + ) + parser.add_argument( + "subject", + help="Subject to subscribe to", + ) + parser.add_argument( + "queue", + help="Queue group name", + ) + + args = parser.parse_args() + + # Load credentials if provided + token = None + user = None + password = None + nkey = None + + if args.credentials: + with open(args.credentials) as f: + token = f.read().strip() + + if args.nkey: + with open(args.nkey) as f: + nkey = f.read().strip() + + # Setup signal handler + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + # Get process ID for logging + pid = os.getpid() + + try: + # Connect to NATS + client = await connect( + args.server, + token=token, + user=user, + password=password, + nkey=nkey, + ) + + print(f"Listening on [{args.subject}] in queue group [{args.queue}] (PID: {pid})") + + # Subscribe to the subject with queue group + subscription = await client.subscribe(args.subject, queue=args.queue) + + # Message counter + count = 0 + + async with subscription: + while not shutdown_event.is_set(): + try: + # Wait for message with timeout to allow checking shutdown_event + message = await asyncio.wait_for(subscription.next(), timeout=0.5) + count += 1 + + # Format output + if args.timestamp: + timestamp = datetime.now().strftime("%H:%M:%S") + print( + f"[#{count} {timestamp}] Received on [{message.subject}] " + f"Queue[{args.queue}] Pid[{pid}]: {message.data.decode()}" + ) + else: + print( + f"[#{count}] Received on [{message.subject}] Queue[{args.queue}] Pid[{pid}]: {message.data.decode()}" + ) + + except asyncio.TimeoutError: + # No message received, continue loop to check shutdown + continue + except Exception as e: + print(f"Error receiving message: {e}", file=sys.stderr) + break + + # Close the connection (drains pending messages) + await client.close() + print("Queue subscription closed") + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/nats-client/examples/nats-req.py b/nats-client/examples/nats-req.py new file mode 100755 index 000000000..f295591cb --- /dev/null +++ b/nats-client/examples/nats-req.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""NATS Request Example. + +Sends a request message to a subject and waits for a reply. + +Usage: + python nats-req.py [-s server] [-creds file] [-nkey file] + +Examples: + python nats-req.py help "What is NATS?" + python nats-req.py -s nats://demo.nats.io:4222 help "What is NATS?" +""" + +import argparse +import asyncio +import sys + +from nats.client import connect + + +async def main(): + """Send a request to NATS and wait for a reply.""" + parser = argparse.ArgumentParser( + description="NATS Request", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "-s", + "--server", + default="nats://localhost:4222", + help="NATS server URL (default: nats://localhost:4222)", + ) + parser.add_argument( + "-creds", + "--credentials", + help="User credentials file", + ) + parser.add_argument( + "-nkey", + "--nkey", + help="NKey seed file", + ) + parser.add_argument( + "subject", + help="Subject to send request to", + ) + parser.add_argument( + "message", + help="Request message", + ) + + args = parser.parse_args() + + # Load credentials if provided + token = None + user = None + password = None + nkey = None + + if args.credentials: + with open(args.credentials) as f: + token = f.read().strip() + + if args.nkey: + with open(args.nkey) as f: + nkey = f.read().strip() + + try: + # Connect to NATS + client = await connect( + args.server, + token=token, + user=user, + password=password, + nkey=nkey, + ) + + print(f"Published [{args.subject}] : '{args.message}'") + + # Send request and wait for reply (2 second timeout) + try: + response = await client.request(args.subject, args.message.encode(), timeout=2.0) + print(f"Received [{response.subject}] : '{response.data.decode()}'") + except asyncio.TimeoutError: + print("Request timeout - no reply received", file=sys.stderr) + sys.exit(1) + + # Close the connection + await client.close() + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/nats-client/examples/nats-rply.py b/nats-client/examples/nats-rply.py new file mode 100755 index 000000000..3a108ff75 --- /dev/null +++ b/nats-client/examples/nats-rply.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""NATS Reply Example. + +Listens for requests on a subject and automatically replies with a predefined response. + +Usage: + python nats-rply.py [-s server] [-creds file] [-nkey file] [-t] [-q queue] + +Examples: + python nats-rply.py help "I can help!" + python nats-rply.py -s nats://demo.nats.io:4222 help "I can help!" + python nats-rply.py -q workers help "I can help!" +""" + +import argparse +import asyncio +import signal +import sys +from datetime import datetime + +from nats.client import connect + +# Global flag for graceful shutdown +shutdown_event = asyncio.Event() + + +def signal_handler(sig, frame): + """Handle interrupt signal for graceful shutdown.""" + print("\nShutting down...") + shutdown_event.set() + + +async def main(): + """Listen for requests and send replies.""" + parser = argparse.ArgumentParser( + description="NATS Reply", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "-s", + "--server", + default="nats://localhost:4222", + help="NATS server URL (default: nats://localhost:4222)", + ) + parser.add_argument( + "-creds", + "--credentials", + help="User credentials file", + ) + parser.add_argument( + "-nkey", + "--nkey", + help="NKey seed file", + ) + parser.add_argument( + "-t", + "--timestamp", + action="store_true", + help="Display timestamps", + ) + parser.add_argument( + "-q", + "--queue", + default="NATS-RPLY-22", + help="Queue group name (default: NATS-RPLY-22)", + ) + parser.add_argument( + "subject", + help="Subject to listen on", + ) + parser.add_argument( + "response", + help="Response message to send", + ) + + args = parser.parse_args() + + # Load credentials if provided + token = None + user = None + password = None + nkey = None + + if args.credentials: + with open(args.credentials) as f: + token = f.read().strip() + + if args.nkey: + with open(args.nkey) as f: + nkey = f.read().strip() + + # Setup signal handler + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + try: + # Connect to NATS + client = await connect( + args.server, + token=token, + user=user, + password=password, + nkey=nkey, + ) + + print(f"Listening on [{args.subject}] in queue group [{args.queue}]") + + # Subscribe to the subject with queue group + subscription = await client.subscribe(args.subject, queue=args.queue) + + # Message counter + count = 0 + + async with subscription: + while not shutdown_event.is_set(): + try: + # Wait for message with timeout to allow checking shutdown_event + message = await asyncio.wait_for(subscription.next(), timeout=0.5) + count += 1 + + # Log the received request + if args.timestamp: + timestamp = datetime.now().strftime("%H:%M:%S") + print( + f"[#{count} {timestamp}] Received request on [{message.subject}]: {message.data.decode()}" + ) + else: + print(f"[#{count}] Received request on [{message.subject}]: {message.data.decode()}") + + # Send the reply if a reply subject is provided + if message.reply: + await client.publish(message.reply, args.response.encode()) + if args.timestamp: + timestamp = datetime.now().strftime("%H:%M:%S") + print(f"[#{count} {timestamp}] Sent reply: {args.response}") + else: + print(f"[#{count}] Sent reply: {args.response}") + else: + print(f"[#{count}] Warning: No reply subject in request", file=sys.stderr) + + except asyncio.TimeoutError: + # No message received, continue loop to check shutdown + continue + except Exception as e: + print(f"Error processing request: {e}", file=sys.stderr) + break + + # Close the connection (drains pending messages) + await client.close() + print("Reply service stopped") + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/nats-client/examples/nats-sub.py b/nats-client/examples/nats-sub.py new file mode 100755 index 000000000..f096cf253 --- /dev/null +++ b/nats-client/examples/nats-sub.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""NATS Subscriber Example. + +Subscribes to a subject on a NATS server and prints received messages. + +Usage: + python nats-sub.py [-s server] [-creds file] [-nkey file] [-t] + +Examples: + python nats-sub.py hello + python nats-sub.py -s nats://demo.nats.io:4222 hello + python nats-sub.py -t hello # with timestamps +""" + +import argparse +import asyncio +import signal +import sys +from datetime import datetime + +from nats.client import connect + +# Global flag for graceful shutdown +shutdown_event = asyncio.Event() + + +def signal_handler(sig, frame): + """Handle interrupt signal for graceful shutdown.""" + print("\nShutting down...") + shutdown_event.set() + + +async def main(): + """Subscribe to messages from NATS.""" + parser = argparse.ArgumentParser( + description="NATS Subscriber", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "-s", + "--server", + default="nats://localhost:4222", + help="NATS server URL (default: nats://localhost:4222)", + ) + parser.add_argument( + "-creds", + "--credentials", + help="User credentials file", + ) + parser.add_argument( + "-nkey", + "--nkey", + help="NKey seed file", + ) + parser.add_argument( + "-t", + "--timestamp", + action="store_true", + help="Display timestamps", + ) + parser.add_argument( + "subject", + help="Subject to subscribe to", + ) + + args = parser.parse_args() + + # Load credentials if provided + token = None + user = None + password = None + nkey = None + + if args.credentials: + with open(args.credentials) as f: + token = f.read().strip() + + if args.nkey: + with open(args.nkey) as f: + nkey = f.read().strip() + + # Setup signal handler + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + try: + # Connect to NATS + client = await connect( + args.server, + token=token, + user=user, + password=password, + nkey=nkey, + ) + + print(f"Listening on [{args.subject}]") + + # Subscribe to the subject + subscription = await client.subscribe(args.subject) + + # Message counter + count = 0 + + async with subscription: + while not shutdown_event.is_set(): + try: + # Wait for message with timeout to allow checking shutdown_event + message = await asyncio.wait_for(subscription.next(), timeout=0.5) + count += 1 + + # Format output + if args.timestamp: + timestamp = datetime.now().strftime("%H:%M:%S") + print(f"[#{count} {timestamp}] Received on [{message.subject}]: {message.data.decode()}") + else: + print(f"[#{count}] Received on [{message.subject}]: {message.data.decode()}") + + except asyncio.TimeoutError: + # No message received, continue loop to check shutdown + continue + except Exception as e: + print(f"Error receiving message: {e}", file=sys.stderr) + break + + # Close the connection + await client.close() + print("Subscription closed") + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/nats-client/pyproject.toml b/nats-client/pyproject.toml new file mode 100644 index 000000000..06a9685af --- /dev/null +++ b/nats-client/pyproject.toml @@ -0,0 +1,55 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "nats-client" +version = "0.0.0" +description = "NATS client implementation in Python" +readme = "README.md" +requires-python = ">=3.13" +license = "MIT" +keywords = ["nats", "messaging", "client"] +authors = [ + { name = "Casper Beyer", email = "casper@synadia.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Private :: Do Not Upload", + "Programming Language :: Python", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dependencies = [] + +[project.optional-dependencies] +nkeys = [ + "nkeys>=0.1.0", +] + +[project.urls] +Documentation = "https://github.com/nats-io/nats.py" +Issues = "https://github.com/nats-io/nats.py/issues" +Source = "https://github.com/nats-io/nats.py" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.uv] +dev-dependencies = [ + "nats-server", + "nkeys>=0.1.0", + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "pytest-cov>=7.0.0", + "pytest-xdist>=3.0.0", + "pytest-benchmark>=5.2.1", +] + +[tool.uv.sources] +nats-server = { workspace = true } + +[tool.pytest.ini_options] +asyncio_default_fixture_loop_scope = "function" +asyncio_mode = "auto" diff --git a/nats-client/src/nats/client/__init__.py b/nats-client/src/nats/client/__init__.py new file mode 100644 index 000000000..2d944a10d --- /dev/null +++ b/nats-client/src/nats/client/__init__.py @@ -0,0 +1,1695 @@ +"""NATS client implementation. + +This module provides a high-level, asyncio-based client for the NATS messaging system. +It implements core NATS features including: +- Publish/Subscribe messaging +- Request/Reply pattern +- Queue groups for load balancing +- Message headers +- Automatic reconnection +- Wildcard subscriptions + +The primary entry point is the `connect()` function which returns a `Client` instance. +""" + +from __future__ import annotations + +try: + from importlib.metadata import PackageNotFoundError, version + + __version__ = version("nats-client") +except (ImportError, PackageNotFoundError): + __version__ = "unknown" + +import asyncio +import base64 +import contextlib +import json +import logging +import random +import re +import ssl +import uuid +from contextlib import AbstractAsyncContextManager +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import TYPE_CHECKING, Self, TypeAlias +from urllib.parse import urlparse + +import nkeys +from nats.client.connection import Connection, open_tcp_connection +from nats.client.errors import NoRespondersError, SlowConsumerError, StatusError +from nats.client.message import Headers, Message, Status +from nats.client.protocol.command import ( + encode_connect, + encode_hpub, + encode_ping, + encode_pong, + encode_pub, + encode_sub, + encode_unsub, +) +from nats.client.protocol.message import ParseError, parse +from nats.client.protocol.types import ( + ConnectInfo, +) +from nats.client.protocol.types import ( + ServerInfo as ProtocolServerInfo, +) +from nats.client.subscription import Subscription + +if TYPE_CHECKING: + import types + +from collections.abc import Callable + +logger = logging.getLogger("nats.client") + + +# Authentication type aliases + +# NKey handler types +NkeyPublicKeyHandler: TypeAlias = Callable[[], str] +"""Handler that returns the NKey public key.""" + +NkeySignatureHandler: TypeAlias = Callable[[str], bytes] +"""Handler that signs a nonce and returns the signature.""" + +# NKey configuration variants +NkeySeed: TypeAlias = str | Path +"""NKey seed as string or path to seed file.""" + +NkeyHandlers: TypeAlias = tuple[NkeyPublicKeyHandler, NkeySignatureHandler] +"""Custom NKey handlers for full control over authentication.""" + +# JWT handler types +JWTHandler: TypeAlias = Callable[[], bytes] +"""Handler that returns the JWT.""" + +JWTSignatureHandler: TypeAlias = Callable[[str], bytes] +"""Handler that signs a nonce and returns the signature.""" + +# JWT configuration variants +JWTCredentials: TypeAlias = Path | tuple[str, str] | tuple[Path, Path] +"""JWT credentials as .creds file, (jwt_string, seed_string), or (jwt_file, seed_file).""" + +JWTHandlers: TypeAlias = tuple[JWTHandler, JWTSignatureHandler] +"""Custom JWT handlers for full control over authentication.""" + + +class ClientStatus(Enum): + """Client connection status.""" + + DISCONNECTED = "disconnected" + CONNECTING = "connecting" + CONNECTED = "connected" + RECONNECTING = "reconnecting" + DRAINING = "draining" # Draining subscribers + DRAINED = "drained" # Subscribers drained, flushing publishes + CLOSING = "closing" + CLOSED = "closed" + + +@dataclass +class ServerInfo: + """Server information received during connection.""" + + server_id: str + version: str + go_version: str + host: str + port: int + headers: bool + auth_required: bool + tls_required: bool + tls_verify: bool + max_payload: int + proto: int + client_id: int | None = None + connect_urls: list[str] | None = None + jetstream: bool | None = None + nonce: str | None = None + + @classmethod + def from_protocol(cls, info: ProtocolServerInfo) -> ServerInfo: + """Create a ServerInfo instance from protocol info dictionary.""" + return cls( + server_id=info["server_id"], + version=info["version"], + go_version=info["go"], + host=info["host"], + port=info["port"], + headers=info["headers"], + auth_required=info.get("auth_required", False), + tls_required=info.get("tls_required", False), + tls_verify=info.get("tls_verify", False), + max_payload=info.get("max_payload", 1048576), + proto=info.get("proto", 1), + client_id=info.get("client_id"), + connect_urls=info.get("connect_urls"), + jetstream=info.get("jetstream"), + nonce=info.get("nonce"), + ) + + +@dataclass(slots=True) +class ClientStatistics: + """Statistics for messages and bytes sent/received on the connection. + + This is a snapshot of the connection statistics at a point in time. + All fields are monotonically increasing counters. + """ + + in_messages: int = 0 + """Number of incoming messages received.""" + + out_messages: int = 0 + """Number of outgoing messages published.""" + + in_bytes: int = 0 + """Number of bytes received.""" + + out_bytes: int = 0 + """Number of bytes sent.""" + + reconnects: int = 0 + """Number of successful reconnection attempts.""" + + +class Client(AbstractAsyncContextManager["Client"]): + """High-level NATS client.""" + + # Connection and server info + _connection: Connection + _server_info: ServerInfo + _status: ClientStatus + _last_error: str | None + + # Reconnection configuration + _allow_reconnect: bool + _reconnect_max_attempts: int + _reconnect_time_wait: float + _reconnect_time_wait_max: float + _reconnect_jitter: float + _reconnect_timeout: float + _no_randomize: bool + _no_echo: bool + + # Server pool management + _server_pool: list[str] + _last_server: str | None + + # Reconnection state + _reconnecting: bool + _reconnect_attempts: int + _reconnect_time: float + _reconnect_lock: asyncio.Lock + + # Subscriptions + _subscriptions: dict[str, Subscription] + _next_sid: int + + # Write buffering + _pending_bytes: int + _pending_messages: list[bytes] + _max_pending_bytes: int + _max_pending_messages: int + _min_flush_interval: float + _last_flush: float + _flush_waker: asyncio.Event + + # Ping/Pong keep-alive + _ping_interval: float + _max_outstanding_pings: int + _pings_outstanding: int + _last_pong_received: float + _last_ping_sent: float + _pong_waker: asyncio.Event + + # Callbacks + _disconnected_callbacks: list[Callable[[], None]] + _reconnected_callbacks: list[Callable[[], None]] + _error_callbacks: list[Callable[[str], None]] + + # Inbox prefix + _inbox_prefix: str + + # Authentication + _token: str | Callable[[], str] | None + _user: str | Callable[[], str] | None + _password: str | Callable[[], str] | None + _nkey_public_key_handler: Callable[[], str] | None + _nkey_signature_handler: Callable[[str], bytes] | None + _jwt_handler: Callable[[], bytes] | None + _jwt_signature_handler: Callable[[str], bytes] | None + + # TLS + _tls: ssl.SSLContext | None + _tls_hostname: str | None + + # Statistics + _stats_in_messages: int + _stats_out_messages: int + _stats_in_bytes: int + _stats_out_bytes: int + _stats_reconnects: int + + # Background tasks + _read_task: asyncio.Task[None] + _write_task: asyncio.Task[None] + + def __init__( + self, + connection: Connection, + server_info: ServerInfo, + *, + servers: list[str], + allow_reconnect: bool = True, + reconnect_max_attempts: int = 10, + reconnect_time_wait: float = 2.0, + reconnect_time_wait_max: float = 10.0, + reconnect_jitter: float = 0.1, + reconnect_timeout: float = 2.0, + no_randomize: bool = False, + no_echo: bool = False, + inbox_prefix: str = "_INBOX", + ping_interval: float = 120.0, + max_outstanding_pings: int = 2, + token: str | Callable[[], str] | None = None, + user: str | Callable[[], str] | None = None, + password: str | Callable[[], str] | None = None, + nkey_public_key_handler: Callable[[], str] | None = None, + nkey_signature_handler: Callable[[str], bytes] | None = None, + jwt_handler: Callable[[], bytes] | None = None, + jwt_signature_handler: Callable[[str], bytes] | None = None, + tls: ssl.SSLContext | None = None, + tls_hostname: str | None = None, + ): + """Initialize the client. + + Args: + connection: NATS connection + server_info: Server information + servers: List of server addresses for the server pool + allow_reconnect: Whether to automatically reconnect if the connection is lost + reconnect_max_attempts: Maximum number of reconnection attempts (0 for unlimited) + reconnect_time_wait: Initial wait time between reconnection attempts + reconnect_time_wait_max: Maximum wait time between reconnection attempts + reconnect_jitter: Jitter factor for reconnection attempts + reconnect_timeout: Timeout for reconnection attempts + no_randomize: Whether to disable randomizing the server pool + no_echo: If True, the server will not send messages published by this connection back to it + inbox_prefix: Prefix for inbox subjects (default: "_INBOX") + ping_interval: Interval between PINGs in seconds (default: 120.0) + max_outstanding_pings: Maximum number of outstanding PINGs before disconnecting (default: 2) + token: Authentication token for the server + user: Username for authentication + password: Password for authentication + nkey_public_key_handler: Handler to get nkey public key + nkey_signature_handler: Handler to sign nonces with nkey + jwt_handler: Handler to get JWT + jwt_signature_handler: Handler to sign nonces for JWT auth + tls: SSL context for TLS connections + tls_hostname: Hostname for TLS certificate verification + """ + self._connection = connection + self._server_info = server_info + self._allow_reconnect = allow_reconnect + self._reconnect_max_attempts = reconnect_max_attempts + self._reconnect_time_wait = reconnect_time_wait + self._reconnect_time_wait_max = reconnect_time_wait_max + self._reconnect_jitter = reconnect_jitter + self._reconnect_timeout = reconnect_timeout + self._no_randomize = no_randomize + self._no_echo = no_echo + + # Validate inbox prefix (same rules as nats.go) + if not inbox_prefix: + raise ValueError("inbox_prefix cannot be empty") + if ">" in inbox_prefix: + raise ValueError("inbox_prefix cannot contain '>' wildcard") + if "*" in inbox_prefix: + raise ValueError("inbox_prefix cannot contain '*' wildcard") + if inbox_prefix.endswith("."): + raise ValueError("inbox_prefix cannot end with '.'") + + self._inbox_prefix = inbox_prefix + self._token = token + self._user = user + self._password = password + self._nkey_public_key_handler = nkey_public_key_handler + self._nkey_signature_handler = nkey_signature_handler + self._jwt_handler = jwt_handler + self._jwt_signature_handler = jwt_signature_handler + self._tls = tls + self._tls_hostname = tls_hostname + self._status = ClientStatus.CONNECTING + self._subscriptions = {} + self._next_sid = 1 + self._last_error = None + + # Server pool management + self._server_pool = servers + + # Reconnection state + self._reconnect_attempts = 0 + self._reconnecting = False + self._reconnect_time = self._reconnect_time_wait + self._reconnect_lock = asyncio.Lock() + self._last_server = None + + # Subscriptions + self._pending_bytes = 0 + self._pending_messages = [] + self._max_pending_bytes = 1 * 1024 * 1024 + self._max_pending_messages = 1 * 512 + self._min_flush_interval = 0.005 + self._last_flush = asyncio.get_event_loop().time() - self._min_flush_interval + self._flush_waker = asyncio.Event() + + # Ping/Pong keep-alive + self._ping_interval = ping_interval + self._max_outstanding_pings = max_outstanding_pings + self._pings_outstanding = 0 + self._last_pong_received = asyncio.get_event_loop().time() + self._last_ping_sent = self._last_pong_received + self._pong_waker = asyncio.Event() + + # Callbacks + self._disconnected_callbacks = [] + self._reconnected_callbacks = [] + self._error_callbacks = [] + + # Statistics + self._stats_in_messages = 0 + self._stats_out_messages = 0 + self._stats_in_bytes = 0 + self._stats_out_bytes = 0 + self._stats_reconnects = 0 + + # Start background tasks + self._read_task = asyncio.create_task(self._read_loop()) + self._write_task = asyncio.create_task(self._write_loop()) + + @property + def server_info(self) -> ServerInfo | None: + """Get the server info received during connection.""" + return self._server_info + + @property + def status(self) -> ClientStatus: + """Get the current client status.""" + return self._status + + @property + def last_error(self) -> str | None: + """Get the last protocol error received from the server.""" + return self._last_error + + def stats(self) -> ClientStatistics: + """Return a snapshot of the current connection statistics. + + Returns a copy of the statistics at the current point in time. + All counters are monotonically increasing and represent totals + since the connection was established. + + Returns: + ClientStatistics: Snapshot of messages and bytes sent/received, + and number of reconnections. + """ + return ClientStatistics( + in_messages=self._stats_in_messages, + out_messages=self._stats_out_messages, + in_bytes=self._stats_in_bytes, + out_bytes=self._stats_out_bytes, + reconnects=self._stats_reconnects, + ) + + async def _read_loop(self) -> None: + """Background task that reads and processes incoming protocol messages.""" + try: + while True: + try: + protocol_message = await parse(self._connection) + + if not protocol_message: + logger.info("Connection closed by server") + break + + match protocol_message: + case ("MSG", subject, sid, reply, payload): + if logger.isEnabledFor(logging.DEBUG): + logger.debug("<<- MSG %s %s %s %s", subject, sid, reply if reply else "", len(payload)) + await self._handle_msg(subject, sid, reply, payload) + case ("HMSG", subject, sid, reply, headers, payload, status_code, status_description): + if logger.isEnabledFor(logging.DEBUG): + logger.debug("<<- HMSG %s %s %s %s %s", subject, sid, reply, len(headers), len(payload)) + await self._handle_hmsg( + subject, sid, reply, headers, payload, status_code, status_description + ) + case ("PING",): + if logger.isEnabledFor(logging.DEBUG): + logger.debug("<<- PING") + await self._handle_ping() + case ("PONG",): + if logger.isEnabledFor(logging.DEBUG): + logger.debug("<<- PONG") + await self._handle_pong() + case ("INFO", info): + if logger.isEnabledFor(logging.DEBUG): + logger.debug("<<- INFO %s...", json.dumps(info)[:80]) + await self._handle_info(info) + case ("ERR", error): + logger.error("<<- -ERR '%s'", error) + await self._handle_error(error) + except Exception: + logger.exception("Error in read loop") + break + except (asyncio.CancelledError, ParseError) as e: + logger.debug("Read loop exiting: %s", e) + return + + await self._force_disconnect() + + async def _handle_ping(self) -> None: + """Handle PING from server.""" + if logger.isEnabledFor(logging.DEBUG): + logger.debug("->> PONG") + await self._connection.write(encode_pong()) + + async def _handle_pong(self) -> None: + """Handle PONG from server.""" + self._last_pong_received = asyncio.get_event_loop().time() + self._pings_outstanding = 0 + self._pong_waker.set() + + async def _queue_ping(self) -> bool: + """Queue a PING to be sent after the next flush. + + Returns: + bool: True if a PING was queued, False if max outstanding PINGs reached. + """ + if self._pings_outstanding >= self._max_outstanding_pings: + logger.error("Max outstanding PINGs reached") + await self._force_disconnect() + return False + + self._pings_outstanding += 1 + self._last_ping_sent = asyncio.get_event_loop().time() + await self._connection.write(encode_ping()) + return True + + async def _write_loop(self) -> None: + """Background task that handles periodic flushes and PINGs.""" + try: + while self._status == ClientStatus.CONNECTED: + try: + try: + await asyncio.wait_for(self._flush_waker.wait(), timeout=self._ping_interval) + self._flush_waker.clear() + + current_time = asyncio.get_event_loop().time() + since_last_flush = current_time - self._last_flush + if since_last_flush < self._min_flush_interval: + await asyncio.sleep(self._min_flush_interval - since_last_flush) + + if self._pending_messages: + await self._force_flush() + self._last_flush = current_time + + except asyncio.TimeoutError: + current_time = asyncio.get_event_loop().time() + + if current_time - self._last_ping_sent >= self._ping_interval: + if self._pings_outstanding >= self._max_outstanding_pings: + logger.exception("Max outstanding PINGs reached") + await self._force_disconnect() + break + + if self._pending_messages: + await self._force_flush() + self._last_flush = current_time + + await self._queue_ping() + + except Exception: + logger.exception("Error in write loop") + if self._status != ClientStatus.CONNECTED: + break + + except asyncio.CancelledError: + if self._pending_messages: + try: + await self._force_flush() + except Exception: + logger.exception("Error during final flush") + return + + async def _handle_msg(self, subject: str, sid: str, reply: str | None, payload: bytes) -> None: + """Handle MSG from server.""" + self._stats_in_messages += 1 + self._stats_in_bytes += len(payload) + + if sid in self._subscriptions: + subscription = self._subscriptions[sid] + + message = Message(subject=subject, data=payload, reply=reply) + + try: + subscription._enqueue(message) + + if subscription._slow_consumer_reported: + subscription._slow_consumer_reported = False + + except (asyncio.QueueFull, ValueError): + message_size = len(payload) + subscription._dropped_messages += 1 + subscription._dropped_bytes += message_size + + pending_messages, pending_bytes = subscription.pending + + logger.warning( + "Slow consumer on subject %s (sid %s): dropping message, %d pending messages, %d pending bytes", + subject, + sid, + pending_messages, + pending_bytes, + ) + + # Only report once per slow consumer event to avoid noise + if not subscription._slow_consumer_reported: + subscription._slow_consumer_reported = True + error = SlowConsumerError(subject, sid, pending_messages, pending_bytes) + for callback in self._error_callbacks: + try: + callback(error) + except Exception: + logger.exception("Error in error callback") + + async def _handle_hmsg( + self, + subject: str, + sid: str, + reply: str, + headers: dict[str, list[str]], + payload: bytes, + status_code: str | None = None, + status_description: str | None = None, + ) -> None: + """Handle HMSG from server.""" + self._stats_in_messages += 1 + self._stats_in_bytes += len(payload) + + if sid in self._subscriptions: + subscription = self._subscriptions[sid] + + status = None + if status_code is not None: + status = Status(code=status_code, description=status_description) + + message = Message( + subject=subject, + data=payload, + reply=reply, + headers=Headers(headers) if headers else None, # type: ignore[arg-type] + status=status, + ) + + try: + subscription._enqueue(message) + + if subscription._slow_consumer_reported: + subscription._slow_consumer_reported = False + + except (asyncio.QueueFull, ValueError): + message_size = len(payload) + subscription._dropped_messages += 1 + subscription._dropped_bytes += message_size + + pending_messages, pending_bytes = subscription.pending + + logger.warning( + "Slow consumer on subject %s (sid %s): dropping message, %d pending messages, %d pending bytes", + subject, + sid, + pending_messages, + pending_bytes, + ) + + # Only report once per slow consumer event to avoid noise + if not subscription._slow_consumer_reported: + subscription._slow_consumer_reported = True + error = SlowConsumerError(subject, sid, pending_messages, pending_bytes) + for callback in self._error_callbacks: + try: + callback(error) + except Exception: + logger.exception("Error in error callback") + + async def _handle_info(self, info: dict) -> None: + """Handle INFO from server.""" + self._server_info = ServerInfo.from_protocol(info) + if self._server_info.connect_urls: + for url in self._server_info.connect_urls: + if url not in self._server_pool: + self._server_pool.append(url) + + async def _handle_error(self, error: str) -> None: + """Handle ERR from server.""" + self._last_error = error + + if self._error_callbacks: + for callback in self._error_callbacks: + try: + callback(error) + except Exception: + logger.exception("Error in error callback while handling server error: %s", error) + + async def _force_disconnect(self) -> None: + """Force disconnect from server.""" + logger.info("Force disconnecting") + + old_status = self._status + self._status = ClientStatus.DISCONNECTED + if self._read_task and not self._read_task.done(): + self._read_task.cancel() + with contextlib.suppress(asyncio.CancelledError, RuntimeError): + await self._read_task + + if self._write_task and not self._write_task.done(): + self._write_task.cancel() + with contextlib.suppress(asyncio.CancelledError, RuntimeError): + await self._write_task + + if self._connection.is_connected(): + try: + await self._connection.close() + except Exception: + logger.debug("Error closing connection", exc_info=True) + + # Use lock to prevent concurrent reconnection attempts + async with self._reconnect_lock: + if ( + old_status not in (ClientStatus.CLOSING, ClientStatus.CLOSED) + and self._allow_reconnect + and not self._reconnecting + ): + logger.info("Starting reconnection process") + self._status = ClientStatus.RECONNECTING + + if self._disconnected_callbacks: + for callback in self._disconnected_callbacks: + try: + callback() + except Exception: + logger.exception("Error in disconnected callback") + + self._reconnecting = True + self._reconnect_attempts = 0 + self._reconnect_time = self._reconnect_time_wait + + while self._reconnect_max_attempts == 0 or self._reconnect_attempts < self._reconnect_max_attempts: + if not self._allow_reconnect: + logger.info("Reconnection aborted - allow_reconnect flag disabled") + break + + self._reconnect_attempts += 1 + logger.info("Reconnection attempt %s", self._reconnect_attempts) + + try: + actual_wait = self._reconnect_time * (1 + random.random() * self._reconnect_jitter) + + logger.info("Waiting %.2fs before reconnection attempt", actual_wait) + await asyncio.sleep(actual_wait) + + # Create a shuffled copy of the server pool if randomization is enabled + servers_to_try = self._server_pool.copy() + if not self._no_randomize and len(servers_to_try) > 1: + # Shuffle all but the first (original) server + tail = servers_to_try[1:] + random.shuffle(tail) + servers_to_try = [servers_to_try[0]] + tail + + for server in servers_to_try: + if server == self._last_server and len(self._server_pool) > 1: + continue + + logger.info("Trying to reconnect to %s", server) + + if "://" in server: + parsed_url = urlparse(server) + else: + scheme = "tls" if self._server_info.tls_required else "nats" + + if not server.startswith("[") and server.count(":") > 1: + last_colon = server.rfind(":") + try: + port_val = int(server[last_colon + 1 :]) + if 0 <= port_val <= 65535: + host_part = server[:last_colon] + server = f"[{host_part}]:{port_val}" + except ValueError: + server = f"[{server}]" + + parsed_url = urlparse(f"{scheme}://{server}") + + host = parsed_url.hostname + port = parsed_url.port or 4222 + scheme = parsed_url.scheme + + if not host: + logger.warning("Failed to parse hostname from server URL: %s", server) + continue + + try: + # Determine SSL context for reconnection + ssl_context = None + if scheme in ("tls", "wss"): + # Use stored TLS context or create default + ssl_context = self._tls if self._tls is not None else ssl.create_default_context() + elif self._tls is not None: + # User explicitly provided TLS context + ssl_context = self._tls + + # Determine server hostname for TLS verification + server_hostname = ( + self._tls_hostname + if self._tls_hostname is not None + else (host if ssl_context else None) + ) + + # Connect with or without TLS + connection = await asyncio.wait_for( + open_tcp_connection( + host, port, ssl_context=ssl_context, server_hostname=server_hostname + ), + timeout=self._reconnect_timeout, + ) + + protocol_message = await parse(connection) + if not protocol_message or protocol_message.op != "INFO": + msg = "Expected INFO message" + raise RuntimeError(msg) + + new_server_info = ServerInfo.from_protocol(protocol_message.info) + logger.info( + "Reconnected to %s (version %s)", new_server_info.server_id, new_server_info.version + ) + + connect_info = ConnectInfo( + verbose=False, + pedantic=False, + tls_required=False, + lang="python", + version=__version__, + protocol=1, + headers=True, + no_responders=True, + echo=not self._no_echo, + ) + + # Add authentication if provided (resolve callables) + if self._token: + connect_info["auth_token"] = self._token() if callable(self._token) else self._token + if self._user: + connect_info["user"] = self._user() if callable(self._user) else self._user + if self._password: + connect_info["password"] = ( + self._password() if callable(self._password) else self._password + ) + + if self._jwt_handler is not None: + # JWT authentication + connect_info["jwt"] = self._jwt_handler().decode() + if new_server_info.nonce and self._jwt_signature_handler is not None: + connect_info["sig"] = self._jwt_signature_handler( + new_server_info.nonce + ).decode() + elif self._nkey_public_key_handler is not None: + # Bare nkey authentication + connect_info["nkey"] = self._nkey_public_key_handler() + if new_server_info.nonce and self._nkey_signature_handler is not None: + connect_info["sig"] = self._nkey_signature_handler( + new_server_info.nonce + ).decode() + + logger.debug("->> CONNECT %s", json.dumps(connect_info)) + await connection.write(encode_connect(connect_info)) + + self._connection = connection + self._server_info = new_server_info + self._status = ClientStatus.CONNECTED + self._last_server = server + + # Update server pool with new cluster URLs after reconnection + if new_server_info.connect_urls: + # Add new servers from connect_urls, avoiding duplicates + for url in new_server_info.connect_urls: + if url not in self._server_pool: + self._server_pool.append(url) + + for sid, subscription in list(self._subscriptions.items()): + subject = subscription.subject + queue = subscription.queue + logger.debug("->> SUB %s %s %s", subject, sid, queue) + await self._connection.write(encode_sub(subject, sid, queue)) + + await self._force_flush() + + self._read_task = asyncio.create_task(self._read_loop()) + self._write_task = asyncio.create_task(self._write_loop()) + + self._reconnecting = False + self._reconnect_attempts = 0 + self._reconnect_time = self._reconnect_time_wait + + # Update statistics + self._stats_reconnects += 1 + + if self._reconnected_callbacks: + for callback in self._reconnected_callbacks: + try: + callback() + except Exception: + logger.exception("Error in reconnected callback") + + return + + except (asyncio.CancelledError, asyncio.TimeoutError) as e: + logger.error("Failed to connect to %s: %s", server, type(e).__name__) + self._last_server = server + continue + except Exception: + logger.exception("Failed to connect to %s", server) + self._last_server = server + continue + + logger.error("Failed to connect to any server in the pool") + + self._reconnect_time = min(self._reconnect_time * 2, self._reconnect_time_wait_max) + + except Exception: + logger.exception("Reconnection attempt failed") + + logger.error("Reconnection failed after maximum attempts") + self._reconnecting = False + self._status = ClientStatus.CLOSED + else: + # Not attempting reconnection, set status to CLOSED + self._status = ClientStatus.CLOSED + + async def _force_flush(self) -> None: + """Flush pending messages to the server.""" + if not self._pending_messages: + return + + # Check if we're connected before trying to write + if not self._connection.is_connected(): + return + + await self._connection.write(b"".join(self._pending_messages)) + + self._pending_messages.clear() + self._pending_bytes = 0 + + async def flush(self, timeout: float | None = None) -> None: + """Flush pending messages with optional timeout.""" + if self._status == ClientStatus.CLOSED: + logger.debug("Flush called on closed connection, skipping") + return + + if self._pending_messages: + await self._force_flush() + + self._pong_waker.clear() + logger.debug("->> PING") + self._pings_outstanding += 1 + self._last_ping_sent = asyncio.get_event_loop().time() + await self._connection.write(encode_ping()) + try: + await asyncio.wait_for(self._pong_waker.wait(), timeout=timeout) + except asyncio.TimeoutError: + logger.exception("PONG not received within timeout") + await self._force_disconnect() + + async def publish( + self, + subject: str | bytes, + payload: bytes, + *, + reply: str | bytes | None = None, + headers: Headers | dict[str, str | list[str]] | None = None, + ) -> None: + """Publish a message to a subject. + + Args: + subject: Subject to publish to (str or bytes for zero-copy optimization) + payload: Message payload + reply: Optional reply subject (str or bytes for zero-copy optimization) + headers: Optional message headers + """ + if self._status in (ClientStatus.CLOSED, ClientStatus.CLOSING): + msg = "Connection is closed" + raise RuntimeError(msg) + + if isinstance(subject, str): + subject = subject.encode() + + if isinstance(reply, str): + reply = reply.encode() + + if headers: + headers_dict = headers.asdict() if isinstance(headers, Headers) else headers + message_data = encode_hpub( + subject, + payload, + reply=reply, + headers=headers_dict, # type: ignore[arg-type] + ) + else: + message_data = encode_pub( + subject, + payload, + reply=reply, + ) + message_size = len(message_data) + + if ( + self._pending_bytes + message_size > self._max_pending_bytes + or len(self._pending_messages) >= self._max_pending_messages + ): + await self._force_flush() + + self._pending_messages.append(message_data) + self._pending_bytes += message_size + + self._stats_out_messages += 1 + self._stats_out_bytes += len(payload) + + self._flush_waker.set() + + async def subscribe( + self, + subject: str | bytes, + *, + queue: str | bytes = "", + max_pending_messages: int | None = 65536, + max_pending_bytes: int | None = 67108864, # 64 MB + ) -> Subscription: + """Subscribe to a subject. + + Args: + subject: The subject to subscribe to + queue: Optional queue group name for load balancing + max_pending_messages: Maximum number of pending messages before triggering + slow consumer error (default: 65536). Use None for unlimited. + max_pending_bytes: Maximum bytes of pending messages before triggering + slow consumer error (default: 64MB). Use None for unlimited. + + Returns: + The subscription object + + Raises: + RuntimeError: If the connection is closed + """ + if self._status == ClientStatus.CLOSED: + msg = "Connection is closed" + raise RuntimeError(msg) + + # Convert subject and queue to strings for internal storage if they're bytes + subject_str = subject.decode() if isinstance(subject, bytes) else subject + queue_str = queue.decode() if isinstance(queue, bytes) else queue + + sid = str(self._next_sid) + self._next_sid += 1 + + subscription = Subscription( + subject_str, + sid, + queue_str, + self, + max_pending_messages=max_pending_messages, + max_pending_bytes=max_pending_bytes, + ) + + self._subscriptions[sid] = subscription + + command = encode_sub(subject_str, sid, queue_str if queue_str else None) + if queue_str: + logger.debug("->> SUB %s %s %s", subject_str, queue_str, sid) + else: + logger.debug("->> SUB %s %s", subject_str, sid) + + await self._connection.write(command) + + return subscription + + async def _subscribe(self, subject: str, sid: str, queue: str | None) -> asyncio.Queue: + """Create a subscription on the server and return the message queue. + + Args: + subject: The subject to subscribe to + sid: The subscription ID + queue: Optional queue group for load balancing + + Returns: + An asyncio.Queue that will receive messages for this subscription + """ + msg_queue = asyncio.Queue() + + command = encode_sub(subject, sid, queue) + if queue: + logger.debug("->> SUB %s %s %s", subject, queue, sid) + else: + logger.debug("->> SUB %s %s", subject, sid) + + await self._connection.write(command) + + return msg_queue + + async def _unsubscribe(self, sid: str) -> None: + """Send UNSUB command to server for a subscription. + + Args: + sid: Subscription ID + """ + logger.debug("->> UNSUB %s", sid) + + if sid in self._subscriptions: + if self._status not in (ClientStatus.CLOSED, ClientStatus.CLOSING): + await self._connection.write(encode_unsub(sid)) + del self._subscriptions[sid] + + def new_inbox(self) -> str: + """Generate a new inbox subject. + + Returns: + A unique inbox subject using the configured inbox prefix + """ + return f"{self._inbox_prefix}.{uuid.uuid4().hex}" + + async def request( + self, + subject: str, + payload: bytes, + *, + timeout: float = 2.0, + headers: dict[str, str | list[str]] | None = None, + return_on_error: bool = False, + ) -> Message: + """Send a request and wait for a response. + + Args: + subject: The subject to send the request to + payload: The request payload as bytes + timeout: How long to wait for a response (default: 2.0 seconds) + headers: Optional headers to include with the request + return_on_error: If False (default), raises StatusError for error responses. + If True, returns the error response as a normal Message. + + Returns: + The response message + + Raises: + RuntimeError: If the connection is closed + TimeoutError: If no response is received within the timeout + StatusError: If return_on_error=False and the response contains error status headers + """ + if self._status == ClientStatus.CLOSED: + msg = "Connection is closed" + raise RuntimeError(msg) + + inbox = self.new_inbox() + logger.debug("Created inbox %s for request to %s", inbox, subject) + + sub = await self.subscribe(inbox) + try: + await self.publish(subject, payload, reply=inbox, headers=headers) + + try: + response = await asyncio.wait_for(sub.next(), timeout) + + if not return_on_error and response.status is not None and response.status.code != "200": + status = response.status.code + description = response.status.description or "Unknown error" + raise StatusError.from_status(status, description, subject=subject) + + return response + except asyncio.TimeoutError: + logger.exception("Request timeout (%ss) on %s", timeout, subject) + msg = "Request timeout" + raise TimeoutError(msg) + + finally: + await self._unsubscribe(sub._sid) + + async def drain(self, timeout: float = 30.0) -> None: + """Drain the connection. + + Draining a connection puts it into a drain state where: + 1. All subscriptions are drained (unsubscribed but pending messages can be processed) + 2. No new messages can be published + 3. Pending messages in the write buffer are flushed + 4. The connection is closed + + This allows for graceful shutdown without losing messages. After drain completes, + the connection will be closed automatically. + + This method is idempotent - calling it multiple times is safe and will not raise + errors. Subsequent calls after the first will return immediately without error. + + Args: + timeout: Maximum time to wait for drain to complete (default: 30.0 seconds) + + Raises: + TimeoutError: If drain does not complete within the timeout + """ + # Idempotent: if already draining, drained, closing, or closed, return without error + if self._status in (ClientStatus.DRAINING, ClientStatus.DRAINED, ClientStatus.CLOSING, ClientStatus.CLOSED): + return + + logger.info("Draining connection") + self._status = ClientStatus.DRAINING + + # Disable reconnection during drain + self._allow_reconnect = False + + try: + subscriptions_to_drain = list(self._subscriptions.values()) + + if subscriptions_to_drain: + logger.debug("Draining %s subscriptions", len(subscriptions_to_drain)) + drain_tasks = [sub.drain() for sub in subscriptions_to_drain] + await asyncio.wait_for(asyncio.gather(*drain_tasks, return_exceptions=True), timeout=timeout) + + self._status = ClientStatus.DRAINED + + if self._pending_messages: + logger.debug("Flushing pending messages") + await asyncio.wait_for(self.flush(), timeout=timeout) + + await self.close() + + except asyncio.TimeoutError: + logger.error("Drain timeout after %s seconds", timeout) + await self.close() + msg = f"Drain operation timed out after {timeout} seconds" + raise TimeoutError(msg) + + async def close(self) -> None: + """Close the connection.""" + if self._status == ClientStatus.CLOSED: + return + + logger.info("Closing connection") + self._status = ClientStatus.CLOSING + + self._allow_reconnect = False + + if self._read_task and isinstance(self._read_task, asyncio.Task) and not self._read_task.done(): + self._read_task.cancel() + with contextlib.suppress(asyncio.CancelledError, RuntimeError): + await self._read_task + + if self._write_task and isinstance(self._write_task, asyncio.Task) and not self._write_task.done(): + self._write_task.cancel() + with contextlib.suppress(asyncio.CancelledError, RuntimeError): + await self._write_task + + subscription_count = len(self._subscriptions) + if subscription_count > 0: + logger.debug("Closing %s subscriptions", subscription_count) + + sids = list(self._subscriptions.keys()) + for sid in sids: + if sid in self._subscriptions: + subscription = self._subscriptions[sid] + await subscription.unsubscribe() + + try: + await self._connection.close() + except BaseException: + logger.debug("Error closing connection during close", exc_info=True) + + self._flush_waker.set() + + tasks_to_cancel = [] + if self._read_task and not self._read_task.done(): + tasks_to_cancel.append(self._read_task) + self._read_task.cancel() + + if self._write_task and not self._write_task.done(): + tasks_to_cancel.append(self._write_task) + self._write_task.cancel() + + if tasks_to_cancel: + with contextlib.suppress(asyncio.CancelledError): + await asyncio.gather(*tasks_to_cancel, return_exceptions=True) + + self._status = ClientStatus.CLOSED + + async def __aenter__(self) -> Self: + """Enter the async context manager.""" + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: types.TracebackType | None + ) -> None: + """Exit the async context manager, closing the client connection.""" + await self.close() + + def add_disconnected_callback(self, callback: Callable[[], None]) -> None: + """Add a callback to be invoked when the client is disconnected. + + Args: + callback: Function to be called when disconnected + """ + self._disconnected_callbacks.append(callback) + + def add_reconnected_callback(self, callback: Callable[[], None]) -> None: + """Add a callback to be invoked when the client is reconnected. + + Args: + callback: Function to be called when reconnected + """ + self._reconnected_callbacks.append(callback) + + def add_error_callback(self, callback: Callable[[str], None]) -> None: + """Add a callback to be invoked when the client encounters an error. + + Args: + callback: Function to be called with the error message + """ + self._error_callbacks.append(callback) + + async def _send_connect(self) -> None: + """Send CONNECT message to the server.""" + connect_info = ConnectInfo( + verbose=False, + pedantic=False, + tls_required=False, + lang="python", + version=__version__, + protocol=1, + headers=True, + no_responders=True, + echo=not self._no_echo, + ) + + # Add authentication if provided (resolve callables) + if self._token: + connect_info["auth_token"] = self._token() if callable(self._token) else self._token + if self._user: + connect_info["user"] = self._user() if callable(self._user) else self._user + if self._password: + connect_info["password"] = self._password() if callable(self._password) else self._password + + if self._jwt_handler is not None: + # JWT authentication + connect_info["jwt"] = self._jwt_handler().decode() + if self._server_info.nonce and self._jwt_signature_handler is not None: + connect_info["sig"] = self._jwt_signature_handler(self._server_info.nonce).decode() + elif self._nkey_public_key_handler is not None: + # Bare nkey authentication + connect_info["nkey"] = self._nkey_public_key_handler() + if self._server_info.nonce and self._nkey_signature_handler is not None: + connect_info["sig"] = self._nkey_signature_handler(self._server_info.nonce).decode() + + logger.debug("->> CONNECT %s", json.dumps(connect_info)) + await self._connection.write(encode_connect(connect_info)) + self._status = ClientStatus.CONNECTED + + +def _setup_nkey_auth( + nkey: str | Path | tuple[Callable[[], str], Callable[[str], bytes]], +) -> tuple[Callable[[], str], Callable[[str], bytes]]: + """Setup nkey authentication handlers from various input formats. + + Args: + nkey: Nkey seed string, Path to seed file, or tuple of handlers + + Returns: + Tuple of (public_key_handler, signature_handler) + """ + if isinstance(nkey, tuple): + # Already handlers, return as-is + return nkey + + # Load seed from string or file + if isinstance(nkey, Path): + seed_bytes = nkey.read_bytes().strip() + else: + seed_bytes = nkey.encode() + + # Create handlers from seed + def public_key_handler() -> str: + kp = nkeys.from_seed(seed_bytes) + return kp.public_key.decode() + + def signature_handler(nonce: str) -> bytes: + kp = nkeys.from_seed(seed_bytes) + sig = kp.sign(nonce.encode()) + return base64.b64encode(sig) + + return public_key_handler, signature_handler + + +def _setup_jwt_auth( + jwt: tuple[str, str] | Path | tuple[Path, Path] | tuple[Callable[[], bytes], Callable[[str], bytes]], +) -> tuple[Callable[[], bytes], Callable[[str], bytes]]: + """Setup JWT authentication handlers from various input formats. + + Args: + jwt: JWT config as (jwt_string, seed_string), Path to .creds file, + (jwt_file, seed_file), or tuple of handlers + + Returns: + Tuple of (jwt_handler, signature_handler) + """ + if isinstance(jwt, tuple) and callable(jwt[0]): + # Already handlers, return as-is + return jwt # type: ignore[return-value] + + # Parse JWT and seed + jwt_content: bytes + seed_bytes: bytes + + if isinstance(jwt, Path): + # Single .creds file + creds_content = jwt.read_text() + + # Extract JWT + jwt_match = re.search( + r"-----BEGIN NATS USER JWT-----\s*(.+?)\s*------END NATS USER JWT------", + creds_content, + re.DOTALL, + ) + if not jwt_match: + msg = f"No JWT found in credentials file: {jwt}" + raise ValueError(msg) + jwt_content = jwt_match.group(1).strip().encode() + + # Extract seed + seed_match = re.search( + r"-----BEGIN USER NKEY SEED-----\s*(.+?)\s*------END USER NKEY SEED-----", + creds_content, + re.DOTALL, + ) + if not seed_match: + msg = f"No seed found in credentials file: {jwt}" + raise ValueError(msg) + seed_bytes = seed_match.group(1).strip().encode() + + elif isinstance(jwt, tuple) and isinstance(jwt[0], Path): + # Separate files + jwt_file, seed_file = jwt + jwt_content = jwt_file.read_bytes().strip() + seed_bytes = seed_file.read_bytes().strip() + + else: + # Strings + jwt_str, seed_str = jwt # type: ignore[misc] + jwt_content = jwt_str.encode() if isinstance(jwt_str, str) else jwt_str + seed_bytes = seed_str.encode() if isinstance(seed_str, str) else seed_str + + # Create handlers + def jwt_handler() -> bytes: + return jwt_content + + def signature_handler(nonce: str) -> bytes: + kp = nkeys.from_seed(seed_bytes) + sig = kp.sign(nonce.encode()) + return base64.b64encode(sig) + + return jwt_handler, signature_handler + + +async def connect( + url: str = "nats://localhost:4222", + *, + timeout: float = 2.0, + tls: ssl.SSLContext | None = None, + tls_hostname: str | None = None, + tls_handshake_first: bool = False, + allow_reconnect: bool = True, + reconnect_max_attempts: int = 10, + reconnect_time_wait: float = 2.0, + reconnect_time_wait_max: float = 10.0, + reconnect_jitter: float = 0.1, + reconnect_timeout: float | None = None, + no_randomize: bool = False, + no_echo: bool = False, + inbox_prefix: str = "_INBOX", + ping_interval: float = 120.0, + max_outstanding_pings: int = 2, + token: str | Callable[[], str] | None = None, + user: str | Callable[[], str] | None = None, + password: str | Callable[[], str] | None = None, + nkey: NkeySeed | NkeyHandlers | None = None, + jwt: JWTCredentials | JWTHandlers | None = None, +) -> Client: + """Connect to a NATS server. + + Args: + url: Server URL + timeout: Connection timeout in seconds + tls: Custom SSL context for TLS connections (uses default if scheme is tls://) + tls_hostname: Override hostname for TLS certificate verification + tls_handshake_first: Perform TLS handshake before receiving INFO message + allow_reconnect: Whether to automatically reconnect if the connection is lost + reconnect_max_attempts: Maximum number of reconnection attempts (0 for unlimited) + reconnect_time_wait: Initial wait time between reconnection attempts + reconnect_time_wait_max: Maximum wait time between reconnection attempts + reconnect_jitter: Jitter factor for reconnection attempts + reconnect_timeout: Timeout for individual reconnection attempts (defaults to timeout value) + no_randomize: Whether to disable randomizing the server pool + no_echo: If True, the server will not send messages published by this connection back to it (default: False) + inbox_prefix: Prefix for inbox subjects (default: "_INBOX") + ping_interval: Interval between PINGs in seconds (default: 120.0) + max_outstanding_pings: Maximum number of outstanding PINGs before disconnecting (default: 2) + token: Authentication token for the server + user: Username for authentication + password: Password for authentication + nkey: NKey authentication (bare nkey, no JWT). See `Nkey` type alias for options: + - str: seed string (e.g., "SUAMLK2ZNL35...") + - Path: path to seed file + - tuple[NkeyPublicKeyHandler, NkeySignatureHandler]: custom handlers for full control + jwt: JWT + NKey authentication. See `JWT` type alias for options: + - tuple[str, str]: (jwt_string, seed_string) + - Path: single .creds file containing both JWT and seed + - tuple[Path, Path]: (jwt_file, seed_file) + - tuple[JWTHandler, JWTSignatureHandler]: custom handlers for full control + + Returns: + Client instance + + Raises: + TimeoutError: Connection timed out + ConnectionError: Failed to connect + ValueError: Invalid URL + """ + parsed_url = urlparse(url) + if parsed_url.scheme not in ("nats", "tls", "ws", "wss"): + msg = "URL scheme must be 'nats://', 'tls://', 'ws://', or 'wss://'" + raise ValueError(msg) + + host = parsed_url.hostname or "localhost" + port = parsed_url.port or 4222 + + logger.info("Connecting to %s:%s", host, port) + + ssl_context = None + if parsed_url.scheme in ("tls", "wss"): + ssl_context = tls if tls is not None else ssl.create_default_context() + elif tls is not None: + ssl_context = tls + + server_hostname = tls_hostname if tls_hostname is not None else (host if ssl_context else None) + + tls_established = False + try: + if tls_handshake_first and ssl_context: + connection = await asyncio.wait_for( + open_tcp_connection(host, port, ssl_context=ssl_context, server_hostname=server_hostname), + timeout=timeout, + ) + tls_established = True + else: + connection = await asyncio.wait_for( + open_tcp_connection(host, port), + timeout=timeout, + ) + except asyncio.TimeoutError: + msg = f"Connection timed out after {timeout} seconds" + raise TimeoutError(msg) + except Exception as e: + msg = f"Failed to connect: {e}" + raise ConnectionError(msg) + + try: + protocol_message = await parse(connection) + if not protocol_message or protocol_message.op != "INFO": + msg = "Expected INFO message" + raise RuntimeError(msg) + + server_info = ServerInfo.from_protocol(protocol_message.info) + logger.info("Connected to %s (version %s)", server_info.server_id, server_info.version) + + if server_info.tls_required and not tls_established: + logger.info("Server requires TLS, upgrading connection") + upgrade_ssl_context = tls if tls is not None else ssl.create_default_context() + upgrade_hostname = tls_hostname if tls_hostname is not None else host + + if hasattr(connection, "upgrade_to_tls"): + await connection.upgrade_to_tls(upgrade_ssl_context, upgrade_hostname) + ssl_context = upgrade_ssl_context + server_hostname = upgrade_hostname + tls_established = True + else: + await connection.close() + msg = "Server requires TLS but connection does not support upgrade" + raise ConnectionError(msg) + + except Exception as e: + await connection.close() + msg = f"Failed to connect: {e}" + raise ConnectionError(msg) + + servers = [f"{host}:{port}"] + if server_info.connect_urls: + servers.extend(server_info.connect_urls) + + connect_info = ConnectInfo( + verbose=False, + pedantic=False, + tls_required=tls_established, + lang="python", + version=__version__, + protocol=1, + headers=True, + no_responders=True, + echo=not no_echo, + ) + + # Setup authentication handlers + nkey_public_key_handler = None + nkey_signature_handler = None + jwt_handler = None + jwt_signature_handler = None + + if nkey is not None: + nkey_public_key_handler, nkey_signature_handler = _setup_nkey_auth(nkey) + + if jwt is not None: + jwt_handler, jwt_signature_handler = _setup_jwt_auth(jwt) + + # Resolve callables for token/user/password + resolved_token = token() if callable(token) else token + resolved_user = user() if callable(user) else user + resolved_password = password() if callable(password) else password + + # Apply authentication to CONNECT message + if resolved_token: + connect_info["auth_token"] = resolved_token + if resolved_user: + connect_info["user"] = resolved_user + if resolved_password: + connect_info["password"] = resolved_password + + if jwt_handler is not None: + # JWT authentication + connect_info["jwt"] = jwt_handler().decode() + if server_info.nonce and jwt_signature_handler is not None: + connect_info["sig"] = jwt_signature_handler(server_info.nonce).decode() + elif nkey_public_key_handler is not None: + # Bare nkey authentication + connect_info["nkey"] = nkey_public_key_handler() + if server_info.nonce and nkey_signature_handler is not None: + connect_info["sig"] = nkey_signature_handler(server_info.nonce).decode() + + logger.debug("->> CONNECT %s", json.dumps(connect_info)) + await connection.write(encode_connect(connect_info)) + + # Send a PING and wait for PONG to verify the handshake completed successfully + # If auth fails, the server will send -ERR before we get a PONG + await connection.write(encode_ping()) + + # Wait for response to verify connection is good + try: + response = await asyncio.wait_for(parse(connection), timeout=timeout) + + # Check if we got an error response + if response and response.op == "ERR": + await connection.close() + error_msg = response.error + + # Check for authorization errors + if "authorization" in error_msg.lower(): + msg = f"Authorization failed: {error_msg}" + raise ConnectionError(msg) + else: + msg = f"Connection error: {error_msg}" + raise ConnectionError(msg) + + # If we got PONG or INFO or any other non-error message, connection is good + # (Server may send additional INFO messages after CONNECT) + + except asyncio.TimeoutError: + await connection.close() + msg = "Server did not respond to PING" + raise ConnectionError(msg) + except ConnectionError: + # Re-raise connection errors from error checking above + raise + except Exception as e: + await connection.close() + msg = f"Failed to verify connection: {e}" + raise ConnectionError(msg) + + # Handshake complete - now create the Client with background tasks + # Store the TLS context that was used (original or created during upgrade) + client = Client( + connection, + server_info, + servers=servers, + allow_reconnect=allow_reconnect, + reconnect_max_attempts=reconnect_max_attempts, + reconnect_time_wait=reconnect_time_wait, + reconnect_time_wait_max=reconnect_time_wait_max, + reconnect_jitter=reconnect_jitter, + reconnect_timeout=reconnect_timeout if reconnect_timeout is not None else timeout, + no_randomize=no_randomize, + no_echo=no_echo, + inbox_prefix=inbox_prefix, + ping_interval=ping_interval, + max_outstanding_pings=max_outstanding_pings, + token=token, + user=user, + password=password, + nkey_public_key_handler=nkey_public_key_handler, + nkey_signature_handler=nkey_signature_handler, + jwt_handler=jwt_handler, + jwt_signature_handler=jwt_signature_handler, + tls=ssl_context if ssl_context else tls, # Use actual context if TLS was used + tls_hostname=server_hostname if server_hostname else tls_hostname, + ) + + client._status = ClientStatus.CONNECTED + + return client + + +__all__ = [ + "__version__", + "Message", + "Headers", + "Status", + "Subscription", + "Client", + "ServerInfo", + "ClientStatus", + "ClientStatistics", + "StatusError", + "NoRespondersError", +] diff --git a/nats-client/src/nats/client/connection.py b/nats-client/src/nats/client/connection.py new file mode 100644 index 000000000..3642d217a --- /dev/null +++ b/nats-client/src/nats/client/connection.py @@ -0,0 +1,208 @@ +"""Connection classes for NATS client.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +if TYPE_CHECKING: + import ssl + +logger = logging.getLogger("nats.client") + + +@runtime_checkable +class Connection(Protocol): + """Protocol for NATS connections. + + This is a structural type (Protocol) rather than a nominal type (ABC), + allowing any class with the required methods to be used as a connection + without explicit inheritance. + """ + + async def close(self) -> None: + """Close the connection.""" + ... + + async def read(self, n: int) -> bytes: + """Read n bytes from the connection.""" + ... + + async def write(self, data: bytes) -> None: + """Write data to the connection.""" + ... + + def is_connected(self) -> bool: + """Check if the connection is active.""" + ... + + async def readline(self) -> bytes: + """Read a line from the connection. + + Returns: + Line read from the connection ending with newline + """ + ... + + async def readexactly(self, n: int) -> bytes: + """Read exactly n bytes from the connection. + + Args: + n: Number of bytes to read + + Returns: + Bytes read + + Raises: + asyncio.IncompleteReadError: If fewer than n bytes are available + """ + ... + + +class TcpConnection: + """TCP-based NATS connection. + + Implements the Connection protocol for TCP connections. + """ + + _reader: asyncio.StreamReader | None + _writer: asyncio.StreamWriter | None + + def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + """Initialize TCP connection. + + Args: + reader: Stream reader for the connection + writer: Stream writer for the connection + """ + self._reader = reader + self._writer = writer + + async def upgrade_to_tls( + self, + ssl_context: ssl.SSLContext, + server_hostname: str | None = None, + ) -> None: + """Upgrade existing connection to TLS. + + Args: + ssl_context: SSL context for TLS + server_hostname: Hostname for SSL certificate verification + + Raises: + ConnectionError: If upgrade fails + """ + if not self._writer: + msg = "Not connected" + raise ConnectionError(msg) + + try: + # Get the transport and protocol from the writer + transport = self._writer.transport + protocol = transport.get_protocol() + + # Start TLS upgrade on the transport + loop = asyncio.get_running_loop() + new_transport = await loop.start_tls( + transport, + protocol, + ssl_context, + server_hostname=server_hostname, + ) + + # Update the writer's transport + self._writer._transport = new_transport # type: ignore[attr-defined] + logger.debug("Connection upgraded to TLS") + + except Exception as e: + msg = f"Failed to upgrade connection to TLS: {e}" + raise ConnectionError(msg) from e + + async def close(self) -> None: + """Close TCP connection.""" + if self._writer: + self._writer.close() + await self._writer.wait_closed() + self._writer = None + self._reader = None + logger.debug("TCP connection closed") + + async def read(self, n: int) -> bytes: + """Read n bytes from TCP connection.""" + if not self._reader: + msg = "Not connected" + raise ConnectionError(msg) + return await self._reader.read(n) + + async def write(self, data: bytes) -> None: + """Write data to TCP connection.""" + if not self._writer: + msg = "Not connected" + raise ConnectionError(msg) + self._writer.write(data) + await self._writer.drain() + + def is_connected(self) -> bool: + """Check if TCP connection is active.""" + return self._writer is not None and not self._writer.is_closing() + + async def readline(self) -> bytes: + """Read a line from TCP connection. + + Returns: + A line of bytes ending with newline + + Raises: + ConnectionError: If not connected + """ + if not self._reader: + msg = "Not connected" + raise ConnectionError(msg) + return await self._reader.readline() + + async def readexactly(self, n: int) -> bytes: + """Read exactly n bytes from TCP connection. + + Args: + n: Number of bytes to read + + Returns: + Exactly n bytes + + Raises: + ConnectionError: If not connected + asyncio.IncompleteReadError: If connection closed before n bytes were read + """ + if not self._reader: + msg = "Not connected" + raise ConnectionError(msg) + return await self._reader.readexactly(n) + + +async def open_tcp_connection( + host: str, + port: int, + ssl_context: ssl.SSLContext | None = None, + server_hostname: str | None = None, +) -> TcpConnection: + """Open a TCP connection to a NATS server. + + Args: + host: Server hostname + port: Server port + ssl_context: Optional SSL context for TLS + server_hostname: Hostname for SSL certificate verification (defaults to host) + + Returns: + TCP connection + + Raises: + ConnectionError: If connection fails + """ + try: + reader, writer = await asyncio.open_connection(host, port, ssl=ssl_context, server_hostname=server_hostname) + return TcpConnection(reader, writer) + except Exception as e: + msg = f"Failed to connect: {e}" + raise ConnectionError(msg) diff --git a/nats-client/src/nats/client/errors.py b/nats-client/src/nats/client/errors.py new file mode 100644 index 000000000..e2e095634 --- /dev/null +++ b/nats-client/src/nats/client/errors.py @@ -0,0 +1,89 @@ +"""NATS client error classes.""" + +from __future__ import annotations + +__all__ = ["StatusError", "NoRespondersError", "SlowConsumerError"] + + +class StatusError(Exception): + """Base class for NATS status-related errors.""" + + status: str + description: str + subject: str | None + + def __init__(self, status: str, description: str, subject: str | None = None) -> None: + """Initialize StatusError. + + Args: + status: The error status code + description: Human-readable error description + subject: The subject that caused the error (optional) + """ + self.status = status + self.description = description + self.subject = subject + super().__init__(f"{status}: {description}") + + @classmethod + def from_status(cls, status: str, description: str, *, subject: str | None = None) -> StatusError: + """Create appropriate StatusError subclass based on status code. + + Args: + status: The error status code + description: Human-readable error description + subject: The subject that caused the error (optional) + + Returns: + Appropriate StatusError subclass instance + """ + match status: + case "503": + return NoRespondersError(status, description, subject) + case _: + return cls(status, description, subject) + + +class NoRespondersError(StatusError): + """Error raised when no responders are available (503).""" + + def __init__(self, status: str, description: str, subject: str | None = None) -> None: + """Initialize NoRespondersError. + + Args: + status: The error status code + description: Human-readable error description + subject: The subject that caused the error (optional) + """ + super().__init__(status, description, subject) + + +class SlowConsumerError(Exception): + """Error raised when a subscription cannot keep up with message flow. + + This occurs when the subscription's pending message queue exceeds + the configured limits (pending_msgs_limit or pending_bytes_limit). + Messages will be dropped to prevent memory exhaustion. + """ + + subject: str + sid: str + pending_messages: int + pending_bytes: int + + def __init__(self, subject: str, sid: str, pending_messages: int, pending_bytes: int) -> None: + """Initialize SlowConsumerError. + + Args: + subject: The subscription subject + sid: The subscription ID + pending_messages: Number of pending messages in queue + pending_bytes: Number of pending bytes in queue + """ + self.subject = subject + self.sid = sid + self.pending_messages = pending_messages + self.pending_bytes = pending_bytes + super().__init__( + f"Slow consumer on subject '{subject}': {pending_messages} pending messages, {pending_bytes} pending bytes" + ) diff --git a/nats-client/src/nats/client/message.py b/nats-client/src/nats/client/message.py new file mode 100644 index 000000000..7def47621 --- /dev/null +++ b/nats-client/src/nats/client/message.py @@ -0,0 +1,147 @@ +"""NATS message types and utilities.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class Headers: + """NATS message headers.""" + + _data: dict[str, list[str]] + + def __init__(self, headers: dict[str, str | list[str]]) -> None: + self._data = {} + for key, value in headers.items(): + if isinstance(value, str): + self._data[key] = [value] + elif isinstance(value, list): + if not all(isinstance(v, str) for v in value): + msg = "All items in header value list must be strings" + raise ValueError(msg) + self._data[key] = value + else: + msg = "Header values must be strings or lists of strings" + raise TypeError(msg) + + def get(self, key: str) -> str | None: + """Get a header value. If multiple values exist, returns the first one. + + Args: + key: The header name + + Returns: + The first header value or None if the header doesn't exist + """ + values = self._data.get(key) + if values is None or len(values) == 0: + return None + return values[0] + + def get_all(self, key: str) -> list[str]: + """Get all values for a header. + + Args: + key: The header name + + Returns: + A list of all values for the header. Returns an empty list if the header doesn't exist. + """ + return self._data.get(key, []) + + def set(self, key: str, value: str) -> None: + """Set a header value, replacing any existing values. + + This operation is case-sensitive and will remove any exact-match keys + before adding the new value. + + Args: + key: The header name + value: The header value to set + """ + self._data[key] = [value] + + def delete(self, key: str) -> None: + """Delete a header by key. + + This operation is case-sensitive and will only remove exact-match keys. + + Args: + key: The header name to delete + """ + self._data.pop(key, None) + + def append(self, key: str, value: str) -> None: + """Append a value to a header, preserving existing values. + + This operation is case-sensitive and case-preserving. If the key exists, + the value is added to it. If not, the key is created with the specified case. + + Args: + key: The header name + value: The header value to append + """ + if key in self._data: + self._data[key].append(value) + else: + self._data[key] = [value] + + def items(self): + """Get all header items as key-value pairs. + + Returns: + An iterable of (key, value_list) pairs. + """ + return self._data.items() + + def asdict(self) -> dict[str, list[str]]: + """Convert headers to a dictionary. + + Returns: + A dictionary mapping header names to lists of values. + """ + return self._data.copy() + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Headers): + return NotImplemented + return self._data == other._data + + +@dataclass(slots=True) +class Status: + """NATS message status information. + + Attributes: + code: The status code (e.g., "503") + description: Human-readable description (e.g., "No Responders") + """ + + code: str + description: str | None = None + + def __str__(self) -> str: + """String representation of the status.""" + if self.description: + return f"{self.code}: {self.description}" + return self.code + + +@dataclass(slots=True) +class Message: + """A NATS message. + + Attributes: + subject: The subject the message was published to + data: The message payload as bytes + reply: Optional reply subject for request-reply messaging + headers: Optional message headers + status: Optional NATS status information + """ + + subject: str + data: bytes + reply: str | None = None + headers: Headers | None = None + status: Status | None = None diff --git a/nats-client/src/nats/client/protocol/__init__.py b/nats-client/src/nats/client/protocol/__init__.py new file mode 100644 index 000000000..faa676865 --- /dev/null +++ b/nats-client/src/nats/client/protocol/__init__.py @@ -0,0 +1,5 @@ +"""NATS protocol implementation. + +Contains the low-level handling of the NATS protocol, including +message parsing, command encoding, and type definitions. +""" diff --git a/nats-client/src/nats/client/protocol/command.py b/nats-client/src/nats/client/protocol/command.py new file mode 100644 index 000000000..4a54b88f9 --- /dev/null +++ b/nats-client/src/nats/client/protocol/command.py @@ -0,0 +1,136 @@ +"""NATS protocol command encoding. + +This module provides functions to encode NATS protocol commands +that are sent from the client to the server. Each function handles +the proper formatting according to the NATS protocol specification. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from nats.client.protocol.types import ConnectInfo + + +def encode_connect(info: ConnectInfo) -> bytes: + """Encode CONNECT command. + + Args: + info: Connection information + + Returns: + Encoded CONNECT command + """ + # Convert 'password' to 'pass' for the NATS protocol + # (we use 'password' in Python since 'pass' is a reserved keyword) + connect_dict = dict(info) + if "password" in connect_dict: + connect_dict["pass"] = connect_dict.pop("password") + + return f"CONNECT {json.dumps(connect_dict)}\r\n".encode() + + +def encode_pub( + subject: bytes, + payload: bytes, + *, + reply: bytes | None = None, +) -> bytes: + """Encode PUB command. + + Args: + subject: Subject to publish to + payload: Message payload + reply: Optional reply subject + + Returns: + Encoded PUB command with payload + """ + # PUB format: PUB [reply-to] <#bytes> + if reply: + command = b"PUB %b %b %d\r\n" % (subject, reply, len(payload)) + else: + command = b"PUB %b %d\r\n" % (subject, len(payload)) + + return command + payload + b"\r\n" + + +def encode_hpub( + subject: bytes, + payload: bytes, + *, + reply: bytes | None = None, + headers: dict[str, str | list[str]], +) -> bytes: + """Encode HPUB command. + + Args: + subject: Subject to publish to + payload: Message payload + reply: Optional reply subject + headers: Headers to include with the message + + Returns: + Encoded HPUB command with headers and payload + """ + # Format headers with version indicator + header_lines = ["NATS/1.0"] + [ + f"{key}: {item}" for key, value in headers.items() for item in (value if isinstance(value, list) else [value]) + ] + + # IMPORTANT: Headers must end with \r\n\r\n (empty line after headers) + header_data = ("\r\n".join(header_lines) + "\r\n\r\n").encode() + + # HPUB format: HPUB [reply-to] <#header bytes> <#total bytes> + hdr_len = len(header_data) + total_len = hdr_len + len(payload) + + if reply: + command = b"HPUB %b %b %d %d\r\n" % (subject, reply, hdr_len, total_len) + else: + command = b"HPUB %b %d %d\r\n" % (subject, hdr_len, total_len) + + return command + header_data + payload + b"\r\n" + + +def encode_sub(subject: str, sid: str, queue: str | None = None) -> bytes: + """Encode SUB command. + + Args: + subject: Subject to subscribe to + sid: Subscription ID + queue: Optional queue group + + Returns: + Encoded SUB command + """ + if queue: + return f"SUB {subject} {queue} {sid}\r\n".encode() + return f"SUB {subject} {sid}\r\n".encode() + + +def encode_unsub(sid: str, max_msgs: int | None = None) -> bytes: + """Encode UNSUB command. + + Args: + sid: Subscription ID to unsubscribe + max_msgs: Optional number of messages to receive before auto-unsubscribe + + Returns: + Encoded UNSUB command + """ + if max_msgs is not None: + return f"UNSUB {sid} {max_msgs}\r\n".encode() + return f"UNSUB {sid}\r\n".encode() + + +def encode_ping() -> bytes: + """Encode PING command.""" + return b"PING\r\n" + + +def encode_pong() -> bytes: + """Encode PONG command.""" + return b"PONG\r\n" diff --git a/nats-client/src/nats/client/protocol/message.py b/nats-client/src/nats/client/protocol/message.py new file mode 100644 index 000000000..93e05da6d --- /dev/null +++ b/nats-client/src/nats/client/protocol/message.py @@ -0,0 +1,409 @@ +"""NATS protocol message parsing and type definitions. + +This module implements the core protocol message parsing for the NATS protocol, +handling different message types including MSG, HMSG, PING, PONG, INFO, and ERR. +It provides both low-level parsing functions and the main `parse` function that +reads and interprets messages from a NATS server connection. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Final, Literal, NamedTuple, Protocol, cast, runtime_checkable + +from nats.client.protocol.types import ServerInfo + +if TYPE_CHECKING: + pass + + +@runtime_checkable +class Reader(Protocol): + """Protocol for reading data from a stream. + + This defines the minimal interface needed by the protocol parser. + Both asyncio.StreamReader and Connection implement this protocol. + """ + + async def readline(self) -> bytes: + """Read a line from the stream. + + Returns: + Line read from the stream ending with newline + """ + ... + + async def readexactly(self, n: int) -> bytes: + """Read exactly n bytes from the stream. + + Args: + n: Number of bytes to read + + Returns: + Exactly n bytes + + Raises: + asyncio.IncompleteReadError: If stream closed before n bytes were read + """ + ... + + +# Protocol constants +CRLF: Final[bytes] = b"\r\n" +MAX_CONTROL_LINE: Final[int] = 4096 # Max length of control line +MAX_HEADER_SIZE: Final[int] = 64 * 1024 # Max header size (64KB) +MAX_PAYLOAD_SIZE: Final[int] = 64 * 1024 * 1024 # Max payload size (64MB) +MIN_MSG_ARGS: Final[int] = 3 # Minimum arguments for MSG command +MIN_HMSG_ARGS: Final[int] = 4 # Minimum arguments for HMSG command +MIN_STATUS_PARTS: Final[int] = 2 # Minimum parts for status line (NATS/1.0 CODE) +MIN_STATUS_PARTS_WITH_DESC: Final[int] = 3 # Parts for status with description + + +# Message type definitions using NamedTuple +class Msg(NamedTuple): + """MSG protocol message.""" + + op: Literal["MSG"] + subject: str + sid: str + reply: str | None + payload: bytes + + +class HMsg(NamedTuple): + """HMSG protocol message.""" + + op: Literal["HMSG"] + subject: str + sid: str + reply: str | None + headers: dict[str, list[str]] + payload: bytes + status_code: str | None + status_description: str | None + + +class Info(NamedTuple): + """INFO protocol message.""" + + op: Literal["INFO"] + info: ServerInfo + + +class Err(NamedTuple): + """ERR protocol message.""" + + op: Literal["ERR"] + error: str + + +class Ping(NamedTuple): + """PING protocol message.""" + + op: Literal["PING"] + + +class Pong(NamedTuple): + """PONG protocol message.""" + + op: Literal["PONG"] + + +# Union of all possible message types +Message = Msg | HMsg | Info | Err | Ping | Pong + + +class ParseError(Exception): + """Parser error when handling NATS protocol messages.""" + + +def parse_headers(data: bytes) -> tuple[dict[str, list[str]], str | None, str | None]: + """Parse header data into multi-value dictionary and status information. + + Args: + data: Raw header bytes + + Returns: + Tuple of (headers dict, status_code, status_description) + + Raises: + ParseError: If headers are invalid + """ + try: + lines = data.decode().split("\r\n") + except UnicodeDecodeError as e: + msg = f"Invalid header encoding: {e}" + raise ParseError(msg) from e + + headers: dict[str, list[str]] = {} + status_code: str | None = None + status_description: str | None = None + + # First line should be NATS/1.0 (version) + if not lines[0].startswith("NATS/"): + msg = "Invalid header format: missing NATS version" + raise ParseError(msg) + + # Parse NATS status line (e.g., "NATS/1.0 503" or "NATS/1.0 503 No Responders") + status_line = lines[0] + status_parts = status_line.split(" ", 2) # Split into at most 3 parts + if len(status_parts) >= MIN_STATUS_PARTS: + status_code = status_parts[1] + + # If there's a description part, extract it + if len(status_parts) >= MIN_STATUS_PARTS_WITH_DESC: + status_description = status_parts[2] + + # Parse header key-value pairs + for line in lines[1:]: + if not line: + continue + + if ":" not in line: + msg = f"Invalid header line (missing ':'): {line!r}" + raise ParseError(msg) + + key, value = line.split(":", 1) + key = key.strip() + value = value.strip() + + if key in headers: + # If header already exists, append to the list + headers[key].append(value) + else: + # Initialize as a single-item list + headers[key] = [value] + + return headers, status_code, status_description + + +async def parse_msg(reader: Reader, args: list[bytes]) -> Msg: + """Parse MSG message. + + Args: + reader: Reader protocol implementation + args: Message arguments + + Returns: + Parsed MSG message + + Raises: + ParseError: If message format is invalid + """ + # MSG format: MSG [reply-to] <#bytes> + if len(args) < MIN_MSG_ARGS: + msg = "Invalid MSG: not enough arguments" + raise ParseError(msg) + + subject_bytes = args[0] + sid_bytes = args[1] + + if len(args) == MIN_MSG_ARGS: + # No reply subject + reply_bytes = None + size = int(args[2]) + else: + # With reply subject + reply_bytes = args[2] + size = int(args[3]) + + # Check payload size limit + if size > MAX_PAYLOAD_SIZE: + msg = f"Payload too large: {size} bytes (max {MAX_PAYLOAD_SIZE})" + raise ParseError(msg) + + # Read payload + trailing CRLF in one call + payload_with_crlf = await reader.readexactly(size + 2) + payload = payload_with_crlf[:size] + + # Only convert to strings at the last moment + subject = subject_bytes.decode() + sid = sid_bytes.decode() + reply = reply_bytes.decode() if reply_bytes is not None else None + + return Msg("MSG", subject, sid, reply, payload) + + +async def parse_hmsg(reader: Reader, args: list[bytes]) -> HMsg: + """Parse HMSG message. + + Args: + reader: Reader protocol implementation + args: Message arguments + + Returns: + Parsed HMSG message + + Raises: + ParseError: If message format is invalid + """ + # HMSG format: HMSG [reply-to] <#header bytes> <#total bytes> + if len(args) < MIN_HMSG_ARGS: + msg = "Invalid HMSG: not enough arguments" + raise ParseError(msg) + + subject_bytes = args[0] + sid_bytes = args[1] + + if len(args) == MIN_HMSG_ARGS: + # No reply subject + reply_bytes = None + header_size = int(args[2]) + total_size = int(args[3]) + else: + # With reply subject + reply_bytes = args[2] + header_size = int(args[3]) + total_size = int(args[4]) + + # Check size limits + if header_size > MAX_HEADER_SIZE: + msg = f"Headers too large: {header_size} bytes (max {MAX_HEADER_SIZE})" + raise ParseError(msg) + + if total_size > MAX_PAYLOAD_SIZE: + msg = f"Total message too large: {total_size} bytes (max {MAX_PAYLOAD_SIZE})" + raise ParseError(msg) + + # Read header bytes + header_bytes = await reader.readexactly(header_size) + + # Use the parse_headers function to parse the headers + headers, status_code, status_description = parse_headers(header_bytes) + + # Read payload + trailing CRLF in one call (total size minus header size + 2 for CRLF) + payload_size = total_size - header_size + payload_with_crlf = await reader.readexactly(payload_size + 2) + payload = payload_with_crlf[:payload_size] + + # Convert remaining bytes to strings only at the final step + subject = subject_bytes.decode() + sid = sid_bytes.decode() + reply = reply_bytes.decode() if reply_bytes is not None else None + + return HMsg("HMSG", subject, sid, reply, headers, payload, status_code, status_description) + + +async def parse_info(args: list[bytes]) -> Info: + """Parse INFO message. + + Args: + args: Message arguments + + Returns: + Parsed INFO message + + Raises: + ParseError: If message format is invalid + """ + if not args: + msg = "INFO message missing JSON data" + raise ParseError(msg) + + # Join the args and decode once for JSON parsing + info_bytes = b" ".join(args) + info_data = info_bytes.decode() + + try: + data = json.loads(info_data) + return Info("INFO", cast(ServerInfo, data)) + except json.JSONDecodeError as e: + msg = f"Invalid INFO JSON: {e}" + raise ParseError(msg) from e + + +async def parse_err(args: list[bytes]) -> Err: + """Parse ERR message. + + Args: + args: Message arguments + + Returns: + Parsed ERR message + + Raises: + ParseError: If message format is invalid + """ + if not args: + msg = "ERR message missing error text" + raise ParseError(msg) + + # Join the args and decode once + error_bytes = b" ".join(args) + error_text = error_bytes.decode() + + # Remove quotes if present + if error_text.startswith("'") and error_text.endswith("'"): + error_text = error_text[1:-1] + + return Err("ERR", error_text) + + +async def ping() -> Ping: + """Create PING message. + + Returns: + PING message + """ + return Ping("PING") + + +async def pong() -> Pong: + """Create PONG message. + + Returns: + PONG message + """ + return Pong("PONG") + + +async def parse(reader: Reader) -> Message | None: + """Parse a message from the protocol stream. + + Args: + reader: Reader protocol implementation + + Returns: + Parsed protocol message or None if connection closed + + Raises: + ParseError: If message format is invalid + """ + # Read control line + control_line = await reader.readline() + if not control_line: + return None + + control_line = control_line.rstrip() + + # Check control line length + if len(control_line) > MAX_CONTROL_LINE: + msg = f"Control line too long: {len(control_line)} bytes (max {MAX_CONTROL_LINE})" + raise ParseError(msg) + + # Parse operation and arguments + # Use maxsplit=5 to limit to 6 parts max (most we need for HMSG with reply) + # This is more efficient than unlimited split + parts = control_line.split(b" ", 5) + op = parts[0] # Keep as bytes + args = parts[1:] # Keep as bytes + + # Handle different operations + # NATS server always sends uppercase commands + match op: + case b"MSG": + return await parse_msg(reader, args) + case b"HMSG": + return await parse_hmsg(reader, args) + case b"PING": + return await ping() + case b"PONG": + return await pong() + case b"INFO": + return await parse_info(args) + case b"-ERR" | b"ERR": + return await parse_err(args) + case _: + # Use repr for better error reporting with control characters + msg = f"Unknown operation: {op!r}" + raise ParseError(msg) diff --git a/nats-client/src/nats/client/protocol/types.py b/nats-client/src/nats/client/protocol/types.py new file mode 100644 index 000000000..55a67a012 --- /dev/null +++ b/nats-client/src/nats/client/protocol/types.py @@ -0,0 +1,107 @@ +"""NATS protocol type definitions. + +This module defines TypedDict classes for NATS protocol message types +used in communication between client and server. These types follow +the NATS protocol specification. +""" + +from __future__ import annotations + +from typing import NotRequired, Required, TypedDict + + +class ConnectInfo(TypedDict): + """CONNECT message info. + + Attributes documented at: https://docs.nats.io/reference/reference-protocols/nats-protocol#connect + """ + + verbose: Required[bool] + """Turns on +OK protocol acknowledgments""" + pedantic: Required[bool] + """Turns on additional protocol checks""" + tls_required: Required[bool] + """Indicates whether the client requires an SSL connection""" + lang: Required[str] + """The implementation language of the client""" + version: Required[str] + """The version of the client""" + auth_token: NotRequired[str] + """Authentication token (required if auth_required is true)""" + user: NotRequired[str] + """Connection username (required if auth_required is true)""" + password: NotRequired[str] + """Connection password (required if auth_required is true)""" + name: NotRequired[str] + """Optional client name""" + protocol: NotRequired[int] + """Optional int indicating protocol version""" + echo: NotRequired[bool] + """If set to true, the server will not send originating messages""" + sig: NotRequired[str] + """Client's JWT signature (required if nonce received)""" + jwt: NotRequired[str] + """Client's JWT""" + no_responders: NotRequired[bool] + """Enable no responders tracking""" + headers: NotRequired[bool] + """Support for headers""" + nkey: NotRequired[str] + """User's public NKey""" + + +class ServerInfo(TypedDict): + """INFO message from server. + + Attributes documented at: https://docs.nats.io/reference/reference-protocols/nats-protocol#info + Lame duck mode: https://docs.nats.io/running-a-nats-service/nats_admin/lame_duck_mode + """ + + server_id: Required[str] + """Server's unique identifier""" + server_name: Required[str] + """Server's name""" + version: Required[str] + """Version of the NATS server""" + proto: Required[int] + """Protocol version""" + go: Required[str] + """Version of golang runtime""" + host: Required[str] + """IP address of the NATS server host""" + port: Required[int] + """Port number the NATS server is configured to listen on""" + max_payload: Required[int] + """Maximum allowed payload size""" + headers: Required[bool] + """If set, server supports headers""" + client_id: NotRequired[int] + """Client ID assigned by the server""" + auth_required: NotRequired[bool] + """If this is set, client must authenticate""" + tls_required: NotRequired[bool] + """If this is set, client must use TLS""" + tls_verify: NotRequired[bool] + """If this is set, client must use TLS with valid cert""" + tls_available: NotRequired[bool] + """If this is true, client can provide valid cert during TLS handshake""" + connect_urls: NotRequired[list[str]] + """List of server URLs available for client to connect""" + ws_connect_urls: NotRequired[list[str]] + """List of websocket server URLs""" + ldm: NotRequired[bool] + """If true, server has entered lame duck mode (graceful shutdown in progress)""" + git_commit: NotRequired[str] + """Git hash at which the NATS server was built""" + jetstream: NotRequired[bool] + """If set, server supports JetStream""" + ip: NotRequired[str] + """IP of the server""" + client_ip: NotRequired[str] + """IP of the client""" + nonce: NotRequired[str] + """Server-side nonce challenge for NKey auth""" + cluster: NotRequired[str] + """Name of the cluster this server is part of""" + domain: NotRequired[str] + """Domain name this server is part of""" diff --git a/nats-client/src/nats/client/subscription.py b/nats-client/src/nats/client/subscription.py new file mode 100644 index 000000000..c13fd881f --- /dev/null +++ b/nats-client/src/nats/client/subscription.py @@ -0,0 +1,285 @@ +"""NATS subscription implementation. + +This module provides the Subscription class which represents an active +subscription to a NATS subject. Subscriptions can be used as async +iterators and context managers for ergonomic message handling. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncIterable, AsyncIterator, Callable +from contextlib import AbstractAsyncContextManager, suppress +from typing import TYPE_CHECKING, Self, TypeVar + +if TYPE_CHECKING: + import types + + from nats.client import Client +from nats.client.message import Message + +T = TypeVar("T") + +logger = logging.getLogger(__name__) + + +class Subscription(AsyncIterable[Message], AbstractAsyncContextManager["Subscription"]): + """A subscription to a NATS subject. + + This class represents an active subscription to a NATS subject. + It can be used as an async iterator to receive messages or as an + async context manager to automatically close the subscription when done. + + Examples: + # As an async iterator + async for message in subscription: + process(message) + + # As a context manager + async with await client.subscribe("my.subject") as subscription: + message = await subscription.next() + process(message) + """ + + _subject: str + _sid: str + _queue: str + _client: Client + _pending_queue: asyncio.Queue[Message] + _max_pending_messages: int | None + _max_pending_bytes: int | None + _pending_messages: int + _pending_bytes: int + _dropped_messages: int + _dropped_bytes: int + _callbacks: list[Callable[[Message], None]] + _closed: bool + _slow_consumer_reported: bool + + def __init__( + self, + subject: str, + sid: str, + queue: str, + client: Client, + max_pending_messages: int | None = None, + max_pending_bytes: int | None = None, + ): + self._subject = subject + self._sid = sid + self._queue = queue + self._client = client + + # Create underlying queue with maxsize (0 means unlimited) + maxsize = max_pending_messages if max_pending_messages is not None else 0 + self._pending_queue = asyncio.Queue(maxsize=maxsize) + self._max_pending_messages = max_pending_messages + self._max_pending_bytes = max_pending_bytes + self._pending_messages = 0 + self._pending_bytes = 0 + self._dropped_messages = 0 + self._dropped_bytes = 0 + self._callbacks = [] + + self._closed = False + self._slow_consumer_reported = False + + @property + def subject(self) -> str: + """Get the subscription subject.""" + return self._subject + + @property + def queue(self) -> str: + """Get the queue group name.""" + return self._queue + + def __aiter__(self) -> AsyncIterator[Message]: + """Return an async iterator for messages. + + This allows using the subscription as an async iterable: + async for message in subscription: + process(message) + + Returns: + An async iterator that yields messages + """ + return self.messages + + @property + def messages(self) -> AsyncIterator[Message]: + """Get an async iterator for messages. + + This property provides API compatibility with nats-py, allowing: + async for message in subscription.messages: + process(message) + + This is equivalent to iterating directly on the subscription: + async for message in subscription: + process(message) + + Returns: + An async iterator that yields messages + """ + + async def iterator(): + while True: + try: + yield await self.next() + except RuntimeError: + break + + return iterator() + + @property + def closed(self) -> bool: + """Get whether the subscription is closed.""" + return self._closed + + @property + def pending(self) -> tuple[int, int]: + """Get the number of pending messages and bytes. + + Returns: + Tuple of (pending_messages, pending_bytes) + """ + return (self._pending_messages, self._pending_bytes) + + @property + def dropped(self) -> tuple[int, int]: + """Get the number of dropped messages and bytes. + + Messages are dropped when the subscription cannot keep up with + the message flow and exceeds its pending limits. + + Returns: + Tuple of (dropped_messages, dropped_bytes) + """ + return (self._dropped_messages, self._dropped_bytes) + + def add_callback(self, callback: Callable[[Message], None]) -> None: + """Add a callback to be invoked when a message is received. + + Callbacks are invoked synchronously as soon as a message is received, + before it is queued in the subscription's message queue. + + Note: Avoid performing heavy computation or blocking operations in callbacks, + as this will block the I/O pipeline and prevent other messages from being received. + + Args: + callback: Function to be called when a message is received + """ + self._callbacks.append(callback) + + def remove_callback(self, callback: Callable[[Message], None]) -> None: + """Remove a callback from the subscription. + + Args: + callback: Function to remove from the callback list + """ + with suppress(ValueError): + self._callbacks.remove(callback) + + def _enqueue(self, message: Message) -> None: + """Enqueue a message without blocking. + + This is an internal method called by the Client when dispatching messages. + + Args: + message: The message to enqueue + + Raises: + asyncio.QueueFull: If message count limit would be exceeded + ValueError: If byte limit would be exceeded + """ + message_size = len(message.data) + + # Check byte limit before attempting to put + if self._max_pending_bytes is not None and self._pending_bytes + message_size > self._max_pending_bytes: + raise ValueError(f"Byte limit exceeded: {self._pending_bytes + message_size} > {self._max_pending_bytes}") + + # Invoke callbacks before queuing + for callback in self._callbacks: + try: + callback(message) + except Exception as e: + # Log callback errors but don't disrupt message flow + logger.exception("Error in message callback: %s", e) + + # Try to put in queue - will raise QueueFull if message limit exceeded + self._pending_queue.put_nowait(message) + + # Update counters after successful put + self._pending_messages += 1 + self._pending_bytes += message_size + + async def next(self, timeout: float | None = None) -> Message: + """Get the next message from the subscription. + + Args: + timeout: How long to wait for the next message in seconds. + If None, wait indefinitely. + + Returns: + The next message + + Raises: + asyncio.TimeoutError: If timeout is reached waiting for a message + RuntimeError: If the subscription is closed and queue is empty + """ + try: + # Get message from queue + if timeout is not None: + message = await asyncio.wait_for(self._pending_queue.get(), timeout) + else: + message = await self._pending_queue.get() + + # Update counters after successful get + self._pending_messages -= 1 + self._pending_bytes -= len(message.data) + + return message + except asyncio.QueueShutDown: + msg = "Subscription is closed" + raise RuntimeError(msg) from None + + async def unsubscribe(self) -> None: + """Unsubscribe from this subscription. + + This sends an UNSUB command to the server and marks the subscription as closed, + preventing further messages from being added to the queue. + """ + if not self._closed: + # Send UNSUB to server and remove from client's subscription map + await self._client._unsubscribe(self._sid) + # Shutdown queue immediately (discard pending messages) + self._pending_queue.shutdown(immediate=True) + # Mark as closed + self._closed = True + + async def drain(self) -> None: + """Drain the subscription. + + This unsubscribes from the server (stopping new messages), allowing all pending + messages that are already in the queue to be processed. After drain, the + subscription is marked as closed but pending messages can still be consumed. + """ + if not self._closed: + # Send UNSUB to server to stop new messages + await self._client._unsubscribe(self._sid) + # Shutdown queue gracefully (allow pending messages to be consumed) + self._pending_queue.shutdown(immediate=False) + # Keep in client's subscription list until queue is drained + # Mark as closed + self._closed = True + + async def __aenter__(self) -> Self: + """Enter the async context manager.""" + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: types.TracebackType | None + ) -> None: + """Exit the async context manager, closing the subscription.""" + await self.unsubscribe() diff --git a/nats-client/tests/__init__.py b/nats-client/tests/__init__.py new file mode 100644 index 000000000..504128fa2 --- /dev/null +++ b/nats-client/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package for nats-client.""" diff --git a/nats-client/tests/certs/ca.key b/nats-client/tests/certs/ca.key new file mode 100644 index 000000000..bf42e6359 --- /dev/null +++ b/nats-client/tests/certs/ca.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCzT/9vZfPFwAuB +bftIQQ2pqp5/ep/HpRxDgBv8jeuEVUF7IpsAuOHg7yGQMkODYfaJAnxCYyoFLxH5 +GJtv0e5xSDgg5W/zcz5PQFPuX2kjOaUBTOFGVAVoxYN2OmffoJW3LYQ6EeoDaY1m +kR/k7gNPCLOaUUbxlWplN1Lsg6dixLUw8BUS95w72R0v4fEXH8CKgp1qy1Jgp0a8 +N58pLa4Hd9QIrosdrLkB1yjXxrmAcWTmRNaD8VmkkO1LPdm5pbLftISoE8EeHRre +uY/6gwSz9HOGSE/mg9NKG3C0wVk8H/wIMhEUdvPdiFIAq+MJWQuh+DDs+YNSS5C2 +s8kYkx2FAgMBAAECggEAKeSdUZn4t7FUHtOiuzFTr1mo+TCdhufY8B5Mq5c+uKaw +i0O9AhCv2T0VUtqjcl0fW8hEXsBLT3W9Vby9iAT8l+PfFTR1kOPSYXKuiUkqR6wO +lHyxPxyfPOi0e5lOrm0WvXq5Y1XPHif5fMaYNIS1KjBmwb2OfR/DAU3JJm/l2gml +sX8zR5dH8ef2Ltp4XKxopfECMcFcyeFtBrR1Bf/TyjfatPXWrifB7vWTkPtwC//l +pWd9FvgSgkfZfIepON4cAd9oDHmKsb77/LOZoL7EjyX2iD71gsgLA+UDMd19Ddhy +yKTWcLxHkMIoan4JjlDS7IWH1+TXe+sArHLC/IKZgQKBgQDpEzEvEyIh9ii8BZMW +Vp2DrnFIcEDtVaQkaukYOTH0J6WzUCemMKeizJ+i5CWfV4jTs+Aakmsj0dBPRggk +2esjvKpWPOECN6Of5jFhP4YByok4oRrRxbc55tL43SsKgRv7Uw1bZxy2xJfooYLz +e8MpEgi4gBXRWNHWRUNKRHM3QQKBgQDE8xIwriiH66gAabBd/S3bi+l4cdD3gFzP +eHKA6SoNBPgS6ePviNI5hAhBxMxL4m/XhbAFoHcyUIsrRtbtlftmN19AUaQZzrrb +n3dIMl30PessNkiw4ma7HK0kkqqdW0esMG4/sfA1Cbqha9NfhHNHs2o10PgR5efV +L8zHkgf5RQKBgCTGz3rzFO6w7cqFQqlCr4YIr31ToRsR6V2JtRAKblRtMRszKZ5y +jPEGQqYsQ7d503A8ML9gnSFAyRLJJR3TeOPhsCr9Xi/AYcQoSWBj6pw7vh0+ZoH1 +Ja0wO9fi5hi5OJYWi8QBaCl6qdGeMpCC1c+UNeYO5+Jplmqt8Sk11cxBAoGBAIwp +MFjUmKOiYf1pOcqp02jbOVwuI7j/8Qz1ewei5/o7/w/2w/BEPtgPzpB/raSTkWSg +D5TxjeKQz3oNAAg1JKSp42+yqkZziSejZRBMR/D539OnDjRT6yheUBonBqTVwTwZ +e1x5FampMtiOE+92I1RzqrKB9QlHxKVDT0j39BTBAoGAOxH8n2/a9ZD82tPXl8cU +ZXMxjCkUVEcAYFFZL3Y66SBgiae4Mwx1553kTHqGPXD2MKEb1AZV8B1msWzknvgm +OW65fq0MBkh+Ha81SWrr/P3B3Fe+rWr1Zot+PU+q9ZH+3quIOSTEnYmR0aCCRUBN +ABxstowFP4Ts1ltDlnsDe7Y= +-----END PRIVATE KEY----- diff --git a/nats-client/tests/certs/ca.pem b/nats-client/tests/certs/ca.pem new file mode 100644 index 000000000..6381f542f --- /dev/null +++ b/nats-client/tests/certs/ca.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDFTCCAf2gAwIBAgIUPOMjEV29PUcF+hgfIozNMUT2wm8wDQYJKoZIhvcNAQEL +BQAwEjEQMA4GA1UEAwwHVGVzdCBDQTAeFw0yNTA2MDMwOTI2MTJaFw0zNTA2MDEw +OTI2MTJaMBIxEDAOBgNVBAMMB1Rlc3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQCzT/9vZfPFwAuBbftIQQ2pqp5/ep/HpRxDgBv8jeuEVUF7IpsA +uOHg7yGQMkODYfaJAnxCYyoFLxH5GJtv0e5xSDgg5W/zcz5PQFPuX2kjOaUBTOFG +VAVoxYN2OmffoJW3LYQ6EeoDaY1mkR/k7gNPCLOaUUbxlWplN1Lsg6dixLUw8BUS +95w72R0v4fEXH8CKgp1qy1Jgp0a8N58pLa4Hd9QIrosdrLkB1yjXxrmAcWTmRNaD +8VmkkO1LPdm5pbLftISoE8EeHRreuY/6gwSz9HOGSE/mg9NKG3C0wVk8H/wIMhEU +dvPdiFIAq+MJWQuh+DDs+YNSS5C2s8kYkx2FAgMBAAGjYzBhMB0GA1UdDgQWBBSa +c7uqIzSclMy/x7b8j3n7/tRa5DAfBgNVHSMEGDAWgBSac7uqIzSclMy/x7b8j3n7 +/tRa5DAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0B +AQsFAAOCAQEAVbAFQpOLLh6lm6xoN6HBmVkARvS81iH1qeJ8+Om07JnbfOIYl6UE +uQbMHld3e5cwheP8AKKmPVNfw+ke/iru1bZf+3YRYpgyHixVrTmXLKA17YnJz5Qh +dmMcqbVov8sl2iHmWfhImaiN5bgVZNd4hEH9HmTkWP2lVBaUX+olSQdtMV+6IPED +X0fjmbD3pLHdeBJCzS0CTATuJWKWXXxY0/+0pQ76qz4IWNaQcLOGWUkPzKbqme1T +RJBu9lrLn2//zMO6CTMHO1iCalSFkkeKuGOShdECWFCVQgqGtFEVbgObV9+ATPLi +TDjOAoO0dm+GwRoNNdi6/KqoERYxHshs/A== +-----END CERTIFICATE----- diff --git a/nats-client/tests/certs/client-cert.pem b/nats-client/tests/certs/client-cert.pem new file mode 100644 index 000000000..1f7f12119 --- /dev/null +++ b/nats-client/tests/certs/client-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDEDCCAfigAwIBAgIUZ9n2dKgknLAUIZQ5Zy1NgDa/PFwwDQYJKoZIhvcNAQEL +BQAwEjEQMA4GA1UEAwwHVGVzdCBDQTAeFw0yNTA2MDMwOTI2MTJaFw0yNjA2MDMw +OTI2MTJaMBYxFDASBgNVBAMMC1Rlc3QgQ2xpZW50MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAo12U+ZuRZ7x6pwJAg3OIPBY7YYkZHg1zyAH1mAwDdoBU +PRQxjOi9WEEUqQ/3Kg1yGWR/qE2VbAOrXfe97Nq8GNbtjfck1WpTAjmRp5Dn/FQv +DZlbUBhhXAodff197j3cesrrul5rI7LXTSNLl4cc3TM9b8/ZaqzhtUfDJod6VBpK +uYpLTVqEHcfb6Jeqhbxh0MIDXjaog/sHGLJTehim3BRvXqec19NpLagepSrBXn6B +PGba8dwatgmn5/21atO3jIV4eYFtspIg509P13iK3EmAKrv98CyBh7jfVoX3CGX7 +Q51iGOR4pDD7m5XengVVOzve9KYNk/9L6RgkWTB0xQIDAQABo1owWDAJBgNVHRME +AjAAMAsGA1UdDwQEAwIFoDAdBgNVHQ4EFgQUkrQ+k4XIx7YI+lBclpubq8FONDgw +HwYDVR0jBBgwFoAUmnO7qiM0nJTMv8e2/I95+/7UWuQwDQYJKoZIhvcNAQELBQAD +ggEBAI7NfbwbeGbZ0deBKIV+/hC8w+tiR+ZPvBDkrYYGqanwaLShrGWfTFk/YLqP +rAup+c2R6v4op5AyA8DKePucbM5zjSEAKY0jzUqg3ZhLiTwlS7PnTCrS3DBFvoeh +wuSFGO6wR6H8MnJAKw7F7HFpiivH0pgYYcR5sRVent9T7DMPusdqd7gjCyQKxhnh +RSvBzWQVsZJPbh3klKeSMpxx8k+4J/tFDxUZSWfUtL9ojAnnircKX7Luy2w0MG+B +UtIyyighmadmJ+es4zyH4ovEXx0eDQvcBhV2Q5JPIl90bWggpDhS4uyKIeerSiTk +ZFd7hOqNck+chAf496rf5pz1kQc= +-----END CERTIFICATE----- diff --git a/nats-client/tests/certs/client-key.pem b/nats-client/tests/certs/client-key.pem new file mode 100644 index 000000000..1244fa748 --- /dev/null +++ b/nats-client/tests/certs/client-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCjXZT5m5FnvHqn +AkCDc4g8FjthiRkeDXPIAfWYDAN2gFQ9FDGM6L1YQRSpD/cqDXIZZH+oTZVsA6td +973s2rwY1u2N9yTValMCOZGnkOf8VC8NmVtQGGFcCh19/X3uPdx6yuu6XmsjstdN +I0uXhxzdMz1vz9lqrOG1R8Mmh3pUGkq5iktNWoQdx9vol6qFvGHQwgNeNqiD+wcY +slN6GKbcFG9ep5zX02ktqB6lKsFefoE8Ztrx3Bq2Cafn/bVq07eMhXh5gW2ykiDn +T0/XeIrcSYAqu/3wLIGHuN9WhfcIZftDnWIY5HikMPubld6eBVU7O970pg2T/0vp +GCRZMHTFAgMBAAECggEAA4mELz9iD5fyI+mVkgMPYRWuJXd9drwd1KfONIOWE5vF +tyjXxdBIQT/vfZWd6WgvH0Ogy/kaEiOmMzuo/hbiWbtVm2xNfN13fKXYdINbsof0 +GNxIMNzGRuLlLpQthNoMCxCXuApRUY2OwdnQyjAfvN5eJyyd7YRewrkSDaSDTozW +S25S9bTdfHV3yLtay+EL01FsUGmLAKjearYRZ5p70TFiJb7DIGzRt96pOYDa5EXq +29CELLUzYh1x6+/DU8+CEjfptWcfwb/J1kQYwIghNtjXRrl6CvpeqKALIS0Lt6Mv +K9pZn1N44MQgvVUAvvhG2zcqZevHLKKszH9ZY5zTRQKBgQDfWpzfaYtnpdsHg0z1 +3eTBaoTkxZoFOOWNKSPFVWfkeSqYll64uw5jNyayJ2Jd0TAtiAsXHHUV8QOcSOAI +Mwwu+Um6GtWg7tlZvoTSr8ycka2UgR8z1LAi5Mz/DO3+4hkKye0RM59YIVXeOk4i +aMWBWYvq5xxIC28Ghrq1NuHMiwKBgQC7PlecmD/U99fbwaLUi7oXw6VIHq5saZie +om9S2R6NZ5Fgl2kG5cE1d23sMOoN1a8uxONvLnTkx8K/DzTm2F91gEsXb0yD5vvI +xfBfF/9TUxQM76fWHNrJ+wO7/FjYdG9ukwhFDAp1FHHYYYHQDtFy6c0/zxXAaEwt +uDZ8fbxd7wKBgC3EToLFCMi7Ro9ai0mSJ06uuf56sx3A0+DB/k9wObKqVV0g6tXl +loQkB+zXfyOEOuAJkWQx85gEY94BXM6xTxzM/huBZibs6mKmVGcrmLy4aR4V+0PO +oCz2Pz8/8gCZadI5IDe3IT6YzhSUU/BfEaF4rN/8bWIh3aUztExvq4MTAoGAeefA +yqclB9bEpsKO7Kp74fTyqt2u2c52GvHOYLYS+iUvtR9etarn+5Nh8aB3AcLZZy2w +lTY9zKVx2UphOtosbCe2u+8udcHws2qLcJ4DEZDJQRLWxsPKfi365G0KuwIzel4n +yCxqsoc5B5OUHI1JN/OYOa7tjnNZBXslni+Ob+kCgYBxpU7+d8MV4cpuYQKzX8o/ +izQDhF8tPDMRpOgo3eRP/XJ9TGQ1TesNnNMwzX7gkzOQWFAxFX45eR6lT3BR806U +0X0aqdW9nYrBb7Bu3sHIizeg757YgRvJ6PKFeWT+Kh+kjERFFLTV3Gd/QrVJINef +ac7ay0cPbZxu0ii+jT4vlg== +-----END PRIVATE KEY----- diff --git a/nats-client/tests/certs/server-cert.pem b/nats-client/tests/certs/server-cert.pem new file mode 100644 index 000000000..bc2af415b --- /dev/null +++ b/nats-client/tests/certs/server-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDKjCCAhKgAwIBAgIUZ9n2dKgknLAUIZQ5Zy1NgDa/PFswDQYJKoZIhvcNAQEL +BQAwEjEQMA4GA1UEAwwHVGVzdCBDQTAeFw0yNTA2MDMwOTI2MTJaFw0yNjA2MDMw +OTI2MTJaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBAJiBzZqtpHHUaSa62mVVpNKioByn2/OhtxO3kGPpBbqruQAQ +brogbBnRzJX7iIdp6Gz5nNX/qSkphZYu2WGt+8D43iYCG7PV/5G+F9YwUra9j4Rr +uXK2cMgDhU0qC0WLpwki/dZXE89YcWDZ8bZdXAa8t3bgRV3p8j3izp7XY8x/3G+L +XTc4uh32cGfN/Oa3GbUaGumgTLJtOkkYGy5ForzLe408lrhOv1RgOczGbVOtMRbR +TTAlIBNwNsqQwCHUx7R3uL9l8Kf7ADsu0GpsBCMszU6TsmfBYKqpP+bDGW7dFh55 +qV3U5kqzPq038rq0R4MGa+RepT3tYkI7zHyRQj8CAwEAAaN2MHQwCQYDVR0TBAIw +ADALBgNVHQ8EBAMCBaAwGgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/AAABMB0GA1Ud +DgQWBBQBaDI7Zz5VlgDY3fgWDxedS+4cvDAfBgNVHSMEGDAWgBSac7uqIzSclMy/ +x7b8j3n7/tRa5DANBgkqhkiG9w0BAQsFAAOCAQEAEB5c4Gnd5Na+mD213eqObslG +o4mgcOkGM8oAjoOG0OjrqtDhG2GvbIqMFufdAbGGe4T1ohZZuUh7mJdg0p6Vfyb6 +lUTiLBlfl1CrqQkeCMt+Pz1j5PqPXHa/8gi2rXO03WaStDyjfc6akOhosu99zv2H +8GlwPBBwd/F/fWOHb/71mp/EXKxe+9ywM91GG4zImQoNgSYhv0tXQmjc3ZJEJBsl +NbuJNNqrWc/mf5aaf40pzgbhEwLXNWslLXOI07CfxHK8yAQ2O1stPQtAFViJcMyg +8bX8h/VNHQIGIL7YUB0ufHebsay4l8PWtVWXY8tyzOo/fdtNPcWseZfgHeKqQA== +-----END CERTIFICATE----- diff --git a/nats-client/tests/certs/server-key.pem b/nats-client/tests/certs/server-key.pem new file mode 100644 index 000000000..cde68e6e6 --- /dev/null +++ b/nats-client/tests/certs/server-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCYgc2araRx1Gkm +utplVaTSoqAcp9vzobcTt5Bj6QW6q7kAEG66IGwZ0cyV+4iHaehs+ZzV/6kpKYWW +LtlhrfvA+N4mAhuz1f+RvhfWMFK2vY+Ea7lytnDIA4VNKgtFi6cJIv3WVxPPWHFg +2fG2XVwGvLd24EVd6fI94s6e12PMf9xvi103OLod9nBnzfzmtxm1GhrpoEyybTpJ +GBsuRaK8y3uNPJa4Tr9UYDnMxm1TrTEW0U0wJSATcDbKkMAh1Me0d7i/ZfCn+wA7 +LtBqbAQjLM1Ok7JnwWCqqT/mwxlu3RYeeald1OZKsz6tN/K6tEeDBmvkXqU97WJC +O8x8kUI/AgMBAAECggEARFTEfq2WpLn7czL9b7Hz67yZ6Lz9PCgVE2yH8XwS6RTQ +C0tRkWu9g8QQ2pKdFa9KSKl+sBOhA6RrCmNrWuzNhCT9xCHlr6oOnIqDu6BjLyW4 +DCkIKnG8DqIlM/HGox5zdRCMk2Doq1qBUlfUiVVDSnF78nOCIRYVR807rsfrG1Dt +agvGoxXPjfmyYUYFeytkCnkXaxtohwxZTcN99Msg5dpdaKGz9XmJ4BEqtJ4vS6nV +jtSj2NxGAOjVwpDc9mCL0aJWYlJcf8gkJ60z5NEFPBRRbPiaiyV09ykGIUpKdNBV +kgBj4CSK+3oY+Aw4+SbhHi60Yl7Xt/aoVWTPXdItAQKBgQDQ0s5yLg2asogpuBn9 +EAfEfc0xuYErq75l+BtMzy4dcmT9YAVUq07qlFw4IM3Worm3qeXamP9vV2BLb33h +F1swLIxb5jKLeSag7onXHM4dlwxeklHfOFEkM44xxGMlrQdi9md7BWK7WT464hQK +Y3jsehJobeISP6lOV6Vi5umUHwKBgQC69fd2myQx521L7Rw+mVYZxtx3DFFQXJn4 +8vbGjrfrh1RLuYQlowIjaKIYSd3XkrTm9GF319W8wV95tgiWgzDdA1mGchxazpxD +Q20ck6IRQbrV4JyRWROLM8i+msBgkU13byjDG4UTiyGhZixxRgp6sKg/bzR5aDkg +DTTDLy+N4QKBgDU3E28bd1IN3w8qoEzSEfiryme6i0VSvfGEWYioX2sXpM7A8nrS +MQksh5eyHGJvE37PaAjd5nI8RQjUP7Ll3dIX+CjPL+BFHmfygeP0rj3yKS/Mf9qv +olXkYX8jOdT3FCPPAS3jgHaX1njw9gRztvQkY56DKhJWcNHLGhVrHOknAoGAMXa/ ++Q5EvjtHbG4YKeuxnvJBA/IrVUKargXCCnhZ6qOMRtiXQMnm1+1n7VslqVI0MGJ5 +oxZkgyL6Gp5xmTiuZdrsBakNHW1KZHVjWcspdr1YLBvX8JCLnimeM+eHfEUVJMMx +GqWeSLRnumbo3TM1tI4mhVnrnS9Y5cT4r6c9QqECgYEAhFiZYnIgoVpS7wuxVllS +g+LBtdJ0eNvbaVsBMy4lVbrbg0P5xDXkUwRCb1FubMCEbyIfLS5YylE1edzqKmrG +pwD9ioxVt6EVdU2/Uk87GHxVgVLIznBfQdTdvibABk2I3TRP4HYIIDwGZXDUKHwx +oiSd/EfI43I2zBjEfBfECqU= +-----END PRIVATE KEY----- diff --git a/nats-client/tests/configs/nkeys_server.conf b/nats-client/tests/configs/nkeys_server.conf new file mode 100644 index 000000000..1aef42e2b --- /dev/null +++ b/nats-client/tests/configs/nkeys_server.conf @@ -0,0 +1,20 @@ + +accounts { + acme { + users [ + { + nkey = "UCK5N7N66OBOINFXAYC2ACJQYFSOD4VYNU6APEJTAVFZB2SVHLKGEW7L", + permissions = { + subscribe = { + allow = ["help", "_INBOX.>"] + deny = ["foo"] + } + publish = { + allow = ["help", "_INBOX.>"] + deny = ["foo"] + } + } + } + ] + } +} diff --git a/nats-client/tests/configs/resolver_preload.conf b/nats-client/tests/configs/resolver_preload.conf new file mode 100644 index 000000000..6287125d0 --- /dev/null +++ b/nats-client/tests/configs/resolver_preload.conf @@ -0,0 +1,15 @@ +operator: eyJ0eXAiOiJqd3QiLCJhbGciOiJlZDI1NTE5In0.eyJqdGkiOiIyTVhXQ0VaRFo2S0g3NU5ZU1NaUFBTTElET1NEVzdMVVE0RVRUSjJMWEhKQlFGWTRTMkZBIiwiaWF0IjoxNTUzODQwMzM3LCJpc3MiOiJPRFdJSUU3SjdOT1M3M1dWQk5WWTdIQ1dYVTRXWFdEQlNDVjRWSUtNNVk0TFhUT1Q1U1FQT0xXTCIsIm5hbWUiOiJ0ZXN0X29wZXJhdG9yIiwic3ViIjoiT0RXSUlFN0o3Tk9TNzNXVkJOVlk3SENXWFU0V1hXREJTQ1Y0VklLTTVZNExYVE9UNVNRUE9MV0wiLCJ0eXBlIjoib3BlcmF0b3IifQ.V_v6k3aMOpyau83RaqNHW_YmZw8X0ZnJWLOas3YvQYIyXrHF0bL9inBaQw6zXzbN_ViQnNskhB7tM40qguitAg + # This is for account resolution. +resolver = MEMORY + + # This is a map that can preload keys:jwts into a memory resolver. +resolver_preload = { + + # foo + AD7SEANS6BCBF6FHIB7SQ3UGJVPW53BXOALP75YXJBBXQL7EAFB6NJNA : "eyJ0eXAiOiJqd3QiLCJhbGciOiJlZDI1NTE5In0.eyJqdGkiOiIyUDNHU1BFSk9DNlVZNE5aM05DNzVQVFJIV1pVRFhPV1pLR0NLUDVPNjJYSlZESVEzQ0ZRIiwiaWF0IjoxNTUzODQwNjE1LCJpc3MiOiJPRFdJSUU3SjdOT1M3M1dWQk5WWTdIQ1dYVTRXWFdEQlNDVjRWSUtNNVk0TFhUT1Q1U1FQT0xXTCIsIm5hbWUiOiJmb28iLCJzdWIiOiJBRDdTRUFOUzZCQ0JGNkZISUI3U1EzVUdKVlBXNTNCWE9BTFA3NVlYSkJCWFFMN0VBRkI2TkpOQSIsInR5cGUiOiJhY2NvdW50IiwibmF0cyI6eyJsaW1pdHMiOnsic3VicyI6LTEsImNvbm4iOi0xLCJpbXBvcnRzIjotMSwiZXhwb3J0cyI6LTEsImRhdGEiOi0xLCJwYXlsb2FkIjotMSwid2lsZGNhcmRzIjp0cnVlfX19.COiKg5EFK4Gb2gA7vtKHQK7vjMEUx-RMWYuN-Bg-uVOFs9GLwW7Dxc4TcN-poBGBEkwKnleiA9SjYO3y4-AqBQ" + + # bar + AAXPTP32BD73YW3ACUY6DPXKWBSUW4VEZNE3LD4FUOFDP6KDU43PQVU2 : "eyJ0eXAiOiJqd3QiLCJhbGciOiJlZDI1NTE5In0.eyJqdGkiOiJPQ1dUQkRQTzVETjRSV0lFNEtJQ1BQWkszUEhHV0dQUVFKNFVET1pQSTVaRzJQUzZKVkpBIiwiaWF0IjoxNTUzODQwNjE5LCJpc3MiOiJPRFdJSUU3SjdOT1M3M1dWQk5WWTdIQ1dYVTRXWFdEQlNDVjRWSUtNNVk0TFhUT1Q1U1FQT0xXTCIsIm5hbWUiOiJiYXIiLCJzdWIiOiJBQVhQVFAzMkJENzNZVzNBQ1VZNkRQWEtXQlNVVzRWRVpORTNMRDRGVU9GRFA2S0RVNDNQUVZVMiIsInR5cGUiOiJhY2NvdW50IiwibmF0cyI6eyJsaW1pdHMiOnsic3VicyI6LTEsImNvbm4iOi0xLCJpbXBvcnRzIjotMSwiZXhwb3J0cyI6LTEsImRhdGEiOi0xLCJwYXlsb2FkIjotMSwid2lsZGNhcmRzIjp0cnVlfX19.KY2fBvYyNCA0dYS7I6_rETGHT4YGkWZSh03XhXxwAvJ8XCfKlVJRY82U-0ERg01SFtPTZ-6BYu-sty1E67ioDA" + + } + \ No newline at end of file diff --git a/nats-client/tests/configs/server_auth_jwt.conf b/nats-client/tests/configs/server_auth_jwt.conf new file mode 100644 index 000000000..6287125d0 --- /dev/null +++ b/nats-client/tests/configs/server_auth_jwt.conf @@ -0,0 +1,15 @@ +operator: eyJ0eXAiOiJqd3QiLCJhbGciOiJlZDI1NTE5In0.eyJqdGkiOiIyTVhXQ0VaRFo2S0g3NU5ZU1NaUFBTTElET1NEVzdMVVE0RVRUSjJMWEhKQlFGWTRTMkZBIiwiaWF0IjoxNTUzODQwMzM3LCJpc3MiOiJPRFdJSUU3SjdOT1M3M1dWQk5WWTdIQ1dYVTRXWFdEQlNDVjRWSUtNNVk0TFhUT1Q1U1FQT0xXTCIsIm5hbWUiOiJ0ZXN0X29wZXJhdG9yIiwic3ViIjoiT0RXSUlFN0o3Tk9TNzNXVkJOVlk3SENXWFU0V1hXREJTQ1Y0VklLTTVZNExYVE9UNVNRUE9MV0wiLCJ0eXBlIjoib3BlcmF0b3IifQ.V_v6k3aMOpyau83RaqNHW_YmZw8X0ZnJWLOas3YvQYIyXrHF0bL9inBaQw6zXzbN_ViQnNskhB7tM40qguitAg + # This is for account resolution. +resolver = MEMORY + + # This is a map that can preload keys:jwts into a memory resolver. +resolver_preload = { + + # foo + AD7SEANS6BCBF6FHIB7SQ3UGJVPW53BXOALP75YXJBBXQL7EAFB6NJNA : "eyJ0eXAiOiJqd3QiLCJhbGciOiJlZDI1NTE5In0.eyJqdGkiOiIyUDNHU1BFSk9DNlVZNE5aM05DNzVQVFJIV1pVRFhPV1pLR0NLUDVPNjJYSlZESVEzQ0ZRIiwiaWF0IjoxNTUzODQwNjE1LCJpc3MiOiJPRFdJSUU3SjdOT1M3M1dWQk5WWTdIQ1dYVTRXWFdEQlNDVjRWSUtNNVk0TFhUT1Q1U1FQT0xXTCIsIm5hbWUiOiJmb28iLCJzdWIiOiJBRDdTRUFOUzZCQ0JGNkZISUI3U1EzVUdKVlBXNTNCWE9BTFA3NVlYSkJCWFFMN0VBRkI2TkpOQSIsInR5cGUiOiJhY2NvdW50IiwibmF0cyI6eyJsaW1pdHMiOnsic3VicyI6LTEsImNvbm4iOi0xLCJpbXBvcnRzIjotMSwiZXhwb3J0cyI6LTEsImRhdGEiOi0xLCJwYXlsb2FkIjotMSwid2lsZGNhcmRzIjp0cnVlfX19.COiKg5EFK4Gb2gA7vtKHQK7vjMEUx-RMWYuN-Bg-uVOFs9GLwW7Dxc4TcN-poBGBEkwKnleiA9SjYO3y4-AqBQ" + + # bar + AAXPTP32BD73YW3ACUY6DPXKWBSUW4VEZNE3LD4FUOFDP6KDU43PQVU2 : "eyJ0eXAiOiJqd3QiLCJhbGciOiJlZDI1NTE5In0.eyJqdGkiOiJPQ1dUQkRQTzVETjRSV0lFNEtJQ1BQWkszUEhHV0dQUVFKNFVET1pQSTVaRzJQUzZKVkpBIiwiaWF0IjoxNTUzODQwNjE5LCJpc3MiOiJPRFdJSUU3SjdOT1M3M1dWQk5WWTdIQ1dYVTRXWFdEQlNDVjRWSUtNNVk0TFhUT1Q1U1FQT0xXTCIsIm5hbWUiOiJiYXIiLCJzdWIiOiJBQVhQVFAzMkJENzNZVzNBQ1VZNkRQWEtXQlNVVzRWRVpORTNMRDRGVU9GRFA2S0RVNDNQUVZVMiIsInR5cGUiOiJhY2NvdW50IiwibmF0cyI6eyJsaW1pdHMiOnsic3VicyI6LTEsImNvbm4iOi0xLCJpbXBvcnRzIjotMSwiZXhwb3J0cyI6LTEsImRhdGEiOi0xLCJwYXlsb2FkIjotMSwid2lsZGNhcmRzIjp0cnVlfX19.KY2fBvYyNCA0dYS7I6_rETGHT4YGkWZSh03XhXxwAvJ8XCfKlVJRY82U-0ERg01SFtPTZ-6BYu-sty1E67ioDA" + + } + \ No newline at end of file diff --git a/nats-client/tests/configs/server_auth_nkey.conf b/nats-client/tests/configs/server_auth_nkey.conf new file mode 100644 index 000000000..8891434cd --- /dev/null +++ b/nats-client/tests/configs/server_auth_nkey.conf @@ -0,0 +1,9 @@ +# NATS Server Configuration for NKey Authentication Testing + +authorization { + users = [ + # Test user with NKey + # Public key: UBABIZX6SZFAKHK2KGUFD6QH53FDAH5QVCH2R5MJLFPEVYAW22QWQQCX + {nkey: UBABIZX6SZFAKHK2KGUFD6QH53FDAH5QVCH2R5MJLFPEVYAW22QWQQCX} + ] +} diff --git a/nats-client/tests/configs/server_auth_token.conf b/nats-client/tests/configs/server_auth_token.conf new file mode 100644 index 000000000..32e08e20d --- /dev/null +++ b/nats-client/tests/configs/server_auth_token.conf @@ -0,0 +1,4 @@ +# NATS server config for testing token authentication +authorization { + token: "test_token_123" +} diff --git a/nats-client/tests/configs/server_auth_user_pass.conf b/nats-client/tests/configs/server_auth_user_pass.conf new file mode 100644 index 000000000..30fb6c877 --- /dev/null +++ b/nats-client/tests/configs/server_auth_user_pass.conf @@ -0,0 +1,6 @@ +# NATS server config for testing user/password authentication +authorization { + users = [ + {user: "testuser", password: "testpass"} + ] +} diff --git a/nats-client/tests/configs/server_ping.conf b/nats-client/tests/configs/server_ping.conf new file mode 100644 index 000000000..ec64184db --- /dev/null +++ b/nats-client/tests/configs/server_ping.conf @@ -0,0 +1,4 @@ +# NATS server config for testing server-initiated PINGs +# Set very short ping interval so server pings client quickly +ping_interval: "100ms" +ping_max: 3 diff --git a/nats-client/tests/configs/server_tls_handshake_first.conf b/nats-client/tests/configs/server_tls_handshake_first.conf new file mode 100644 index 000000000..67fedcd2e --- /dev/null +++ b/nats-client/tests/configs/server_tls_handshake_first.conf @@ -0,0 +1,8 @@ +# NATS server config for testing TLS with handshake first +tls { + cert_file: "./tests/certs/server-cert.pem" + key_file: "./tests/certs/server-key.pem" + ca_file: "./tests/certs/ca.pem" + handshake_first: true + timeout: 2 +} diff --git a/nats-client/tests/configs/server_tls_upgrade.conf b/nats-client/tests/configs/server_tls_upgrade.conf new file mode 100644 index 000000000..1ffd4b7b1 --- /dev/null +++ b/nats-client/tests/configs/server_tls_upgrade.conf @@ -0,0 +1,8 @@ +# NATS server config for testing TLS upgrade after INFO +# Without handshake_first, server sends INFO first and expects client to upgrade +tls { + cert_file: "./tests/certs/server-cert.pem" + key_file: "./tests/certs/server-key.pem" + ca_file: "./tests/certs/ca.pem" + timeout: 2 +} diff --git a/nats-client/tests/configs/server_tls_verify.conf b/nats-client/tests/configs/server_tls_verify.conf new file mode 100644 index 000000000..75d7cbff0 --- /dev/null +++ b/nats-client/tests/configs/server_tls_verify.conf @@ -0,0 +1,9 @@ +# NATS server config for testing mTLS (mutual TLS with client cert verification) +tls { + cert_file: "tests/certs/server-cert.pem" + key_file: "tests/certs/server-key.pem" + ca_file: "tests/certs/ca.pem" + verify: true + handshake_first: true + timeout: 2 +} diff --git a/nats-client/tests/conftest.py b/nats-client/tests/conftest.py new file mode 100644 index 000000000..75eb31e19 --- /dev/null +++ b/nats-client/tests/conftest.py @@ -0,0 +1,36 @@ +import logging +import sys +from collections.abc import AsyncGenerator + +import pytest_asyncio +from nats.client import Client, connect +from nats.server import Server, run + +# Configure logging to see debug messages +logging.basicConfig( + level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", stream=sys.stdout +) + + +@pytest_asyncio.fixture +async def server() -> AsyncGenerator[Server, None]: + """Fixture that provides an isolated NATS server for each test. + + Returns: + The NATS server instance. + """ + server = await run(port=0) + try: + yield server + finally: + await server.shutdown() + + +@pytest_asyncio.fixture +async def client(server: Server) -> AsyncGenerator[Client, None]: + """Fixture that provides a client connected to an isolated server.""" + client = await connect(server.client_url, timeout=1.0) + try: + yield client + finally: + await client.close() diff --git a/nats-client/tests/jwts/bad-user.creds b/nats-client/tests/jwts/bad-user.creds new file mode 100644 index 000000000..849dfbdf3 --- /dev/null +++ b/nats-client/tests/jwts/bad-user.creds @@ -0,0 +1,9 @@ +-----BEGIN NATS USER JWT----- +eyJ0eXAiOiJqd3QiLCJhbGciOiJlZDI1NTE5In0.eyJqdGkiOiJXTURGT1dHV1JGWkRGRFVSM0dPUkdESEtUTTdDUlZBVDQ1RkRFMllNRUY1N0VOQ0JBVFFRIiwiaWF0IjoxNTUzODQwOTQ0LCJpc3MiOiJBRDdTRUFOUzZCQ0JGNkZISUI3U1EzVUdKVlBXNTNCWE9BTFA3NVlYSkJCWFFMN0VBRkI2TkpOQSIsIm5hbWUiOiJmb28tdXNlciIsInN1YiI6IlVDSzVON042Nk9CT0lORlhBWUMyQUNKUVlGU09ENFZZTlU2QVBFSlRBVkZaQjJTVkhMS0dFVzdMIiwidHlwZSI6InVzZXIiLCJuYXRzIjp7InB1YiI6e30sInN1YiI6e319fQ.Vri09BN561m37GvuSWoGN9L9TSkwQbjC_jIv1BCJcoxZqNc_Pa7WbR12b3SAS4_Ip2D9-2HCwyYib1JUEIO8Bg +------END NATS USER JWT------ + +************************* IMPORTANT ************************* +NKEY Seed printed below can be used to sign and prove identity. +NKEYs are sensitive and should be treated as secrets. + + -----BEGIN USER NKEY SEED----- diff --git a/nats-client/tests/jwts/foo-user.creds b/nats-client/tests/jwts/foo-user.creds new file mode 100644 index 000000000..2ebc8fc12 --- /dev/null +++ b/nats-client/tests/jwts/foo-user.creds @@ -0,0 +1,13 @@ +-----BEGIN NATS USER JWT----- +eyJ0eXAiOiJqd3QiLCJhbGciOiJlZDI1NTE5In0.eyJqdGkiOiJXTURGT1dHV1JGWkRGRFVSM0dPUkdESEtUTTdDUlZBVDQ1RkRFMllNRUY1N0VOQ0JBVFFRIiwiaWF0IjoxNTUzODQwOTQ0LCJpc3MiOiJBRDdTRUFOUzZCQ0JGNkZISUI3U1EzVUdKVlBXNTNCWE9BTFA3NVlYSkJCWFFMN0VBRkI2TkpOQSIsIm5hbWUiOiJmb28tdXNlciIsInN1YiI6IlVDSzVON042Nk9CT0lORlhBWUMyQUNKUVlGU09ENFZZTlU2QVBFSlRBVkZaQjJTVkhMS0dFVzdMIiwidHlwZSI6InVzZXIiLCJuYXRzIjp7InB1YiI6e30sInN1YiI6e319fQ.Vri09BN561m37GvuSWoGN9L9TSkwQbjC_jIv1BCJcoxZqNc_Pa7WbR12b3SAS4_Ip2D9-2HCwyYib1JUEIO8Bg +------END NATS USER JWT------ + +************************* IMPORTANT ************************* +NKEY Seed printed below can be used to sign and prove identity. +NKEYs are sensitive and should be treated as secrets. + +-----BEGIN USER NKEY SEED----- +SUAMLK2ZNL35WSMW37E7UD4VZ7ELPKW7DHC3BWBSD2GCZ7IUQQXZIORRBU +------END USER NKEY SEED------ + +************************************************************* diff --git a/nats-client/tests/jwts/foo-user.jwt b/nats-client/tests/jwts/foo-user.jwt new file mode 100644 index 000000000..0c5feb37d --- /dev/null +++ b/nats-client/tests/jwts/foo-user.jwt @@ -0,0 +1 @@ +eyJ0eXAiOiJqd3QiLCJhbGciOiJlZDI1NTE5In0.eyJqdGkiOiJXTURGT1dHV1JGWkRGRFVSM0dPUkdESEtUTTdDUlZBVDQ1RkRFMllNRUY1N0VOQ0JBVFFRIiwiaWF0IjoxNTUzODQwOTQ0LCJpc3MiOiJBRDdTRUFOUzZCQ0JGNkZISUI3U1EzVUdKVlBXNTNCWE9BTFA3NVlYSkJCWFFMN0VBRkI2TkpOQSIsIm5hbWUiOiJmb28tdXNlciIsInN1YiI6IlVDSzVON042Nk9CT0lORlhBWUMyQUNKUVlGU09ENFZZTlU2QVBFSlRBVkZaQjJTVkhMS0dFVzdMIiwidHlwZSI6InVzZXIiLCJuYXRzIjp7InB1YiI6e30sInN1YiI6e319fQ.Vri09BN561m37GvuSWoGN9L9TSkwQbjC_jIv1BCJcoxZqNc_Pa7WbR12b3SAS4_Ip2D9-2HCwyYib1JUEIO8Bg \ No newline at end of file diff --git a/nats-client/tests/jwts/foo-user.nk b/nats-client/tests/jwts/foo-user.nk new file mode 100644 index 000000000..61aa84587 --- /dev/null +++ b/nats-client/tests/jwts/foo-user.nk @@ -0,0 +1 @@ +SUAMLK2ZNL35WSMW37E7UD4VZ7ELPKW7DHC3BWBSD2GCZ7IUQQXZIORRBU \ No newline at end of file diff --git a/nats-client/tests/nkeys/user.nk b/nats-client/tests/nkeys/user.nk new file mode 100644 index 000000000..2f2ad7d66 --- /dev/null +++ b/nats-client/tests/nkeys/user.nk @@ -0,0 +1 @@ +SUAEIV5COV7ADQZE52WTYHVJQRV7WKJE5J7IBBJGATJTUUT2LVFGVXDPRQ diff --git a/nats-client/tests/test_client.py b/nats-client/tests/test_client.py new file mode 100644 index 000000000..0d2521205 --- /dev/null +++ b/nats-client/tests/test_client.py @@ -0,0 +1,2914 @@ +import asyncio +import base64 +import os +import uuid +from pathlib import Path + +import nkeys +import pytest +from nats.client import ClientStatistics, ClientStatus, NoRespondersError, connect +from nats.client.message import Headers +from nats.server import run, run_cluster + + +def token_handler(): + """Helper to create token handler for testing.""" + + def get_token() -> str: + return "test_token_123" + + return get_token + + +def user_handler(): + """Helper to create user handler for testing.""" + + def get_user() -> str: + return "testuser" + + return get_user + + +def password_handler(): + """Helper to create password handler for testing.""" + + def get_password() -> str: + return "testpass" + + return get_password + + +@pytest.mark.asyncio +async def test_connect_succeeds_with_valid_url(server): + """Test that connecting to a valid server URL succeeds.""" + client = await connect(server.client_url, timeout=1.0) + assert client.status == ClientStatus.CONNECTED + assert client.server_info is not None + await client.close() + + +@pytest.mark.asyncio +async def test_connect_fails_with_invalid_url(): + """Test that connecting to an invalid server URL fails appropriately.""" + with pytest.raises(Exception): + await connect("nats://localhost:9999", timeout=0.5) + + +@pytest.mark.parametrize( + "token", + [ + pytest.param("test_token_123", id="string"), + pytest.param(token_handler(), id="callable"), + ], +) +async def test_connect_to_token_server_with_correct_token(token): + """Test that client can connect to an auth token server with the correct token.""" + import os + + # Start server with token authentication + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_auth_token.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Connect with correct token should succeed + client = await connect(server.client_url, timeout=1.0, token=token) + assert client.status == ClientStatus.CONNECTED + assert client.server_info is not None + + # Verify we can publish and receive messages with valid auth + test_subject = f"test.auth.{uuid.uuid4()}" + subscription = await client.subscribe(test_subject) + await client.flush() + + await client.publish(test_subject, b"test") + await client.flush() + + msg = await subscription.next(timeout=1.0) + assert msg.data == b"test" + + await client.close() + finally: + await server.shutdown() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "token", + [ + pytest.param("test_token_123", id="string"), + pytest.param(token_handler(), id="callable"), + ], +) +async def test_reconnect_with_token(token): + """Test that client can reconnect to a token server after disconnection with all variants.""" + import asyncio + import os + + # Start server with token authentication + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_auth_token.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Events to track callback invocations + disconnect_event = asyncio.Event() + reconnect_event = asyncio.Event() + + # Connect client with token and reconnection enabled + client = await connect( + server.client_url, + timeout=1.0, + token=token, + allow_reconnect=True, + reconnect_time_wait=0.1, + ) + + # Register callbacks + def on_disconnect(): + disconnect_event.set() + + def on_reconnect(): + reconnect_event.set() + + client.add_disconnected_callback(on_disconnect) + client.add_reconnected_callback(on_reconnect) + + # Verify client is working before disconnect + test_subject = f"test.reconnect.token.{uuid.uuid4()}" + subscription = await client.subscribe(test_subject) + await client.publish(test_subject, b"before disconnect") + await client.flush() + msg = await subscription.next(timeout=1.0) + assert msg.data == b"before disconnect" + + # Save the server port to reuse it after shutdown + server_port = server.port + + # Stop the server to trigger disconnect + await server.shutdown() + + # Wait for disconnect callback + await asyncio.wait_for(disconnect_event.wait(), timeout=2.0) + assert disconnect_event.is_set() + + # Start a new server on the same port with same auth config + new_server = await run(config_path=config_path, port=server_port, timeout=5.0) + try: + # Wait for reconnect callback + await asyncio.wait_for(reconnect_event.wait(), timeout=2.0) + assert reconnect_event.is_set() + + # Verify client works after reconnection with token preserved + await client.publish(test_subject, b"after reconnect") + await client.flush() + msg = await subscription.next(timeout=1.0) + assert msg.data == b"after reconnect" + finally: + await new_server.shutdown() + await client.close() + finally: + # Ensure original server is shutdown if still running + try: + await server.shutdown() + except Exception: + pass + + +@pytest.mark.asyncio +async def test_connect_to_token_server_with_incorrect_token(): + """Test that connect raises an error when using an incorrect token.""" + import os + + # Start server with token authentication + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_auth_token.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Connect with incorrect token should raise ConnectionError + with pytest.raises(ConnectionError) as exc_info: + await connect(server.client_url, timeout=1.0, token="wrong_token", allow_reconnect=False) + + # Verify the error message mentions authorization + assert "authorization" in str(exc_info.value).lower() + finally: + await server.shutdown() + + +@pytest.mark.asyncio +async def test_connect_to_token_server_with_missing_token(): + """Test that connect raises an error when connecting without a token to a secured server.""" + import os + + # Start server with token authentication + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_auth_token.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Connect without token should raise ConnectionError + with pytest.raises(ConnectionError) as exc_info: + await connect(server.client_url, timeout=1.0, allow_reconnect=False) + + # Verify the error message mentions authorization + assert "authorization" in str(exc_info.value).lower() + finally: + await server.shutdown() + + +@pytest.mark.asyncio +async def test_connect_to_nkey_server_with_correct_nkey(): + """Test that client can connect to an NKey server with the correct NKey.""" + import os + + # Start server with NKey authentication + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_auth_nkey.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Connect with correct NKey should succeed + # Seed corresponds to public key UBABIZX6SZFAKHK2KGUFD6QH53FDAH5QVCH2R5MJLFPEVYAW22QWQQCX + nkey_seed = "SUAEIV5COV7ADQZE52WTYHVJQRV7WKJE5J7IBBJGATJTUUT2LVFGVXDPRQ" + client = await connect(server.client_url, timeout=1.0, nkey=nkey_seed) + assert client.status == ClientStatus.CONNECTED + assert client.server_info is not None + + # Verify we can publish and receive messages with valid auth + test_subject = f"test.nkey.{uuid.uuid4()}" + subscription = await client.subscribe(test_subject) + await client.flush() + + await client.publish(test_subject, b"test") + await client.flush() + + msg = await subscription.next(timeout=1.0) + assert msg.data == b"test" + + await client.close() + finally: + await server.shutdown() + + +@pytest.mark.asyncio +async def test_connect_to_nkey_server_with_incorrect_nkey(): + """Test that connect raises an error when using an incorrect NKey.""" + import os + + import nkeys + from nacl.signing import SigningKey + + # Start server with NKey authentication + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_auth_nkey.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Generate a different NKey (not authorized on server) + signing_key = SigningKey.generate().encode() + src = nkeys.encode_seed(signing_key, prefix=nkeys.PREFIX_BYTE_USER) + wrong_seed = nkeys.from_seed(src).seed.decode() + + # Connect with incorrect NKey should raise ConnectionError + with pytest.raises(ConnectionError) as exc_info: + await connect(server.client_url, timeout=1.0, nkey=wrong_seed, allow_reconnect=False) + + # Verify the error message mentions authorization + assert "authorization" in str(exc_info.value).lower() + finally: + await server.shutdown() + + +@pytest.mark.asyncio +async def test_connect_to_nkey_server_with_missing_nkey(): + """Test that connect raises an error when connecting without an NKey to a secured server.""" + import os + + # Start server with NKey authentication + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_auth_nkey.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Connect without NKey should raise ConnectionError + with pytest.raises(ConnectionError) as exc_info: + await connect(server.client_url, timeout=1.0, allow_reconnect=False) + + # Verify the error message mentions authorization + assert "authorization" in str(exc_info.value).lower() + finally: + await server.shutdown() + + +def nkey_seed_path(): + """Helper to get NKey seed file path for testing.""" + return Path(__file__).parent / "nkeys" / "user.nk" + + +def nkey_handlers(): + """Helper to create NKey handlers for testing.""" + nkey_seed = "SUAEIV5COV7ADQZE52WTYHVJQRV7WKJE5J7IBBJGATJTUUT2LVFGVXDPRQ" + seed_bytes = nkey_seed.encode() + + def public_key_handler() -> str: + kp = nkeys.from_seed(seed_bytes) + return kp.public_key.decode() + + def signature_handler(nonce: str) -> bytes: + kp = nkeys.from_seed(seed_bytes) + sig = kp.sign(nonce.encode()) + return base64.b64encode(sig) + + return (public_key_handler, signature_handler) + + +def jwt_creds_file(): + """Helper to get JWT creds file path for testing.""" + return Path(__file__).parent / "jwts" / "foo-user.creds" + + +def jwt_separate_files(): + """Helper to get JWT separate files for testing.""" + jwts_dir = Path(__file__).parent / "jwts" + return (jwts_dir / "foo-user.jwt", jwts_dir / "foo-user.nk") + + +def jwt_credentials_strings(): + """Helper to get JWT credentials as strings for testing.""" + jwts_dir = Path(__file__).parent / "jwts" + jwt_string = (jwts_dir / "foo-user.jwt").read_text().strip() + seed_string = (jwts_dir / "foo-user.nk").read_text().strip() + return (jwt_string, seed_string) + + +def jwt_handlers(): + """Helper to create JWT handlers for testing.""" + jwts_dir = Path(__file__).parent / "jwts" + jwt_content = (jwts_dir / "foo-user.jwt").read_bytes().strip() + seed_bytes = (jwts_dir / "foo-user.nk").read_bytes().strip() + + def jwt_handler() -> bytes: + return jwt_content + + def signature_handler(nonce: str) -> bytes: + kp = nkeys.from_seed(seed_bytes) + sig = kp.sign(nonce.encode()) + return base64.b64encode(sig) + + return (jwt_handler, signature_handler) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "nkey", + [ + pytest.param("SUAEIV5COV7ADQZE52WTYHVJQRV7WKJE5J7IBBJGATJTUUT2LVFGVXDPRQ", id="seed_string"), + pytest.param(nkey_seed_path(), id="seed_path"), + pytest.param(nkey_handlers(), id="handlers"), + ], +) +async def test_reconnect_with_nkey(nkey): + """Test that client can reconnect to an NKey server after disconnection with all NKey variants.""" + import os + + # Start server with NKey authentication + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_auth_nkey.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Events to track callback invocations + disconnect_event = asyncio.Event() + reconnect_event = asyncio.Event() + + # Connect client with NKey and reconnection enabled + client = await connect( + server.client_url, + timeout=1.0, + nkey=nkey, + allow_reconnect=True, + reconnect_time_wait=0.1, + ) + + # Register callbacks + def on_disconnect(): + disconnect_event.set() + + def on_reconnect(): + reconnect_event.set() + + client.add_disconnected_callback(on_disconnect) + client.add_reconnected_callback(on_reconnect) + + # Verify client is working before disconnect + test_subject = f"test.reconnect.nkey.{uuid.uuid4()}" + subscription = await client.subscribe(test_subject) + await client.publish(test_subject, b"before disconnect") + await client.flush() + msg = await subscription.next(timeout=1.0) + assert msg.data == b"before disconnect" + + # Save the server port to reuse it after shutdown + server_port = server.port + + # Stop the server to trigger disconnect + await server.shutdown() + + # Wait for disconnect callback + await asyncio.wait_for(disconnect_event.wait(), timeout=2.0) + assert disconnect_event.is_set() + + # Start a new server on the same port with same auth config + new_server = await run(config_path=config_path, port=server_port, timeout=5.0) + try: + # Wait for reconnect callback + await asyncio.wait_for(reconnect_event.wait(), timeout=2.0) + assert reconnect_event.is_set() + + # Verify client works after reconnection with NKey preserved + await client.publish(test_subject, b"after reconnect") + await client.flush() + msg = await subscription.next(timeout=1.0) + assert msg.data == b"after reconnect" + finally: + await new_server.shutdown() + await client.close() + finally: + # Ensure original server is shutdown if still running + try: + await server.shutdown() + except Exception: + pass + + +@pytest.mark.parametrize( + "user,password", + [ + pytest.param("testuser", "testpass", id="string_string"), + pytest.param("testuser", password_handler(), id="string_callable"), + pytest.param(user_handler(), "testpass", id="callable_string"), + pytest.param(user_handler(), password_handler(), id="callable_callable"), + ], +) +async def test_connect_to_user_pass_server_with_correct_credentials(user, password): + """Test that client can connect to a user/pass server with correct credentials.""" + import os + + # Start server with user/password authentication + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_auth_user_pass.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Connect with correct credentials should succeed + client = await connect(server.client_url, timeout=1.0, user=user, password=password) + assert client.status == ClientStatus.CONNECTED + assert client.server_info is not None + + # Verify we can publish and receive messages with valid auth + test_subject = f"test.auth.{uuid.uuid4()}" + subscription = await client.subscribe(test_subject) + await client.flush() + + await client.publish(test_subject, b"test") + await client.flush() + + msg = await subscription.next(timeout=1.0) + assert msg.data == b"test" + + await client.close() + finally: + await server.shutdown() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "user,password", + [ + pytest.param("testuser", "testpass", id="string_string"), + pytest.param("testuser", password_handler(), id="string_callable"), + pytest.param(user_handler(), "testpass", id="callable_string"), + pytest.param(user_handler(), password_handler(), id="callable_callable"), + ], +) +async def test_reconnect_with_user_pass(user, password): + """Test that client can reconnect to a user/pass server after disconnection with all variants.""" + import asyncio + import os + + # Start server with user/password authentication + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_auth_user_pass.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Events to track callback invocations + disconnect_event = asyncio.Event() + reconnect_event = asyncio.Event() + + # Connect client with user/password and reconnection enabled + client = await connect( + server.client_url, + timeout=1.0, + user=user, + password=password, + allow_reconnect=True, + reconnect_time_wait=0.1, + ) + + # Register callbacks + def on_disconnect(): + disconnect_event.set() + + def on_reconnect(): + reconnect_event.set() + + client.add_disconnected_callback(on_disconnect) + client.add_reconnected_callback(on_reconnect) + + # Verify client is working before disconnect + test_subject = f"test.reconnect.userpass.{uuid.uuid4()}" + subscription = await client.subscribe(test_subject) + await client.publish(test_subject, b"before disconnect") + await client.flush() + msg = await subscription.next(timeout=1.0) + assert msg.data == b"before disconnect" + + # Save the server port to reuse it after shutdown + server_port = server.port + + # Stop the server to trigger disconnect + await server.shutdown() + + # Wait for disconnect callback + await asyncio.wait_for(disconnect_event.wait(), timeout=2.0) + assert disconnect_event.is_set() + + # Start a new server on the same port with same auth config + new_server = await run(config_path=config_path, port=server_port, timeout=5.0) + try: + # Wait for reconnect callback + await asyncio.wait_for(reconnect_event.wait(), timeout=2.0) + assert reconnect_event.is_set() + + # Verify client works after reconnection with credentials preserved + await client.publish(test_subject, b"after reconnect") + await client.flush() + msg = await subscription.next(timeout=1.0) + assert msg.data == b"after reconnect" + finally: + await new_server.shutdown() + await client.close() + finally: + # Ensure original server is shutdown if still running + try: + await server.shutdown() + except Exception: + pass + + +@pytest.mark.asyncio +async def test_connect_to_user_pass_server_with_incorrect_password(): + """Test that connect raises an error when using an incorrect password.""" + import os + + # Start server with user/password authentication + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_auth_user_pass.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Connect with incorrect password should raise ConnectionError + with pytest.raises(ConnectionError) as exc_info: + await connect(server.client_url, timeout=1.0, user="testuser", password="wrongpass", allow_reconnect=False) + + # Verify the error message mentions authorization + assert "authorization" in str(exc_info.value).lower() + finally: + await server.shutdown() + + +@pytest.mark.asyncio +async def test_connect_to_user_pass_server_with_missing_credentials(): + """Test that connect raises an error when connecting without credentials to a secured server.""" + import os + + # Start server with user/password authentication + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_auth_user_pass.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Connect without credentials should raise ConnectionError + with pytest.raises(ConnectionError) as exc_info: + await connect(server.client_url, timeout=1.0, allow_reconnect=False) + + # Verify the error message mentions authorization + assert "authorization" in str(exc_info.value).lower() + finally: + await server.shutdown() + + +@pytest.mark.asyncio +async def test_connect_to_user_pass_server_with_user_only(): + """Test that server rejects connection when only username is provided without password.""" + import os + + # Start server with user/password authentication + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_auth_user_pass.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Connect with only username should raise ConnectionError (server rejects incomplete credentials) + with pytest.raises(ConnectionError) as exc_info: + await connect(server.client_url, timeout=1.0, user="testuser", allow_reconnect=False) + + # Verify the error message mentions authorization + assert "authorization" in str(exc_info.value).lower() + finally: + await server.shutdown() + + +@pytest.mark.asyncio +async def test_connect_to_user_pass_server_with_password_only(): + """Test that server rejects connection when only password is provided without username.""" + import os + + # Start server with user/password authentication + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_auth_user_pass.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Connect with only password should raise ConnectionError (server rejects incomplete credentials) + with pytest.raises(ConnectionError) as exc_info: + await connect(server.client_url, timeout=1.0, password="testpass", allow_reconnect=False) + + # Verify the error message mentions authorization + assert "authorization" in str(exc_info.value).lower() + finally: + await server.shutdown() + + +@pytest.mark.asyncio +async def test_no_echo_prevents_receiving_own_messages(server): + """Test that no_echo prevents the client from receiving its own published messages.""" + # Create two clients: one with no_echo, one without + client_no_echo = await connect(server.client_url, timeout=1.0, no_echo=True) + client_with_echo = await connect(server.client_url, timeout=1.0) + + try: + test_subject = f"test.no_echo.{uuid.uuid4()}" + test_message = b"Test message" + + # Subscribe with both clients + sub_no_echo = await client_no_echo.subscribe(test_subject) + sub_normal = await client_with_echo.subscribe(test_subject) + + # Ensure both subscriptions are fully registered on the server + # by flushing each client individually + await client_no_echo.flush() + await client_with_echo.flush() + + # Publish from no_echo client + await client_no_echo.publish(test_subject, test_message) + await client_no_echo.flush() + + # Normal client should receive the message + msg = await sub_normal.next(timeout=1.0) + assert msg.data == test_message + + # no_echo client should NOT receive its own message + with pytest.raises(asyncio.TimeoutError): + await sub_no_echo.next(timeout=0.5) + + # Verify no_echo client can still receive messages from other clients + await client_with_echo.publish(test_subject, b"from normal") + await client_with_echo.flush() + + msg = await sub_no_echo.next(timeout=1.0) + assert msg.data == b"from normal" + + finally: + await client_no_echo.close() + await client_with_echo.close() + + +@pytest.mark.asyncio +async def test_publish_delivers_message_to_subscriber(client): + """Test that a published message is delivered to a subscriber.""" + test_subject = f"test.{uuid.uuid4()}" + test_message = b"Hello, NATS!" + + subscription = await client.subscribe(test_subject) + await client.flush() # Ensure subscription is registered + + await client.publish(test_subject, test_message) + await client.flush() + + message = await subscription.next(timeout=1.0) + assert message.data == test_message + + +@pytest.mark.asyncio +async def test_publish_sets_correct_subject(client): + """Test that a published message has the correct subject.""" + test_subject = f"test.{uuid.uuid4()}" + test_message = b"Hello, NATS!" + + subscription = await client.subscribe(test_subject) + await client.flush() + + await client.publish(test_subject, test_message) + await client.flush() + + message = await subscription.next(timeout=1.0) + assert message.subject == test_subject + + +@pytest.mark.asyncio +async def test_publish_with_headers(client): + """Test that a message can be published with headers.""" + test_subject = f"test.headers.{uuid.uuid4()}" + test_headers = Headers({"key1": "value1", "key2": ["value2", "value3"]}) + test_payload = b"Message with headers" + + subscription = await client.subscribe(test_subject) + await client.flush() + + await client.publish(test_subject, test_payload, headers=test_headers) + await client.flush() + + message = await subscription.next(timeout=1.0) + assert message.headers is not None + assert message.headers.get("key1") == "value1" + assert message.headers.get_all("key2") == ["value2", "value3"] + + +@pytest.mark.asyncio +async def test_publish_with_byte_subject(client): + """Test that a message can be published with a byte subject.""" + test_subject_str = f"test.byte.subject.{uuid.uuid4()}" + test_subject_bytes = test_subject_str.encode() + test_payload = b"Message with byte subject" + + subscription = await client.subscribe(test_subject_str) + await client.flush() + + # Publish using bytes subject + await client.publish(test_subject_bytes, test_payload) + await client.flush() + + message = await subscription.next(timeout=1.0) + assert message.data == test_payload + assert message.subject == test_subject_str + + +@pytest.mark.asyncio +async def test_publish_with_byte_reply_subject(client): + """Test that a message can be published with a byte reply subject.""" + test_subject = f"test.byte.reply.{uuid.uuid4()}" + reply_subject_str = f"test.reply.{uuid.uuid4()}" + reply_subject_bytes = reply_subject_str.encode() + test_payload = b"Message with byte reply subject" + + subscription = await client.subscribe(test_subject) + await client.flush() + + # Publish using bytes reply subject + await client.publish(test_subject, test_payload, reply=reply_subject_bytes) + await client.flush() + + message = await subscription.next(timeout=1.0) + assert message.data == test_payload + assert message.subject == test_subject + assert message.reply == reply_subject_str + + +@pytest.mark.asyncio +async def test_subscribe_with_byte_subject(client): + """Test that a subscription can be created with a byte subject.""" + test_subject_str = f"test.byte.subscribe.{uuid.uuid4()}" + test_subject_bytes = test_subject_str.encode() + test_payload = b"Message to byte subscription" + + # Subscribe using bytes subject + subscription = await client.subscribe(test_subject_bytes) + await client.flush() + + # Publish to the same subject (as string) + await client.publish(test_subject_str, test_payload) + await client.flush() + + message = await subscription.next(timeout=1.0) + assert message.data == test_payload + assert message.subject == test_subject_str + + +@pytest.mark.asyncio +async def test_subscribe_with_byte_queue_group(client): + """Test that a subscription can be created with a byte queue group.""" + test_subject = f"test.byte.queue.{uuid.uuid4()}" + queue_group_str = "workers" + queue_group_bytes = queue_group_str.encode() + test_payload = b"Message to queue group" + + # Subscribe using bytes queue group + subscription = await client.subscribe(test_subject, queue=queue_group_bytes) + await client.flush() + + # Publish to the subject + await client.publish(test_subject, test_payload) + await client.flush() + + message = await subscription.next(timeout=1.0) + assert message.data == test_payload + assert message.subject == test_subject + + +@pytest.mark.asyncio +async def test_request_reply_with_single_responder(client): + """Test request-reply messaging pattern with a single responder.""" + test_subject = f"test.request.{uuid.uuid4()}" + request_payload = b"Request data" + reply_payload = b"Reply data" + + # Setup responder + async def handle_request(): + subscription = await client.subscribe(test_subject) + await client.flush() + message = await subscription.next(timeout=1.0) + await client.publish(message.reply, reply_payload) + + responder_task = asyncio.create_task(handle_request()) + await client.flush() + + # Send request + response = await client.request(test_subject, request_payload, timeout=1.0) + + # Verify response + assert response.data == reply_payload + await responder_task + + +@pytest.mark.asyncio +async def test_flush_ensures_message_delivery(client): + """Test that flush ensures all pending messages are delivered.""" + test_subject = f"test.flush.{uuid.uuid4()}" + message_count = 10 + + subscription = await client.subscribe(test_subject) + await client.flush() + + # Publish messages without awaiting between them + for i in range(message_count): + await client.publish(test_subject, f"{i}".encode()) + + # Flush to ensure delivery + await client.flush() + + # Verify all messages are received + received_count = 0 + for _ in range(message_count): + try: + await subscription.next(timeout=0.5) + received_count += 1 + except TimeoutError: + break + + assert received_count == message_count + + +@pytest.mark.asyncio +async def test_client_as_context_manager(server): + """Test that Client can be used as an async context manager.""" + async with await connect(server.client_url, timeout=1.0) as client: + assert client.status == ClientStatus.CONNECTED + + # Verify we can publish and subscribe + test_subject = f"test.context.{uuid.uuid4()}" + async with await client.subscribe(test_subject) as subscription: + await client.flush() + await client.publish(test_subject, b"Context test") + await client.flush() + message = await subscription.next(timeout=1.0) + assert message.data == b"Context test" + + # Client should be closed after exiting context + assert client.status == ClientStatus.CLOSED + + +@pytest.mark.asyncio +async def test_client_close_stops_publishing(client): + """Test that closing the client prevents further publishing.""" + test_subject = f"test.close.{uuid.uuid4()}" + + # Close the client + await client.close() + + # Verify we can't publish anymore + with pytest.raises(Exception): + await client.publish(test_subject, b"Message after close") + + +@pytest.mark.asyncio +async def test_client_close_stops_subscribing(client): + """Test that closing the client prevents further subscriptions.""" + test_subject = f"test.close.{uuid.uuid4()}" + + # Close the client + await client.close() + + # Verify we can't subscribe anymore + with pytest.raises(Exception): + await client.subscribe(test_subject) + + +@pytest.mark.asyncio +async def test_client_close_updates_status(client): + """Test that closing the client updates its status to CLOSED.""" + await client.close() + assert client.status == ClientStatus.CLOSED + + +@pytest.mark.asyncio +async def test_disconnection_and_reconnection_callbacks(server): + """Test that disconnection and reconnection callbacks are properly invoked. + + This test simulates a server disconnection and reconnection scenario: + 1. Create a client with disconnect/reconnect callbacks + 2. Stop the server to trigger disconnection + 3. Start a new server on the same port to trigger reconnection + 4. Verify both callbacks were invoked and client functionality is restored + """ + # Events to track callback invocations + disconnect_event = asyncio.Event() + reconnect_event = asyncio.Event() + + # Connect client with callbacks and reconnection options + client = await connect(server.client_url, timeout=1.0, allow_reconnect=True, reconnect_time_wait=0.1) + + # Register callbacks + def on_disconnect(): + disconnect_event.set() + + def on_reconnect(): + reconnect_event.set() + + client.add_disconnected_callback(on_disconnect) + client.add_reconnected_callback(on_reconnect) + + # Verify client is working before disconnect + test_subject = f"test.reconnect.{uuid.uuid4()}" + subscription = await client.subscribe(test_subject) + await client.publish(test_subject, b"before disconnect") + await client.flush() + msg = await subscription.next(timeout=1.0) + assert msg.data == b"before disconnect" + + # Save the server port to reuse it after shutdown + server_port = server.port + + # Stop the server to trigger disconnect + await server.shutdown() + + # Wait for disconnect callback + try: + await asyncio.wait_for(disconnect_event.wait(), timeout=2.0) + assert disconnect_event.is_set(), "Disconnect callback was not invoked" + except asyncio.TimeoutError: + pytest.fail("Disconnect callback was not invoked within timeout") + + # Start a new server on the same port + new_server = await run(port=server_port) + try: + # Wait for reconnect callback + try: + await asyncio.wait_for(reconnect_event.wait(), timeout=2.0) + assert reconnect_event.is_set(), "Reconnect callback was not invoked" + except asyncio.TimeoutError: + pytest.fail("Reconnect callback was not invoked within timeout") + + # Verify client works after reconnection + await client.publish(test_subject, b"after reconnect") + await client.flush() + msg = await subscription.next(timeout=1.0) + assert msg.data == b"after reconnect" + finally: + # Clean up resources + await new_server.shutdown() + await client.close() + + +@pytest.mark.asyncio +async def test_connect_with_ipv6_localhost(server): + """Test connecting to server using IPv6 localhost address.""" + # Get the server port and construct IPv6 URL + port = server.port + ipv6_url = f"nats://[::1]:{port}" + + try: + client = await connect(ipv6_url, timeout=1.0) + assert client.status == ClientStatus.CONNECTED + + # Verify we can publish/subscribe + test_subject = f"test.ipv6.{uuid.uuid4()}" + subscription = await client.subscribe(test_subject) + await client.flush() + + await client.publish(test_subject, b"IPv6 test") + await client.flush() + + message = await subscription.next(timeout=1.0) + assert message.data == b"IPv6 test" + + await client.close() + except Exception as e: + # IPv6 might not be available on all systems + pytest.skip(f"IPv6 not available: {e}") + + +@pytest.mark.asyncio +async def test_reconnect_with_ipv6_address(): + """Test that reconnection works with IPv6 addresses in server pool.""" + # Start server on IPv6 localhost (let it pick a port) + server = await run(host="::1", port=0) + port = server.port + + # Connect using IPv6 URL + ipv6_url = f"nats://[::1]:{port}" + client = await connect(ipv6_url, timeout=1.0, allow_reconnect=True, reconnect_time_wait=0.1) + + # Verify connection works + test_subject = f"test.ipv6.reconnect.{uuid.uuid4()}" + subscription = await client.subscribe(test_subject) + await client.publish(test_subject, b"before") + await client.flush() + msg = await subscription.next(timeout=1.0) + assert msg.data == b"before" + + # Track reconnection + reconnect_event = asyncio.Event() + client.add_reconnected_callback(lambda: reconnect_event.set()) + + # Shutdown and restart server + await server.shutdown() + new_server = await run(host="::1", port=port) + + # Wait for reconnection + await asyncio.wait_for(reconnect_event.wait(), timeout=3.0) + + # Verify client works after reconnection + await client.publish(test_subject, b"after") + await client.flush() + msg = await subscription.next(timeout=1.0) + assert msg.data == b"after" + + await client.close() + await new_server.shutdown() + + +@pytest.mark.asyncio +async def test_request_with_no_responders_raises_error(client): + """Test that sending a request to a subject with no responders raises NoRespondersError.""" + test_subject = f"test.no_responders.{uuid.uuid4()}" + request_payload = b"Request with no responders" + + # Send request to a subject with no subscribers/responders + # The NATS server should automatically respond with "503 No Responders" + # because no_responders=True is set in the CONNECT message + with pytest.raises(NoRespondersError) as exc_info: + await client.request(test_subject, request_payload, timeout=1.0) + + # Verify the exception details + error = exc_info.value + assert isinstance(error, NoRespondersError) + assert error.subject == test_subject + assert error.status == "503" + + +@pytest.mark.asyncio +async def test_message_status_properties(client): + """Test that Message status properties work correctly.""" + test_subject = f"test.status_properties.{uuid.uuid4()}" + + # Test no responders case (status 503) + with pytest.raises(NoRespondersError): + await client.request(test_subject, b"test", timeout=1.0) + + # Test with return_on_error=True to get the Message object + response = await client.request(test_subject, b"test", timeout=1.0, return_on_error=True) + + # Verify status properties + assert response.status is not None + assert response.status.code == "503" + + # Test normal message (no status) + subscription = await client.subscribe(test_subject) + await client.flush() + + # Publish a normal message without status + await client.publish(test_subject, b"normal message") + await client.flush() + + normal_msg = await subscription.next(timeout=1.0) + assert normal_msg.status is None + + +@pytest.mark.asyncio +async def test_multiple_disconnect_reconnect_callbacks(server): + """Test that multiple disconnect and reconnect callbacks are all properly invoked. + + This test verifies that: + 1. Multiple disconnection callbacks are all invoked when a server disconnects + 2. Multiple reconnection callbacks are all invoked when a server reconnects + 3. Client functionality is restored after reconnection + """ + # Counters and events to track callback invocations + disconnect_count = 0 + reconnect_count = 0 + disconnect_event = asyncio.Event() + reconnect_event = asyncio.Event() + + # Connect client with callbacks and reconnection options + client = await connect(server.client_url, timeout=1.0, allow_reconnect=True, reconnect_time_wait=0.1) + + # Register multiple callbacks + def on_disconnect1(): + nonlocal disconnect_count + disconnect_count += 1 + if disconnect_count == 2: + disconnect_event.set() + + def on_disconnect2(): + nonlocal disconnect_count + disconnect_count += 1 + if disconnect_count == 2: + disconnect_event.set() + + def on_reconnect1(): + nonlocal reconnect_count + reconnect_count += 1 + if reconnect_count == 2: + reconnect_event.set() + + def on_reconnect2(): + nonlocal reconnect_count + reconnect_count += 1 + if reconnect_count == 2: + reconnect_event.set() + + # Register all callbacks + client.add_disconnected_callback(on_disconnect1) + client.add_disconnected_callback(on_disconnect2) + client.add_reconnected_callback(on_reconnect1) + client.add_reconnected_callback(on_reconnect2) + + # Verify client is working before disconnect + test_subject = f"test.multiple_callbacks.{uuid.uuid4()}" + subscription = await client.subscribe(test_subject) + await client.publish(test_subject, b"test message") + await client.flush() + msg = await subscription.next(timeout=1.0) + assert msg.data == b"test message" + + # Save the server port to reuse it + server_port = server.port + + # Stop the server to trigger disconnect + await server.shutdown() + + # Wait for disconnect callbacks + try: + await asyncio.wait_for(disconnect_event.wait(), timeout=5.0) + assert disconnect_count == 2, f"Expected 2 disconnect callbacks, got {disconnect_count}" + except asyncio.TimeoutError: + pytest.fail("Not all disconnect callbacks were invoked within timeout") + + # Start a new server on the same port + new_server = await run(port=server_port) + try: + # Wait for reconnect callbacks + try: + await asyncio.wait_for(reconnect_event.wait(), timeout=5.0) + assert reconnect_count == 2, f"Expected 2 reconnect callbacks, got {reconnect_count}" + except asyncio.TimeoutError: + pytest.fail("Not all reconnect callbacks were invoked within timeout") + + # Verify client works after reconnection + await client.publish(test_subject, b"after reconnect") + await client.flush() + msg = await subscription.next(timeout=1.0) + assert msg.data == b"after reconnect" + finally: + # Clean up resources + await new_server.shutdown() + await client.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("cluster_size", [3, 5]) +async def test_cluster_reconnect_sequential_shutdown(cluster_size): + """Test client reconnection when cluster servers are shut down sequentially. + + This test verifies that: + 1. Client connects to a cluster with multiple servers + 2. Client reconnects as servers are shut down one by one in sequence + 3. Client maintains functionality throughout the sequential shutdowns + 4. Client continues to work as long as at least one server is available + """ + # Start a cluster with the specified size + cluster = await run_cluster(size=cluster_size) + + try: + # Track reconnection events + reconnect_count = 0 + reconnect_event = asyncio.Event() + + def on_reconnect(): + nonlocal reconnect_count + reconnect_count += 1 + reconnect_event.set() + + # Connect to the first server - cluster will gossip other servers via INFO + client = await connect( + cluster.servers[0].client_url, + timeout=0.5, + allow_reconnect=True, + reconnect_max_attempts=60, + reconnect_time_wait=0.0, + no_randomize=True, + ) + + client.add_reconnected_callback(on_reconnect) + + # Verify client is working + test_subject = f"test.cluster.sequential.{uuid.uuid4()}" + subscription = await client.subscribe(test_subject) + await client.flush() + + await client.publish(test_subject, b"initial message") + await client.flush() + msg = await subscription.next(timeout=1.0) + assert msg.data == b"initial message" + + # Shut down servers one by one (shut down the server we're connected to) + for i in range(len(cluster.servers) - 1): # Keep last server running + # Find which server the client is currently connected to using server_info + assert client.server_info is not None + connected_port = client.server_info.port + + # Find the matching server in the cluster by port + server_to_shutdown = None + for server in cluster.servers: + if server.port == connected_port: + server_to_shutdown = server + break + + assert server_to_shutdown is not None, f"Could not find server for port {connected_port}" + + # Shutdown the connected server + await server_to_shutdown.shutdown() + + # Wait for reconnection to another server + reconnect_event.clear() + try: + await asyncio.wait_for(reconnect_event.wait(), timeout=10.0) + except asyncio.TimeoutError: + pytest.fail(f"Client did not reconnect after shutting down server {i}") + + # Flush ensures subscriptions are re-established (no sleep needed) + await client.flush() + + # Verify client still works after reconnection + await client.publish(test_subject, f"message after shutdown {i}".encode()) + await client.flush() + msg = await subscription.next(timeout=5.0) + assert msg.data == f"message after shutdown {i}".encode() + + # Verify we had the expected number of reconnections (cluster_size - 1) + expected_reconnects = cluster_size - 1 + assert reconnect_count == expected_reconnects, ( + f"Expected {expected_reconnects} reconnects, got {reconnect_count}" + ) + + await client.close() + + finally: + await cluster.shutdown() + + +@pytest.mark.asyncio +async def test_new_inbox(server): + """Test that new_inbox generates unique inbox subjects with the configured prefix.""" + custom_prefix = "_MY_INBOX" + client = await connect(server.client_url, inbox_prefix=custom_prefix, timeout=1.0) + + try: + # Generate multiple inboxes + inbox1 = client.new_inbox() + inbox2 = client.new_inbox() + inbox3 = client.new_inbox() + + # All should start with the custom prefix + assert inbox1.startswith(custom_prefix) + assert inbox2.startswith(custom_prefix) + assert inbox3.startswith(custom_prefix) + + # All should be unique + assert inbox1 != inbox2 + assert inbox1 != inbox3 + assert inbox2 != inbox3 + + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_custom_inbox_prefix(server): + """Test that custom inbox prefix is used for request-reply inboxes.""" + custom_prefix = "_MY_CUSTOM_INBOX" + + # Connect with custom inbox prefix + client = await connect(server.client_url, inbox_prefix=custom_prefix, timeout=1.0) + + try: + test_subject = f"test.custom_inbox.{uuid.uuid4()}" + request_payload = b"Request data" + reply_payload = b"Reply data" + + # Track the inbox subject used in the request + received_reply = None + + # Setup responder that captures the reply-to subject + subscription = await client.subscribe(test_subject) + await client.flush() + + async def handle_request(): + nonlocal received_reply + message = await subscription.next(timeout=2.0) + received_reply = message.reply + assert received_reply is not None + await client.publish(received_reply, reply_payload) + + responder_task = asyncio.create_task(handle_request()) + + # Send request + response = await client.request(test_subject, request_payload, timeout=2.0) + + # Verify response + assert response.data == reply_payload + await responder_task + + # Verify that the inbox used the custom prefix + assert received_reply is not None + assert received_reply.startswith(custom_prefix), ( + f"Expected inbox to start with '{custom_prefix}', got '{received_reply}'" + ) + + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_max_outstanding_pings_closes_connection(): + """Test that connection closes when max outstanding pings is exceeded.""" + + async def mock_server(reader, writer): + """Mock NATS server that stops responding to PINGs.""" + # Send INFO + info = b'INFO {"server_id":"test","version":"2.0.0","go":"go1.20","host":"127.0.0.1","port":4222,"headers":true,"max_payload":1048576}\r\n' + writer.write(info) + await writer.drain() + + # Read CONNECT from client + await reader.readline() + + # Read and respond to first PING + await reader.readline() + writer.write(b"PONG\r\n") + await writer.drain() + + # Now stop responding to PINGs - just read them without PONGing + # This will cause outstanding pings to accumulate + try: + while True: + line = await reader.readline() + if not line: + break + except Exception: + pass + finally: + writer.close() + await writer.wait_closed() + + # Start mock server + server = await asyncio.start_server(mock_server, "127.0.0.1", 0) + addr = server.sockets[0].getsockname() + server_url = f"nats://{addr[0]}:{addr[1]}" + + try: + # Connect with short ping interval and low max pings + client = await connect( + server_url, + ping_interval=0.05, # Ping every 50ms + max_outstanding_pings=2, + allow_reconnect=False, + timeout=1.0, + ) + + try: + # Verify client starts connected + assert client.status == ClientStatus.CONNECTED + + # Wait for outstanding pings to accumulate and trigger disconnect + # With ping_interval=0.05 and max=2, should disconnect after ~150ms + await asyncio.sleep(0.3) + + # Verify client is no longer connected (closed due to max pings exceeded) + assert client.status == ClientStatus.CLOSED, f"Expected CLOSED status, got {client.status}" + finally: + await client.close() + finally: + server.close() + await server.wait_closed() + + +@pytest.mark.asyncio +async def test_inbox_prefix_cannot_be_empty(server): + """Test that empty inbox prefix is rejected.""" + with pytest.raises(ValueError, match="inbox_prefix cannot be empty"): + await connect(server.client_url, inbox_prefix="", timeout=1.0) + + +@pytest.mark.asyncio +async def test_inbox_prefix_cannot_contain_greater_than_wildcard(server): + """Test that inbox prefix with '>' wildcard is rejected.""" + with pytest.raises(ValueError, match="inbox_prefix cannot contain '>' wildcard"): + await connect(server.client_url, inbox_prefix="test.>", timeout=1.0) + + +@pytest.mark.asyncio +async def test_inbox_prefix_cannot_contain_asterisk_wildcard(server): + """Test that inbox prefix with '*' wildcard is rejected.""" + with pytest.raises(ValueError, match=r"inbox_prefix cannot contain '\*' wildcard"): + await connect(server.client_url, inbox_prefix="test.*", timeout=1.0) + + +@pytest.mark.asyncio +async def test_inbox_prefix_cannot_end_with_dot(server): + """Test that inbox prefix ending with '.' is rejected.""" + with pytest.raises(ValueError, match="inbox_prefix cannot end with '.'"): + await connect(server.client_url, inbox_prefix="test.", timeout=1.0) + + +@pytest.mark.asyncio +async def test_server_initiated_ping_pong(): + """Test that client properly handles PING from server and responds with PONG.""" + import os + + # Start server with very short ping interval + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_ping.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + client = await connect(server.client_url, timeout=1.0, allow_reconnect=False) + + try: + # Wait long enough for server to send at least one PING + # Server is configured to ping every 100ms + await asyncio.sleep(0.3) + + # If ping/pong handling didn't work, client would be disconnected + assert client.status == ClientStatus.CONNECTED, "Client should still be connected after server PINGs" + finally: + await client.close() + finally: + await server.shutdown() + + +@pytest.mark.asyncio +async def test_reconnect_while_publishing(): + """Test that client can reconnect while actively publishing messages. + + This test verifies that: + 1. Client continues to publish messages during normal operation + 2. When the server disconnects, the client detects the disconnection + 3. Publishing blocks during reconnection (waiting for connection) + 4. Client successfully reconnects to a new server + 5. Publishing resumes after reconnection + 6. Messages published after reconnection are successfully delivered + """ + # Start initial server + server = await run(port=0) + server_port = server.port + + # Events to track lifecycle + disconnect_event = asyncio.Event() + reconnect_event = asyncio.Event() + + # Connect client with reconnection enabled + client = await connect( + server.client_url, + timeout=1.0, + allow_reconnect=True, + reconnect_time_wait=0.1, + reconnect_max_attempts=100, + ) + + client.add_disconnected_callback(disconnect_event.set) + client.add_reconnected_callback(reconnect_event.set) + + # Set up subscription to verify messages + test_subject = f"test.reconnect.load.{uuid.uuid4()}" + subscription = await client.subscribe(test_subject) + await client.flush() + + # Counters for tracking + messages_sent_before_disconnect = 0 + messages_sent_after_reconnect = 0 + publish_task_running = True + + async def publish_continuously(): + """Continuously publish messages until told to stop. + + publish() will block during reconnection, not raise exceptions. + """ + nonlocal messages_sent_before_disconnect, messages_sent_after_reconnect + counter = 0 + + while publish_task_running: + message = f"message_{counter}".encode() + # This may block during reconnection but won't raise + await client.publish(test_subject, message) + counter += 1 + + # Track message counts based on connection state + if reconnect_event.is_set(): + messages_sent_after_reconnect += 1 + elif not disconnect_event.is_set(): + messages_sent_before_disconnect += 1 + + # Small delay to simulate realistic publish rate + await asyncio.sleep(0.01) + + # Start publishing task + publish_task = asyncio.create_task(publish_continuously()) + + try: + # Let some messages publish successfully + await asyncio.sleep(0.2) + assert messages_sent_before_disconnect > 0, "Should have published messages before disconnect" + + # Verify we're receiving messages + msg = await subscription.next(timeout=1.0) + assert msg.data.startswith(b"message_") + + # Shutdown server while publishing is active + await server.shutdown() + + # Wait for disconnect to be detected + await asyncio.wait_for(disconnect_event.wait(), timeout=2.0) + assert disconnect_event.is_set() + + # Start new server on same port + new_server = await run(port=server_port) + + try: + # Wait for reconnection + await asyncio.wait_for(reconnect_event.wait(), timeout=5.0) + assert reconnect_event.is_set() + + # Give time for publishing to resume + await asyncio.sleep(0.3) + + # Verify publishing resumed after reconnection + assert messages_sent_after_reconnect > 0, "Should have published messages after reconnect" + + # Verify we can receive messages after reconnection + await client.flush() + msg = await subscription.next(timeout=2.0) + assert msg.data.startswith(b"message_") + + finally: + await new_server.shutdown() + + finally: + # Stop publishing task + publish_task_running = False + await publish_task + await client.close() + + +@pytest.mark.asyncio +async def test_reconnect_with_high_volume_publishing(): + """Test reconnection behavior under high message volume. + + This test verifies that the client can handle reconnection even when + publishing a large number of messages rapidly, ensuring buffering and + flow control work correctly across reconnection boundaries. + """ + # Start initial server + server = await run(port=0) + server_port = server.port + + # Events to track lifecycle + disconnect_event = asyncio.Event() + reconnect_event = asyncio.Event() + + # Connect client + client = await connect( + server.client_url, + timeout=1.0, + allow_reconnect=True, + reconnect_time_wait=0.1, + reconnect_max_attempts=100, + ) + + client.add_disconnected_callback(disconnect_event.set) + client.add_reconnected_callback(reconnect_event.set) + + test_subject = f"test.reconnect.highvolume.{uuid.uuid4()}" + subscription = await client.subscribe(test_subject) + await client.flush() + + # Track successful publishes + successful_publishes = 0 + publish_task_running = True + + async def publish_high_volume(): + """Publish messages rapidly - will block during reconnection.""" + nonlocal successful_publishes + counter = 0 + + while publish_task_running: + # Publish rapidly - may block during reconnection + await client.publish(test_subject, f"msg_{counter}".encode()) + successful_publishes += 1 + counter += 1 + # Small sleep every N messages to prevent overwhelming + if counter % 50 == 0: + await asyncio.sleep(0.01) + + # Start high-volume publishing + publish_task = asyncio.create_task(publish_high_volume()) + + try: + # Let messages accumulate + await asyncio.sleep(0.2) + publishes_before = successful_publishes + assert publishes_before > 50, f"Should have published many messages, got {publishes_before}" + + # Trigger disconnect during heavy load + await server.shutdown() + await asyncio.wait_for(disconnect_event.wait(), timeout=2.0) + + # Restart server + new_server = await run(port=server_port) + + try: + # Wait for reconnection + await asyncio.wait_for(reconnect_event.wait(), timeout=5.0) + + # Let publishing resume + await asyncio.sleep(0.2) + publishes_after = successful_publishes + + # Verify publishing continued after reconnection + assert publishes_after > publishes_before, ( + f"Publishing should resume after reconnect: before={publishes_before}, after={publishes_after}" + ) + + # Verify we can still receive messages + await client.flush() + msg = await subscription.next(timeout=2.0) + assert msg.data.startswith(b"msg_") + + finally: + await new_server.shutdown() + + finally: + publish_task_running = False + await publish_task + await client.close() + + +@pytest.mark.asyncio +async def test_reconnect_with_multiple_concurrent_publishers(): + """Test reconnection with multiple publishing tasks running concurrently. + + This simulates a realistic scenario where multiple application components + are publishing to different subjects simultaneously when a reconnection occurs. + """ + # Start initial server + server = await run(port=0) + server_port = server.port + + # Events + disconnect_event = asyncio.Event() + reconnect_event = asyncio.Event() + + # Connect client + client = await connect( + server.client_url, + timeout=1.0, + allow_reconnect=True, + reconnect_time_wait=0.1, + reconnect_max_attempts=100, + ) + + client.add_disconnected_callback(disconnect_event.set) + client.add_reconnected_callback(reconnect_event.set) + + # Create multiple subjects and subscriptions + num_subjects = 5 + subjects = [f"test.subject.{i}.{uuid.uuid4()}" for i in range(num_subjects)] + subscriptions = [] + + for subject in subjects: + sub = await client.subscribe(subject) + subscriptions.append(sub) + await client.flush() + + # Track publishes per subject + publish_counts = {subject: 0 for subject in subjects} + publish_lock = asyncio.Lock() + tasks_running = True + + async def publish_to_subject(subject: str): + """Publish continuously to a specific subject.""" + counter = 0 + while tasks_running: + await client.publish(subject, f"{subject}_msg_{counter}".encode()) + async with publish_lock: + publish_counts[subject] += 1 + counter += 1 + await asyncio.sleep(0.02) + + # Start multiple publishing tasks + publish_tasks = [asyncio.create_task(publish_to_subject(subject)) for subject in subjects] + + try: + # Let all publishers run + await asyncio.sleep(0.3) + + # Verify all subjects are being published to + async with publish_lock: + for subject, count in publish_counts.items(): + assert count > 0, f"Subject {subject} should have messages" + + # Trigger disconnect + await server.shutdown() + await asyncio.wait_for(disconnect_event.wait(), timeout=2.0) + + # Restart server + new_server = await run(port=server_port) + + try: + # Wait for reconnection + await asyncio.wait_for(reconnect_event.wait(), timeout=5.0) + + # Let publishing resume + await asyncio.sleep(0.3) + + # Verify all subjects resume publishing + async with publish_lock: + counts_before = dict(publish_counts) + + await asyncio.sleep(0.2) + + async with publish_lock: + counts_after = dict(publish_counts) + + for subject in subjects: + assert counts_after[subject] > counts_before[subject], ( + f"Subject {subject} should continue publishing after reconnect" + ) + + # Verify we can receive on all subscriptions + await client.flush() + for i, subscription in enumerate(subscriptions): + msg = await subscription.next(timeout=2.0) + assert subjects[i].encode() in msg.data + + finally: + await new_server.shutdown() + + finally: + tasks_running = False + await asyncio.gather(*publish_tasks) + await client.close() + + +@pytest.mark.asyncio +async def test_client_drain_closes_connection(client): + """Test that drain closes the connection.""" + # Verify client is connected + assert client.status == ClientStatus.CONNECTED + + # Drain the client + await client.drain() + + # Verify client is closed + assert client.status == ClientStatus.CLOSED + + +@pytest.mark.asyncio +async def test_client_drain_processes_pending_messages(server): + """Test that drain allows pending messages in subscriptions to be processed.""" + client = await connect(server.client_url, timeout=1.0) + + try: + test_subject = f"test.drain.pending.{uuid.uuid4()}" + + # Create subscription + subscription = await client.subscribe(test_subject) + await client.flush() + + # Publish multiple messages + message_count = 10 + for i in range(message_count): + await client.publish(test_subject, f"message-{i}".encode()) + await client.flush() + + # Wait for messages to arrive + await asyncio.sleep(0.1) + + # Drain the client (should allow pending messages to be processed) + drain_task = asyncio.create_task(client.drain()) + + # Read all pending messages before drain completes + messages_received = [] + try: + while len(messages_received) < message_count: + msg = await asyncio.wait_for(subscription.next(), timeout=1.0) + messages_received.append(msg.data.decode()) + except (RuntimeError, asyncio.TimeoutError): + # Expected when subscription is drained + pass + + # Wait for drain to complete + await drain_task + + # Verify we received all messages + assert len(messages_received) == message_count + for i in range(message_count): + assert f"message-{i}" in messages_received + + finally: + if client.status != ClientStatus.CLOSED: + await client.close() + + +@pytest.mark.asyncio +async def test_client_drain_flushes_pending_publishes(server): + """Test that drain flushes pending published messages.""" + # Create two clients: one publisher, one subscriber + publisher = await connect(server.client_url, timeout=1.0) + subscriber = await connect(server.client_url, timeout=1.0) + + try: + test_subject = f"test.drain.flush.{uuid.uuid4()}" + + # Create subscription on subscriber client + subscription = await subscriber.subscribe(test_subject) + await subscriber.flush() + + # Publish messages without flushing + message_count = 5 + for i in range(message_count): + await publisher.publish(test_subject, f"message-{i}".encode()) + + # Drain should flush these pending messages + await publisher.drain() + + # Verify subscriber receives all messages + messages_received = [] + for _ in range(message_count): + try: + msg = await asyncio.wait_for(subscription.next(), timeout=2.0) + messages_received.append(msg.data.decode()) + except asyncio.TimeoutError: + break + + assert len(messages_received) == message_count, ( + f"Expected {message_count} messages, got {len(messages_received)}" + ) + for i in range(message_count): + assert f"message-{i}" in messages_received + + finally: + if publisher.status != ClientStatus.CLOSED: + await publisher.close() + if subscriber.status != ClientStatus.CLOSED: + await subscriber.close() + + +@pytest.mark.asyncio +async def test_client_drain_multiple_subscriptions(server): + """Test that drain handles multiple subscriptions correctly.""" + client = await connect(server.client_url, timeout=1.0) + + try: + # Create multiple subscriptions + num_subscriptions = 5 + subjects = [f"test.drain.multi.{uuid.uuid4()}.{i}" for i in range(num_subscriptions)] + subscriptions = [] + + for subject in subjects: + sub = await client.subscribe(subject) + subscriptions.append(sub) + await client.flush() + + # Publish messages to each subscription + messages_per_sub = 3 + for subject in subjects: + for i in range(messages_per_sub): + await client.publish(subject, f"{subject}-msg-{i}".encode()) + await client.flush() + + # Wait for messages to arrive + await asyncio.sleep(0.1) + + # Drain the client + drain_task = asyncio.create_task(client.drain()) + + # Collect messages from all subscriptions + all_messages = [] + + async def collect_messages(sub): + messages = [] + try: + while True: + msg = await asyncio.wait_for(sub.next(), timeout=1.0) + messages.append(msg.data.decode()) + except (RuntimeError, asyncio.TimeoutError): + pass + return messages + + # Collect from all subscriptions concurrently + collection_tasks = [asyncio.create_task(collect_messages(sub)) for sub in subscriptions] + results = await asyncio.gather(*collection_tasks, return_exceptions=True) + + for result in results: + if isinstance(result, list): + all_messages.extend(result) + + # Wait for drain to complete + await drain_task + + # Verify we received all messages + expected_count = num_subscriptions * messages_per_sub + assert len(all_messages) == expected_count + + finally: + if client.status != ClientStatus.CLOSED: + await client.close() + + +@pytest.mark.asyncio +async def test_client_drain_with_custom_timeout(server): + """Test that drain accepts a custom timeout parameter.""" + client = await connect(server.client_url, timeout=1.0) + + try: + test_subject = f"test.drain.timeout.{uuid.uuid4()}" + + # Create subscription + await client.subscribe(test_subject) + await client.flush() + + # Publish a few messages + for i in range(5): + await client.publish(test_subject, f"message-{i}".encode()) + await client.flush() + + # Drain with a generous timeout - should complete successfully + await client.drain(timeout=5.0) + + # Client should be closed + assert client.status == ClientStatus.CLOSED + + finally: + if client.status != ClientStatus.CLOSED: + await client.close() + + +@pytest.mark.asyncio +async def test_client_drain_on_already_closed_client(server): + """Test that drain is idempotent when called on already closed client.""" + client = await connect(server.client_url, timeout=1.0) + + # Close the client + await client.close() + assert client.status == ClientStatus.CLOSED + + # Try to drain - should return without error (idempotent behavior, matching Go) + await client.drain() + + # Should still be closed + assert client.status == ClientStatus.CLOSED + + +@pytest.mark.asyncio +async def test_client_drain_multiple_calls_idempotent(server): + """Test that calling drain multiple times is idempotent (following Go semantics).""" + client = await connect(server.client_url, timeout=1.0) + + try: + test_subject = f"test.drain.multiple.{uuid.uuid4()}" + + # Create subscription + await client.subscribe(test_subject) + await client.flush() + + # Call drain multiple times - all should succeed without error + await client.drain() + await client.drain() # Second call - should be no-op + await client.drain() # Third call - should be no-op + + # Verify client is closed + assert client.status == ClientStatus.CLOSED + + finally: + if client.status != ClientStatus.CLOSED: + await client.close() + + +@pytest.mark.asyncio +async def test_client_drain_disables_reconnect(server): + """Test that drain disables automatic reconnection.""" + client = await connect( + server.client_url, + timeout=1.0, + allow_reconnect=True, + reconnect_max_attempts=10, + ) + + try: + # Verify reconnect is enabled + assert client._allow_reconnect is True + + # Start draining + await client.drain() + + # Verify reconnect has been disabled + assert client._allow_reconnect is False + assert client.status == ClientStatus.CLOSED + + finally: + if client.status != ClientStatus.CLOSED: + await client.close() + + +@pytest.mark.asyncio +async def test_client_drain_with_no_subscriptions(server): + """Test that drain works correctly even with no active subscriptions.""" + client = await connect(server.client_url, timeout=1.0) + + try: + # Drain without any subscriptions + await client.drain() + + # Client should be closed + assert client.status == ClientStatus.CLOSED + + finally: + if client.status != ClientStatus.CLOSED: + await client.close() + + +@pytest.mark.asyncio +async def test_client_drain_preferred_over_close(server): + """Test that drain is the preferred way to shutdown (following Go semantics).""" + # This test demonstrates the recommended usage pattern + client = await connect(server.client_url, timeout=1.0) + + test_subject = f"test.drain.preferred.{uuid.uuid4()}" + + # Create subscription and publish messages + subscription = await client.subscribe(test_subject) + await client.flush() + + for i in range(5): + await client.publish(test_subject, f"message-{i}".encode()) + await client.flush() + + # Wait for messages to arrive + await asyncio.sleep(0.1) + + # Use drain instead of close - this is the preferred pattern + drain_task = asyncio.create_task(client.drain()) + + # Can still process pending messages during drain + messages = [] + try: + while True: + msg = await asyncio.wait_for(subscription.next(), timeout=0.5) + messages.append(msg.data.decode()) + except (RuntimeError, asyncio.TimeoutError): + pass + + await drain_task + + # Verify we processed messages before shutdown + assert len(messages) > 0 + assert client.status == ClientStatus.CLOSED + + # Note: No need to call close() after drain() - drain handles it + + +@pytest.mark.asyncio +async def test_statistics_initial_values(client): + """Test that statistics start at zero.""" + stats = client.stats() + + assert isinstance(stats, ClientStatistics) + assert stats.in_messages == 0 + assert stats.out_messages == 0 + assert stats.in_bytes == 0 + assert stats.out_bytes == 0 + assert stats.reconnects == 0 + + +@pytest.mark.asyncio +async def test_statistics_publish_counts(client): + """Test that publishing messages increments out_msgs and out_bytes.""" + await client.publish("test.subject", b"Hello") + await client.publish("test.subject", b"World!") + await client.flush() + + stats = client.stats() + assert stats.out_messages == 2 + assert stats.out_bytes == len(b"Hello") + len(b"World!") + + +@pytest.mark.asyncio +async def test_statistics_subscribe_counts(client): + """Test that receiving messages increments in_msgs and in_bytes.""" + sub = await client.subscribe("test.stats") + + await client.publish("test.stats", b"Test message") + await client.flush() + + msg = await sub.next(timeout=1.0) + assert msg.data == b"Test message" + + stats = client.stats() + assert stats.in_messages == 1 + assert stats.in_bytes == len(b"Test message") + assert stats.out_messages == 1 + + +@pytest.mark.asyncio +async def test_statistics_multiple_messages(client): + """Test statistics with multiple messages.""" + sub = await client.subscribe("test.multiple") + + messages = [b"Message 1", b"Message 2", b"Message 3"] + for msg_data in messages: + await client.publish("test.multiple", msg_data) + await client.flush() + + received = [] + for _ in range(len(messages)): + msg = await sub.next(timeout=1.0) + received.append(msg.data) + + assert received == messages + + stats = client.stats() + assert stats.in_messages == 3 + assert stats.out_messages == 3 + + total_bytes = sum(len(m) for m in messages) + assert stats.in_bytes == total_bytes + assert stats.out_bytes == total_bytes + + +@pytest.mark.asyncio +async def test_statistics_with_headers(client): + """Test that statistics count payload bytes, not protocol overhead.""" + sub = await client.subscribe("test.headers") + + payload = b"Test payload" + headers = {"X-Custom": "value"} + + await client.publish("test.headers", payload, headers=headers) + await client.flush() + + msg = await sub.next(timeout=1.0) + assert msg.data == payload + + stats = client.stats() + assert stats.out_bytes == len(payload) + assert stats.in_bytes == len(payload) + + +@pytest.mark.asyncio +async def test_statistics_request_reply(client): + """Test statistics with request/reply pattern.""" + sub = await client.subscribe("test.request") + + async def handle_request(): + msg = await sub.next(timeout=2.0) + await client.publish(msg.reply, b"Response") + + request_task = asyncio.create_task(handle_request()) + await asyncio.sleep(0.1) + + response = await client.request("test.request", b"Request", timeout=1.0) + assert response.data == b"Response" + + await request_task + + stats = client.stats() + assert stats.out_messages == 2 + assert stats.in_messages == 2 + assert stats.out_bytes == len(b"Request") + len(b"Response") + + +@pytest.mark.asyncio +async def test_statistics_snapshot(client): + """Test that stats() returns a snapshot, not a reference.""" + stats1 = client.stats() + + await client.publish("test.snapshot", b"Data") + await client.flush() + + stats2 = client.stats() + + assert stats1.out_messages == 0 + assert stats1.out_bytes == 0 + assert stats2.out_messages == 1 + assert stats2.out_bytes == len(b"Data") + + +@pytest.mark.asyncio +async def test_statistics_reconnect_counter(server): + """Test that reconnects are counted.""" + async with await connect(server.client_url, reconnect_time_wait=0.1) as client: + initial_stats = client.stats() + assert initial_stats.reconnects == 0 + + await client._connection.close() + await asyncio.sleep(0.5) + + stats = client.stats() + assert stats.reconnects >= 1 + + +@pytest.mark.asyncio +async def test_subscription_pending_messages_limit(client): + """Test that messages are dropped when pending_msgs_limit is exceeded.""" + from nats.client import SlowConsumerError + + test_subject = f"test.slow_consumer.msgs.{uuid.uuid4()}" + + # Track slow consumer errors + slow_consumer_errors = [] + + def on_error(error): + if isinstance(error, SlowConsumerError): + slow_consumer_errors.append(error) + + client.add_error_callback(on_error) + + # Create subscription with low message limit + subscription = await client.subscribe(test_subject, max_pending_messages=5) + await client.flush() + + # Publish more messages than the limit without consuming + num_messages = 20 + for i in range(num_messages): + await client.publish(test_subject, f"message-{i}".encode()) + await client.flush() + + # Wait for messages to arrive and trigger slow consumer + await asyncio.sleep(0.2) + + # Verify slow consumer error was triggered + assert len(slow_consumer_errors) == 1, "Should have received exactly one slow consumer error" + error = slow_consumer_errors[0] + assert error.subject == test_subject + assert error.pending_messages >= 5 + + # Verify pending count + pending_msgs, pending_bytes = subscription.pending + assert pending_msgs <= 5, f"Should not exceed limit of 5, got {pending_msgs}" + + # Consume available messages (should be approximately the limit) + consumed = 0 + while True: + try: + await asyncio.wait_for(subscription.next(), timeout=0.1) + consumed += 1 + except asyncio.TimeoutError: + break + + # Should have consumed around the limit, not all messages + assert consumed <= 6, f"Should have consumed around the limit, got {consumed}" + assert consumed < num_messages, "Should not have received all messages (some dropped)" + + +@pytest.mark.asyncio +async def test_subscription_pending_bytes_limit(client): + """Test that messages are dropped when pending_bytes_limit is exceeded.""" + from nats.client import SlowConsumerError + + test_subject = f"test.slow_consumer.bytes.{uuid.uuid4()}" + + # Track slow consumer errors + slow_consumer_errors = [] + + def on_error(error): + if isinstance(error, SlowConsumerError): + slow_consumer_errors.append(error) + + client.add_error_callback(on_error) + + # Create subscription with low byte limit (100 bytes) + subscription = await client.subscribe(test_subject, max_pending_bytes=100) + await client.flush() + + # Publish messages that will exceed the byte limit + # Each message is 50 bytes, so 3 messages = 150 bytes > 100 byte limit + large_message = b"x" * 50 + num_messages = 10 + for i in range(num_messages): + await client.publish(test_subject, large_message) + await client.flush() + + # Wait for messages to arrive and trigger slow consumer + await asyncio.sleep(0.2) + + # Verify slow consumer error was triggered + assert len(slow_consumer_errors) == 1, "Should have received exactly one slow consumer error" + error = slow_consumer_errors[0] + assert error.subject == test_subject + assert error.pending_bytes <= 150, "Pending bytes should be near limit" + + # Verify pending count + pending_msgs, pending_bytes = subscription.pending + assert pending_bytes <= 150, f"Should not far exceed limit, got {pending_bytes}" + + # Consume available messages + consumed = 0 + while True: + try: + await asyncio.wait_for(subscription.next(), timeout=0.1) + consumed += 1 + except asyncio.TimeoutError: + break + + # Should have consumed only a few messages, not all + assert consumed < num_messages, "Should not have received all messages (some dropped)" + + +@pytest.mark.asyncio +async def test_slow_consumer_error_only_once(client): + """Test that slow consumer error is only reported once per slow event.""" + from nats.client import SlowConsumerError + + test_subject = f"test.slow_consumer.once.{uuid.uuid4()}" + + # Track slow consumer errors + slow_consumer_errors = [] + + def on_error(error): + if isinstance(error, SlowConsumerError): + slow_consumer_errors.append(error) + + client.add_error_callback(on_error) + + # Create subscription with low limit + await client.subscribe(test_subject, max_pending_messages=5) + await client.flush() + + # Publish many messages to trigger slow consumer multiple times + for i in range(50): + await client.publish(test_subject, f"message-{i}".encode()) + await client.flush() + await asyncio.sleep(0.01) # Small delay to ensure messages are processed + + # Wait for processing + await asyncio.sleep(0.2) + + # Should only get ONE slow consumer error, not multiple + assert len(slow_consumer_errors) == 1, ( + f"Should have received exactly one slow consumer error, got {len(slow_consumer_errors)}" + ) + + +@pytest.mark.asyncio +async def test_slow_consumer_flag_resets_when_under_limit(client): + """Test that slow consumer flag resets when pending count drops below limit.""" + from nats.client import SlowConsumerError + + test_subject = f"test.slow_consumer.reset.{uuid.uuid4()}" + + # Track slow consumer errors + slow_consumer_errors = [] + + def on_error(error): + if isinstance(error, SlowConsumerError): + slow_consumer_errors.append(error) + + client.add_error_callback(on_error) + + # Create subscription with low limit + subscription = await client.subscribe(test_subject, max_pending_messages=3) + await client.flush() + + # Publish messages to trigger slow consumer + for i in range(10): + await client.publish(test_subject, f"message-{i}".encode()) + await client.flush() + await asyncio.sleep(0.1) + + # Should have triggered slow consumer + assert len(slow_consumer_errors) == 1 + + # Consume messages to get below limit + for _ in range(3): + try: + await asyncio.wait_for(subscription.next(), timeout=0.5) + except asyncio.TimeoutError: + break + + # Wait a bit + await asyncio.sleep(0.1) + + # Publish more messages to trigger slow consumer again + for i in range(10): + await client.publish(test_subject, f"message2-{i}".encode()) + await client.flush() + await asyncio.sleep(0.1) + + # Should have triggered slow consumer a SECOND time (flag was reset) + assert len(slow_consumer_errors) == 2, ( + f"Expected 2 slow consumer errors after reset, got {len(slow_consumer_errors)}" + ) + + +@pytest.mark.asyncio +async def test_unlimited_pending_with_none_limit(client): + """Test that None limit means unlimited pending messages.""" + test_subject = f"test.unlimited.{uuid.uuid4()}" + + # Create subscription with unlimited limits (None) + subscription = await client.subscribe(test_subject, max_pending_messages=None, max_pending_bytes=None) + await client.flush() + + # Publish many messages + num_messages = 100 + for i in range(num_messages): + await client.publish(test_subject, f"message-{i}".encode()) + await client.flush() + + # Wait for all messages to arrive + await asyncio.sleep(0.3) + + # Consume all messages + consumed = 0 + while consumed < num_messages: + try: + await asyncio.wait_for(subscription.next(), timeout=1.0) + consumed += 1 + except asyncio.TimeoutError: + break + + # Should have received ALL messages (no limit) + assert consumed == num_messages, f"Expected {num_messages} messages, got {consumed}" + + +@pytest.mark.asyncio +async def test_subscription_pending_method(client): + """Test that pending() method returns correct counts.""" + test_subject = f"test.pending_method.{uuid.uuid4()}" + + subscription = await client.subscribe(test_subject) + await client.flush() + + # Initial pending should be zero + pending_msgs, pending_bytes = subscription.pending + assert pending_msgs == 0 + assert pending_bytes == 0 + + # Publish messages + messages = [b"message1", b"message22", b"message333"] + for msg in messages: + await client.publish(test_subject, msg) + await client.flush() + + # Wait for messages to arrive + await asyncio.sleep(0.1) + + # Check pending + pending_msgs, pending_bytes = subscription.pending + assert pending_msgs == len(messages) + expected_bytes = sum(len(m) for m in messages) + assert pending_bytes == expected_bytes + + # Consume one message + await subscription.next(timeout=1.0) + + # Check pending decreased + pending_msgs, pending_bytes = subscription.pending + assert pending_msgs == len(messages) - 1 + assert pending_bytes == expected_bytes - len(messages[0]) + + # Consume remaining + await subscription.next(timeout=1.0) + await subscription.next(timeout=1.0) + + # Check pending is zero again + pending_msgs, pending_bytes = subscription.pending + assert pending_msgs == 0 + assert pending_bytes == 0 + + +@pytest.mark.asyncio +async def test_slow_consumer_with_headers(client): + """Test that slow consumer correctly counts bytes for messages with headers.""" + from nats.client import SlowConsumerError + + test_subject = f"test.slow_consumer.headers.{uuid.uuid4()}" + + slow_consumer_errors = [] + + def on_error(error): + if isinstance(error, SlowConsumerError): + slow_consumer_errors.append(error) + + client.add_error_callback(on_error) + + # Create subscription with low byte limit + # Note: byte limit counts ONLY payload, not headers + subscription = await client.subscribe(test_subject, max_pending_bytes=100) + await client.flush() + + # Publish messages with headers + # Payload is 50 bytes, so 3 messages = 150 bytes > 100 byte limit + payload = b"x" * 50 + headers = {"X-Test": "value"} + + for i in range(10): + await client.publish(test_subject, payload, headers=headers) + await client.flush() + + # Wait for processing + await asyncio.sleep(0.2) + + # Should trigger slow consumer (counts payload bytes only) + assert len(slow_consumer_errors) == 1 + + # Consume available messages + consumed = 0 + while True: + try: + msg = await asyncio.wait_for(subscription.next(), timeout=0.1) + # Verify message has headers + assert msg.headers is not None + consumed += 1 + except asyncio.TimeoutError: + break + + # Should have dropped some messages + assert consumed < 10 + + +@pytest.mark.asyncio +async def test_subscription_default_limits(client): + """Test that default pending limits are applied.""" + test_subject = f"test.default_limits.{uuid.uuid4()}" + + # Create subscription with default limits + subscription = await client.subscribe(test_subject) + await client.flush() + + # Verify internal limits are set to defaults + # Default: 65536 messages, 64 MB + assert subscription._max_pending_messages == 65536 + assert subscription._max_pending_bytes == 67108864 # 64 * 1024 * 1024 + + +@pytest.mark.asyncio +async def test_subscription_messages_property(client): + """Test that subscription.messages property works for API compatibility with nats-py.""" + test_subject = f"test.messages_property.{uuid.uuid4()}" + + subscription = await client.subscribe(test_subject) + await client.flush() + + # Publish some messages + for i in range(3): + await client.publish(test_subject, f"message {i}".encode()) + await client.flush() + + # Use the messages property (nats-py style) + received = [] + async for msg in subscription.messages: + received.append(msg.data.decode()) + if len(received) == 3: + break + + assert received == ["message 0", "message 1", "message 2"] + + await subscription.unsubscribe() + + +@pytest.mark.asyncio +async def test_subscription_dropped_counters(client): + """Test that dropped message counters are updated when messages are dropped.""" + test_subject = f"test.dropped.{uuid.uuid4()}" + + # Create subscription with very low limits + subscription = await client.subscribe(test_subject, max_pending_messages=2, max_pending_bytes=100) + await client.flush() + + # Verify dropped counters start at zero + dropped_msgs, dropped_bytes = subscription.dropped + assert dropped_msgs == 0 + assert dropped_bytes == 0 + + # Publish enough messages to exceed limits + for i in range(10): + await client.publish(test_subject, b"test message") + await client.flush() + + # Wait for messages to arrive + await asyncio.sleep(0.1) + + # Verify some messages were dropped + dropped_msgs, dropped_bytes = subscription.dropped + assert dropped_msgs > 0, "Should have dropped some messages" + assert dropped_bytes > 0, "Should have dropped some bytes" + + # Verify pending is at or near limit + pending_msgs, pending_bytes = subscription.pending + assert pending_msgs <= 2, "Pending should not exceed limit" + + # Verify dropped count increases as we publish more + initial_dropped = dropped_msgs + for i in range(5): + await client.publish(test_subject, b"more messages") + await client.flush() + await asyncio.sleep(0.1) + + dropped_msgs, dropped_bytes = subscription.dropped + assert dropped_msgs > initial_dropped, "Dropped count should increase" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "nkey", + [ + pytest.param("SUAEIV5COV7ADQZE52WTYHVJQRV7WKJE5J7IBBJGATJTUUT2LVFGVXDPRQ", id="seed_string"), + pytest.param(nkey_seed_path(), id="seed_path"), + pytest.param(nkey_handlers(), id="handlers"), + ], +) +async def test_connect_with_nkey(nkey): + """Test that client can connect using NKey with all variants.""" + import os + + # Start server with NKey authentication + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_auth_nkey.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Connect using NKey + client = await connect(server.client_url, timeout=1.0, nkey=nkey) + assert client.status == ClientStatus.CONNECTED + assert client.server_info is not None + + # Verify we can publish and receive messages + test_subject = f"test.nkey.{uuid.uuid4()}" + subscription = await client.subscribe(test_subject) + await client.flush() + + await client.publish(test_subject, b"test") + await client.flush() + + msg = await subscription.next(timeout=1.0) + assert msg.data == b"test" + + await client.close() + finally: + await server.shutdown() + + +# JWT Authentication Tests +# These tests use a JWT-enabled NATS server with operator/account resolution +# to properly validate JWT authentication with challenge-response signatures. + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "jwt", + [ + pytest.param(jwt_creds_file(), id="creds_file"), + pytest.param(jwt_separate_files(), id="separate_files"), + pytest.param(jwt_credentials_strings(), id="credentials_strings"), + pytest.param(jwt_handlers(), id="handlers"), + ], +) +async def test_connect_with_jwt(jwt): + """Test connecting with JWT authentication using all variants.""" + import os + + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_auth_jwt.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + client = await connect(server.client_url, timeout=1.0, jwt=jwt, allow_reconnect=False) + + # Verify we can actually use the connection + await client.publish("test.subject", b"Hello JWT!") + await client.flush() + + await client.close() + + finally: + await server.shutdown() + + +@pytest.mark.asyncio +async def test_connect_with_jwt_bad_credentials(): + """Test that connecting with malformed JWT credentials file fails.""" + # Start server with JWT authentication + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_auth_jwt.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Use bad credentials file (missing seed section) + creds_path = Path(__file__).parent / "jwts" / "bad-user.creds" + + # Should raise ValueError when parsing malformed .creds file + with pytest.raises(ValueError, match="No seed found in credentials file"): + await connect(server.client_url, timeout=1.0, jwt=creds_path, allow_reconnect=False) + finally: + await server.shutdown() + + +@pytest.mark.asyncio +async def test_connect_with_jwt_request_response(): + """Test that request/response patterns work after connecting with JWT authentication.""" + import asyncio + import os + from pathlib import Path + + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_auth_jwt.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + creds_path = Path(__file__).parent / "jwts" / "foo-user.creds" + + client = await connect(server.client_url, timeout=1.0, jwt=creds_path, allow_reconnect=False) + + # Setup responder using subscription iterator + subscription = await client.subscribe("help") + + async def responder(): + async for msg in subscription: + await client.publish(msg.reply, b"OK!") + break # Only handle one message for this test + + # Start responder in background + responder_task = asyncio.create_task(responder()) + + await client.flush() + + # Send request and verify response + msg = await client.request("help", b"I need help", timeout=1.0) + assert msg.data == b"OK!" + + # Wait for responder to finish + await responder_task + + await client.close() + + finally: + await server.shutdown() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "jwt", + [ + pytest.param(jwt_creds_file(), id="creds_file"), + pytest.param(jwt_separate_files(), id="separate_files"), + pytest.param(jwt_credentials_strings(), id="credentials_strings"), + pytest.param(jwt_handlers(), id="handlers"), + ], +) +async def test_reconnect_with_jwt(jwt): + """Test that client can reconnect to a JWT server after disconnection with all JWT variants.""" + import asyncio + import os + + # Start server with JWT authentication + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_auth_jwt.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Events to track callback invocations + disconnect_event = asyncio.Event() + reconnect_event = asyncio.Event() + + # Connect client with JWT credentials and reconnection enabled + client = await connect( + server.client_url, + timeout=1.0, + jwt=jwt, + allow_reconnect=True, + reconnect_time_wait=0.1, + ) + + # Register callbacks + def on_disconnect(): + disconnect_event.set() + + def on_reconnect(): + reconnect_event.set() + + client.add_disconnected_callback(on_disconnect) + client.add_reconnected_callback(on_reconnect) + + # Verify client is working before disconnect + test_subject = f"test.reconnect.jwt.{uuid.uuid4()}" + subscription = await client.subscribe(test_subject) + await client.publish(test_subject, b"before disconnect") + await client.flush() + msg = await subscription.next(timeout=1.0) + assert msg.data == b"before disconnect" + + # Save the server port to reuse it after shutdown + server_port = server.port + + # Stop the server to trigger disconnect + await server.shutdown() + + # Wait for disconnect callback + await asyncio.wait_for(disconnect_event.wait(), timeout=2.0) + assert disconnect_event.is_set() + + # Start a new server on the same port with same auth config + new_server = await run(config_path=config_path, port=server_port, timeout=5.0) + try: + # Wait for reconnect callback + await asyncio.wait_for(reconnect_event.wait(), timeout=2.0) + assert reconnect_event.is_set() + + # Verify client works after reconnection with JWT preserved + await client.publish(test_subject, b"after reconnect") + await client.flush() + msg = await subscription.next(timeout=1.0) + assert msg.data == b"after reconnect" + finally: + await new_server.shutdown() + await client.close() + finally: + # Ensure original server is shutdown if still running + try: + await server.shutdown() + except Exception: + pass + + +@pytest.mark.asyncio +async def test_connect_with_jwt_file_parsing(): + """Test that JWT .creds file parsing correctly extracts JWT and seed.""" + from pathlib import Path + + from nats.client import _setup_jwt_auth + + jwts_dir = Path(__file__).parent / "jwts" + creds_path = jwts_dir / "foo-user.creds" + + # Test parsing .creds file + jwt_handler, sig_handler = _setup_jwt_auth(creds_path) + + # JWT handler should return the JWT + jwt_content = jwt_handler() + assert jwt_content.startswith(b"eyJ") # JWT format + + # Signature handler should be callable + assert callable(sig_handler) + + +@pytest.mark.asyncio +async def test_connect_with_nkey_and_jwt_precedence(): + """Test that when both nkey and jwt parameters are provided, jwt takes precedence.""" + import os + from pathlib import Path + + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_auth_nkey.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + jwts_dir = Path(__file__).parent / "jwts" + nkey_seed = "SUAEIV5COV7ADQZE52WTYHVJQRV7WKJE5J7IBBJGATJTUUT2LVFGVXDPRQ" + creds_path = jwts_dir / "foo-user.creds" + + # If both provided, JWT takes precedence + try: + client = await connect( + server.client_url, timeout=1.0, nkey=nkey_seed, jwt=creds_path, allow_reconnect=False + ) + await client.close() + except ConnectionError: + # Expected - JWT auth attempted (and failed on nkey server) + pass + + finally: + await server.shutdown() diff --git a/nats-client/tests/test_connection.py b/nats-client/tests/test_connection.py new file mode 100644 index 000000000..166b928db --- /dev/null +++ b/nats-client/tests/test_connection.py @@ -0,0 +1,203 @@ +"""Tests for connection module.""" + +import asyncio + +import pytest +from nats.client.connection import TcpConnection + + +async def create_echo_server_connection() -> tuple[TcpConnection, asyncio.Server]: + """Create a real connection using a local echo server. + + Returns: + Tuple of (TcpConnection, server) + """ + + async def handle_client(reader, writer): + """Echo server handler.""" + try: + while True: + data = await reader.read(4096) + if not data: + break + writer.write(data) + await writer.drain() + except Exception: + pass + finally: + writer.close() + await writer.wait_closed() + + server = await asyncio.start_server(handle_client, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + + reader, writer = await asyncio.open_connection("127.0.0.1", port) + connection = TcpConnection(reader, writer) + + return connection, server + + +async def test_tcp_connection_read_when_connected(): + """Test reading from a connected TCP connection.""" + connection, server = await create_echo_server_connection() + + try: + # Write some data to ourselves (echo server will return it) + await connection.write(b"test data") + result = await connection.read(9) + assert result == b"test data" + finally: + await connection.close() + server.close() + await server.wait_closed() + + +async def test_tcp_connection_read_when_not_connected(): + """Test reading from a disconnected TCP connection raises error.""" + connection, server = await create_echo_server_connection() + + try: + await connection.close() + + with pytest.raises(ConnectionError, match="Not connected"): + await connection.read(100) + finally: + server.close() + await server.wait_closed() + + +async def test_tcp_connection_write_when_connected(): + """Test writing to a connected TCP connection.""" + connection, server = await create_echo_server_connection() + + try: + # Should not raise + await connection.write(b"test data") + finally: + await connection.close() + server.close() + await server.wait_closed() + + +async def test_tcp_connection_write_when_not_connected(): + """Test writing to a disconnected TCP connection raises error.""" + connection, server = await create_echo_server_connection() + + try: + await connection.close() + + with pytest.raises(ConnectionError, match="Not connected"): + await connection.write(b"test data") + finally: + server.close() + await server.wait_closed() + + +async def test_tcp_connection_readline_when_connected(): + """Test reading a line from a connected TCP connection.""" + connection, server = await create_echo_server_connection() + + try: + await connection.write(b"test line\n") + result = await connection.readline() + assert result == b"test line\n" + finally: + await connection.close() + server.close() + await server.wait_closed() + + +async def test_tcp_connection_readline_when_not_connected(): + """Test reading a line from a disconnected TCP connection raises error.""" + connection, server = await create_echo_server_connection() + + try: + await connection.close() + + with pytest.raises(ConnectionError, match="Not connected"): + await connection.readline() + finally: + server.close() + await server.wait_closed() + + +async def test_tcp_connection_readexactly_when_connected(): + """Test reading exactly n bytes from a connected TCP connection.""" + connection, server = await create_echo_server_connection() + + try: + await connection.write(b"exactly5") + result = await connection.readexactly(8) + assert result == b"exactly5" + finally: + await connection.close() + server.close() + await server.wait_closed() + + +async def test_tcp_connection_readexactly_when_not_connected(): + """Test reading exactly n bytes from a disconnected TCP connection raises error.""" + connection, server = await create_echo_server_connection() + + try: + await connection.close() + + with pytest.raises(ConnectionError, match="Not connected"): + await connection.readexactly(5) + finally: + server.close() + await server.wait_closed() + + +async def test_tcp_connection_is_connected_when_connected(): + """Test is_connected returns True for an active TCP connection.""" + connection, server = await create_echo_server_connection() + + try: + assert connection.is_connected() is True + finally: + await connection.close() + server.close() + await server.wait_closed() + + +async def test_tcp_connection_is_connected_after_close(): + """Test is_connected returns False after closing TCP connection.""" + connection, server = await create_echo_server_connection() + + try: + await connection.close() + assert connection.is_connected() is False + finally: + server.close() + await server.wait_closed() + + +async def test_tcp_connection_close_sets_reader_and_writer_to_none(): + """Test that close properly cleans up TCP connection reader and writer.""" + connection, server = await create_echo_server_connection() + + try: + await connection.close() + assert connection._reader is None + assert connection._writer is None + finally: + server.close() + await server.wait_closed() + + +async def test_tcp_connection_close_when_already_closed(): + """Test that closing an already closed TCP connection is safe.""" + connection, server = await create_echo_server_connection() + + try: + await connection.close() + + # Close again - should not raise error + await connection.close() + + assert connection._reader is None + assert connection._writer is None + finally: + server.close() + await server.wait_closed() diff --git a/nats-client/tests/test_examples.py b/nats-client/tests/test_examples.py new file mode 100644 index 000000000..6e2a2c288 --- /dev/null +++ b/nats-client/tests/test_examples.py @@ -0,0 +1,263 @@ +"""Integration tests for example scripts. + +These tests verify that all example scripts work correctly. +""" + +import asyncio +import subprocess +import sys +from pathlib import Path + +import pytest +from nats.server import Server + + +@pytest.fixture +def examples_dir() -> Path: + """Get the examples directory path.""" + return Path(__file__).parent.parent / "examples" + + +@pytest.mark.asyncio +async def test_pub_sub_example(server: Server, examples_dir: Path): + """Test that nats-pub and nats-sub work together.""" + # Start a subscriber in the background + sub_proc = subprocess.Popen( + [ + sys.executable, + str(examples_dir / "nats-sub.py"), + "-s", + server.client_url, + "test.subject", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + try: + # Give subscriber time to connect + await asyncio.sleep(0.1) + + # Publish a message + pub_result = subprocess.run( + [ + sys.executable, + str(examples_dir / "nats-pub.py"), + "-s", + server.client_url, + "test.subject", + "Hello from test!", + ], + capture_output=True, + text=True, + timeout=5, + ) + + assert pub_result.returncode == 0 + assert "Published [test.subject]" in pub_result.stdout + + # Give subscriber time to receive + await asyncio.sleep(0.1) + + finally: + sub_proc.terminate() + sub_proc.wait(timeout=2) + + +@pytest.mark.asyncio +async def test_request_reply_example(server: Server, examples_dir: Path): + """Test that nats-req and nats-rply work together.""" + # Start a replier in the background + rply_proc = subprocess.Popen( + [ + sys.executable, + str(examples_dir / "nats-rply.py"), + "-s", + server.client_url, + "test.help", + "I can help!", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + try: + # Give replier time to connect + await asyncio.sleep(0.1) + + # Send a request + req_result = subprocess.run( + [ + sys.executable, + str(examples_dir / "nats-req.py"), + "-s", + server.client_url, + "test.help", + "What is NATS?", + ], + capture_output=True, + text=True, + timeout=5, + ) + + assert req_result.returncode == 0 + assert "Published [test.help]" in req_result.stdout + assert "I can help!" in req_result.stdout + + finally: + rply_proc.terminate() + rply_proc.wait(timeout=2) + + +@pytest.mark.asyncio +async def test_echo_example(server: Server, examples_dir: Path): + """Test that nats-echo works correctly.""" + # Start echo service in the background + echo_proc = subprocess.Popen( + [ + sys.executable, + str(examples_dir / "nats-echo.py"), + "-s", + server.client_url, + "-id", + "test-echo", + "echo.test", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + try: + # Give echo service time to connect + await asyncio.sleep(0.1) + + # Test echo functionality + echo_result = subprocess.run( + [ + sys.executable, + str(examples_dir / "nats-req.py"), + "-s", + server.client_url, + "echo.test", + "Echo this!", + ], + capture_output=True, + text=True, + timeout=5, + ) + + assert echo_result.returncode == 0 + assert "Echo this!" in echo_result.stdout + + # Test status endpoint + status_result = subprocess.run( + [ + sys.executable, + str(examples_dir / "nats-req.py"), + "-s", + server.client_url, + "echo.test.status", + "", + ], + capture_output=True, + text=True, + timeout=5, + ) + + assert status_result.returncode == 0 + assert "test-echo" in status_result.stdout + assert "echo_count" in status_result.stdout + + finally: + echo_proc.terminate() + echo_proc.wait(timeout=2) + + +@pytest.mark.asyncio +async def test_queue_example(server: Server, examples_dir: Path): + """Test that nats-qsub distributes messages across queue members.""" + # Start two queue subscribers + qsub1_proc = subprocess.Popen( + [ + sys.executable, + str(examples_dir / "nats-qsub.py"), + "-s", + server.client_url, + "test.queue", + "workers", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + qsub2_proc = subprocess.Popen( + [ + sys.executable, + str(examples_dir / "nats-qsub.py"), + "-s", + server.client_url, + "test.queue", + "workers", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + try: + # Give subscribers time to connect + await asyncio.sleep(0.1) + + # Publish multiple messages + for i in range(10): + pub_result = subprocess.run( + [ + sys.executable, + str(examples_dir / "nats-pub.py"), + "-s", + server.client_url, + "test.queue", + f"Message {i + 1}", + ], + capture_output=True, + text=True, + timeout=5, + ) + assert pub_result.returncode == 0 + + # Give subscribers time to receive messages + await asyncio.sleep(0.2) + + finally: + qsub1_proc.terminate() + qsub2_proc.terminate() + qsub1_proc.wait(timeout=2) + qsub2_proc.wait(timeout=2) + + +@pytest.mark.asyncio +async def test_examples_help_text(examples_dir: Path): + """Test that all examples have working --help.""" + examples = [ + "nats-pub.py", + "nats-sub.py", + "nats-qsub.py", + "nats-req.py", + "nats-rply.py", + "nats-echo.py", + ] + + for example in examples: + result = subprocess.run( + [sys.executable, str(examples_dir / example), "--help"], + capture_output=True, + text=True, + timeout=5, + ) + assert result.returncode == 0 + assert "usage:" in result.stdout.lower() + assert "--server" in result.stdout or "-s" in result.stdout diff --git a/nats-client/tests/test_message.py b/nats-client/tests/test_message.py new file mode 100644 index 000000000..aa4af4b43 --- /dev/null +++ b/nats-client/tests/test_message.py @@ -0,0 +1,251 @@ +"""Tests for message module.""" + +import pytest +from nats.client.message import Headers, Status + + +def test_headers_init(): + """Test Headers initialization.""" + # Single string value gets converted to a single-item list + headers = Headers({"key1": "value1"}) + assert headers.get("key1") == "value1" + assert headers.get_all("key1") == ["value1"] + + # List value stays as list + headers = Headers({"key2": ["value2", "value3"]}) + assert headers.get("key2") == "value2" + assert headers.get_all("key2") == ["value2", "value3"] + + # Mixed values + headers = Headers({"key1": "value1", "key2": ["value2", "value3"]}) + assert headers.get("key1") == "value1" + assert headers.get_all("key1") == ["value1"] + assert headers.get("key2") == "value2" + assert headers.get_all("key2") == ["value2", "value3"] + + # Invalid header values + with pytest.raises(TypeError): + Headers({"key1": 123}) # type: ignore[dict-item] + + with pytest.raises(ValueError): + Headers({"key1": ["value1", 123]}) # type: ignore[list-item] + + +def test_headers_get(): + """Test Headers.get() method.""" + # Single string value + headers = Headers({"key1": "value1"}) + assert headers.get("key1") == "value1" + + # First value from list + headers = Headers({"key2": ["value2", "value3"]}) + assert headers.get("key2") == "value2" + + # Empty list returns None + headers = Headers({"key3": []}) + assert headers.get("key3") is None + + # Non-existent key returns None + assert headers.get("nonexistent") is None + + +def test_headers_get_all(): + """Test Headers.get_all() method.""" + # Single string value becomes a list + headers = Headers({"key1": "value1"}) + assert headers.get_all("key1") == ["value1"] + + # List remains as is + headers = Headers({"key2": ["value2", "value3"]}) + assert headers.get_all("key2") == ["value2", "value3"] + + # Empty list stays empty + headers = Headers({"key3": []}) + assert headers.get_all("key3") == [] + + # Non-existent key returns empty list + assert headers.get_all("nonexistent") == [] + + +def test_headers_equality(): + """Test Headers equality comparison.""" + headers1 = Headers({"key1": "value1", "key2": ["value2", "value3"]}) + headers2 = Headers({"key1": "value1", "key2": ["value2", "value3"]}) + headers3 = Headers({"key1": "different", "key2": ["value2", "value3"]}) + + assert headers1 == headers2 + assert headers1 != headers3 + assert headers1 != "not a headers object" + + +def test_status_creation(): + """Test creating Status objects.""" + # Test with code only + status = Status(code="200") + assert status.code == "200" + assert status.description is None + + # Test with code and description + status = Status(code="503", description="No Responders") + assert status.code == "503" + assert status.description == "No Responders" + + +def test_status_string_representation(): + """Test Status string conversion.""" + # With description + status = Status(code="503", description="No Responders") + assert str(status) == "503: No Responders" + + # Without description + status = Status(code="200") + assert str(status) == "200" + + # Empty description should be treated as None + status = Status(code="400", description="") + assert str(status) == "400" + + +def test_status_equality(): + """Test Status equality comparison.""" + status1 = Status(code="503", description="No Responders") + status2 = Status(code="503", description="No Responders") + status3 = Status(code="503", description="Service Unavailable") + status4 = Status(code="400", description="No Responders") + + # Same code and description should be equal + assert status1 == status2 + + # Different description should not be equal + assert status1 != status3 + + # Different code should not be equal + assert status1 != status4 + + # Should not be equal to non-Status objects + assert status1 != "503: No Responders" + assert status1 != 503 + + +def test_status_common_codes(): + """Test common status codes.""" + # Success + success = Status(code="200", description="OK") + assert str(success) == "200: OK" + + # Bad Request + bad_request = Status(code="400", description="Bad Request") + assert str(bad_request) == "400: Bad Request" + + # No Responders + no_responders = Status(code="503", description="No Responders") + assert str(no_responders) == "503: No Responders" + + # Server Error + server_error = Status(code="500", description="Internal Server Error") + assert str(server_error) == "500: Internal Server Error" + + +def test_headers_set(): + """Test Headers.set() method.""" + # Set a new header + headers = Headers({}) + headers.set("key1", "value1") + assert headers.get("key1") == "value1" + assert headers.get_all("key1") == ["value1"] + + # Replace existing header + headers.set("key1", "new_value") + assert headers.get("key1") == "new_value" + assert headers.get_all("key1") == ["new_value"] + + # Replace header with multiple values + headers = Headers({"key2": ["value1", "value2", "value3"]}) + headers.set("key2", "single_value") + assert headers.get("key2") == "single_value" + assert headers.get_all("key2") == ["single_value"] + + # Case-sensitive: different keys + headers = Headers({"key1": "lowercase", "Key1": "uppercase"}) + assert headers.get("key1") == "lowercase" + assert headers.get("Key1") == "uppercase" + headers.set("key1", "new_lowercase") + assert headers.get("key1") == "new_lowercase" + assert headers.get("Key1") == "uppercase" + + +def test_headers_delete(): + """Test Headers.delete() method.""" + # Delete existing header + headers = Headers({"key1": "value1", "key2": "value2"}) + headers.delete("key1") + assert headers.get("key1") is None + assert headers.get("key2") == "value2" + + # Delete non-existent header (should not raise error) + headers.delete("nonexistent") + assert headers.get("key2") == "value2" + + # Delete header with multiple values + headers = Headers({"key3": ["value1", "value2", "value3"]}) + headers.delete("key3") + assert headers.get("key3") is None + assert headers.get_all("key3") == [] + + # Case-sensitive: only deletes exact match + headers = Headers({"key1": "value1", "Key1": "value2"}) + headers.delete("key1") + assert headers.get("key1") is None + assert headers.get("Key1") == "value2" + + +def test_headers_append(): + """Test Headers.append() method.""" + # Append to non-existent header (creates new) + headers = Headers({}) + headers.append("key1", "value1") + assert headers.get("key1") == "value1" + assert headers.get_all("key1") == ["value1"] + + # Append to existing header + headers.append("key1", "value2") + assert headers.get("key1") == "value1" # get returns first value + assert headers.get_all("key1") == ["value1", "value2"] + + # Append multiple times + headers.append("key1", "value3") + assert headers.get_all("key1") == ["value1", "value2", "value3"] + + # Case-sensitive: different keys + headers.append("Key1", "uppercase") + assert headers.get_all("Key1") == ["uppercase"] + assert headers.get_all("key1") == ["value1", "value2", "value3"] + + # Append preserves case of existing key + headers = Headers({"Content-Type": "application/json"}) + headers.append("Content-Type", "text/plain") + assert headers.get_all("Content-Type") == ["application/json", "text/plain"] + + +def test_headers_operations_integration(): + """Test combining set, delete, and append operations.""" + headers = Headers({}) + + # Build headers using operations + headers.set("X-Custom", "value1") + headers.append("X-Custom", "value2") + headers.set("Authorization", "Bearer token") + headers.append("Accept", "application/json") + headers.append("Accept", "text/plain") + + assert headers.get_all("X-Custom") == ["value1", "value2"] + assert headers.get("Authorization") == "Bearer token" + assert headers.get_all("Accept") == ["application/json", "text/plain"] + + # Delete one header + headers.delete("Authorization") + assert headers.get("Authorization") is None + + # Set replaces multi-value header + headers.set("Accept", "application/xml") + assert headers.get_all("Accept") == ["application/xml"] diff --git a/nats-client/tests/test_protocol.py b/nats-client/tests/test_protocol.py new file mode 100644 index 000000000..bbd2220f7 --- /dev/null +++ b/nats-client/tests/test_protocol.py @@ -0,0 +1,367 @@ +"""Tests for NATS protocol message parsing and command encoding.""" + +import asyncio +import json + +import pytest +from nats.client.protocol.command import ( + encode_connect, + encode_hpub, + encode_ping, + encode_pong, + encode_pub, + encode_sub, + encode_unsub, +) +from nats.client.protocol.message import ( + ParseError, + parse_headers, + parse_hmsg, + parse_msg, +) +from nats.client.protocol.types import ConnectInfo + + +@pytest.mark.asyncio +async def test_parse_msg(): + """Test parsing MSG messages.""" + # Test valid MSG without reply + reader = asyncio.StreamReader() + reader.feed_data(b"hello\r\n") + reader.feed_eof() + + msg = await parse_msg(reader, [b"foo.bar", b"1", b"5"]) + assert msg.subject == "foo.bar" + assert msg.sid == "1" + assert msg.reply is None + assert msg.payload == b"hello" + + # Test valid MSG with reply + reader = asyncio.StreamReader() + reader.feed_data(b"hello\r\n") + reader.feed_eof() + + msg = await parse_msg(reader, [b"foo.bar", b"1", b"reply.to", b"5"]) + assert msg.subject == "foo.bar" + assert msg.sid == "1" + assert msg.reply == "reply.to" + assert msg.payload == b"hello" + + # Test invalid size + reader = asyncio.StreamReader() + with pytest.raises(ValueError): + await parse_msg(reader, [b"foo.bar", b"1", b"invalid"]) + + # Test not enough arguments + reader = asyncio.StreamReader() + with pytest.raises(ParseError, match="Invalid MSG: not enough arguments"): + await parse_msg(reader, [b"foo.bar", b"1"]) + + # Test payload too large + reader = asyncio.StreamReader() + with pytest.raises(ParseError, match="Payload too large"): + await parse_msg(reader, [b"foo.bar", b"1", b"67108865"]) + + +@pytest.mark.asyncio +async def test_parse_hmsg(): + """Test parsing HMSG messages.""" + # Test valid HMSG + reader = asyncio.StreamReader() + header_data = b"NATS/1.0\r\n\r\n" + payload = b"hello" + reader.feed_data(header_data + payload + b"\r\n") + reader.feed_eof() + + header_size = len(header_data) + total_size = header_size + len(payload) + msg = await parse_hmsg(reader, [b"foo.bar", b"1", b"reply.to", str(header_size).encode(), str(total_size).encode()]) + assert msg.subject == "foo.bar" + assert msg.sid == "1" + assert msg.reply == "reply.to" + assert msg.payload == b"hello" + assert msg.headers == {} + + # Test invalid sizes + reader = asyncio.StreamReader() + with pytest.raises(ValueError): + await parse_hmsg(reader, [b"foo.bar", b"1", b"reply.to", b"invalid", b"52"]) + + # Test header size too large + reader = asyncio.StreamReader() + with pytest.raises(ParseError, match="Headers too large"): + await parse_hmsg(reader, [b"foo.bar", b"1", b"reply.to", b"65537", b"65538"]) + + # Test total size too large + reader = asyncio.StreamReader() + with pytest.raises(ParseError, match="Total message too large"): + await parse_hmsg(reader, [b"foo.bar", b"1", b"reply.to", b"10", b"67108865"]) + + # Test not enough arguments + reader = asyncio.StreamReader() + with pytest.raises(ParseError, match="Invalid HMSG: not enough arguments"): + await parse_hmsg(reader, [b"foo.bar", b"1", b"10"]) + + +def test_parse_headers(): + """Test parsing message headers.""" + # Test valid headers + header_data = b"NATS/1.0\r\nfoo: bar\r\nmulti: val1\r\nmulti: val2\r\n\r\n" + headers, status_code, status_description = parse_headers(header_data) + assert headers == { + "foo": ["bar"], + "multi": ["val1", "val2"], + } + assert status_code is None + assert status_description is None + + # Test headers with status + header_data_with_status = b"NATS/1.0 503 No Responders\r\nfoo: bar\r\n\r\n" + headers, status_code, status_description = parse_headers(header_data_with_status) + assert headers == {"foo": ["bar"]} + assert status_code == "503" + assert status_description == "No Responders" + + # Test status only (no headers) + status_data = b"NATS/1.0 503\r\n\r\n" + headers, status_code, status_description = parse_headers(status_data) + assert headers == {} + assert status_code == "503" + assert status_description is None + + # Test missing version + with pytest.raises(ParseError, match="Invalid header format"): + parse_headers(b"foo: bar\r\n\r\n") + + # Test invalid encoding + with pytest.raises(ParseError, match="Invalid header encoding"): + parse_headers(b"\xff\xff") + + +def test_encode_connect(): + """Test encoding CONNECT command.""" + info = ConnectInfo( + verbose=False, + pedantic=False, + tls_required=False, + lang="python", + version="0.1.0", + protocol=1, + echo=True, + no_responders=False, + headers=True, + ) + command = encode_connect(info) + assert command.startswith(b"CONNECT {") + assert command.endswith(b"}\r\n") + + # Verify JSON is valid + json_str = command[8:-2].decode() # Remove CONNECT and \r\n + data = json.loads(json_str) + assert data["lang"] == "python" + assert data["version"] == "0.1.0" + assert data["protocol"] == 1 + assert data["headers"] is True + + +def test_encode_pub(): + """Test encoding PUB command.""" + # Test without reply + command = encode_pub(b"foo.bar", b"hello") + assert command == b"PUB foo.bar 5\r\nhello\r\n" + + # Test with reply + command = encode_pub(b"foo.bar", b"hello", reply=b"reply.to") + assert command == b"PUB foo.bar reply.to 5\r\nhello\r\n" + + +def test_encode_hpub(): + """Test encoding HPUB command.""" + headers = {"foo": "bar", "multi": ["val1", "val2"]} + payload = b"hello" + + # Test without reply + command = encode_hpub(b"foo.bar", payload, headers=headers) + assert isinstance(command, bytes) + assert command.startswith(b"HPUB foo.bar") + assert b"NATS/1.0\r\n" in command + assert command.endswith(b"hello\r\n") + # Verify headers are present + assert b"foo: bar" in command + assert b"multi: val1" in command + assert b"multi: val2" in command + + # Test with reply + command = encode_hpub(b"foo.bar", payload, reply=b"reply.to", headers=headers) + assert isinstance(command, bytes) + assert command.startswith(b"HPUB foo.bar reply.to") + assert b"NATS/1.0\r\n" in command + assert command.endswith(b"hello\r\n") + # Verify headers are present + assert b"foo: bar" in command + assert b"multi: val1" in command + assert b"multi: val2" in command + + +def test_encode_sub(): + """Test encoding SUB command.""" + # Test without queue group + command = encode_sub("foo.bar", "1") + assert command == b"SUB foo.bar 1\r\n" + + # Test with queue group + command = encode_sub("foo.bar", "1", "queue") + assert command == b"SUB foo.bar queue 1\r\n" + + +def test_encode_unsub(): + """Test encoding UNSUB command.""" + # Test without max messages + command = encode_unsub("1") + assert command == b"UNSUB 1\r\n" + + # Test with max messages + command = encode_unsub("1", 5) + assert command == b"UNSUB 1 5\r\n" + + +def test_encode_ping(): + """Test encoding PING command.""" + command = encode_ping() + assert command == b"PING\r\n" + + +def test_encode_pong(): + """Test encoding PONG command.""" + command = encode_pong() + assert command == b"PONG\r\n" + + +def test_parse_headers_unicode_error(): + """Test parsing headers with invalid UTF-8.""" + # Invalid UTF-8 in headers + with pytest.raises(ParseError, match="Invalid header encoding"): + parse_headers(b"NATS/1.0\r\n\xff\xfe\r\n\r\n") + + +def test_parse_headers_missing_colon(): + """Test parsing header line without colon.""" + with pytest.raises(ParseError, match="Invalid header line"): + parse_headers(b"NATS/1.0\r\nInvalidHeaderLine\r\n\r\n") + + +@pytest.mark.asyncio +async def test_parse_info_missing_json(): + """Test parsing INFO message without JSON data.""" + from nats.client.protocol.message import parse_info + + with pytest.raises(ParseError, match="INFO message missing JSON data"): + await parse_info([]) + + +@pytest.mark.asyncio +async def test_parse_info_invalid_json(): + """Test parsing INFO message with invalid JSON.""" + from nats.client.protocol.message import parse_info + + with pytest.raises(ParseError, match="Invalid INFO JSON"): + await parse_info([b"not-valid-json"]) + + +@pytest.mark.asyncio +async def test_parse_err_missing_text(): + """Test parsing ERR message without error text.""" + from nats.client.protocol.message import parse_err + + with pytest.raises(ParseError, match="ERR message missing error text"): + await parse_err([]) + + +@pytest.mark.asyncio +async def test_parse_err_with_quotes(): + """Test parsing ERR message with quoted text.""" + from nats.client.protocol.message import parse_err + + err = await parse_err([b"'Permission", b"Denied'"]) + assert err.op == "ERR" + assert err.error == "Permission Denied" + + +@pytest.mark.asyncio +async def test_parse_err_without_quotes(): + """Test parsing ERR message without quotes.""" + from nats.client.protocol.message import parse_err + + err = await parse_err([b"Unknown", b"Protocol", b"Error"]) + assert err.op == "ERR" + assert err.error == "Unknown Protocol Error" + + +@pytest.mark.asyncio +async def test_parse_ping_message(): + """Test parsing PING message through parse() function.""" + from nats.client.protocol.message import parse + + reader = asyncio.StreamReader() + reader.feed_data(b"PING\r\n") + reader.feed_eof() + + msg = await parse(reader) + assert msg is not None + assert msg.op == "PING" + + +@pytest.mark.asyncio +async def test_parse_pong_message(): + """Test parsing PONG message through parse() function.""" + from nats.client.protocol.message import parse + + reader = asyncio.StreamReader() + reader.feed_data(b"PONG\r\n") + reader.feed_eof() + + msg = await parse(reader) + assert msg is not None + assert msg.op == "PONG" + + +@pytest.mark.asyncio +async def test_parse_unknown_operation(): + """Test parsing unknown operation raises ParseError.""" + from nats.client.protocol.message import parse + + reader = asyncio.StreamReader() + reader.feed_data(b"UNKNOWN\r\n") + reader.feed_eof() + + with pytest.raises(ParseError, match="Unknown operation"): + await parse(reader) + + +@pytest.mark.asyncio +async def test_parse_control_line_too_long(): + """Test parsing control line that exceeds max length.""" + from nats.client.protocol.message import parse + + reader = asyncio.StreamReader() + # Create a control line longer than MAX_CONTROL_LINE (4096) + reader.feed_data(b"MSG " + b"x" * 4096 + b"\r\n") + reader.feed_eof() + + with pytest.raises(ParseError, match="Control line too long"): + await parse(reader) + + +@pytest.mark.asyncio +async def test_parse_err_message(): + """Test parsing ERR message through parse() function.""" + from nats.client.protocol.message import parse + + reader = asyncio.StreamReader() + reader.feed_data(b"ERR 'Unknown Protocol'\r\n") + reader.feed_eof() + + msg = await parse(reader) + assert msg is not None + assert msg.op == "ERR" + assert msg.error == "Unknown Protocol" diff --git a/nats-client/tests/test_subscription.py b/nats-client/tests/test_subscription.py new file mode 100644 index 000000000..d6ef17071 --- /dev/null +++ b/nats-client/tests/test_subscription.py @@ -0,0 +1,1267 @@ +import asyncio +import uuid + +import pytest +from nats.client import ClientStatus, connect +from nats.server import run + + +@pytest.mark.asyncio +async def test_subscription_receives_messages(client): + """Test that a subscription receives messages published to its subject.""" + test_subject = f"test.{uuid.uuid4()}" + test_message = b"Hello, NATS!" + + subscription = await client.subscribe(test_subject) + await client.flush() # Ensure subscription is registered + + await client.publish(test_subject, test_message) + await client.flush() + + message = await subscription.next(timeout=1.0) + assert message.data == test_message + + +@pytest.mark.asyncio +async def test_subscription_with_queue_receives_subset_of_messages_different_clients(server): + """Test that subscriptions from different clients with queue group receives only a subset of messages.""" + # Create two clients + client1 = await connect(server.client_url, timeout=1.0) + client2 = await connect(server.client_url, timeout=1.0) + + try: + test_subject = f"test.queue.{uuid.uuid4()}" + queue = "test_queue" + message_count = 20 # Send enough messages to ensure distribution + + # Set up subscriptions with the same queue group + sub1 = await client1.subscribe(test_subject, queue=queue) + sub2 = await client2.subscribe(test_subject, queue=queue) + await client1.flush() + await client2.flush() # Ensure subscriptions are registered + + # Add small delay to ensure subscriptions are fully registered on server + await asyncio.sleep(0.1) + + # Publish messages + for i in range(message_count): + await client1.publish(test_subject, f"Message {i}".encode()) + await client1.flush() + + # Count messages received by each subscription + msg_count1 = 0 + msg_count2 = 0 + + # Try to collect counts with timeout - use a longer timeout and better collection strategy + # Collect all available messages with a reasonable timeout + total_timeout = 3.0 + start_time = asyncio.get_event_loop().time() + + while ( + msg_count1 + msg_count2 < message_count and (asyncio.get_event_loop().time() - start_time) < total_timeout + ): + try: + await asyncio.wait_for(sub1.next(), 0.1) + msg_count1 += 1 + except asyncio.TimeoutError: + pass + + try: + await asyncio.wait_for(sub2.next(), 0.1) + msg_count2 += 1 + except asyncio.TimeoutError: + pass + + # Each subscription should receive fewer than all messages + assert msg_count1 < message_count + assert msg_count2 < message_count + + # But together they should receive most or all messages + assert msg_count1 + msg_count2 >= message_count * 0.8 + finally: + await client1.close() + await client2.close() + + +@pytest.mark.asyncio +async def test_subscription_with_queue_receives_subset_of_messages_same_client(client): + """Test that subscriptions from the same client with queue group receives only a subset of messages.""" + test_subject = f"test.queue_same_client.{uuid.uuid4()}" + queue = "test_queue_same_client" + message_count = 20 # Send enough messages to ensure distribution + + # Set up subscriptions with the same queue group from the same client + sub1 = await client.subscribe(test_subject, queue=queue) + sub2 = await client.subscribe(test_subject, queue=queue) + await client.flush() # Ensure subscriptions are registered + + # Add small delay to ensure subscriptions are fully registered on server + await asyncio.sleep(0.1) + + # Publish messages + for i in range(message_count): + await client.publish(test_subject, f"Message {i}".encode()) + await client.flush() + + # Count messages received by each subscription + msg_count1 = 0 + msg_count2 = 0 + + # Try to collect counts with timeout - use a longer timeout and better collection strategy + # Collect all available messages with a reasonable timeout + total_timeout = 3.0 + start_time = asyncio.get_event_loop().time() + + while msg_count1 + msg_count2 < message_count and (asyncio.get_event_loop().time() - start_time) < total_timeout: + try: + await asyncio.wait_for(sub1.next(), 0.1) + msg_count1 += 1 + except asyncio.TimeoutError: + pass + + try: + await asyncio.wait_for(sub2.next(), 0.1) + msg_count2 += 1 + except asyncio.TimeoutError: + pass + + # Each subscription should receive fewer than all messages + assert msg_count1 < message_count + assert msg_count2 < message_count + + # But together they should receive most or all messages + assert msg_count1 + msg_count2 >= message_count * 0.8 + + +@pytest.mark.asyncio +async def test_subscription_without_queue_receives_all_messages_different_clients(server): + """Test that multiple subscriptions from different clients without queue groups each receive all messages.""" + # Create two clients + client1 = await connect(server.client_url, timeout=1.0) + client2 = await connect(server.client_url, timeout=1.0) + + try: + test_subject = f"test.no_queue.{uuid.uuid4()}" + message_count = 5 + + # Set up subscriptions without queue group + sub1 = await client1.subscribe(test_subject) + sub2 = await client2.subscribe(test_subject) + await client1.flush() + await client2.flush() + + # Add small delay to ensure subscriptions are fully registered on server + await asyncio.sleep(0.1) + + # Publish messages + for i in range(message_count): + await client1.publish(test_subject, f"Message {i}".encode()) + + # Ensure all messages are published + await client1.flush() + + # Collect all messages from both subscriptions + messages1 = [] + messages2 = [] + + # Collect messages with timeout + try: + for _ in range(message_count): + message1 = await sub1.next(timeout=3.0) + messages1.append(message1.data) + except asyncio.TimeoutError: + pass + + try: + for _ in range(message_count): + message2 = await sub2.next(timeout=3.0) + messages2.append(message2.data) + except asyncio.TimeoutError: + pass + + # Both subscriptions should receive all messages + assert len(messages1) == message_count, f"sub1 received {len(messages1)} messages, expected {message_count}" + assert len(messages2) == message_count, f"sub2 received {len(messages2)} messages, expected {message_count}" + + # Both should receive the same set of messages (order may vary) + assert set(messages1) == set(messages2) + finally: + await client1.close() + await client2.close() + + +@pytest.mark.asyncio +async def test_subscription_without_queue_receives_all_messages_same_client(client): + """Test that multiple subscriptions from the same client without queue groups each receive all messages.""" + test_subject = f"test.no_queue_same_client.{uuid.uuid4()}" + message_count = 5 + + # Set up two subscriptions from the same client without queue group + sub1 = await client.subscribe(test_subject) + sub2 = await client.subscribe(test_subject) + await client.flush() + + # Add small delay to ensure subscriptions are fully registered on server + await asyncio.sleep(0.1) + + # Publish messages + for i in range(message_count): + await client.publish(test_subject, f"Message {i}".encode()) + + # Ensure all messages are published + await client.flush() + + # Collect all messages from both subscriptions + messages1 = [] + messages2 = [] + + # Collect messages with timeout + try: + for _ in range(message_count): + message1 = await sub1.next(timeout=3.0) + messages1.append(message1.data) + except asyncio.TimeoutError: + pass + + try: + for _ in range(message_count): + message2 = await sub2.next(timeout=3.0) + messages2.append(message2.data) + except asyncio.TimeoutError: + pass + + # Both subscriptions should receive all messages + assert len(messages1) == message_count, f"sub1 received {len(messages1)} messages, expected {message_count}" + assert len(messages2) == message_count, f"sub2 received {len(messages2)} messages, expected {message_count}" + + # Both should receive the same set of messages (order may vary) + assert set(messages1) == set(messages2) + + +@pytest.mark.asyncio +async def test_subscription_star_wildcard_receives_matching_messages(client): + """Test that a subscription with * wildcard receives messages for matching subjects.""" + # Create base subject and variants + base = f"test.wild.{uuid.uuid4()}" + subject1 = f"{base}.foo" + subject2 = f"{base}.bar" + subject3 = f"{base}.foo.bar" # Should not match with * + + # Subscribe with * wildcard (matches single token) + subscription = await client.subscribe(f"{base}.*") + await client.flush() + + # Publish messages to different subjects + await client.publish(subject1, b"Message 1") + await client.publish(subject2, b"Message 2") + await client.publish(subject3, b"Message 3") + await client.flush() + + # Should receive messages for subject1 and subject2 only + received_subjects = set() + try: + while True: + message = await asyncio.wait_for(subscription.next(), 0.5) + received_subjects.add(message.subject) + except asyncio.TimeoutError: + pass + + assert subject1 in received_subjects + assert subject2 in received_subjects + assert subject3 not in received_subjects + assert len(received_subjects) == 2 + + +@pytest.mark.asyncio +async def test_subscription_greater_than_wildcard_receives_all_matching(client): + """Test that subscription with > wildcard receives all matching hierarchical messages.""" + # Create base subject and variants + base = f"test.wild.{uuid.uuid4()}" + subject1 = f"{base}.foo" + subject2 = f"{base}.bar" + subject3 = f"{base}.foo.bar" # Should match with > + + # Subscribe with > wildcard (matches all remaining tokens) + subscription = await client.subscribe(f"{base}.>") + await client.flush() + + # Publish messages to different subjects + await client.publish(subject1, b"Message 1") + await client.publish(subject2, b"Message 2") + await client.publish(subject3, b"Message 3") + await client.flush() + + # Should receive all messages + received_subjects = set() + try: + while True: + message = await asyncio.wait_for(subscription.next(), 0.5) + received_subjects.add(message.subject) + except asyncio.TimeoutError: + pass + + assert subject1 in received_subjects + assert subject2 in received_subjects + assert subject3 in received_subjects + assert len(received_subjects) == 3 + + +@pytest.mark.asyncio +async def test_subscription_next_with_timeout_raises_on_timeout(client): + """Test that subscription.next() with timeout raises TimeoutError when no message received.""" + test_subject = f"test.timeout.{uuid.uuid4()}" + + subscription = await client.subscribe(test_subject) + await client.flush() + + with pytest.raises(asyncio.TimeoutError): + await subscription.next(timeout=0.2) + + +@pytest.mark.asyncio +async def test_subscription_unsubscribe_stops_receiving(client): + """Test that unsubscribing stops receiving any further messages.""" + test_subject = f"test.unsub.{uuid.uuid4()}" + + # Create subscription + subscription = await client.subscribe(test_subject) + await client.flush() + + # Publish a message and verify it's received + await client.publish(test_subject, b"Before unsubscribe") + await client.flush() + + message = await subscription.next(timeout=1.0) + assert message.data == b"Before unsubscribe" + + # Unsubscribe + await subscription.unsubscribe() + + # Publish another message + await client.publish(test_subject, b"After unsubscribe") + await client.flush() + + # Verify the message is not received + with pytest.raises(RuntimeError): + await subscription.next(timeout=0.5) + + +@pytest.mark.asyncio +async def test_subscription_close_is_same_as_unsubscribe(client): + """Test that closing a subscription is equivalent to unsubscribing.""" + test_subject = f"test.close.{uuid.uuid4()}" + + # Create subscription + subscription = await client.subscribe(test_subject) + await client.flush() + + # Publish a message and verify it's received + await client.publish(test_subject, b"Before close") + await client.flush() + + message = await subscription.next(timeout=1.0) + assert message.data == b"Before close" + + # Unsubscribe from the subscription + await subscription.unsubscribe() + + # Publish another message + await client.publish(test_subject, b"After close") + await client.flush() + + # Verify the message is not received + with pytest.raises(RuntimeError): + await subscription.next(timeout=0.5) + + +@pytest.mark.asyncio +async def test_subscription_as_context_manager(client): + """Test that Subscription can be used as an async context manager.""" + test_subject = f"test.context.{uuid.uuid4()}" + + # Use subscription as context manager + async with await client.subscribe(test_subject) as subscription: + await client.flush() + # Publish a message + await client.publish(test_subject, b"Context test") + await client.flush() + + # Verify message is received + message = await subscription.next(timeout=1.0) + assert message.data == b"Context test" + + # Subscription should still be active + assert not subscription.closed + + # Subscription should be closed after context exit + assert subscription.closed + + # Verify subscription is closed by attempting to receive + with pytest.raises(RuntimeError): + await subscription.next(timeout=0.5) + + +@pytest.mark.asyncio +async def test_client_close_also_closes_subscriptions(client): + """Test that closing the client also closes all its subscriptions.""" + test_subject = f"test.client_close.{uuid.uuid4()}" + + # Create subscription + subscription = await client.subscribe(test_subject) + await client.flush() + + # Close the client + await client.close() + + # Verify client status + assert client.status == ClientStatus.CLOSED + + # Verify subscription is also closed + with pytest.raises(RuntimeError): + await subscription.next(timeout=0.5) + + +@pytest.mark.asyncio +async def test_subscription_receives_headers(client): + """Test that a subscription receives headers in messages when the server supports them.""" + # Skip if headers not supported + if not client.server_info or not client.server_info.headers: + pytest.skip("Server does not support headers") + + test_subject = f"test.headers.{uuid.uuid4()}" + header_key = "custom-header" + header_value = "test-value" + headers = {header_key: header_value} + + # Setup subscription + subscription = await client.subscribe(test_subject) + await client.flush() + + # Publish message with headers + await client.publish(test_subject, b"Test", headers=headers) + await client.flush() + + # Receive and verify + message = await subscription.next(timeout=1.0) + assert message.headers is not None + assert message.headers.get(header_key) == header_value + + +@pytest.mark.asyncio +async def test_subscription_receives_messages_after_reconnection(server): + """Test that a subscription continues to receive messages after reconnection.""" + # Create a client with reconnection enabled + client = await connect( + server.client_url, + timeout=1.0, + allow_reconnect=True, + reconnect_max_attempts=10, + reconnect_time_wait=1.0, + ) + + new_server = None + try: + test_subject = f"test.reconnect.{uuid.uuid4()}" + test_message = b"Hello, NATS!" + + # Create subscription + subscription = await client.subscribe(test_subject) + await client.flush() + + # Publish and verify first message + await client.publish(test_subject, test_message) + await client.flush() + message = await subscription.next(timeout=1.0) + assert message.data == test_message + + # Store the server port before stopping it + server_port = server.port + + # Stop the server to simulate connection loss + await server.shutdown() + + # Create a new server on the same port + new_server = await run(port=server_port) + + # Wait for client to reconnect + max_wait = 5.0 + start_time = asyncio.get_event_loop().time() + while client.status != ClientStatus.CONNECTED: + if asyncio.get_event_loop().time() - start_time > max_wait: + msg = "Client failed to reconnect within timeout" + raise TimeoutError(msg) + await asyncio.sleep(0.1) + + # Publish and verify second message + await client.publish(test_subject, test_message) + await client.flush() + message = await subscription.next(timeout=1.0) + assert message.data == test_message + + finally: + # Clean up in reverse order of creation + if client: + await client.close() + + # Clean up the new server if it was created + if new_server: + await new_server.shutdown() + + +@pytest.mark.asyncio +async def test_subscription_multiple_callbacks(client): + """Test that multiple callbacks can be added to a subscription and all are invoked.""" + test_subject = f"test.multiple_callbacks.{uuid.uuid4()}" + test_message = b"Hello, multiple callbacks!" + + # Counters to track callback invocations + callback1_count = 0 + callback2_count = 0 + callback3_count = 0 + + received_messages = [] + + def callback1(msg): + nonlocal callback1_count + callback1_count += 1 + received_messages.append(f"callback1: {msg.data}") + + def callback2(msg): + nonlocal callback2_count + callback2_count += 1 + received_messages.append(f"callback2: {msg.data}") + + def callback3(msg): + nonlocal callback3_count + callback3_count += 1 + received_messages.append(f"callback3: {msg.data}") + + # Create subscription and add multiple callbacks + subscription = await client.subscribe(test_subject) + subscription.add_callback(callback1) + subscription.add_callback(callback2) + subscription.add_callback(callback3) + + await client.flush() + + # Publish a message + await client.publish(test_subject, test_message) + await client.flush() + + # Give callbacks time to execute + await asyncio.sleep(0.1) + + # Verify all callbacks were invoked + assert callback1_count == 1, f"Expected callback1 to be called once, got {callback1_count}" + assert callback2_count == 1, f"Expected callback2 to be called once, got {callback2_count}" + assert callback3_count == 1, f"Expected callback3 to be called once, got {callback3_count}" + + # Verify messages were received by all callbacks + assert len(received_messages) == 3 + assert f"callback1: {test_message}" in received_messages + assert f"callback2: {test_message}" in received_messages + assert f"callback3: {test_message}" in received_messages + + # Verify message is still available via next() + message = await subscription.next(timeout=1.0) + assert message.data == test_message + + +@pytest.mark.asyncio +async def test_subscription_remove_callback(client): + """Test that callbacks can be removed from a subscription.""" + test_subject = f"test.remove_callback.{uuid.uuid4()}" + test_message = b"Hello, remove callback!" + + # Counters to track callback invocations + callback1_count = 0 + callback2_count = 0 + + def callback1(_msg): + nonlocal callback1_count + callback1_count += 1 + + def callback2(_msg): + nonlocal callback2_count + callback2_count += 1 + + # Create subscription and add callbacks + subscription = await client.subscribe(test_subject) + subscription.add_callback(callback1) + subscription.add_callback(callback2) + + await client.flush() + + # Publish first message + await client.publish(test_subject, test_message) + await client.flush() + await asyncio.sleep(0.1) + + # Verify both callbacks were invoked + assert callback1_count == 1 + assert callback2_count == 1 + + # Remove callback1 + subscription.remove_callback(callback1) + + # Reset counters + callback1_count = 0 + callback2_count = 0 + + # Publish second message + await client.publish(test_subject, test_message) + await client.flush() + await asyncio.sleep(0.1) + + # Verify only callback2 was invoked + assert callback1_count == 0, f"Expected callback1 to not be called, got {callback1_count}" + assert callback2_count == 1, f"Expected callback2 to be called once, got {callback2_count}" + + # Try to remove a callback that's not in the list (should not raise) + def callback3(_msg): + pass + + subscription.remove_callback(callback3) # Should not raise + + +@pytest.mark.asyncio +async def test_subscription_callback_with_initial_callback(client): + """Test that add_callback/remove_callback works correctly.""" + test_subject = f"test.initial_callback.{uuid.uuid4()}" + test_message = b"Hello, initial callback!" + + # Counters to track callback invocations + initial_callback_count = 0 + added_callback_count = 0 + + def initial_callback(_msg): + nonlocal initial_callback_count + initial_callback_count += 1 + + def added_callback(_msg): + nonlocal added_callback_count + added_callback_count += 1 + + # Create subscription and add callbacks + subscription = await client.subscribe(test_subject) + subscription.add_callback(initial_callback) + subscription.add_callback(added_callback) + + await client.flush() + + # Publish a message + await client.publish(test_subject, test_message) + await client.flush() + await asyncio.sleep(0.1) + + # Verify both callbacks were invoked + assert initial_callback_count == 1, f"Expected initial callback to be called once, got {initial_callback_count}" + assert added_callback_count == 1, f"Expected added callback to be called once, got {added_callback_count}" + + # Remove the initial callback + subscription.remove_callback(initial_callback) + + # Reset counters + initial_callback_count = 0 + added_callback_count = 0 + + # Publish second message + await client.publish(test_subject, test_message) + await client.flush() + await asyncio.sleep(0.1) + + # Verify only added_callback was invoked + assert initial_callback_count == 0, f"Expected initial callback to not be called, got {initial_callback_count}" + assert added_callback_count == 1, f"Expected added callback to be called once, got {added_callback_count}" + + +@pytest.mark.asyncio +async def test_subscription_callbacks_with_headers(client): + """Test that callbacks work correctly with messages that have headers.""" + test_subject = f"test.callbacks_headers.{uuid.uuid4()}" + test_message = b"Hello, headers!" + test_headers = {"X-Test": "value", "X-Count": "42"} + + received_messages = [] + + def callback_with_headers(msg): + # Convert headers to simple dict with single values + headers_dict = None + if msg.headers: + headers_dict = {} + for key, value_list in msg.headers.items(): + headers_dict[key] = value_list[0] if value_list else None + + received_messages.append({"data": msg.data, "headers": headers_dict, "subject": msg.subject}) + + # Create subscription with callback + subscription = await client.subscribe(test_subject) + subscription.add_callback(callback_with_headers) + + await client.flush() + + # Publish message with headers + await client.publish(test_subject, test_message, headers=test_headers) + await client.flush() + await asyncio.sleep(0.1) + + # Verify callback received message with headers + assert len(received_messages) == 1 + received = received_messages[0] + assert received["data"] == test_message + assert received["subject"] == test_subject + assert received["headers"] is not None + assert received["headers"]["X-Test"] == "value" + assert received["headers"]["X-Count"] == "42" + + +@pytest.mark.asyncio +async def test_subscription_callback_exception_handling(client): + """Test that exceptions in callbacks don't break the subscription.""" + test_subject = f"test.callback_exception.{uuid.uuid4()}" + test_message = b"Hello, exception handling!" + + # Counters to track callback invocations + good_callback_count = 0 + bad_callback_count = 0 + + def good_callback(_msg): + nonlocal good_callback_count + good_callback_count += 1 + + def bad_callback(_msg): + nonlocal bad_callback_count + bad_callback_count += 1 + error_msg = "This callback always fails" + raise ValueError(error_msg) + + # Create subscription with both good and bad callbacks + subscription = await client.subscribe(test_subject) + subscription.add_callback(good_callback) + subscription.add_callback(bad_callback) + + await client.flush() + + # Publish a message + await client.publish(test_subject, test_message) + await client.flush() + await asyncio.sleep(0.1) + + # Verify both callbacks were called despite the exception + assert good_callback_count == 1, f"Expected good callback to be called once, got {good_callback_count}" + assert bad_callback_count == 1, f"Expected bad callback to be called once, got {bad_callback_count}" + + # Verify message is still available via next() despite callback exception + message = await subscription.next(timeout=1.0) + assert message.data == test_message + + +@pytest.mark.asyncio +async def test_subscription_stops_iterating_on_close(client): + """Test that async iterator stops when subscription is closed.""" + test_subject = f"test.iterator_close.{uuid.uuid4()}" + + subscription = await client.subscribe(test_subject) + await client.flush() + + # Unsubscribe from the subscription + await subscription.unsubscribe() + + # Try to iterate - should stop immediately (StopAsyncIteration) + messages_received = 0 + async for _msg in subscription: + messages_received += 1 + + # Should receive no messages since subscription is closed + assert messages_received == 0 + + +@pytest.mark.asyncio +async def test_subscription_drain_processes_pending_messages(client): + """Test that drain allows pending messages to be processed.""" + test_subject = f"test.drain.{uuid.uuid4()}" + + # Subscribe + subscription = await client.subscribe(test_subject) + await client.flush() + + # Publish multiple messages + for i in range(5): + await client.publish(test_subject, f"message-{i}".encode()) + await client.flush() + + # Wait for one message to be received + messages_received = [] + message = await subscription.next(timeout=0.5) + messages_received.append(message.data.decode()) + + # Drain the subscription (stops new messages, allows pending to be consumed) + await subscription.drain() + + # We should still be able to read all pending messages + try: + while True: + message = await subscription.next(timeout=0.5) + messages_received.append(message.data.decode()) + except (RuntimeError, asyncio.TimeoutError): + # Expected when queue is exhausted or closed + pass + + # Verify we received all 5 messages + assert len(messages_received) == 5 + assert messages_received == ["message-0", "message-1", "message-2", "message-3", "message-4"] + + # Verify subscription is closed + assert subscription.closed + + # Publish another message - it should NOT be received since we drained + await client.publish(test_subject, b"after-drain") + await client.flush() + + # Try to get a message - should fail since subscription is closed + with pytest.raises(RuntimeError, match="Subscription is closed"): + await subscription.next(timeout=0.5) + + +@pytest.mark.asyncio +async def test_many_subscriptions_on_same_subject(): + """Test that client can handle many concurrent subscriptions on the same subject. + + This stress test verifies that the client can manage a large number of + subscriptions all listening to the same subject, with each receiving all messages. + """ + server = await run(port=0) + + try: + client = await connect(server.client_url, timeout=1.0) + + try: + num_subscriptions = 100 + test_subject = f"test.many.same.{uuid.uuid4()}" + subscriptions = [] + + # Create many subscriptions on the same subject + for i in range(num_subscriptions): + sub = await client.subscribe(test_subject) + subscriptions.append(sub) + + await client.flush() + + # Publish a single message + test_message = b"shared_message" + await client.publish(test_subject, test_message) + await client.flush() + + # Verify all subscriptions receive the message + for i, sub in enumerate(subscriptions): + msg = await sub.next(timeout=2.0) + assert msg.data == test_message, f"Subscription {i} received wrong message" + assert msg.subject == test_subject, f"Subscription {i} received wrong subject" + + finally: + await client.close() + + finally: + await server.shutdown() + + +@pytest.mark.asyncio +async def test_many_subscriptions_on_unique_subjects(): + """Test that client can handle many concurrent subscriptions on unique subjects. + + This stress test verifies that the client can manage a large number of + subscriptions simultaneously, each on a unique subject and receiving its own messages. + """ + server = await run(port=0) + + try: + client = await connect(server.client_url, timeout=1.0) + + try: + num_subscriptions = 100 + subscriptions = [] + subjects = [] + + # Create many subscriptions on unique subjects + for i in range(num_subscriptions): + subject = f"test.many.unique.{uuid.uuid4()}.{i}" + subjects.append(subject) + sub = await client.subscribe(subject) + subscriptions.append(sub) + + await client.flush() + + # Publish a message to each unique subject + for i, subject in enumerate(subjects): + await client.publish(subject, f"msg_{i}".encode()) + + await client.flush() + + # Verify each subscription receives its specific message + for i, sub in enumerate(subscriptions): + msg = await sub.next(timeout=2.0) + assert msg.data == f"msg_{i}".encode(), f"Subscription {i} received wrong message" + assert msg.subject == subjects[i], f"Subscription {i} received wrong subject" + + finally: + await client.close() + + finally: + await server.shutdown() + + +@pytest.mark.asyncio +async def test_multiple_concurrent_consumers_using_next(client): + """Test multiple tasks consuming from the same subscription using .next(). + + This verifies that multiple concurrent consumers can safely read from the + same subscription, with each message being delivered to exactly one consumer. + This simulates real-world scenarios like worker pools processing messages. + """ + test_subject = f"test.concurrent.next.{uuid.uuid4()}" + message_count = 50 + + # Create subscription + subscription = await client.subscribe(test_subject) + await client.flush() + + # Track messages received by each consumer + consumer_messages = {0: [], 1: [], 2: []} + + async def consumer_task(consumer_id: int): + """Consumer task that processes messages using .next(). + + Simulates a worker that continuously processes messages from a queue. + """ + while True: + try: + msg = await subscription.next(timeout=0.5) + # Simulate some processing work + await asyncio.sleep(0.01) + consumer_messages[consumer_id].append(msg.data.decode()) + except asyncio.TimeoutError: + # No more messages available - worker is done + break + except RuntimeError: + # Subscription closed + break + + # Start multiple concurrent consumer tasks (simulating a worker pool) + num_consumers = 3 + consumer_tasks = [asyncio.create_task(consumer_task(i)) for i in range(num_consumers)] + + try: + # Give consumers time to start waiting for work + await asyncio.sleep(0.1) + + # Publish messages slowly to allow fair distribution across workers + for i in range(message_count): + await client.publish(test_subject, f"message_{i}".encode()) + if i % 10 == 0: + await asyncio.sleep(0.01) # Small delay to allow distribution + await client.flush() + + # Wait for all consumer tasks to finish processing + await asyncio.gather(*consumer_tasks, return_exceptions=True) + + # Verify all messages were received exactly once + all_messages = [] + for messages in consumer_messages.values(): + all_messages.extend(messages) + + assert len(all_messages) == message_count, f"Expected {message_count} messages, got {len(all_messages)}" + + # Verify no duplicate messages + assert len(set(all_messages)) == message_count, "Some messages were received multiple times" + + finally: + # Ensure tasks are complete + for task in consumer_tasks: + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + +@pytest.mark.asyncio +async def test_multiple_concurrent_consumers_using_async_for(client): + """Test multiple tasks consuming from the same subscription using async for. + + This verifies that multiple concurrent consumers using async iteration + can safely read from the same subscription. + This simulates real-world scenarios like event processors using async iteration. + """ + test_subject = f"test.concurrent.iter.{uuid.uuid4()}" + message_count = 50 + + # Create subscription + subscription = await client.subscribe(test_subject) + await client.flush() + + # Track messages received by each consumer + consumer_messages = {0: [], 1: [], 2: []} + stop_event = asyncio.Event() + + async def consumer_task(consumer_id: int): + """Consumer task that processes messages using async for iteration. + + Simulates an event processor that uses async iteration to handle messages. + """ + async for msg in subscription: + # Simulate some processing work + await asyncio.sleep(0.01) + consumer_messages[consumer_id].append(msg.data.decode()) + + # Stop when we've received all expected messages across all consumers + total = sum(len(messages) for messages in consumer_messages.values()) + if total >= message_count: + break + if stop_event.is_set(): + break + + # Start multiple concurrent consumer tasks (simulating event processors) + num_consumers = 3 + consumer_tasks = [asyncio.create_task(consumer_task(i)) for i in range(num_consumers)] + + try: + # Give consumers time to start their event loops + await asyncio.sleep(0.1) + + # Publish messages slowly to allow fair distribution across processors + for i in range(message_count): + await client.publish(test_subject, f"message_{i}".encode()) + if i % 10 == 0: + await asyncio.sleep(0.01) # Small delay to allow distribution + await client.flush() + + # Wait for all messages to be consumed (with timeout) + max_wait = 5.0 + start = asyncio.get_event_loop().time() + while sum(len(messages) for messages in consumer_messages.values()) < message_count: + if asyncio.get_event_loop().time() - start > max_wait: + break + await asyncio.sleep(0.1) + + # Signal consumers to stop + stop_event.set() + await subscription.unsubscribe() + + # Wait for consumer tasks to finish + await asyncio.wait_for(asyncio.gather(*consumer_tasks, return_exceptions=True), timeout=2.0) + + # Verify all messages were received + all_messages = [] + for messages in consumer_messages.values(): + all_messages.extend(messages) + + assert len(all_messages) == message_count, f"Expected {message_count} messages, got {len(all_messages)}" + + # Verify no duplicate messages + assert len(set(all_messages)) == message_count, "Some messages were received multiple times" + + finally: + stop_event.set() + for task in consumer_tasks: + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + +@pytest.mark.asyncio +async def test_async_iteration_with_concurrent_publishers(client): + """Test async iteration while multiple tasks are publishing concurrently. + + This verifies that async for iteration works correctly when messages are + being published continuously by multiple publishers. + This simulates real-world scenarios with multiple producers and a single consumer. + """ + test_subject = f"test.iter.concurrent.pub.{uuid.uuid4()}" + messages_per_publisher = 20 + num_publishers = 3 + + # Create subscription + subscription = await client.subscribe(test_subject) + await client.flush() + + received_messages = [] + stop_iteration = asyncio.Event() + + async def consumer_task(): + """Consumer task using async for iteration. + + Simulates a single consumer processing events from multiple producers. + """ + async for msg in subscription: + # Simulate some processing work + await asyncio.sleep(0.005) + received_messages.append(msg.data.decode()) + if stop_iteration.is_set(): + break + + async def publisher_task(publisher_id: int): + """Publisher task that continuously produces messages. + + Simulates a producer generating events. + """ + for i in range(messages_per_publisher): + await client.publish(test_subject, f"pub{publisher_id}_msg{i}".encode()) + await asyncio.sleep(0.01) # Small delay to simulate realistic publishing + + # Start consumer task + consumer = asyncio.create_task(consumer_task()) + + # Start multiple publisher tasks + publisher_tasks = [asyncio.create_task(publisher_task(i)) for i in range(num_publishers)] + + try: + # Wait for all publishers to finish + await asyncio.gather(*publisher_tasks) + await client.flush() + + # Wait for consumer to receive all messages + expected_count = messages_per_publisher * num_publishers + max_wait = 5.0 + start = asyncio.get_event_loop().time() + while len(received_messages) < expected_count: + if asyncio.get_event_loop().time() - start > max_wait: + break + await asyncio.sleep(0.1) + + # Stop consumer task + stop_iteration.set() + await subscription.unsubscribe() + await asyncio.wait_for(consumer, timeout=2.0) + + # Verify all messages received + assert len(received_messages) == expected_count, ( + f"Expected {expected_count} messages, got {len(received_messages)}" + ) + + # Verify messages from all publishers + for pub_id in range(num_publishers): + pub_messages = [msg for msg in received_messages if msg.startswith(f"pub{pub_id}_")] + assert len(pub_messages) == messages_per_publisher, ( + f"Publisher {pub_id} messages: expected {messages_per_publisher}, got {len(pub_messages)}" + ) + + finally: + stop_iteration.set() + for task in publisher_tasks: + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + if not consumer.done(): + consumer.cancel() + try: + await consumer + except asyncio.CancelledError: + pass + + +@pytest.mark.asyncio +async def test_reconnect_preserves_subscription_during_publishing(): + """Test that subscriptions remain active after reconnection during active publishing. + + This ensures that the client properly re-establishes subscriptions on the + new connection so that messages published after reconnection are received. + """ + # Start initial server + server = await run(port=0) + server_port = server.port + + # Events + disconnect_event = asyncio.Event() + reconnect_event = asyncio.Event() + + # Connect client + client = await connect( + server.client_url, + timeout=1.0, + allow_reconnect=True, + reconnect_time_wait=0.1, + ) + + client.add_disconnected_callback(disconnect_event.set) + client.add_reconnected_callback(reconnect_event.set) + + # Create subscription + test_subject = f"test.reconnect.subscription.{uuid.uuid4()}" + subscription = await client.subscribe(test_subject) + await client.flush() + + messages_received = [] + receive_task_running = True + + async def receive_messages(): + """Continuously receive messages.""" + while receive_task_running: + try: + msg = await subscription.next(timeout=0.1) + messages_received.append(msg.data.decode()) + except TimeoutError: + continue + except Exception: + # Expected during disconnect + await asyncio.sleep(0.05) + + # Start receiver + receive_task = asyncio.create_task(receive_messages()) + + # Publishing control + publish_task_running = True + + async def publish_messages(): + """Publish messages continuously.""" + counter = 0 + while publish_task_running: + await client.publish(test_subject, f"message_{counter}".encode()) + counter += 1 + await asyncio.sleep(0.05) + + publish_task = asyncio.create_task(publish_messages()) + + try: + # Let some messages flow + await asyncio.sleep(0.3) + messages_before_disconnect = len(messages_received) + assert messages_before_disconnect > 0, "Should receive messages before disconnect" + + # Trigger disconnect + await server.shutdown() + await asyncio.wait_for(disconnect_event.wait(), timeout=2.0) + + # Restart server + new_server = await run(port=server_port) + + try: + # Wait for reconnection + await asyncio.wait_for(reconnect_event.wait(), timeout=5.0) + + # Wait for messages to flow again + await asyncio.sleep(0.5) + + messages_after_reconnect = len(messages_received) + + # Verify subscription is still active and receiving messages + assert messages_after_reconnect > messages_before_disconnect, ( + f"Should receive messages after reconnect: " + f"before={messages_before_disconnect}, after={messages_after_reconnect}" + ) + + finally: + await new_server.shutdown() + + finally: + publish_task_running = False + receive_task_running = False + await publish_task + await receive_task + await client.close() diff --git a/nats-client/tests/test_tls.py b/nats-client/tests/test_tls.py new file mode 100644 index 000000000..ac493f89f --- /dev/null +++ b/nats-client/tests/test_tls.py @@ -0,0 +1,208 @@ +"""Tests for TLS functionality in NATS client.""" + +import os +import ssl + +import pytest +from nats.client import connect +from nats.server import run + + +@pytest.mark.asyncio +async def test_tls_handshake_first_with_custom_ssl_context(): + """Test TLS connection with handshake first mode using custom SSL context.""" + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_tls_handshake_first.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Create SSL context that trusts our self-signed certificate + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + # Connect with TLS handshake first + client = await connect( + server.client_url, + tls=ssl_context, + tls_handshake_first=True, + timeout=2.0, + ) + + # Verify we can publish and subscribe + await client.publish("test.subject", b"Hello TLS") + await client.flush() + + await client.close() + finally: + await server.shutdown() + + +@pytest.mark.asyncio +async def test_tls_handshake_first_with_hostname_verification(): + """Test TLS connection with hostname verification.""" + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_tls_handshake_first.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Create SSL context with custom CA (using self-signed cert as CA) + ssl_context = ssl.create_default_context( + cafile=os.path.join(os.path.dirname(__file__), "certs", "server-cert.pem") + ) + + # Connect with TLS and hostname verification + client = await connect( + server.client_url, + tls=ssl_context, + tls_hostname="localhost", + tls_handshake_first=True, + timeout=2.0, + ) + + # Verify connection works + await client.publish("test", b"data") + await client.flush() + + await client.close() + finally: + await server.shutdown() + + +@pytest.mark.asyncio +async def test_tls_without_handshake_first(): + """Test TLS connection without handshake first (normal TLS mode).""" + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_tls_upgrade.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Create SSL context that trusts our self-signed certificate + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + # Connect with TLS but without handshake first + # Server has TLS configured, so it will advertise tls_available/tls_required in INFO + # and we'll upgrade after receiving INFO + client = await connect( + server.client_url, + tls=ssl_context, + tls_handshake_first=False, + timeout=2.0, + ) + + # Verify we can publish over TLS connection + await client.publish("test.tls", b"TLS without handshake first") + await client.flush() + + await client.close() + finally: + await server.shutdown() + + +@pytest.mark.asyncio +async def test_tls_reconnection_preserves_settings(): + """Test that TLS settings are preserved across reconnections.""" + import asyncio + + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_tls_handshake_first.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + server_port = server.port + + try: + # Create SSL context + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + # Use event to wait for reconnection + reconnected = asyncio.Event() + + def on_reconnected(): + reconnected.set() + + client = await connect( + server.client_url, + tls=ssl_context, + tls_handshake_first=True, + timeout=2.0, + ) + + client.add_reconnected_callback(on_reconnected) + + # Publish a message to verify connection + await client.publish("test.before", b"Before reconnect") + await client.flush() + + # Shutdown server to trigger reconnection + await server.shutdown() + + # Start new server on same port (small delay for port to be released) + await asyncio.sleep(0.1) + new_server = await run(config_path=config_path, port=server_port, timeout=5.0) + + # Wait for reconnection event (with timeout) + await asyncio.wait_for(reconnected.wait(), timeout=5.0) + + # Verify we can still publish over TLS after reconnection + await client.publish("test.after", b"After reconnect") + await client.flush() + + await client.close() + await new_server.shutdown() + except Exception: + await server.shutdown() + raise + + +@pytest.mark.asyncio +async def test_tls_verify_with_client_certificate(): + """Test TLS connection with client certificate verification.""" + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_tls_verify.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Create SSL context with client certificate + ssl_context = ssl.create_default_context( + purpose=ssl.Purpose.SERVER_AUTH, cafile=os.path.join(os.path.dirname(__file__), "certs", "ca.pem") + ) + # Load client certificate and key + ssl_context.load_cert_chain( + certfile=os.path.join(os.path.dirname(__file__), "certs", "client-cert.pem"), + keyfile=os.path.join(os.path.dirname(__file__), "certs", "client-key.pem"), + ) + + # Connect with TLS and client certificate + client = await connect( + server.client_url, + tls=ssl_context, + tls_hostname="localhost", + tls_handshake_first=True, + timeout=2.0, + ) + + # Verify we can publish + await client.publish("test.tls.verify", b"TLS with client verification works!") + await client.flush() + + await client.close() + finally: + await server.shutdown() + + +@pytest.mark.asyncio +async def test_tls_connection_without_ssl_context_fails(): + """Test that connecting to TLS server without SSL context fails.""" + config_path = os.path.join(os.path.dirname(__file__), "configs", "server_tls_handshake_first.conf") + server = await run(config_path=config_path, port=0, timeout=5.0) + + try: + # Try to connect without SSL context to a TLS server + # This should fail because the server expects TLS + # Use short timeout since we expect immediate failure + with pytest.raises(Exception): # Could be ConnectionError or timeout + await connect( + server.client_url, + timeout=0.5, + allow_reconnect=False, + ) + finally: + await server.shutdown() diff --git a/nats-client/tools/bench.py b/nats-client/tools/bench.py new file mode 100755 index 000000000..f7270f7fd --- /dev/null +++ b/nats-client/tools/bench.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +"""NATS benchmarking tool.""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +import time +from dataclasses import dataclass +from typing import Any + + +@dataclass +class BenchmarkResults: + """Results from a benchmark run.""" + + msg_count: int + msg_bytes: int + duration: float + throughput: float + avg_latency: float | None + min_latency: float | None + max_latency: float | None + std_latency: float | None + bytes_per_sec: float + mb_per_sec: float + + def __str__(self) -> str: + result = ( + f"\nTest completed: {self.msg_count:,} messages, " + f"{self.msg_bytes:,} bytes, {self.duration:.2f} seconds\n" + f" Throughput: {self.throughput:,.0f} messages/sec, " + f"{self.mb_per_sec:.2f} MB/sec" + ) + if self.avg_latency is not None: + result += ( + f"\n Latency: (min/avg/max/std) = " + f"{self.min_latency * 1000:.2f}/" # type: ignore[operator] + f"{self.avg_latency * 1000:.2f}/" + f"{self.max_latency * 1000:.2f}/" # type: ignore[operator] + f"{self.std_latency * 1000:.2f} ms" # type: ignore[operator] + ) + return result + + +async def run_pub_benchmark( + *, + client_type: str = "client", + url: str = "nats://localhost:4222", + msg_count: int = 100_000, + msg_size: int = 128, + pub_subject: str = "test", + headers: dict[str, str | list[str]] | Any | None = None, + track_latency: bool = True, +) -> BenchmarkResults: + """Run publisher benchmark.""" + + # Connect to server based on client type + if client_type == "aio": + import nats + + nc = await nats.connect(url) + else: + from nats.client import connect + + nc = await connect(url) + + try: + # Prepare payload and encode subject once for performance + payload = b"x" * msg_size + subject = pub_subject.encode() + + # Track latencies if requested + latencies = [] if track_latency else None + start_time = time.perf_counter() + + # Publish messages + if track_latency: + for _ in range(msg_count): + msg_start = time.perf_counter() + await nc.publish(subject, payload, headers=headers) # type: ignore[arg-type] + latencies.append(time.perf_counter() - msg_start) # type: ignore[union-attr] + else: + for _ in range(msg_count): + await nc.publish(subject, payload, headers=headers) # type: ignore[arg-type] + + await nc.flush() + + duration = time.perf_counter() - start_time + + # Calculate stats + total_bytes = msg_count * msg_size + throughput = msg_count / duration + bytes_per_sec = total_bytes / duration + mb_per_sec = bytes_per_sec / (1024 * 1024) + + # Calculate latency stats if tracked + if track_latency and latencies: + min_latency = min(latencies) + max_latency = max(latencies) + avg_latency = sum(latencies) / len(latencies) + variance = sum((latency - avg_latency) ** 2 for latency in latencies) / len(latencies) + std_latency = variance**0.5 + else: + min_latency = None + max_latency = None + avg_latency = None + std_latency = None + + return BenchmarkResults( + msg_count=msg_count, + msg_bytes=total_bytes, + duration=duration, + throughput=throughput, + avg_latency=avg_latency, + min_latency=min_latency, + max_latency=max_latency, + std_latency=std_latency, + bytes_per_sec=bytes_per_sec, + mb_per_sec=mb_per_sec, + ) + + finally: + await nc.close() + + +async def run_sub_benchmark( + *, + client_type: str = "client", + url: str = "nats://localhost:4222", + msg_count: int = 100_000, + sub_subject: str = "test", + track_latency: bool = True, +) -> BenchmarkResults: + """Run subscriber benchmark.""" + + # Connect to server based on client type + if client_type == "aio": + import nats + + nc = await nats.connect(url) + else: + from nats.client import connect + + nc = await connect(url) + received = 0 + first_msg_time = 0.0 + last_msg_time = 0.0 + total_bytes = 0 + latencies = [] if track_latency else None + + try: + # Create subscription + sub = await nc.subscribe(sub_subject) + start_time = time.perf_counter() + + # Receive messages + async for msg in sub.messages: # type: ignore[attr-defined] + msg_time = time.perf_counter() + if received == 0: + first_msg_time = msg_time + + received += 1 + total_bytes += len(msg.data) + if track_latency: + latencies.append(msg_time - start_time) # type: ignore[union-attr] + + if received >= msg_count: + last_msg_time = msg_time + break + + duration = last_msg_time - first_msg_time + + # Assert we received all expected messages + assert received == msg_count, f"Message loss detected! Received {received}/{msg_count} messages" + + # Calculate stats + throughput = received / duration + bytes_per_sec = total_bytes / duration + mb_per_sec = bytes_per_sec / (1024 * 1024) + + # Calculate latency stats if tracked + if track_latency and latencies: + min_latency = min(latencies) + max_latency = max(latencies) + avg_latency = sum(latencies) / len(latencies) + variance = sum((latency - avg_latency) ** 2 for latency in latencies) / len(latencies) + std_latency = variance**0.5 + else: + min_latency = None + max_latency = None + avg_latency = None + std_latency = None + + return BenchmarkResults( + msg_count=received, + msg_bytes=total_bytes, + duration=duration, + throughput=throughput, + avg_latency=avg_latency, + min_latency=min_latency, + max_latency=max_latency, + std_latency=std_latency, + bytes_per_sec=bytes_per_sec, + mb_per_sec=mb_per_sec, + ) + + finally: + await nc.close() + + +async def run_pubsub_benchmark( + *, + client_type: str = "client", + url: str = "nats://localhost:4222", + msg_count: int = 100_000, + msg_size: int = 128, + subject: str = "test", + headers: dict[str, str | list[str]] | Any | None = None, + track_latency: bool = True, +) -> tuple[BenchmarkResults, BenchmarkResults]: + """Run combined publisher/subscriber benchmark.""" + + # Start subscriber first + sub_task = asyncio.create_task( + run_sub_benchmark( + client_type=client_type, url=url, msg_count=msg_count, sub_subject=subject, track_latency=track_latency + ) + ) + + # Small delay to ensure subscriber is ready + await asyncio.sleep(0.1) + + # Run publisher + pub_results = await run_pub_benchmark( + client_type=client_type, + url=url, + msg_count=msg_count, + msg_size=msg_size, + pub_subject=subject, + headers=headers, + track_latency=track_latency, + ) + + # Wait for subscriber to finish + sub_results = await sub_task + + return pub_results, sub_results + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser(description="NATS benchmarking tool") + parser.add_argument( + "--client", + choices=["client", "aio"], + default="client", + help="Client type to use: 'client' (nats-client) or 'aio' (nats.aio)", + ) + parser.add_argument("--url", default="nats://localhost:4222", help="NATS server URL") + parser.add_argument("--messages", type=int, default=100_000, help="Number of messages to publish") + parser.add_argument("--size", type=int, default=128, help="Size of the message payload") + parser.add_argument("--subject", default="test", help="Subject to use for messages") + parser.add_argument("--pub", action="store_true", help="Run publisher benchmark") + parser.add_argument("--sub", action="store_true", help="Run subscriber benchmark") + parser.add_argument("--headers", type=int, help="Number of headers to add to messages") + parser.add_argument("--latency", action="store_true", help="Track per-message latency (may impact performance)") + + args = parser.parse_args() + + # Default to pub/sub if neither specified + if not args.pub and not args.sub: + args.pub = True + args.sub = True + + # Create headers if requested + headers = None + if args.headers: + if args.client == "client": + from nats.client import Headers + + headers = Headers({f"key{i}": f"value{i}" for i in range(args.headers)}) + else: + headers = {f"key{i}": f"value{i}" for i in range(args.headers)} + + async def run(): + client_name = "nats-client" if args.client == "client" else "nats.aio" + if args.pub and args.sub: + sys.stdout.write( + f"\nStarting pub/sub benchmark with {client_name} [msgs={args.messages:,}, size={args.size:,} B]\n" + ) + pub_results, sub_results = await run_pubsub_benchmark( + client_type=args.client, + url=args.url, + msg_count=args.messages, + msg_size=args.size, + subject=args.subject, + headers=headers, + track_latency=args.latency, + ) + sys.stdout.write(f"\nPublisher results: {pub_results}\n") + sys.stdout.write(f"\nSubscriber results: {sub_results}\n") + + elif args.pub: + sys.stdout.write( + f"\nStarting publisher benchmark with {client_name} [msgs={args.messages:,}, size={args.size:,} B]\n" + ) + results = await run_pub_benchmark( + client_type=args.client, + url=args.url, + msg_count=args.messages, + msg_size=args.size, + pub_subject=args.subject, + headers=headers, + track_latency=args.latency, + ) + sys.stdout.write(f"\nResults: {results}\n") + + elif args.sub: + sys.stdout.write(f"\nStarting subscriber benchmark with {client_name} [msgs={args.messages:,}]\n") + results = await run_sub_benchmark( + client_type=args.client, + url=args.url, + msg_count=args.messages, + sub_subject=args.subject, + track_latency=args.latency, + ) + sys.stdout.write(f"\nResults: {results}\n") + + asyncio.run(run()) + + +if __name__ == "__main__": + main() diff --git a/nats/benchmark/obj_fetch_perf.py b/nats/benchmark/obj_fetch_perf.py new file mode 100644 index 000000000..0c5e3d184 --- /dev/null +++ b/nats/benchmark/obj_fetch_perf.py @@ -0,0 +1,249 @@ +import argparse +import asyncio +import os +import sys +import time + +import nats + +try: + import uvloop + + asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) +except ImportError: + pass + +DEFAULT_NUM_FETCHES = 10 +DEFAULT_TIMEOUT = 30 +DEFAULT_BUCKET = "" +DEFAULT_OBJECT = "" + + +class ProgressFileWrapper: + """ + A file wrapper that shows download progress as data is written. + """ + + def __init__(self, file_obj, total_size: int, object_name: str): + self.file = file_obj + self.total_size = total_size + self.object_name = object_name + self.bytes_written = 0 + self.last_progress = -1 + self.start_time = time.time() + + def write(self, data): + """Write data to file and update progress.""" + result = self.file.write(data) + self.bytes_written += len(data) + self._update_progress() + return result + + def _update_progress(self): + """Update progress display.""" + if self.total_size <= 0: + return + + progress = int((self.bytes_written / self.total_size) * 100) + + # Only update every 5% to avoid too much output + if progress >= self.last_progress + 5: + elapsed = time.time() - self.start_time + if elapsed > 0: + speed_mbps = (self.bytes_written / (1024 * 1024)) / elapsed + mb_written = self.bytes_written / (1024 * 1024) + mb_total = self.total_size / (1024 * 1024) + + # Clear the current line and show progress + print( + f"\r {self.object_name}: {progress:3d}% ({mb_written:.1f}/{mb_total:.1f} MB) @ {speed_mbps:.1f} MB/s", + end="", + flush=True, + ) + self.last_progress = progress + + def __getattr__(self, name): + """Delegate other attributes to the wrapped file.""" + return getattr(self.file, name) + + +def show_usage(): + message = """ +Usage: obj_fetch_perf [options] + +options: + -n COUNT Number of fetches to perform (default: 10) + -b BUCKET Object store bucket name + -o OBJECT Object name to fetch + -t TIMEOUT Timeout per fetch in seconds (default: 30) + -f FILE Write to file (streaming mode, memory efficient) + --overwrite Overwrite output file if it exists + --servers SERVERS NATS server URLs (default: nats://demo.nats.io:4222) + """ + print(message) + + +def show_usage_and_die(): + show_usage() + sys.exit(1) + + +async def main(): + parser = argparse.ArgumentParser() + parser.add_argument("-n", "--count", default=DEFAULT_NUM_FETCHES, type=int) + parser.add_argument("-b", "--bucket", default=DEFAULT_BUCKET) + parser.add_argument("-o", "--object", default=DEFAULT_OBJECT) + parser.add_argument("-t", "--timeout", default=DEFAULT_TIMEOUT, type=int) + parser.add_argument("-f", "--file", help="Write to file (streaming mode)") + parser.add_argument("--overwrite", action="store_true", help="Overwrite output file if it exists") + parser.add_argument("--servers", default=[], action="append") + args = parser.parse_args() + + servers = args.servers + if len(args.servers) < 1: + servers = ["nats://demo.nats.io:4222"] + + print(f"Connecting to NATS servers: {servers}") + + # Connect to NATS with JetStream + try: + nc = await nats.connect(servers, pending_size=1024 * 1024) + js = nc.jetstream() + except Exception as e: + sys.stderr.write(f"ERROR: Failed to connect to NATS: {e}\n") + show_usage_and_die() + + # Get object store + try: + obs = await js.object_store(bucket=args.bucket) + print(f"Connected to object store bucket: {args.bucket}") + except Exception as e: + sys.stderr.write(f"ERROR: Failed to access object store bucket '{args.bucket}': {e}\n") + await nc.close() + sys.exit(1) + + # Get object info first to verify it exists and show stats + try: + info = await obs.get_info(args.object) + size_mb = info.size / (1024 * 1024) + print(f"Object: {args.object}") + print(f"Size: {info.size} bytes ({size_mb:.2f} MB)") + print(f"Chunks: {info.chunks}") + print(f"Description: {info.description}") + print() + except Exception as e: + sys.stderr.write(f"ERROR: Failed to get object info for '{args.object}': {e}\n") + await nc.close() + sys.exit(1) + + # Handle file output setup + if args.file: + if os.path.exists(args.file) and not args.overwrite: + sys.stderr.write(f"ERROR: File '{args.file}' already exists. Use --overwrite to replace it.\n") + await nc.close() + sys.exit(1) + + # For multiple fetches with file output, append a counter + if args.count > 1: + base, ext = os.path.splitext(args.file) + print(f"Multiple fetches with file output - files will be named: {base}_1{ext}, {base}_2{ext}, etc.") + else: + print(f"Streaming output to file: {args.file}") + print() + + # Start the benchmark + print(f"Starting benchmark: fetching '{args.object}' {args.count} times") + if args.file: + print("Progress (streaming to file):") + else: + print("Progress: ", end="", flush=True) + + start = time.time() + total_bytes = 0 + successful_fetches = 0 + failed_fetches = 0 + + for i in range(args.count): + try: + # Determine output file for this fetch + current_file = None + if args.file: + if args.count > 1: + base, ext = os.path.splitext(args.file) + current_file = f"{base}_{i + 1}{ext}" + else: + current_file = args.file + + # Fetch the object + if current_file: + # Stream to file with progress tracking + with open(current_file, "wb") as f: + # Wrap the file with progress tracker + progress_wrapper = ProgressFileWrapper(f, info.size, args.object) + result = await asyncio.wait_for( + obs.get(args.object, writeinto=progress_wrapper), timeout=args.timeout + ) + # Get file size for stats + fetch_bytes = os.path.getsize(current_file) + # Ensure we show 100% completion + if progress_wrapper.bytes_written > 0: + print( + f"\r 📥 {args.object}: 100% ({fetch_bytes / (1024 * 1024):.1f}/{info.size / (1024 * 1024):.1f} MB) ✓" + ) + else: + # Load into memory + result = await asyncio.wait_for(obs.get(args.object), timeout=args.timeout) + fetch_bytes = len(result.data) + + total_bytes += fetch_bytes + successful_fetches += 1 + + # Show simple progress for in-memory mode + if not current_file: + print("#", end="", flush=True) + + except asyncio.TimeoutError: + failed_fetches += 1 + if args.file: + print(f"\r ❌ {args.object}: Timeout after {args.timeout}s") + else: + print("T", end="", flush=True) # T for timeout + except Exception as e: + failed_fetches += 1 + if args.file: + print(f"\r ❌ {args.object}: Error - {str(e)[:50]}") + else: + print("E", end="", flush=True) # E for error + if i == 0: # Show first error for debugging + sys.stderr.write(f"\nFirst fetch error: {e}\n") + + # Small pause between fetches + await asyncio.sleep(0.01) + + elapsed = time.time() - start + + print("\n\nBenchmark Results:") + print("=================") + if args.file: + print("Mode: Streaming to file(s) (memory efficient)") + else: + print("Mode: In-memory loading") + print(f"Total time: {elapsed:.2f} seconds") + print(f"Successful fetches: {successful_fetches}/{args.count}") + print(f"Failed fetches: {failed_fetches}") + + if successful_fetches > 0: + avg_time = elapsed / successful_fetches + mbytes_per_sec = (total_bytes / elapsed) / (1024 * 1024) + fetches_per_sec = successful_fetches / elapsed + + print(f"Average fetch time: {avg_time:.3f} seconds") + print(f"Fetches per second: {fetches_per_sec:.2f}") + print(f"Throughput: {mbytes_per_sec:.2f} MB/sec") + print(f"Total data transferred: {total_bytes / (1024 * 1024):.2f} MB") + + await nc.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/nats/benchmark/sub_next_perf.py b/nats/benchmark/sub_next_perf.py new file mode 100644 index 000000000..47635de1c --- /dev/null +++ b/nats/benchmark/sub_next_perf.py @@ -0,0 +1,132 @@ +import argparse +import asyncio +import sys +import time + +import nats + +try: + import uvloop + + asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) +except ImportError: + pass + +DEFAULT_NUM_MSGS = 100000 +DEFAULT_MSG_SIZE = 16 +DEFAULT_TIMEOUT = 10.0 +DEFAULT_SUBJECT = "test" +HASH_MODULO = 1000 + + +def show_usage(): + message = """ +Usage: sub_next_perf [options] + +options: + -n COUNT Messages to consume (default: 100000) + -S SUBJECT Subject to subscribe to (default: test) + -t TIMEOUT Timeout for next_msg calls (default: 1.0, use 0 to wait forever) + --servers SERVERS NATS server URLs (default: nats://127.0.0.1:4222) + """ + print(message) + + +def show_usage_and_die(): + show_usage() + sys.exit(1) + + +async def main(): + parser = argparse.ArgumentParser() + parser.add_argument("-n", "--count", default=DEFAULT_NUM_MSGS, type=int) + parser.add_argument("-S", "--subject", default=DEFAULT_SUBJECT) + parser.add_argument("-t", "--timeout", default=DEFAULT_TIMEOUT, type=float) + parser.add_argument("--servers", default=[], action="append") + args = parser.parse_args() + + servers = args.servers + if len(args.servers) < 1: + servers = ["nats://127.0.0.1:4222"] + + # Connect to NATS + try: + nc = await nats.connect(servers, allow_reconnect=False) + except Exception as e: + sys.stderr.write(f"ERROR: Failed to connect: {e}\n") + show_usage_and_die() + + print(f"Connected to NATS server: {servers}") + print(f"Subscribing to subject: {args.subject}") + print(f"Expecting {args.count} messages with {args.timeout}s timeout per next_msg()") + print("Waiting for messages...") + print() + + # Subscribe without callback to use next_msg() + sub = await nc.subscribe(args.subject) + + received = 0 + timeouts = 0 + errors = 0 + start_time = time.time() + first_msg_time = None + + print("Progress: ", end="", flush=True) + + # Consume messages using next_msg() + for i in range(args.count): + try: + await sub.next_msg(timeout=args.timeout) + received += 1 + + # Record when first message arrives for accurate timing + if received == 1: + first_msg_time = time.time() + + # Show progress + if received % HASH_MODULO == 0: + print("#", end="", flush=True) + + except nats.errors.TimeoutError: + timeouts += 1 + if timeouts % HASH_MODULO == 0: + print("T", end="", flush=True) + except Exception as e: + errors += 1 + if errors == 1: + sys.stderr.write(f"\nFirst error: {e}\n") + if errors % HASH_MODULO == 0: + print("E", end="", flush=True) + + total_time = time.time() - start_time + + # Calculate timing based on actual message flow + if first_msg_time and received > 0: + msg_processing_time = time.time() - first_msg_time + msgs_per_sec = received / msg_processing_time + else: + msg_processing_time = total_time + msgs_per_sec = received / total_time if total_time > 0 else 0 + + print("\n\nBenchmark Results:") + print("=================") + print(f"Total time: {total_time:.2f} seconds") + print(f"Message processing time: {msg_processing_time:.2f} seconds") + print(f"Messages received: {received}/{args.count}") + print(f"Timeouts: {timeouts}") + print(f"Errors: {errors}") + + if received > 0: + print(f"Messages per second: {msgs_per_sec:.2f}") + print(f"Average time per next_msg(): {msg_processing_time / received * 1000:.3f} ms") + + if received < args.count: + print(f"Warning: Only received {received} out of {args.count} expected messages") + print("Make sure to publish messages to the same subject before or during this benchmark") + print(f"Example: nats bench pub {args.subject} --msgs {args.count} --size {DEFAULT_MSG_SIZE}") + + await nc.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/nats/benchmark/sub_perf_messages.py b/nats/benchmark/sub_perf_messages.py new file mode 100644 index 000000000..b5e6e99f3 --- /dev/null +++ b/nats/benchmark/sub_perf_messages.py @@ -0,0 +1,83 @@ +import argparse +import asyncio +import sys +import time + +import nats + +DEFAULT_FLUSH_TIMEOUT = 30 +DEFAULT_NUM_MSGS = 100000 +DEFAULT_MSG_SIZE = 16 +DEFAULT_BATCH_SIZE = 100 +HASH_MODULO = 1000 + + +def show_usage(): + message = """ +Usage: sub_perf_messages [options] + +options: + -n COUNT Messages to expect (default: 100000} + -S SUBJECT Send subject (default: (test) + """ + print(message) + + +def show_usage_and_die(): + show_usage() + sys.exit(1) + + +async def main(): + parser = argparse.ArgumentParser() + parser.add_argument("-n", "--count", default=DEFAULT_NUM_MSGS, type=int) + parser.add_argument("-S", "--subject", default="test") + parser.add_argument("--servers", default=[], action="append") + args = parser.parse_args() + + servers = args.servers + if len(args.servers) < 1: + servers = ["nats://127.0.0.1:4222"] + + # Make sure we're connected to a server first... + try: + nc = await nats.connect(servers, allow_reconnect=False) + except Exception as e: + sys.stderr.write(f"ERROR: {e}") + show_usage_and_die() + + received = 0 + start = None + + sub = await nc.subscribe(args.subject) + + print(f"Waiting for {args.count} messages on [{args.subject}]...") + try: + # Additional roundtrip with server to ensure everything has been + # processed by the server already. + await nc.flush() + except nats.aio.errors.ErrTimeout: + print(f"Server flush timeout after {DEFAULT_FLUSH_TIMEOUT}") + + async for msg in sub.messages: + received += 1 + + # Measure time from when we get the first message. + if received == 1: + start = time.monotonic() + if (received % HASH_MODULO) == 0: + sys.stdout.write("*") + sys.stdout.flush() + + if received >= args.count: + break + + elapsed = time.monotonic() - start + print("\nTest completed : {} msgs/sec sent".format(args.count / elapsed)) + + print("Received {} messages ({} msgs/sec)".format(received, received / elapsed)) + await nc.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index e750ccb79..2677a611c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ -[tool.uv] -dev-dependencies = [ +[dependency-groups] +dev = [ "pytest>=7.0.0", "pytest-asyncio>=0.21.0", "pytest-cov>=7.0.0", @@ -8,17 +8,19 @@ dev-dependencies = [ "ruff>=0.1.0", "codespell>=2.2.0", ] -workspace = { members = ["nats", "nats-server"] } + +[tool.uv] +workspace = { members = ["nats", "nats-server", "nats-client"] } [tool.ty.environment] -root = ["nats-server/src"] +root = ["nats-client/src", "nats-server/src"] [tool.ty.src] exclude = ["nats/"] [tool.ruff] line-length = 120 -target-version = "py37" +target-version = "py311" [tool.ruff.lint] select = ["E", "F", "W", "I"] diff --git a/uv.lock b/uv.lock index c60fcdfc4..cbfff8cc0 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 2 -requires-python = ">=3.11" +requires-python = ">=3.13" resolution-markers = [ "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'", "python_full_version < '3.14' and platform_python_implementation != 'PyPy'", @@ -9,6 +9,7 @@ resolution-markers = [ [manifest] members = [ + "nats-client", "nats-py", "nats-server", ] @@ -35,7 +36,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.12.15" +version = "3.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -46,59 +47,59 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload-time = "2025-07-29T05:52:32.215Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/f1/8515650ac3121a9e55c7b217c60e7fae3e0134b5acfe65691781b5356929/aiohttp-3.13.0.tar.gz", hash = "sha256:378dbc57dd8cf341ce243f13fa1fa5394d68e2e02c15cd5f28eae35a70ec7f67", size = 7832348, upload-time = "2025-10-06T19:58:48.089Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/19/9e86722ec8e835959bd97ce8c1efa78cf361fa4531fca372551abcc9cdd6/aiohttp-3.12.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce17ce0220383a0f9ea07175eeaa6aa13ae5a41f30bc61d84df17f0e9b1117", size = 711246, upload-time = "2025-07-29T05:50:15.937Z" }, - { url = "https://files.pythonhosted.org/packages/71/f9/0a31fcb1a7d4629ac9d8f01f1cb9242e2f9943f47f5d03215af91c3c1a26/aiohttp-3.12.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:010cc9bbd06db80fe234d9003f67e97a10fe003bfbedb40da7d71c1008eda0fe", size = 483515, upload-time = "2025-07-29T05:50:17.442Z" }, - { url = "https://files.pythonhosted.org/packages/62/6c/94846f576f1d11df0c2e41d3001000527c0fdf63fce7e69b3927a731325d/aiohttp-3.12.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f9d7c55b41ed687b9d7165b17672340187f87a773c98236c987f08c858145a9", size = 471776, upload-time = "2025-07-29T05:50:19.568Z" }, - { url = "https://files.pythonhosted.org/packages/f8/6c/f766d0aaafcee0447fad0328da780d344489c042e25cd58fde566bf40aed/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4fbc61bb3548d3b482f9ac7ddd0f18c67e4225aaa4e8552b9f1ac7e6bda9e5", size = 1741977, upload-time = "2025-07-29T05:50:21.665Z" }, - { url = "https://files.pythonhosted.org/packages/17/e5/fb779a05ba6ff44d7bc1e9d24c644e876bfff5abe5454f7b854cace1b9cc/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbc8a7c410bb3ad5d595bb7118147dfbb6449d862cc1125cf8867cb337e8728", size = 1690645, upload-time = "2025-07-29T05:50:23.333Z" }, - { url = "https://files.pythonhosted.org/packages/37/4e/a22e799c2035f5d6a4ad2cf8e7c1d1bd0923192871dd6e367dafb158b14c/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74dad41b3458dbb0511e760fb355bb0b6689e0630de8a22b1b62a98777136e16", size = 1789437, upload-time = "2025-07-29T05:50:25.007Z" }, - { url = "https://files.pythonhosted.org/packages/28/e5/55a33b991f6433569babb56018b2fb8fb9146424f8b3a0c8ecca80556762/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6f0af863cf17e6222b1735a756d664159e58855da99cfe965134a3ff63b0b0", size = 1828482, upload-time = "2025-07-29T05:50:26.693Z" }, - { url = "https://files.pythonhosted.org/packages/c6/82/1ddf0ea4f2f3afe79dffed5e8a246737cff6cbe781887a6a170299e33204/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b7fe4972d48a4da367043b8e023fb70a04d1490aa7d68800e465d1b97e493b", size = 1730944, upload-time = "2025-07-29T05:50:28.382Z" }, - { url = "https://files.pythonhosted.org/packages/1b/96/784c785674117b4cb3877522a177ba1b5e4db9ce0fd519430b5de76eec90/aiohttp-3.12.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6443cca89553b7a5485331bc9bedb2342b08d073fa10b8c7d1c60579c4a7b9bd", size = 1668020, upload-time = "2025-07-29T05:50:30.032Z" }, - { url = "https://files.pythonhosted.org/packages/12/8a/8b75f203ea7e5c21c0920d84dd24a5c0e971fe1e9b9ebbf29ae7e8e39790/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c5f40ec615e5264f44b4282ee27628cea221fcad52f27405b80abb346d9f3f8", size = 1716292, upload-time = "2025-07-29T05:50:31.983Z" }, - { url = "https://files.pythonhosted.org/packages/47/0b/a1451543475bb6b86a5cfc27861e52b14085ae232896a2654ff1231c0992/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2abbb216a1d3a2fe86dbd2edce20cdc5e9ad0be6378455b05ec7f77361b3ab50", size = 1711451, upload-time = "2025-07-29T05:50:33.989Z" }, - { url = "https://files.pythonhosted.org/packages/55/fd/793a23a197cc2f0d29188805cfc93aa613407f07e5f9da5cd1366afd9d7c/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:db71ce547012a5420a39c1b744d485cfb823564d01d5d20805977f5ea1345676", size = 1691634, upload-time = "2025-07-29T05:50:35.846Z" }, - { url = "https://files.pythonhosted.org/packages/ca/bf/23a335a6670b5f5dfc6d268328e55a22651b440fca341a64fccf1eada0c6/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ced339d7c9b5030abad5854aa5413a77565e5b6e6248ff927d3e174baf3badf7", size = 1785238, upload-time = "2025-07-29T05:50:37.597Z" }, - { url = "https://files.pythonhosted.org/packages/57/4f/ed60a591839a9d85d40694aba5cef86dde9ee51ce6cca0bb30d6eb1581e7/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7c7dd29c7b5bda137464dc9bfc738d7ceea46ff70309859ffde8c022e9b08ba7", size = 1805701, upload-time = "2025-07-29T05:50:39.591Z" }, - { url = "https://files.pythonhosted.org/packages/85/e0/444747a9455c5de188c0f4a0173ee701e2e325d4b2550e9af84abb20cdba/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:421da6fd326460517873274875c6c5a18ff225b40da2616083c5a34a7570b685", size = 1718758, upload-time = "2025-07-29T05:50:41.292Z" }, - { url = "https://files.pythonhosted.org/packages/36/ab/1006278d1ffd13a698e5dd4bfa01e5878f6bddefc296c8b62649753ff249/aiohttp-3.12.15-cp311-cp311-win32.whl", hash = "sha256:4420cf9d179ec8dfe4be10e7d0fe47d6d606485512ea2265b0d8c5113372771b", size = 428868, upload-time = "2025-07-29T05:50:43.063Z" }, - { url = "https://files.pythonhosted.org/packages/10/97/ad2b18700708452400278039272032170246a1bf8ec5d832772372c71f1a/aiohttp-3.12.15-cp311-cp311-win_amd64.whl", hash = "sha256:edd533a07da85baa4b423ee8839e3e91681c7bfa19b04260a469ee94b778bf6d", size = 453273, upload-time = "2025-07-29T05:50:44.613Z" }, - { url = "https://files.pythonhosted.org/packages/63/97/77cb2450d9b35f517d6cf506256bf4f5bda3f93a66b4ad64ba7fc917899c/aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7", size = 702333, upload-time = "2025-07-29T05:50:46.507Z" }, - { url = "https://files.pythonhosted.org/packages/83/6d/0544e6b08b748682c30b9f65640d006e51f90763b41d7c546693bc22900d/aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444", size = 476948, upload-time = "2025-07-29T05:50:48.067Z" }, - { url = "https://files.pythonhosted.org/packages/3a/1d/c8c40e611e5094330284b1aea8a4b02ca0858f8458614fa35754cab42b9c/aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d", size = 469787, upload-time = "2025-07-29T05:50:49.669Z" }, - { url = "https://files.pythonhosted.org/packages/38/7d/b76438e70319796bfff717f325d97ce2e9310f752a267bfdf5192ac6082b/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c", size = 1716590, upload-time = "2025-07-29T05:50:51.368Z" }, - { url = "https://files.pythonhosted.org/packages/79/b1/60370d70cdf8b269ee1444b390cbd72ce514f0d1cd1a715821c784d272c9/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0", size = 1699241, upload-time = "2025-07-29T05:50:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2b/4968a7b8792437ebc12186db31523f541943e99bda8f30335c482bea6879/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab", size = 1754335, upload-time = "2025-07-29T05:50:55.394Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c1/49524ed553f9a0bec1a11fac09e790f49ff669bcd14164f9fab608831c4d/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb", size = 1800491, upload-time = "2025-07-29T05:50:57.202Z" }, - { url = "https://files.pythonhosted.org/packages/de/5e/3bf5acea47a96a28c121b167f5ef659cf71208b19e52a88cdfa5c37f1fcc/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", size = 1719929, upload-time = "2025-07-29T05:50:59.192Z" }, - { url = "https://files.pythonhosted.org/packages/39/94/8ae30b806835bcd1cba799ba35347dee6961a11bd507db634516210e91d8/aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c", size = 1635733, upload-time = "2025-07-29T05:51:01.394Z" }, - { url = "https://files.pythonhosted.org/packages/7a/46/06cdef71dd03acd9da7f51ab3a9107318aee12ad38d273f654e4f981583a/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd", size = 1696790, upload-time = "2025-07-29T05:51:03.657Z" }, - { url = "https://files.pythonhosted.org/packages/02/90/6b4cfaaf92ed98d0ec4d173e78b99b4b1a7551250be8937d9d67ecb356b4/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f", size = 1718245, upload-time = "2025-07-29T05:51:05.911Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e6/2593751670fa06f080a846f37f112cbe6f873ba510d070136a6ed46117c6/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d", size = 1658899, upload-time = "2025-07-29T05:51:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/8f/28/c15bacbdb8b8eb5bf39b10680d129ea7410b859e379b03190f02fa104ffd/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519", size = 1738459, upload-time = "2025-07-29T05:51:09.56Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/c269cbc4faa01fb10f143b1670633a8ddd5b2e1ffd0548f7aa49cb5c70e2/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea", size = 1766434, upload-time = "2025-07-29T05:51:11.423Z" }, - { url = "https://files.pythonhosted.org/packages/52/b0/4ff3abd81aa7d929b27d2e1403722a65fc87b763e3a97b3a2a494bfc63bc/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3", size = 1726045, upload-time = "2025-07-29T05:51:13.689Z" }, - { url = "https://files.pythonhosted.org/packages/71/16/949225a6a2dd6efcbd855fbd90cf476052e648fb011aa538e3b15b89a57a/aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1", size = 423591, upload-time = "2025-07-29T05:51:15.452Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d8/fa65d2a349fe938b76d309db1a56a75c4fb8cc7b17a398b698488a939903/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34", size = 450266, upload-time = "2025-07-29T05:51:17.239Z" }, - { url = "https://files.pythonhosted.org/packages/f2/33/918091abcf102e39d15aba2476ad9e7bd35ddb190dcdd43a854000d3da0d/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315", size = 696741, upload-time = "2025-07-29T05:51:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/7495a81e39a998e400f3ecdd44a62107254803d1681d9189be5c2e4530cd/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd", size = 474407, upload-time = "2025-07-29T05:51:21.165Z" }, - { url = "https://files.pythonhosted.org/packages/49/fc/a9576ab4be2dcbd0f73ee8675d16c707cfc12d5ee80ccf4015ba543480c9/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4", size = 466703, upload-time = "2025-07-29T05:51:22.948Z" }, - { url = "https://files.pythonhosted.org/packages/09/2f/d4bcc8448cf536b2b54eed48f19682031ad182faa3a3fee54ebe5b156387/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7", size = 1705532, upload-time = "2025-07-29T05:51:25.211Z" }, - { url = "https://files.pythonhosted.org/packages/f1/f3/59406396083f8b489261e3c011aa8aee9df360a96ac8fa5c2e7e1b8f0466/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d", size = 1686794, upload-time = "2025-07-29T05:51:27.145Z" }, - { url = "https://files.pythonhosted.org/packages/dc/71/164d194993a8d114ee5656c3b7ae9c12ceee7040d076bf7b32fb98a8c5c6/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b", size = 1738865, upload-time = "2025-07-29T05:51:29.366Z" }, - { url = "https://files.pythonhosted.org/packages/1c/00/d198461b699188a93ead39cb458554d9f0f69879b95078dce416d3209b54/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d", size = 1788238, upload-time = "2025-07-29T05:51:31.285Z" }, - { url = "https://files.pythonhosted.org/packages/85/b8/9e7175e1fa0ac8e56baa83bf3c214823ce250d0028955dfb23f43d5e61fd/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d", size = 1710566, upload-time = "2025-07-29T05:51:33.219Z" }, - { url = "https://files.pythonhosted.org/packages/59/e4/16a8eac9df39b48ae102ec030fa9f726d3570732e46ba0c592aeeb507b93/aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645", size = 1624270, upload-time = "2025-07-29T05:51:35.195Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f8/cd84dee7b6ace0740908fd0af170f9fab50c2a41ccbc3806aabcb1050141/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461", size = 1677294, upload-time = "2025-07-29T05:51:37.215Z" }, - { url = "https://files.pythonhosted.org/packages/ce/42/d0f1f85e50d401eccd12bf85c46ba84f947a84839c8a1c2c5f6e8ab1eb50/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9", size = 1708958, upload-time = "2025-07-29T05:51:39.328Z" }, - { url = "https://files.pythonhosted.org/packages/d5/6b/f6fa6c5790fb602538483aa5a1b86fcbad66244997e5230d88f9412ef24c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d", size = 1651553, upload-time = "2025-07-29T05:51:41.356Z" }, - { url = "https://files.pythonhosted.org/packages/04/36/a6d36ad545fa12e61d11d1932eef273928b0495e6a576eb2af04297fdd3c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693", size = 1727688, upload-time = "2025-07-29T05:51:43.452Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c8/f195e5e06608a97a4e52c5d41c7927301bf757a8e8bb5bbf8cef6c314961/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64", size = 1761157, upload-time = "2025-07-29T05:51:45.643Z" }, - { url = "https://files.pythonhosted.org/packages/05/6a/ea199e61b67f25ba688d3ce93f63b49b0a4e3b3d380f03971b4646412fc6/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51", size = 1710050, upload-time = "2025-07-29T05:51:48.203Z" }, - { url = "https://files.pythonhosted.org/packages/b4/2e/ffeb7f6256b33635c29dbed29a22a723ff2dd7401fff42ea60cf2060abfb/aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0", size = 422647, upload-time = "2025-07-29T05:51:50.718Z" }, - { url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067, upload-time = "2025-07-29T05:51:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/86/2c/ac53efdc9c10e41399acc2395af98f835b86d0141d5c3820857eb9f6a14a/aiohttp-3.13.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:00243e51f16f6ec0fb021659d4af92f675f3cf9f9b39efd142aa3ad641d8d1e6", size = 730090, upload-time = "2025-10-06T19:56:16.858Z" }, + { url = "https://files.pythonhosted.org/packages/13/18/1ac95683e1c1d48ef4503965c96f5401618a04c139edae12e200392daae8/aiohttp-3.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:059978d2fddc462e9211362cbc8446747ecd930537fa559d3d25c256f032ff54", size = 488041, upload-time = "2025-10-06T19:56:18.659Z" }, + { url = "https://files.pythonhosted.org/packages/fd/79/ef0d477c771a642d1a881b92d226314c43d3c74bc674c93e12e679397a97/aiohttp-3.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:564b36512a7da3b386143c611867e3f7cfb249300a1bf60889bd9985da67ab77", size = 486989, upload-time = "2025-10-06T19:56:20.371Z" }, + { url = "https://files.pythonhosted.org/packages/37/b4/0e440481a0e77a551d6c5dcab5d11f1ff6b2b2ddb8dedc24f54f5caad732/aiohttp-3.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4aa995b9156ae499393d949a456a7ab0b994a8241a96db73a3b73c7a090eff6a", size = 1718331, upload-time = "2025-10-06T19:56:22.188Z" }, + { url = "https://files.pythonhosted.org/packages/e6/59/76c421cc4a75bb1aceadb92f20ee6f05a990aa6960c64b59e8e0d340e3f5/aiohttp-3.13.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55ca0e95a3905f62f00900255ed807c580775174252999286f283e646d675a49", size = 1686263, upload-time = "2025-10-06T19:56:24.393Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ac/5095f12a79c7775f402cfc3e83651b6e0a92ade10ddf7f2c78c4fed79f71/aiohttp-3.13.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:49ce7525853a981fc35d380aa2353536a01a9ec1b30979ea4e35966316cace7e", size = 1754265, upload-time = "2025-10-06T19:56:26.365Z" }, + { url = "https://files.pythonhosted.org/packages/05/d7/a48e4989bd76cc70600c505bbdd0d90ca1ad7f9053eceeb9dbcf9345a9ec/aiohttp-3.13.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2117be9883501eaf95503bd313eb4c7a23d567edd44014ba15835a1e9ec6d852", size = 1856486, upload-time = "2025-10-06T19:56:28.438Z" }, + { url = "https://files.pythonhosted.org/packages/1e/02/45b388b49e37933f316e1fb39c0de6fb1d77384b0c8f4cf6af5f2cbe3ea6/aiohttp-3.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d169c47e40c911f728439da853b6fd06da83761012e6e76f11cb62cddae7282b", size = 1737545, upload-time = "2025-10-06T19:56:30.688Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a7/4fde058f1605c34a219348a83a99f14724cc64e68a42480fc03cf40f9ea3/aiohttp-3.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:703ad3f742fc81e543638a7bebddd35acadaa0004a5e00535e795f4b6f2c25ca", size = 1552958, upload-time = "2025-10-06T19:56:32.528Z" }, + { url = "https://files.pythonhosted.org/packages/d1/12/0bac4d29231981e3aa234e88d1931f6ba38135ff4c2cf3afbb7895527630/aiohttp-3.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5bf635c3476f4119b940cc8d94ad454cbe0c377e61b4527f0192aabeac1e9370", size = 1681166, upload-time = "2025-10-06T19:56:34.81Z" }, + { url = "https://files.pythonhosted.org/packages/71/95/b829eb5f8ac1ca1d8085bb8df614c8acf3ff32e23ad5ad1173c7c9761daa/aiohttp-3.13.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cfe6285ef99e7ee51cef20609be2bc1dd0e8446462b71c9db8bb296ba632810a", size = 1710516, upload-time = "2025-10-06T19:56:36.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/15ccf4ef3c254d899f62580e0c7fc717014f4d14a3ac31771e505d2c736c/aiohttp-3.13.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8af6391c5f2e69749d7f037b614b8c5c42093c251f336bdbfa4b03c57d6c4", size = 1731354, upload-time = "2025-10-06T19:56:38.659Z" }, + { url = "https://files.pythonhosted.org/packages/46/6a/8acf6c57e03b6fdcc8b4c06392e66abaff3213ea275e41db3edb20738d91/aiohttp-3.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:12f5d820fadc5848d4559ea838aef733cf37ed2a1103bba148ac2f5547c14c29", size = 1548040, upload-time = "2025-10-06T19:56:40.578Z" }, + { url = "https://files.pythonhosted.org/packages/75/7d/fbfd59ab2a83fe2578ce79ac3db49727b81e9f4c3376217ad09c03c6d279/aiohttp-3.13.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0f1338b61ea66f4757a0544ed8a02ccbf60e38d9cfb3225888888dd4475ebb96", size = 1756031, upload-time = "2025-10-06T19:56:42.492Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/cc9f0fdf06cab3ca61e6b62bff9a4b978b8ca736e9d76ddf54365673ab19/aiohttp-3.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:582770f82513419512da096e8df21ca44f86a2e56e25dc93c5ab4df0fe065bf0", size = 1714933, upload-time = "2025-10-06T19:56:45.542Z" }, + { url = "https://files.pythonhosted.org/packages/db/43/7abbe1de94748a58a71881163ee280fd3217db36e8344d109f63638fe16a/aiohttp-3.13.0-cp313-cp313-win32.whl", hash = "sha256:3194b8cab8dbc882f37c13ef1262e0a3d62064fa97533d3aa124771f7bf1ecee", size = 423799, upload-time = "2025-10-06T19:56:47.779Z" }, + { url = "https://files.pythonhosted.org/packages/c9/58/afab7f2b9e7df88c995995172eb78cae8a3d5a62d5681abaade86b3f0089/aiohttp-3.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:7897298b3eedc790257fef8a6ec582ca04e9dbe568ba4a9a890913b925b8ea21", size = 450138, upload-time = "2025-10-06T19:56:49.49Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c1/93bb1e35cd0c4665bb422b1ca3d87b588f4bca2656bbe9292b963d5b76a9/aiohttp-3.13.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c417f8c2e1137775569297c584a8a7144e5d1237789eae56af4faf1894a0b861", size = 733187, upload-time = "2025-10-06T19:56:51.385Z" }, + { url = "https://files.pythonhosted.org/packages/5e/36/2d50eba91992d3fe7a6452506ccdab45d03685ee8d8acaa5b289384a7d4c/aiohttp-3.13.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f84b53326abf8e56ebc28a35cebf4a0f396a13a76300f500ab11fe0573bf0b52", size = 488684, upload-time = "2025-10-06T19:56:53.25Z" }, + { url = "https://files.pythonhosted.org/packages/82/93/fa4b1d5ecdc7805bdf0815ef00257db4632ccf0a8bffd44f9fc4657b1677/aiohttp-3.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:990a53b9d6a30b2878789e490758e568b12b4a7fb2527d0c89deb9650b0e5813", size = 489255, upload-time = "2025-10-06T19:56:55.136Z" }, + { url = "https://files.pythonhosted.org/packages/05/0f/85241f0d158da5e24e8ac9d50c0849ed24f882cafc53dc95749ef85eef09/aiohttp-3.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c811612711e01b901e18964b3e5dec0d35525150f5f3f85d0aee2935f059910a", size = 1715914, upload-time = "2025-10-06T19:56:57.286Z" }, + { url = "https://files.pythonhosted.org/packages/ab/fc/c755590d6f6d2b5d1565c72d6ee658d3c30ec61acb18964d1e9bf991d9b5/aiohttp-3.13.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ee433e594d7948e760b5c2a78cc06ac219df33b0848793cf9513d486a9f90a52", size = 1665171, upload-time = "2025-10-06T19:56:59.688Z" }, + { url = "https://files.pythonhosted.org/packages/3a/de/caa61e213ff546b8815aef5e931d7eae1dbe8c840a3f11ec5aa41c5ae462/aiohttp-3.13.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:19bb08e56f57c215e9572cd65cb6f8097804412c54081d933997ddde3e5ac579", size = 1755124, upload-time = "2025-10-06T19:57:02.69Z" }, + { url = "https://files.pythonhosted.org/packages/fb/b7/40c3219dd2691aa35cf889b4fbb0c00e48a19092928707044bfe92068e01/aiohttp-3.13.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f27b7488144eb5dd9151cf839b195edd1569629d90ace4c5b6b18e4e75d1e63a", size = 1835949, upload-time = "2025-10-06T19:57:05.251Z" }, + { url = "https://files.pythonhosted.org/packages/57/e8/66e3c32841fc0e26a09539c377aa0f3bbf6deac1957ac5182cf276c5719c/aiohttp-3.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d812838c109757a11354a161c95708ae4199c4fd4d82b90959b20914c1d097f6", size = 1714276, upload-time = "2025-10-06T19:57:07.41Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a5/c68e5b46ff0410fe3abfa508651b09372428f27036138beacf4ff6b7cb8c/aiohttp-3.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c20db99da682f9180fa5195c90b80b159632fb611e8dbccdd99ba0be0970620", size = 1545929, upload-time = "2025-10-06T19:57:09.336Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a6/4c97dc27f9935c0c0aa6e3e10e5b4548823ab5d056636bde374fcd297256/aiohttp-3.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cf8b0870047900eb1f17f453b4b3953b8ffbf203ef56c2f346780ff930a4d430", size = 1679988, upload-time = "2025-10-06T19:57:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1b/11f9c52fd72b786a47e796e6794883417280cdca8eb1032d8d0939928dfa/aiohttp-3.13.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b8a5557d5af3f4e3add52a58c4cf2b8e6e59fc56b261768866f5337872d596d", size = 1678031, upload-time = "2025-10-06T19:57:13.357Z" }, + { url = "https://files.pythonhosted.org/packages/ea/eb/948903d40505f3a25e53e051488d2714ded3afac1f961df135f2936680f9/aiohttp-3.13.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:052bcdd80c1c54b8a18a9ea0cd5e36f473dc8e38d51b804cea34841f677a9971", size = 1726184, upload-time = "2025-10-06T19:57:15.478Z" }, + { url = "https://files.pythonhosted.org/packages/44/14/c8ced38c7dfe80804dec17a671963ccf3cb282f12700ec70b1f689d8de7d/aiohttp-3.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:76484ba17b2832776581b7ab466d094e48eba74cb65a60aea20154dae485e8bd", size = 1542344, upload-time = "2025-10-06T19:57:17.611Z" }, + { url = "https://files.pythonhosted.org/packages/a4/6e/f2e6bff550a51fd7c45fdab116a1dab7cc502e5d942956f10fc5c626bb15/aiohttp-3.13.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:62d8a0adcdaf62ee56bfb37737153251ac8e4b27845b3ca065862fb01d99e247", size = 1740913, upload-time = "2025-10-06T19:57:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/da/00/8f057300d9b598a706348abb375b3de9a253195fb615f17c0b2be2a72836/aiohttp-3.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5004d727499ecb95f7c9147dd0bfc5b5670f71d355f0bd26d7af2d3af8e07d2f", size = 1695535, upload-time = "2025-10-06T19:57:21.856Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ab/6919d584d8f053a14b15f0bfa3f315b3f548435c2142145459da2efa8673/aiohttp-3.13.0-cp314-cp314-win32.whl", hash = "sha256:a1c20c26af48aea984f63f96e5d7af7567c32cb527e33b60a0ef0a6313cf8b03", size = 429548, upload-time = "2025-10-06T19:57:24.285Z" }, + { url = "https://files.pythonhosted.org/packages/c5/59/5d9e78de6132079066f5077d9687bf524f764a2f8207e04d8d68790060c6/aiohttp-3.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:56f7d230ec66e799fbfd8350e9544f8a45a4353f1cf40c1fea74c1780f555b8f", size = 455548, upload-time = "2025-10-06T19:57:26.136Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ea/7d98da03d1e9798bb99c3ca4963229150d45c9b7a3a16210c5b4a5f89e07/aiohttp-3.13.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:2fd35177dc483ae702f07b86c782f4f4b100a8ce4e7c5778cea016979023d9fd", size = 765319, upload-time = "2025-10-06T19:57:28.278Z" }, + { url = "https://files.pythonhosted.org/packages/5c/02/37f29beced8213bb467c52ad509a5e3b41e6e967de2f6eaf7f8db63bea54/aiohttp-3.13.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:4df1984c8804ed336089e88ac81a9417b1fd0db7c6f867c50a9264488797e778", size = 502567, upload-time = "2025-10-06T19:57:30.273Z" }, + { url = "https://files.pythonhosted.org/packages/e7/22/b0afcafcfe3637bc8d7992abf08ee9452018366c0801e4e7d4efda2ed839/aiohttp-3.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e68c0076052dd911a81d3acc4ef2911cc4ef65bf7cadbfbc8ae762da24da858f", size = 507078, upload-time = "2025-10-06T19:57:32.619Z" }, + { url = "https://files.pythonhosted.org/packages/49/4c/046c847b7a1993b49f3855cc3b97872d5df193d9240de835d0dc6a97b164/aiohttp-3.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc95c49853cd29613e4fe4ff96d73068ff89b89d61e53988442e127e8da8e7ba", size = 1862115, upload-time = "2025-10-06T19:57:34.758Z" }, + { url = "https://files.pythonhosted.org/packages/1a/25/1449a59e3c6405da5e47b0138ee0855414dc12a8c306685d7fc3dd300e1f/aiohttp-3.13.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3b3bdc89413117b40cc39baae08fd09cbdeb839d421c4e7dce6a34f6b54b3ac1", size = 1717147, upload-time = "2025-10-06T19:57:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/23/8f/50cc34ad267b38608f21c6a74327015dd08a66f1dd8e7ceac954d0953191/aiohttp-3.13.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e77a729df23be2116acc4e9de2767d8e92445fbca68886dd991dc912f473755", size = 1841443, upload-time = "2025-10-06T19:57:39.708Z" }, + { url = "https://files.pythonhosted.org/packages/df/b9/b3ab1278faa0d1b8f434c85f9cf34eeb0a25016ffe1ee6bc361d09fef0ec/aiohttp-3.13.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e88ab34826d6eeb6c67e6e92400b9ec653faf5092a35f07465f44c9f1c429f82", size = 1933652, upload-time = "2025-10-06T19:57:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/86050aaa3bd7021b115cdfc88477b754e8cf93ef0079867840eee22d3c34/aiohttp-3.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:019dbef24fe28ce2301419dd63a2b97250d9760ca63ee2976c2da2e3f182f82e", size = 1790682, upload-time = "2025-10-06T19:57:44.851Z" }, + { url = "https://files.pythonhosted.org/packages/78/8d/9af903324c2ba24a0c4778e9bcc738b773c98dded3a4fcf8041d5211769f/aiohttp-3.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2c4aeaedd20771b7b4bcdf0ae791904445df6d856c02fc51d809d12d17cffdc7", size = 1622011, upload-time = "2025-10-06T19:57:47.025Z" }, + { url = "https://files.pythonhosted.org/packages/84/97/5174971ba4986d913554ceb248b0401eb5358cb60672ea0166f9f596cd08/aiohttp-3.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b3a8e6a2058a0240cfde542b641d0e78b594311bc1a710cbcb2e1841417d5cb3", size = 1787148, upload-time = "2025-10-06T19:57:49.149Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ae/8b397e980ac613ef3ddd8e996aa7a40a1828df958257800d4bb325657db3/aiohttp-3.13.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8e38d55ca36c15f36d814ea414ecb2401d860de177c49f84a327a25b3ee752b", size = 1774816, upload-time = "2025-10-06T19:57:51.523Z" }, + { url = "https://files.pythonhosted.org/packages/c7/54/0e8e2111dd92051c787e934b6bbf30c213daaa5e7ee5f51bca8913607492/aiohttp-3.13.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a921edbe971aade1bf45bcbb3494e30ba6863a5c78f28be992c42de980fd9108", size = 1788610, upload-time = "2025-10-06T19:57:54.337Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dd/c9283dbfd9325ed6fa6c91f009db6344d8d370a7bcf09f36e7b2fcbfae02/aiohttp-3.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:474cade59a447cb4019c0dce9f0434bf835fb558ea932f62c686fe07fe6db6a1", size = 1615498, upload-time = "2025-10-06T19:57:56.604Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f6/da76230679bd9ef175d876093f89e7fd6d6476c18505e115e3026fe5ef95/aiohttp-3.13.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:99a303ad960747c33b65b1cb65d01a62ac73fa39b72f08a2e1efa832529b01ed", size = 1815187, upload-time = "2025-10-06T19:57:59.036Z" }, + { url = "https://files.pythonhosted.org/packages/d5/78/394003ac738703822616f4f922705b54e5b3d8e7185831ecc1c97904174d/aiohttp-3.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bb34001fc1f05f6b323e02c278090c07a47645caae3aa77ed7ed8a3ce6abcce9", size = 1760281, upload-time = "2025-10-06T19:58:01.585Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b0/4bad0a9dd5910bd01c3119f8bd3d71887cd412d4105e4acddcdacf3cfa76/aiohttp-3.13.0-cp314-cp314t-win32.whl", hash = "sha256:dea698b64235d053def7d2f08af9302a69fcd760d1c7bd9988fd5d3b6157e657", size = 462608, upload-time = "2025-10-06T19:58:03.674Z" }, + { url = "https://files.pythonhosted.org/packages/bd/af/ad12d592f623aae2bd1d3463201dc39c201ea362f9ddee0d03efd9e83720/aiohttp-3.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1f164699a060c0b3616459d13c1464a981fddf36f892f0a5027cbd45121fb14b", size = 496010, upload-time = "2025-10-06T19:58:05.589Z" }, ] [[package]] @@ -107,7 +108,6 @@ version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ @@ -116,11 +116,11 @@ wheels = [ [[package]] name = "attrs" -version = "25.3.0" +version = "25.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] [[package]] @@ -132,31 +132,6 @@ dependencies = [ ] 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/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" }, @@ -213,94 +188,63 @@ wheels = [ [[package]] name = "coverage" -version = "7.10.7" +version = "7.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/38/ee22495420457259d2f3390309505ea98f98a5eed40901cf62196abad006/coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050", size = 811905, upload-time = "2025-10-15T15:15:08.542Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, - { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, - { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, - { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, - { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, - { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, - { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, - { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, - { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, - { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, - { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, - { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, - { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, - { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, - { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, - { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, - { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, - { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, - { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, - { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, - { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, - { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, - { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, - { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, - { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, - { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, - { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, - { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, - { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, - { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, - { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, - { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, - { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, - { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, - { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, - { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, - { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, - { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, - { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, - { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, - { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, - { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, - { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, - { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, - { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, - { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, - { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, - { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, - { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, - { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, - { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, - { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, - { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, - { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, - { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, - { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, - { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, - { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, -] - -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version <= '3.11'" }, + { url = "https://files.pythonhosted.org/packages/60/7f/85e4dfe65e400645464b25c036a26ac226cf3a69d4a50c3934c532491cdd/coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1", size = 216129, upload-time = "2025-10-15T15:13:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/96/5d/dc5fa98fea3c175caf9d360649cb1aa3715e391ab00dc78c4c66fabd7356/coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be", size = 216380, upload-time = "2025-10-15T15:13:26.976Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f5/3da9cc9596708273385189289c0e4d8197d37a386bdf17619013554b3447/coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d", size = 247375, upload-time = "2025-10-15T15:13:28.923Z" }, + { url = "https://files.pythonhosted.org/packages/65/6c/f7f59c342359a235559d2bc76b0c73cfc4bac7d61bb0df210965cb1ecffd/coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82", size = 249978, upload-time = "2025-10-15T15:13:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8c/042dede2e23525e863bf1ccd2b92689692a148d8b5fd37c37899ba882645/coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52", size = 251253, upload-time = "2025-10-15T15:13:32.174Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/3c58df67bfa809a7bddd786356d9c5283e45d693edb5f3f55d0986dd905a/coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b", size = 247591, upload-time = "2025-10-15T15:13:34.147Z" }, + { url = "https://files.pythonhosted.org/packages/26/5b/c7f32efd862ee0477a18c41e4761305de6ddd2d49cdeda0c1116227570fd/coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4", size = 249411, upload-time = "2025-10-15T15:13:38.425Z" }, + { url = "https://files.pythonhosted.org/packages/76/b5/78cb4f1e86c1611431c990423ec0768122905b03837e1b4c6a6f388a858b/coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd", size = 247303, upload-time = "2025-10-15T15:13:40.464Z" }, + { url = "https://files.pythonhosted.org/packages/87/c9/23c753a8641a330f45f221286e707c427e46d0ffd1719b080cedc984ec40/coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc", size = 247157, upload-time = "2025-10-15T15:13:42.087Z" }, + { url = "https://files.pythonhosted.org/packages/c5/42/6e0cc71dc8a464486e944a4fa0d85bdec031cc2969e98ed41532a98336b9/coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48", size = 248921, upload-time = "2025-10-15T15:13:43.715Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1c/743c2ef665e6858cccb0f84377dfe3a4c25add51e8c7ef19249be92465b6/coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040", size = 218526, upload-time = "2025-10-15T15:13:45.336Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d5/226daadfd1bf8ddbccefbd3aa3547d7b960fb48e1bdac124e2dd13a2b71a/coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05", size = 219317, upload-time = "2025-10-15T15:13:47.401Z" }, + { url = "https://files.pythonhosted.org/packages/97/54/47db81dcbe571a48a298f206183ba8a7ba79200a37cd0d9f4788fcd2af4a/coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a", size = 217948, upload-time = "2025-10-15T15:13:49.096Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8b/cb68425420154e7e2a82fd779a8cc01549b6fa83c2ad3679cd6c088ebd07/coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b", size = 216837, upload-time = "2025-10-15T15:13:51.09Z" }, + { url = "https://files.pythonhosted.org/packages/33/55/9d61b5765a025685e14659c8d07037247de6383c0385757544ffe4606475/coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37", size = 217061, upload-time = "2025-10-15T15:13:52.747Z" }, + { url = "https://files.pythonhosted.org/packages/52/85/292459c9186d70dcec6538f06ea251bc968046922497377bf4a1dc9a71de/coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de", size = 258398, upload-time = "2025-10-15T15:13:54.45Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e2/46edd73fb8bf51446c41148d81944c54ed224854812b6ca549be25113ee0/coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f", size = 260574, upload-time = "2025-10-15T15:13:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/07/5e/1df469a19007ff82e2ca8fe509822820a31e251f80ee7344c34f6cd2ec43/coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c", size = 262797, upload-time = "2025-10-15T15:13:58.635Z" }, + { url = "https://files.pythonhosted.org/packages/f9/50/de216b31a1434b94d9b34a964c09943c6be45069ec704bfc379d8d89a649/coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa", size = 257361, upload-time = "2025-10-15T15:14:00.409Z" }, + { url = "https://files.pythonhosted.org/packages/82/1e/3f9f8344a48111e152e0fd495b6fff13cc743e771a6050abf1627a7ba918/coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740", size = 260349, upload-time = "2025-10-15T15:14:02.188Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/3f52741f9e7d82124272f3070bbe316006a7de1bad1093f88d59bfc6c548/coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef", size = 258114, upload-time = "2025-10-15T15:14:03.907Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8b/918f0e15f0365d50d3986bbd3338ca01178717ac5678301f3f547b6619e6/coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0", size = 256723, upload-time = "2025-10-15T15:14:06.324Z" }, + { url = "https://files.pythonhosted.org/packages/44/9e/7776829f82d3cf630878a7965a7d70cc6ca94f22c7d20ec4944f7148cb46/coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca", size = 259238, upload-time = "2025-10-15T15:14:08.002Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b8/49cf253e1e7a3bedb85199b201862dd7ca4859f75b6cf25ffa7298aa0760/coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2", size = 219180, upload-time = "2025-10-15T15:14:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e1/1a541703826be7ae2125a0fb7f821af5729d56bb71e946e7b933cc7a89a4/coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268", size = 220241, upload-time = "2025-10-15T15:14:11.471Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/5ee0e0a08621140fd418ec4020f595b4d52d7eb429ae6a0c6542b4ba6f14/coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836", size = 218510, upload-time = "2025-10-15T15:14:13.46Z" }, + { url = "https://files.pythonhosted.org/packages/f4/06/e923830c1985ce808e40a3fa3eb46c13350b3224b7da59757d37b6ce12b8/coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497", size = 216110, upload-time = "2025-10-15T15:14:15.157Z" }, + { url = "https://files.pythonhosted.org/packages/42/82/cdeed03bfead45203fb651ed756dfb5266028f5f939e7f06efac4041dad5/coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e", size = 216395, upload-time = "2025-10-15T15:14:16.863Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ba/e1c80caffc3199aa699813f73ff097bc2df7b31642bdbc7493600a8f1de5/coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1", size = 247433, upload-time = "2025-10-15T15:14:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/80/c0/5b259b029694ce0a5bbc1548834c7ba3db41d3efd3474489d7efce4ceb18/coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca", size = 249970, upload-time = "2025-10-15T15:14:20.307Z" }, + { url = "https://files.pythonhosted.org/packages/8c/86/171b2b5e1aac7e2fd9b43f7158b987dbeb95f06d1fbecad54ad8163ae3e8/coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd", size = 251324, upload-time = "2025-10-15T15:14:22.419Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/7e10414d343385b92024af3932a27a1caf75c6e27ee88ba211221ff1a145/coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43", size = 247445, upload-time = "2025-10-15T15:14:24.205Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/e4f966b21f5be8c4bf86ad75ae94efa0de4c99c7bbb8114476323102e345/coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777", size = 249324, upload-time = "2025-10-15T15:14:26.234Z" }, + { url = "https://files.pythonhosted.org/packages/00/a2/8479325576dfcd909244d0df215f077f47437ab852ab778cfa2f8bf4d954/coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2", size = 247261, upload-time = "2025-10-15T15:14:28.42Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d8/3a9e2db19d94d65771d0f2e21a9ea587d11b831332a73622f901157cc24b/coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d", size = 247092, upload-time = "2025-10-15T15:14:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b1/bbca3c472544f9e2ad2d5116b2379732957048be4b93a9c543fcd0207e5f/coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4", size = 248755, upload-time = "2025-10-15T15:14:32.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/49/638d5a45a6a0f00af53d6b637c87007eb2297042186334e9923a61aa8854/coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721", size = 218793, upload-time = "2025-10-15T15:14:34.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/cc/b675a51f2d068adb3cdf3799212c662239b0ca27f4691d1fff81b92ea850/coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad", size = 219587, upload-time = "2025-10-15T15:14:37.047Z" }, + { url = "https://files.pythonhosted.org/packages/93/98/5ac886876026de04f00820e5094fe22166b98dcb8b426bf6827aaf67048c/coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479", size = 218168, upload-time = "2025-10-15T15:14:38.861Z" }, + { url = "https://files.pythonhosted.org/packages/14/d1/b4145d35b3e3ecf4d917e97fc8895bcf027d854879ba401d9ff0f533f997/coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f", size = 216850, upload-time = "2025-10-15T15:14:40.651Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d1/7f645fc2eccd318369a8a9948acc447bb7c1ade2911e31d3c5620544c22b/coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e", size = 217071, upload-time = "2025-10-15T15:14:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/64d124649db2737ceced1dfcbdcb79898d5868d311730f622f8ecae84250/coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44", size = 258570, upload-time = "2025-10-15T15:14:44.542Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3f/6f5922f80dc6f2d8b2c6f974835c43f53eb4257a7797727e6ca5b7b2ec1f/coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3", size = 260738, upload-time = "2025-10-15T15:14:46.436Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5f/9e883523c4647c860b3812b417a2017e361eca5b635ee658387dc11b13c1/coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b", size = 262994, upload-time = "2025-10-15T15:14:48.3Z" }, + { url = "https://files.pythonhosted.org/packages/07/bb/43b5a8e94c09c8bf51743ffc65c4c841a4ca5d3ed191d0a6919c379a1b83/coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d", size = 257282, upload-time = "2025-10-15T15:14:50.236Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e5/0ead8af411411330b928733e1d201384b39251a5f043c1612970310e8283/coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2", size = 260430, upload-time = "2025-10-15T15:14:52.413Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/03dd8bb0ba5b971620dcaac145461950f6d8204953e535d2b20c6b65d729/coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e", size = 258190, upload-time = "2025-10-15T15:14:54.268Z" }, + { url = "https://files.pythonhosted.org/packages/45/ae/28a9cce40bf3174426cb2f7e71ee172d98e7f6446dff936a7ccecee34b14/coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996", size = 256658, upload-time = "2025-10-15T15:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7c/3a44234a8599513684bfc8684878fd7b126c2760f79712bb78c56f19efc4/coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11", size = 259342, upload-time = "2025-10-15T15:14:58.538Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e6/0108519cba871af0351725ebdb8660fd7a0fe2ba3850d56d32490c7d9b4b/coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73", size = 219568, upload-time = "2025-10-15T15:15:00.382Z" }, + { url = "https://files.pythonhosted.org/packages/c9/76/44ba876e0942b4e62fdde23ccb029ddb16d19ba1bef081edd00857ba0b16/coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547", size = 220687, upload-time = "2025-10-15T15:15:02.322Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0c/0df55ecb20d0d0ed5c322e10a441775e1a3a5d78c60f0c4e1abfe6fcf949/coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3", size = 218711, upload-time = "2025-10-15T15:15:04.575Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" }, ] [[package]] @@ -320,88 +264,84 @@ sdist = { url = "https://files.pythonhosted.org/packages/dd/b1/af43ef34a7c2560d3 [[package]] name = "frozenlist" -version = "1.7.0" +version = "1.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/7e/803dde33760128acd393a27eb002f2020ddb8d99d30a44bfbaab31c5f08a/frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a", size = 82251, upload-time = "2025-06-09T23:00:16.279Z" }, - { url = "https://files.pythonhosted.org/packages/75/a9/9c2c5760b6ba45eae11334db454c189d43d34a4c0b489feb2175e5e64277/frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750", size = 48183, upload-time = "2025-06-09T23:00:17.698Z" }, - { url = "https://files.pythonhosted.org/packages/47/be/4038e2d869f8a2da165f35a6befb9158c259819be22eeaf9c9a8f6a87771/frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd", size = 47107, upload-time = "2025-06-09T23:00:18.952Z" }, - { url = "https://files.pythonhosted.org/packages/79/26/85314b8a83187c76a37183ceed886381a5f992975786f883472fcb6dc5f2/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2", size = 237333, upload-time = "2025-06-09T23:00:20.275Z" }, - { url = "https://files.pythonhosted.org/packages/1f/fd/e5b64f7d2c92a41639ffb2ad44a6a82f347787abc0c7df5f49057cf11770/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f", size = 231724, upload-time = "2025-06-09T23:00:21.705Z" }, - { url = "https://files.pythonhosted.org/packages/20/fb/03395c0a43a5976af4bf7534759d214405fbbb4c114683f434dfdd3128ef/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30", size = 245842, upload-time = "2025-06-09T23:00:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/d0/15/c01c8e1dffdac5d9803507d824f27aed2ba76b6ed0026fab4d9866e82f1f/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98", size = 239767, upload-time = "2025-06-09T23:00:25.103Z" }, - { url = "https://files.pythonhosted.org/packages/14/99/3f4c6fe882c1f5514b6848aa0a69b20cb5e5d8e8f51a339d48c0e9305ed0/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86", size = 224130, upload-time = "2025-06-09T23:00:27.061Z" }, - { url = "https://files.pythonhosted.org/packages/4d/83/220a374bd7b2aeba9d0725130665afe11de347d95c3620b9b82cc2fcab97/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae", size = 235301, upload-time = "2025-06-09T23:00:29.02Z" }, - { url = "https://files.pythonhosted.org/packages/03/3c/3e3390d75334a063181625343e8daab61b77e1b8214802cc4e8a1bb678fc/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8", size = 234606, upload-time = "2025-06-09T23:00:30.514Z" }, - { url = "https://files.pythonhosted.org/packages/23/1e/58232c19608b7a549d72d9903005e2d82488f12554a32de2d5fb59b9b1ba/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31", size = 248372, upload-time = "2025-06-09T23:00:31.966Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a4/e4a567e01702a88a74ce8a324691e62a629bf47d4f8607f24bf1c7216e7f/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7", size = 229860, upload-time = "2025-06-09T23:00:33.375Z" }, - { url = "https://files.pythonhosted.org/packages/73/a6/63b3374f7d22268b41a9db73d68a8233afa30ed164c46107b33c4d18ecdd/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5", size = 245893, upload-time = "2025-06-09T23:00:35.002Z" }, - { url = "https://files.pythonhosted.org/packages/6d/eb/d18b3f6e64799a79673c4ba0b45e4cfbe49c240edfd03a68be20002eaeaa/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898", size = 246323, upload-time = "2025-06-09T23:00:36.468Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f5/720f3812e3d06cd89a1d5db9ff6450088b8f5c449dae8ffb2971a44da506/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56", size = 233149, upload-time = "2025-06-09T23:00:37.963Z" }, - { url = "https://files.pythonhosted.org/packages/69/68/03efbf545e217d5db8446acfd4c447c15b7c8cf4dbd4a58403111df9322d/frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7", size = 39565, upload-time = "2025-06-09T23:00:39.753Z" }, - { url = "https://files.pythonhosted.org/packages/58/17/fe61124c5c333ae87f09bb67186d65038834a47d974fc10a5fadb4cc5ae1/frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d", size = 44019, upload-time = "2025-06-09T23:00:40.988Z" }, - { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" }, - { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" }, - { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload-time = "2025-06-09T23:00:46.125Z" }, - { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload-time = "2025-06-09T23:00:47.73Z" }, - { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload-time = "2025-06-09T23:00:49.742Z" }, - { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload-time = "2025-06-09T23:00:51.352Z" }, - { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload-time = "2025-06-09T23:00:52.855Z" }, - { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload-time = "2025-06-09T23:00:54.43Z" }, - { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload-time = "2025-06-09T23:00:56.409Z" }, - { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload-time = "2025-06-09T23:00:58.468Z" }, - { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload-time = "2025-06-09T23:01:00.015Z" }, - { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload-time = "2025-06-09T23:01:01.474Z" }, - { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload-time = "2025-06-09T23:01:02.961Z" }, - { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload-time = "2025-06-09T23:01:05.095Z" }, - { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" }, - { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" }, - { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, - { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, - { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, - { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, - { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, - { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, - { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, - { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, - { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, - { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, - { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, - { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, - { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, - { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, - { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, - { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, - { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, - { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, - { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, - { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, - { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, - { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, - { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, - { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, - { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, - { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, - { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, - { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, - { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, - { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, - { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] [[package]] name = "idna" -version = "3.10" +version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] @@ -415,83 +355,119 @@ wheels = [ [[package]] name = "multidict" -version = "6.6.4" +version = "6.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843, upload-time = "2025-08-11T12:08:48.217Z" } +sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/7f/90a7f01e2d005d6653c689039977f6856718c75c5579445effb7e60923d1/multidict-6.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c7a0e9b561e6460484318a7612e725df1145d46b0ef57c6b9866441bf6e27e0c", size = 76472, upload-time = "2025-08-11T12:06:29.006Z" }, - { url = "https://files.pythonhosted.org/packages/54/a3/bed07bc9e2bb302ce752f1dabc69e884cd6a676da44fb0e501b246031fdd/multidict-6.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6bf2f10f70acc7a2446965ffbc726e5fc0b272c97a90b485857e5c70022213eb", size = 44634, upload-time = "2025-08-11T12:06:30.374Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4b/ceeb4f8f33cf81277da464307afeaf164fb0297947642585884f5cad4f28/multidict-6.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66247d72ed62d5dd29752ffc1d3b88f135c6a8de8b5f63b7c14e973ef5bda19e", size = 44282, upload-time = "2025-08-11T12:06:31.958Z" }, - { url = "https://files.pythonhosted.org/packages/03/35/436a5da8702b06866189b69f655ffdb8f70796252a8772a77815f1812679/multidict-6.6.4-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:105245cc6b76f51e408451a844a54e6823bbd5a490ebfe5bdfc79798511ceded", size = 229696, upload-time = "2025-08-11T12:06:33.087Z" }, - { url = "https://files.pythonhosted.org/packages/b6/0e/915160be8fecf1fca35f790c08fb74ca684d752fcba62c11daaf3d92c216/multidict-6.6.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cbbc54e58b34c3bae389ef00046be0961f30fef7cb0dd9c7756aee376a4f7683", size = 246665, upload-time = "2025-08-11T12:06:34.448Z" }, - { url = "https://files.pythonhosted.org/packages/08/ee/2f464330acd83f77dcc346f0b1a0eaae10230291450887f96b204b8ac4d3/multidict-6.6.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:56c6b3652f945c9bc3ac6c8178cd93132b8d82dd581fcbc3a00676c51302bc1a", size = 225485, upload-time = "2025-08-11T12:06:35.672Z" }, - { url = "https://files.pythonhosted.org/packages/71/cc/9a117f828b4d7fbaec6adeed2204f211e9caf0a012692a1ee32169f846ae/multidict-6.6.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b95494daf857602eccf4c18ca33337dd2be705bccdb6dddbfc9d513e6addb9d9", size = 257318, upload-time = "2025-08-11T12:06:36.98Z" }, - { url = "https://files.pythonhosted.org/packages/25/77/62752d3dbd70e27fdd68e86626c1ae6bccfebe2bb1f84ae226363e112f5a/multidict-6.6.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e5b1413361cef15340ab9dc61523e653d25723e82d488ef7d60a12878227ed50", size = 254689, upload-time = "2025-08-11T12:06:38.233Z" }, - { url = "https://files.pythonhosted.org/packages/00/6e/fac58b1072a6fc59af5e7acb245e8754d3e1f97f4f808a6559951f72a0d4/multidict-6.6.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e167bf899c3d724f9662ef00b4f7fef87a19c22b2fead198a6f68b263618df52", size = 246709, upload-time = "2025-08-11T12:06:39.517Z" }, - { url = "https://files.pythonhosted.org/packages/01/ef/4698d6842ef5e797c6db7744b0081e36fb5de3d00002cc4c58071097fac3/multidict-6.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aaea28ba20a9026dfa77f4b80369e51cb767c61e33a2d4043399c67bd95fb7c6", size = 243185, upload-time = "2025-08-11T12:06:40.796Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c9/d82e95ae1d6e4ef396934e9b0e942dfc428775f9554acf04393cce66b157/multidict-6.6.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8c91cdb30809a96d9ecf442ec9bc45e8cfaa0f7f8bdf534e082c2443a196727e", size = 237838, upload-time = "2025-08-11T12:06:42.595Z" }, - { url = "https://files.pythonhosted.org/packages/57/cf/f94af5c36baaa75d44fab9f02e2a6bcfa0cd90acb44d4976a80960759dbc/multidict-6.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1a0ccbfe93ca114c5d65a2471d52d8829e56d467c97b0e341cf5ee45410033b3", size = 246368, upload-time = "2025-08-11T12:06:44.304Z" }, - { url = "https://files.pythonhosted.org/packages/4a/fe/29f23460c3d995f6a4b678cb2e9730e7277231b981f0b234702f0177818a/multidict-6.6.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:55624b3f321d84c403cb7d8e6e982f41ae233d85f85db54ba6286f7295dc8a9c", size = 253339, upload-time = "2025-08-11T12:06:45.597Z" }, - { url = "https://files.pythonhosted.org/packages/29/b6/fd59449204426187b82bf8a75f629310f68c6adc9559dc922d5abe34797b/multidict-6.6.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4a1fb393a2c9d202cb766c76208bd7945bc194eba8ac920ce98c6e458f0b524b", size = 246933, upload-time = "2025-08-11T12:06:46.841Z" }, - { url = "https://files.pythonhosted.org/packages/19/52/d5d6b344f176a5ac3606f7a61fb44dc746e04550e1a13834dff722b8d7d6/multidict-6.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:43868297a5759a845fa3a483fb4392973a95fb1de891605a3728130c52b8f40f", size = 242225, upload-time = "2025-08-11T12:06:48.588Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d3/5b2281ed89ff4d5318d82478a2a2450fcdfc3300da48ff15c1778280ad26/multidict-6.6.4-cp311-cp311-win32.whl", hash = "sha256:ed3b94c5e362a8a84d69642dbeac615452e8af9b8eb825b7bc9f31a53a1051e2", size = 41306, upload-time = "2025-08-11T12:06:49.95Z" }, - { url = "https://files.pythonhosted.org/packages/74/7d/36b045c23a1ab98507aefd44fd8b264ee1dd5e5010543c6fccf82141ccef/multidict-6.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:d8c112f7a90d8ca5d20213aa41eac690bb50a76da153e3afb3886418e61cb22e", size = 46029, upload-time = "2025-08-11T12:06:51.082Z" }, - { url = "https://files.pythonhosted.org/packages/0f/5e/553d67d24432c5cd52b49047f2d248821843743ee6d29a704594f656d182/multidict-6.6.4-cp311-cp311-win_arm64.whl", hash = "sha256:3bb0eae408fa1996d87247ca0d6a57b7fc1dcf83e8a5c47ab82c558c250d4adf", size = 43017, upload-time = "2025-08-11T12:06:52.243Z" }, - { url = "https://files.pythonhosted.org/packages/05/f6/512ffd8fd8b37fb2680e5ac35d788f1d71bbaf37789d21a820bdc441e565/multidict-6.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ffb87be160942d56d7b87b0fdf098e81ed565add09eaa1294268c7f3caac4c8", size = 76516, upload-time = "2025-08-11T12:06:53.393Z" }, - { url = "https://files.pythonhosted.org/packages/99/58/45c3e75deb8855c36bd66cc1658007589662ba584dbf423d01df478dd1c5/multidict-6.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d191de6cbab2aff5de6c5723101705fd044b3e4c7cfd587a1929b5028b9714b3", size = 45394, upload-time = "2025-08-11T12:06:54.555Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ca/e8c4472a93a26e4507c0b8e1f0762c0d8a32de1328ef72fd704ef9cc5447/multidict-6.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38a0956dd92d918ad5feff3db8fcb4a5eb7dba114da917e1a88475619781b57b", size = 43591, upload-time = "2025-08-11T12:06:55.672Z" }, - { url = "https://files.pythonhosted.org/packages/05/51/edf414f4df058574a7265034d04c935aa84a89e79ce90fcf4df211f47b16/multidict-6.6.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6865f6d3b7900ae020b495d599fcf3765653bc927951c1abb959017f81ae8287", size = 237215, upload-time = "2025-08-11T12:06:57.213Z" }, - { url = "https://files.pythonhosted.org/packages/c8/45/8b3d6dbad8cf3252553cc41abea09ad527b33ce47a5e199072620b296902/multidict-6.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2088c126b6f72db6c9212ad827d0ba088c01d951cee25e758c450da732c138", size = 258299, upload-time = "2025-08-11T12:06:58.946Z" }, - { url = "https://files.pythonhosted.org/packages/3c/e8/8ca2e9a9f5a435fc6db40438a55730a4bf4956b554e487fa1b9ae920f825/multidict-6.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f37bed7319b848097085d7d48116f545985db988e2256b2e6f00563a3416ee6", size = 242357, upload-time = "2025-08-11T12:07:00.301Z" }, - { url = "https://files.pythonhosted.org/packages/0f/84/80c77c99df05a75c28490b2af8f7cba2a12621186e0a8b0865d8e745c104/multidict-6.6.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:01368e3c94032ba6ca0b78e7ccb099643466cf24f8dc8eefcfdc0571d56e58f9", size = 268369, upload-time = "2025-08-11T12:07:01.638Z" }, - { url = "https://files.pythonhosted.org/packages/0d/e9/920bfa46c27b05fb3e1ad85121fd49f441492dca2449c5bcfe42e4565d8a/multidict-6.6.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe323540c255db0bffee79ad7f048c909f2ab0edb87a597e1c17da6a54e493c", size = 269341, upload-time = "2025-08-11T12:07:02.943Z" }, - { url = "https://files.pythonhosted.org/packages/af/65/753a2d8b05daf496f4a9c367fe844e90a1b2cac78e2be2c844200d10cc4c/multidict-6.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8eb3025f17b0a4c3cd08cda49acf312a19ad6e8a4edd9dbd591e6506d999402", size = 256100, upload-time = "2025-08-11T12:07:04.564Z" }, - { url = "https://files.pythonhosted.org/packages/09/54/655be13ae324212bf0bc15d665a4e34844f34c206f78801be42f7a0a8aaa/multidict-6.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbc14f0365534d35a06970d6a83478b249752e922d662dc24d489af1aa0d1be7", size = 253584, upload-time = "2025-08-11T12:07:05.914Z" }, - { url = "https://files.pythonhosted.org/packages/5c/74/ab2039ecc05264b5cec73eb018ce417af3ebb384ae9c0e9ed42cb33f8151/multidict-6.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:75aa52fba2d96bf972e85451b99d8e19cc37ce26fd016f6d4aa60da9ab2b005f", size = 251018, upload-time = "2025-08-11T12:07:08.301Z" }, - { url = "https://files.pythonhosted.org/packages/af/0a/ccbb244ac848e56c6427f2392741c06302bbfba49c0042f1eb3c5b606497/multidict-6.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fefd4a815e362d4f011919d97d7b4a1e566f1dde83dc4ad8cfb5b41de1df68d", size = 251477, upload-time = "2025-08-11T12:07:10.248Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b0/0ed49bba775b135937f52fe13922bc64a7eaf0a3ead84a36e8e4e446e096/multidict-6.6.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db9801fe021f59a5b375ab778973127ca0ac52429a26e2fd86aa9508f4d26eb7", size = 263575, upload-time = "2025-08-11T12:07:11.928Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d9/7fb85a85e14de2e44dfb6a24f03c41e2af8697a6df83daddb0e9b7569f73/multidict-6.6.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a650629970fa21ac1fb06ba25dabfc5b8a2054fcbf6ae97c758aa956b8dba802", size = 259649, upload-time = "2025-08-11T12:07:13.244Z" }, - { url = "https://files.pythonhosted.org/packages/03/9e/b3a459bcf9b6e74fa461a5222a10ff9b544cb1cd52fd482fb1b75ecda2a2/multidict-6.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:452ff5da78d4720d7516a3a2abd804957532dd69296cb77319c193e3ffb87e24", size = 251505, upload-time = "2025-08-11T12:07:14.57Z" }, - { url = "https://files.pythonhosted.org/packages/86/a2/8022f78f041dfe6d71e364001a5cf987c30edfc83c8a5fb7a3f0974cff39/multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793", size = 41888, upload-time = "2025-08-11T12:07:15.904Z" }, - { url = "https://files.pythonhosted.org/packages/c7/eb/d88b1780d43a56db2cba24289fa744a9d216c1a8546a0dc3956563fd53ea/multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e", size = 46072, upload-time = "2025-08-11T12:07:17.045Z" }, - { url = "https://files.pythonhosted.org/packages/9f/16/b929320bf5750e2d9d4931835a4c638a19d2494a5b519caaaa7492ebe105/multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364", size = 43222, upload-time = "2025-08-11T12:07:18.328Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5d/e1db626f64f60008320aab00fbe4f23fc3300d75892a3381275b3d284580/multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e", size = 75848, upload-time = "2025-08-11T12:07:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/4c/aa/8b6f548d839b6c13887253af4e29c939af22a18591bfb5d0ee6f1931dae8/multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657", size = 45060, upload-time = "2025-08-11T12:07:21.163Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c6/f5e97e5d99a729bc2aa58eb3ebfa9f1e56a9b517cc38c60537c81834a73f/multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da", size = 43269, upload-time = "2025-08-11T12:07:22.392Z" }, - { url = "https://files.pythonhosted.org/packages/dc/31/d54eb0c62516776f36fe67f84a732f97e0b0e12f98d5685bebcc6d396910/multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa", size = 237158, upload-time = "2025-08-11T12:07:23.636Z" }, - { url = "https://files.pythonhosted.org/packages/c4/1c/8a10c1c25b23156e63b12165a929d8eb49a6ed769fdbefb06e6f07c1e50d/multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f", size = 257076, upload-time = "2025-08-11T12:07:25.049Z" }, - { url = "https://files.pythonhosted.org/packages/ad/86/90e20b5771d6805a119e483fd3d1e8393e745a11511aebca41f0da38c3e2/multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0", size = 240694, upload-time = "2025-08-11T12:07:26.458Z" }, - { url = "https://files.pythonhosted.org/packages/e7/49/484d3e6b535bc0555b52a0a26ba86e4d8d03fd5587d4936dc59ba7583221/multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879", size = 266350, upload-time = "2025-08-11T12:07:27.94Z" }, - { url = "https://files.pythonhosted.org/packages/bf/b4/aa4c5c379b11895083d50021e229e90c408d7d875471cb3abf721e4670d6/multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a", size = 267250, upload-time = "2025-08-11T12:07:29.303Z" }, - { url = "https://files.pythonhosted.org/packages/80/e5/5e22c5bf96a64bdd43518b1834c6d95a4922cc2066b7d8e467dae9b6cee6/multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f", size = 254900, upload-time = "2025-08-11T12:07:30.764Z" }, - { url = "https://files.pythonhosted.org/packages/17/38/58b27fed927c07035abc02befacab42491e7388ca105e087e6e0215ead64/multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5", size = 252355, upload-time = "2025-08-11T12:07:32.205Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a1/dad75d23a90c29c02b5d6f3d7c10ab36c3197613be5d07ec49c7791e186c/multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438", size = 250061, upload-time = "2025-08-11T12:07:33.623Z" }, - { url = "https://files.pythonhosted.org/packages/b8/1a/ac2216b61c7f116edab6dc3378cca6c70dc019c9a457ff0d754067c58b20/multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e", size = 249675, upload-time = "2025-08-11T12:07:34.958Z" }, - { url = "https://files.pythonhosted.org/packages/d4/79/1916af833b800d13883e452e8e0977c065c4ee3ab7a26941fbfdebc11895/multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7", size = 261247, upload-time = "2025-08-11T12:07:36.588Z" }, - { url = "https://files.pythonhosted.org/packages/c5/65/d1f84fe08ac44a5fc7391cbc20a7cedc433ea616b266284413fd86062f8c/multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812", size = 257960, upload-time = "2025-08-11T12:07:39.735Z" }, - { url = "https://files.pythonhosted.org/packages/13/b5/29ec78057d377b195ac2c5248c773703a6b602e132a763e20ec0457e7440/multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a", size = 250078, upload-time = "2025-08-11T12:07:41.525Z" }, - { url = "https://files.pythonhosted.org/packages/c4/0e/7e79d38f70a872cae32e29b0d77024bef7834b0afb406ddae6558d9e2414/multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69", size = 41708, upload-time = "2025-08-11T12:07:43.405Z" }, - { url = "https://files.pythonhosted.org/packages/9d/34/746696dffff742e97cd6a23da953e55d0ea51fa601fa2ff387b3edcfaa2c/multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf", size = 45912, upload-time = "2025-08-11T12:07:45.082Z" }, - { url = "https://files.pythonhosted.org/packages/c7/87/3bac136181e271e29170d8d71929cdeddeb77f3e8b6a0c08da3a8e9da114/multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605", size = 43076, upload-time = "2025-08-11T12:07:46.746Z" }, - { url = "https://files.pythonhosted.org/packages/64/94/0a8e63e36c049b571c9ae41ee301ada29c3fee9643d9c2548d7d558a1d99/multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb", size = 82812, upload-time = "2025-08-11T12:07:48.402Z" }, - { url = "https://files.pythonhosted.org/packages/25/1a/be8e369dfcd260d2070a67e65dd3990dd635cbd735b98da31e00ea84cd4e/multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e", size = 48313, upload-time = "2025-08-11T12:07:49.679Z" }, - { url = "https://files.pythonhosted.org/packages/26/5a/dd4ade298674b2f9a7b06a32c94ffbc0497354df8285f27317c66433ce3b/multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f", size = 46777, upload-time = "2025-08-11T12:07:51.318Z" }, - { url = "https://files.pythonhosted.org/packages/89/db/98aa28bc7e071bfba611ac2ae803c24e96dd3a452b4118c587d3d872c64c/multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773", size = 229321, upload-time = "2025-08-11T12:07:52.965Z" }, - { url = "https://files.pythonhosted.org/packages/c7/bc/01ddda2a73dd9d167bd85d0e8ef4293836a8f82b786c63fb1a429bc3e678/multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e", size = 249954, upload-time = "2025-08-11T12:07:54.423Z" }, - { url = "https://files.pythonhosted.org/packages/06/78/6b7c0f020f9aa0acf66d0ab4eb9f08375bac9a50ff5e3edb1c4ccd59eafc/multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0", size = 228612, upload-time = "2025-08-11T12:07:55.914Z" }, - { url = "https://files.pythonhosted.org/packages/00/44/3faa416f89b2d5d76e9d447296a81521e1c832ad6e40b92f990697b43192/multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395", size = 257528, upload-time = "2025-08-11T12:07:57.371Z" }, - { url = "https://files.pythonhosted.org/packages/05/5f/77c03b89af0fcb16f018f668207768191fb9dcfb5e3361a5e706a11db2c9/multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45", size = 256329, upload-time = "2025-08-11T12:07:58.844Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e9/ed750a2a9afb4f8dc6f13dc5b67b514832101b95714f1211cd42e0aafc26/multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb", size = 247928, upload-time = "2025-08-11T12:08:01.037Z" }, - { url = "https://files.pythonhosted.org/packages/1f/b5/e0571bc13cda277db7e6e8a532791d4403dacc9850006cb66d2556e649c0/multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5", size = 245228, upload-time = "2025-08-11T12:08:02.96Z" }, - { url = "https://files.pythonhosted.org/packages/f3/a3/69a84b0eccb9824491f06368f5b86e72e4af54c3067c37c39099b6687109/multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141", size = 235869, upload-time = "2025-08-11T12:08:04.746Z" }, - { url = "https://files.pythonhosted.org/packages/a9/9d/28802e8f9121a6a0804fa009debf4e753d0a59969ea9f70be5f5fdfcb18f/multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d", size = 243446, upload-time = "2025-08-11T12:08:06.332Z" }, - { url = "https://files.pythonhosted.org/packages/38/ea/6c98add069b4878c1d66428a5f5149ddb6d32b1f9836a826ac764b9940be/multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d", size = 252299, upload-time = "2025-08-11T12:08:07.931Z" }, - { url = "https://files.pythonhosted.org/packages/3a/09/8fe02d204473e14c0af3affd50af9078839dfca1742f025cca765435d6b4/multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0", size = 246926, upload-time = "2025-08-11T12:08:09.467Z" }, - { url = "https://files.pythonhosted.org/packages/37/3d/7b1e10d774a6df5175ecd3c92bff069e77bed9ec2a927fdd4ff5fe182f67/multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92", size = 243383, upload-time = "2025-08-11T12:08:10.981Z" }, - { url = "https://files.pythonhosted.org/packages/50/b0/a6fae46071b645ae98786ab738447de1ef53742eaad949f27e960864bb49/multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e", size = 47775, upload-time = "2025-08-11T12:08:12.439Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0a/2436550b1520091af0600dff547913cb2d66fbac27a8c33bc1b1bccd8d98/multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4", size = 53100, upload-time = "2025-08-11T12:08:13.823Z" }, - { url = "https://files.pythonhosted.org/packages/97/ea/43ac51faff934086db9c072a94d327d71b7d8b40cd5dcb47311330929ef0/multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad", size = 45501, upload-time = "2025-08-11T12:08:15.173Z" }, - { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, + { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" }, + { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" }, + { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" }, + { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" }, + { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" }, + { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" }, + { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" }, + { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" }, + { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" }, + { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" }, + { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" }, + { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" }, + { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" }, + { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" }, + { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" }, + { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload-time = "2025-10-06T14:50:51.92Z" }, + { url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload-time = "2025-10-06T14:50:53.275Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload-time = "2025-10-06T14:50:54.911Z" }, + { url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload-time = "2025-10-06T14:50:56.369Z" }, + { url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload-time = "2025-10-06T14:50:57.991Z" }, + { url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload-time = "2025-10-06T14:50:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload-time = "2025-10-06T14:51:01.183Z" }, + { url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload-time = "2025-10-06T14:51:02.794Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload-time = "2025-10-06T14:51:04.724Z" }, + { url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload-time = "2025-10-06T14:51:06.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload-time = "2025-10-06T14:51:08.091Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload-time = "2025-10-06T14:51:10.365Z" }, + { url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload-time = "2025-10-06T14:51:12.466Z" }, + { url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload-time = "2025-10-06T14:51:14.48Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload-time = "2025-10-06T14:51:16.072Z" }, + { url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload-time = "2025-10-06T14:51:17.544Z" }, + { url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload-time = "2025-10-06T14:51:18.875Z" }, + { url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload-time = "2025-10-06T14:51:20.225Z" }, + { url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload-time = "2025-10-06T14:51:21.588Z" }, + { url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload-time = "2025-10-06T14:51:22.93Z" }, + { url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload-time = "2025-10-06T14:51:24.352Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload-time = "2025-10-06T14:51:25.822Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload-time = "2025-10-06T14:51:27.604Z" }, + { url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload-time = "2025-10-06T14:51:29.664Z" }, + { url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload-time = "2025-10-06T14:51:31.684Z" }, + { url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload-time = "2025-10-06T14:51:33.699Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload-time = "2025-10-06T14:51:36.189Z" }, + { url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload-time = "2025-10-06T14:51:41.291Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload-time = "2025-10-06T14:51:43.55Z" }, + { url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload-time = "2025-10-06T14:51:45.265Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload-time = "2025-10-06T14:51:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload-time = "2025-10-06T14:51:48.541Z" }, + { url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload-time = "2025-10-06T14:51:50.355Z" }, + { url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload-time = "2025-10-06T14:51:51.883Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload-time = "2025-10-06T14:51:53.672Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload-time = "2025-10-06T14:51:55.415Z" }, + { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, +] + +[[package]] +name = "nats-client" +version = "0.0.0" +source = { editable = "nats-client" } + +[package.optional-dependencies] +nkeys = [ + { name = "nkeys" }, +] + +[package.dev-dependencies] +dev = [ + { name = "nats-server" }, + { name = "nkeys" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-benchmark" }, + { name = "pytest-cov" }, + { name = "pytest-xdist" }, +] + +[package.metadata] +requires-dist = [{ name = "nkeys", marker = "extra == 'nkeys'", specifier = ">=0.1.0" }] +provides-extras = ["nkeys"] + +[package.metadata.requires-dev] +dev = [ + { name = "nats-server", editable = "nats-server" }, + { name = "nkeys", specifier = ">=0.1.0" }, + { name = "pytest", specifier = ">=7.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "pytest-benchmark", specifier = ">=5.2.1" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "pytest-xdist", specifier = ">=3.0.0" }, ] [[package]] @@ -552,75 +528,80 @@ wheels = [ [[package]] name = "propcache" -version = "0.3.2" +version = "0.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/8d/e8b436717ab9c2cfc23b116d2c297305aa4cd8339172a456d61ebf5669b8/propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be", size = 74207, upload-time = "2025-06-09T22:54:05.399Z" }, - { url = "https://files.pythonhosted.org/packages/d6/29/1e34000e9766d112171764b9fa3226fa0153ab565d0c242c70e9945318a7/propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f", size = 43648, upload-time = "2025-06-09T22:54:08.023Z" }, - { url = "https://files.pythonhosted.org/packages/46/92/1ad5af0df781e76988897da39b5f086c2bf0f028b7f9bd1f409bb05b6874/propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9", size = 43496, upload-time = "2025-06-09T22:54:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ce/e96392460f9fb68461fabab3e095cb00c8ddf901205be4eae5ce246e5b7e/propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf", size = 217288, upload-time = "2025-06-09T22:54:10.466Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2a/866726ea345299f7ceefc861a5e782b045545ae6940851930a6adaf1fca6/propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9", size = 227456, upload-time = "2025-06-09T22:54:11.828Z" }, - { url = "https://files.pythonhosted.org/packages/de/03/07d992ccb6d930398689187e1b3c718339a1c06b8b145a8d9650e4726166/propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66", size = 225429, upload-time = "2025-06-09T22:54:13.823Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e6/116ba39448753b1330f48ab8ba927dcd6cf0baea8a0ccbc512dfb49ba670/propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df", size = 213472, upload-time = "2025-06-09T22:54:15.232Z" }, - { url = "https://files.pythonhosted.org/packages/a6/85/f01f5d97e54e428885a5497ccf7f54404cbb4f906688a1690cd51bf597dc/propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2", size = 204480, upload-time = "2025-06-09T22:54:17.104Z" }, - { url = "https://files.pythonhosted.org/packages/e3/79/7bf5ab9033b8b8194cc3f7cf1aaa0e9c3256320726f64a3e1f113a812dce/propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7", size = 214530, upload-time = "2025-06-09T22:54:18.512Z" }, - { url = "https://files.pythonhosted.org/packages/31/0b/bd3e0c00509b609317df4a18e6b05a450ef2d9a963e1d8bc9c9415d86f30/propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95", size = 205230, upload-time = "2025-06-09T22:54:19.947Z" }, - { url = "https://files.pythonhosted.org/packages/7a/23/fae0ff9b54b0de4e819bbe559508da132d5683c32d84d0dc2ccce3563ed4/propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e", size = 206754, upload-time = "2025-06-09T22:54:21.716Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7f/ad6a3c22630aaa5f618b4dc3c3598974a72abb4c18e45a50b3cdd091eb2f/propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e", size = 218430, upload-time = "2025-06-09T22:54:23.17Z" }, - { url = "https://files.pythonhosted.org/packages/5b/2c/ba4f1c0e8a4b4c75910742f0d333759d441f65a1c7f34683b4a74c0ee015/propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf", size = 223884, upload-time = "2025-06-09T22:54:25.539Z" }, - { url = "https://files.pythonhosted.org/packages/88/e4/ebe30fc399e98572019eee82ad0caf512401661985cbd3da5e3140ffa1b0/propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e", size = 211480, upload-time = "2025-06-09T22:54:26.892Z" }, - { url = "https://files.pythonhosted.org/packages/96/0a/7d5260b914e01d1d0906f7f38af101f8d8ed0dc47426219eeaf05e8ea7c2/propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897", size = 37757, upload-time = "2025-06-09T22:54:28.241Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2d/89fe4489a884bc0da0c3278c552bd4ffe06a1ace559db5ef02ef24ab446b/propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39", size = 41500, upload-time = "2025-06-09T22:54:29.4Z" }, - { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" }, - { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" }, - { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload-time = "2025-06-09T22:54:35.186Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload-time = "2025-06-09T22:54:36.708Z" }, - { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload-time = "2025-06-09T22:54:38.062Z" }, - { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload-time = "2025-06-09T22:54:39.634Z" }, - { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload-time = "2025-06-09T22:54:41.565Z" }, - { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload-time = "2025-06-09T22:54:43.038Z" }, - { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload-time = "2025-06-09T22:54:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload-time = "2025-06-09T22:54:46.243Z" }, - { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload-time = "2025-06-09T22:54:47.63Z" }, - { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload-time = "2025-06-09T22:54:48.982Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload-time = "2025-06-09T22:54:50.424Z" }, - { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, - { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, - { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, - { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, - { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, - { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, - { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, - { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, - { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, - { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, - { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, - { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, - { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, - { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, - { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, - { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, - { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, - { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, - { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, ] [[package]] @@ -700,19 +681,31 @@ version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, ] +[[package]] +name = "pytest-benchmark" +version = "5.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/ff/82fd8c413eae2f9a3abb0eb8a13d73f2223bfae7b84cf318a3d9c278965a/pytest_benchmark-5.2.2.tar.gz", hash = "sha256:af6717b86a29a9fcd79eda8745883eff3a7fdd626326a6639db0d6cf4d334a89", size = 339608, upload-time = "2025-11-07T13:07:11.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/b0/56b035d19bf0848d3ef0ac428f2852aebed027e33fc0cb389686d3a91a12/pytest_benchmark-5.2.2-py3-none-any.whl", hash = "sha256:00a57d6b5c04e09052890bf04a0417192dd74427c7386a65e3db0a375753dc77", size = 44913, upload-time = "2025-11-07T13:07:09.435Z" }, +] + [[package]] name = "pytest-cov" version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage", extra = ["toml"] }, + { name = "coverage" }, { name = "pluggy" }, { name = "pytest" }, ] @@ -736,67 +729,28 @@ wheels = [ [[package]] name = "ruff" -version = "0.13.3" +version = "0.14.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/8e/f9f9ca747fea8e3ac954e3690d4698c9737c23b51731d02df999c150b1c9/ruff-0.13.3.tar.gz", hash = "sha256:5b0ba0db740eefdfbcce4299f49e9eaefc643d4d007749d77d047c2bab19908e", size = 5438533, upload-time = "2025-10-02T19:29:31.582Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/58/6ca66896635352812de66f71cdf9ff86b3a4f79071ca5730088c0cd0fc8d/ruff-0.14.1.tar.gz", hash = "sha256:1dd86253060c4772867c61791588627320abcb6ed1577a90ef432ee319729b69", size = 5513429, upload-time = "2025-10-16T18:05:41.766Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/33/8f7163553481466a92656d35dea9331095122bb84cf98210bef597dd2ecd/ruff-0.13.3-py3-none-linux_armv6l.whl", hash = "sha256:311860a4c5e19189c89d035638f500c1e191d283d0cc2f1600c8c80d6dcd430c", size = 12484040, upload-time = "2025-10-02T19:28:49.199Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b5/4a21a4922e5dd6845e91896b0d9ef493574cbe061ef7d00a73c61db531af/ruff-0.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2bdad6512fb666b40fcadb65e33add2b040fc18a24997d2e47fee7d66f7fcae2", size = 13122975, upload-time = "2025-10-02T19:28:52.446Z" }, - { url = "https://files.pythonhosted.org/packages/40/90/15649af836d88c9f154e5be87e64ae7d2b1baa5a3ef317cb0c8fafcd882d/ruff-0.13.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fc6fa4637284708d6ed4e5e970d52fc3b76a557d7b4e85a53013d9d201d93286", size = 12346621, upload-time = "2025-10-02T19:28:54.712Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/bcbccb8141305f9a6d3f72549dd82d1134299177cc7eaf832599700f95a7/ruff-0.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c9e6469864f94a98f412f20ea143d547e4c652f45e44f369d7b74ee78185838", size = 12574408, upload-time = "2025-10-02T19:28:56.679Z" }, - { url = "https://files.pythonhosted.org/packages/ce/19/0f3681c941cdcfa2d110ce4515624c07a964dc315d3100d889fcad3bfc9e/ruff-0.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bf62b705f319476c78891e0e97e965b21db468b3c999086de8ffb0d40fd2822", size = 12285330, upload-time = "2025-10-02T19:28:58.79Z" }, - { url = "https://files.pythonhosted.org/packages/10/f8/387976bf00d126b907bbd7725219257feea58650e6b055b29b224d8cb731/ruff-0.13.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cc1abed87ce40cb07ee0667ce99dbc766c9f519eabfd948ed87295d8737c60", size = 13980815, upload-time = "2025-10-02T19:29:01.577Z" }, - { url = "https://files.pythonhosted.org/packages/0c/a6/7c8ec09d62d5a406e2b17d159e4817b63c945a8b9188a771193b7e1cc0b5/ruff-0.13.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4fb75e7c402d504f7a9a259e0442b96403fa4a7310ffe3588d11d7e170d2b1e3", size = 14987733, upload-time = "2025-10-02T19:29:04.036Z" }, - { url = "https://files.pythonhosted.org/packages/97/e5/f403a60a12258e0fd0c2195341cfa170726f254c788673495d86ab5a9a9d/ruff-0.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17b951f9d9afb39330b2bdd2dd144ce1c1335881c277837ac1b50bfd99985ed3", size = 14439848, upload-time = "2025-10-02T19:29:06.684Z" }, - { url = "https://files.pythonhosted.org/packages/39/49/3de381343e89364c2334c9f3268b0349dc734fc18b2d99a302d0935c8345/ruff-0.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6052f8088728898e0a449f0dde8fafc7ed47e4d878168b211977e3e7e854f662", size = 13421890, upload-time = "2025-10-02T19:29:08.767Z" }, - { url = "https://files.pythonhosted.org/packages/ab/b5/c0feca27d45ae74185a6bacc399f5d8920ab82df2d732a17213fb86a2c4c/ruff-0.13.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc742c50f4ba72ce2a3be362bd359aef7d0d302bf7637a6f942eaa763bd292af", size = 13444870, upload-time = "2025-10-02T19:29:11.234Z" }, - { url = "https://files.pythonhosted.org/packages/50/a1/b655298a1f3fda4fdc7340c3f671a4b260b009068fbeb3e4e151e9e3e1bf/ruff-0.13.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8e5640349493b378431637019366bbd73c927e515c9c1babfea3e932f5e68e1d", size = 13691599, upload-time = "2025-10-02T19:29:13.353Z" }, - { url = "https://files.pythonhosted.org/packages/32/b0/a8705065b2dafae007bcae21354e6e2e832e03eb077bb6c8e523c2becb92/ruff-0.13.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6b139f638a80eae7073c691a5dd8d581e0ba319540be97c343d60fb12949c8d0", size = 12421893, upload-time = "2025-10-02T19:29:15.668Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1e/cbe7082588d025cddbb2f23e6dfef08b1a2ef6d6f8328584ad3015b5cebd/ruff-0.13.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6b547def0a40054825de7cfa341039ebdfa51f3d4bfa6a0772940ed351d2746c", size = 12267220, upload-time = "2025-10-02T19:29:17.583Z" }, - { url = "https://files.pythonhosted.org/packages/a5/99/4086f9c43f85e0755996d09bdcb334b6fee9b1eabdf34e7d8b877fadf964/ruff-0.13.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9cc48a3564423915c93573f1981d57d101e617839bef38504f85f3677b3a0a3e", size = 13177818, upload-time = "2025-10-02T19:29:19.943Z" }, - { url = "https://files.pythonhosted.org/packages/9b/de/7b5db7e39947d9dc1c5f9f17b838ad6e680527d45288eeb568e860467010/ruff-0.13.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1a993b17ec03719c502881cb2d5f91771e8742f2ca6de740034433a97c561989", size = 13618715, upload-time = "2025-10-02T19:29:22.527Z" }, - { url = "https://files.pythonhosted.org/packages/28/d3/bb25ee567ce2f61ac52430cf99f446b0e6d49bdfa4188699ad005fdd16aa/ruff-0.13.3-py3-none-win32.whl", hash = "sha256:f14e0d1fe6460f07814d03c6e32e815bff411505178a1f539a38f6097d3e8ee3", size = 12334488, upload-time = "2025-10-02T19:29:24.782Z" }, - { url = "https://files.pythonhosted.org/packages/cf/49/12f5955818a1139eed288753479ba9d996f6ea0b101784bb1fe6977ec128/ruff-0.13.3-py3-none-win_amd64.whl", hash = "sha256:621e2e5812b691d4f244638d693e640f188bacbb9bc793ddd46837cea0503dd2", size = 13455262, upload-time = "2025-10-02T19:29:26.882Z" }, - { url = "https://files.pythonhosted.org/packages/fe/72/7b83242b26627a00e3af70d0394d68f8f02750d642567af12983031777fc/ruff-0.13.3-py3-none-win_arm64.whl", hash = "sha256:9e9e9d699841eaf4c2c798fa783df2fabc680b72059a02ca0ed81c460bc58330", size = 12538484, upload-time = "2025-10-02T19:29:28.951Z" }, -] - -[[package]] -name = "tomli" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, - { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, - { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, - { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, - { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, - { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, - { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, - { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, - { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, - { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, - { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, - { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, - { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, - { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, - { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, - { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, - { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, - { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, - { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, - { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, - { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, - { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, - { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, - { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, - { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, + { url = "https://files.pythonhosted.org/packages/8d/39/9cc5ab181478d7a18adc1c1e051a84ee02bec94eb9bdfd35643d7c74ca31/ruff-0.14.1-py3-none-linux_armv6l.whl", hash = "sha256:083bfc1f30f4a391ae09c6f4f99d83074416b471775b59288956f5bc18e82f8b", size = 12445415, upload-time = "2025-10-16T18:04:48.227Z" }, + { url = "https://files.pythonhosted.org/packages/ef/2e/1226961855ccd697255988f5a2474890ac7c5863b080b15bd038df820818/ruff-0.14.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f6fa757cd717f791009f7669fefb09121cc5f7d9bd0ef211371fad68c2b8b224", size = 12784267, upload-time = "2025-10-16T18:04:52.515Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/fd9e95863124ed159cd0667ec98449ae461de94acda7101f1acb6066da00/ruff-0.14.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d6191903d39ac156921398e9c86b7354d15e3c93772e7dbf26c9fcae59ceccd5", size = 11781872, upload-time = "2025-10-16T18:04:55.396Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5a/e890f7338ff537dba4589a5e02c51baa63020acfb7c8cbbaea4831562c96/ruff-0.14.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed04f0e04f7a4587244e5c9d7df50e6b5bf2705d75059f409a6421c593a35896", size = 12226558, upload-time = "2025-10-16T18:04:58.166Z" }, + { url = "https://files.pythonhosted.org/packages/a6/7a/8ab5c3377f5bf31e167b73651841217542bcc7aa1c19e83030835cc25204/ruff-0.14.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9e6cf6cd4acae0febbce29497accd3632fe2025c0c583c8b87e8dbdeae5f61", size = 12187898, upload-time = "2025-10-16T18:05:01.455Z" }, + { url = "https://files.pythonhosted.org/packages/48/8d/ba7c33aa55406955fc124e62c8259791c3d42e3075a71710fdff9375134f/ruff-0.14.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6fa2458527794ecdfbe45f654e42c61f2503a230545a91af839653a0a93dbc6", size = 12939168, upload-time = "2025-10-16T18:05:04.397Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c2/70783f612b50f66d083380e68cbd1696739d88e9b4f6164230375532c637/ruff-0.14.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:39f1c392244e338b21d42ab29b8a6392a722c5090032eb49bb4d6defcdb34345", size = 14386942, upload-time = "2025-10-16T18:05:07.102Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/cd7abb9c776b66d332119d67f96acf15830d120f5b884598a36d9d3f4d83/ruff-0.14.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7382fa12a26cce1f95070ce450946bec357727aaa428983036362579eadcc5cf", size = 13990622, upload-time = "2025-10-16T18:05:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/4259b696db12ac152fe472764b4f78bbdd9b477afd9bc3a6d53c01300b37/ruff-0.14.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd0bf2be3ae8521e1093a487c4aa3b455882f139787770698530d28ed3fbb37c", size = 13431143, upload-time = "2025-10-16T18:05:13.46Z" }, + { url = "https://files.pythonhosted.org/packages/e0/35/266a80d0eb97bd224b3265b9437bd89dde0dcf4faf299db1212e81824e7e/ruff-0.14.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cabcaa9ccf8089fb4fdb78d17cc0e28241520f50f4c2e88cb6261ed083d85151", size = 13132844, upload-time = "2025-10-16T18:05:16.1Z" }, + { url = "https://files.pythonhosted.org/packages/65/6e/d31ce218acc11a8d91ef208e002a31acf315061a85132f94f3df7a252b18/ruff-0.14.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:747d583400f6125ec11a4c14d1c8474bf75d8b419ad22a111a537ec1a952d192", size = 13401241, upload-time = "2025-10-16T18:05:19.395Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b5/dbc4221bf0b03774b3b2f0d47f39e848d30664157c15b965a14d890637d2/ruff-0.14.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5a6e74c0efd78515a1d13acbfe6c90f0f5bd822aa56b4a6d43a9ffb2ae6e56cd", size = 12132476, upload-time = "2025-10-16T18:05:22.163Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/ac99194e790ccd092d6a8b5f341f34b6e597d698e3077c032c502d75ea84/ruff-0.14.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0ea6a864d2fb41a4b6d5b456ed164302a0d96f4daac630aeba829abfb059d020", size = 12139749, upload-time = "2025-10-16T18:05:25.162Z" }, + { url = "https://files.pythonhosted.org/packages/47/26/7df917462c3bb5004e6fdfcc505a49e90bcd8a34c54a051953118c00b53a/ruff-0.14.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0826b8764f94229604fa255918d1cc45e583e38c21c203248b0bfc9a0e930be5", size = 12544758, upload-time = "2025-10-16T18:05:28.018Z" }, + { url = "https://files.pythonhosted.org/packages/64/d0/81e7f0648e9764ad9b51dd4be5e5dac3fcfff9602428ccbae288a39c2c22/ruff-0.14.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cbc52160465913a1a3f424c81c62ac8096b6a491468e7d872cb9444a860bc33d", size = 13221811, upload-time = "2025-10-16T18:05:30.707Z" }, + { url = "https://files.pythonhosted.org/packages/c3/07/3c45562c67933cc35f6d5df4ca77dabbcd88fddaca0d6b8371693d29fd56/ruff-0.14.1-py3-none-win32.whl", hash = "sha256:e037ea374aaaff4103240ae79168c0945ae3d5ae8db190603de3b4012bd1def6", size = 12319467, upload-time = "2025-10-16T18:05:33.261Z" }, + { url = "https://files.pythonhosted.org/packages/02/88/0ee4ca507d4aa05f67e292d2e5eb0b3e358fbcfe527554a2eda9ac422d6b/ruff-0.14.1-py3-none-win_amd64.whl", hash = "sha256:59d599cdff9c7f925a017f6f2c256c908b094e55967f93f2821b1439928746a1", size = 13401123, upload-time = "2025-10-16T18:05:35.984Z" }, + { url = "https://files.pythonhosted.org/packages/b8/81/4b6387be7014858d924b843530e1b2a8e531846807516e9bea2ee0936bf7/ruff-0.14.1-py3-none-win_arm64.whl", hash = "sha256:e3b443c4c9f16ae850906b8d0a707b2a4c16f8d2f0a7fe65c475c5886665ce44", size = 12436636, upload-time = "2025-10-16T18:05:38.995Z" }, ] [[package]] @@ -824,93 +778,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/33/9fffb57f66317082fe3de4d08bb71557105c47676a114bdc9d52f6d3a910/ty-0.0.1a23-py3-none-win_arm64.whl", hash = "sha256:71aa203d6ae4de863a7f4626a8fe5f723beaa219988d176a6667f021b78a2af3", size = 8400343, upload-time = "2025-10-16T18:18:57.387Z" }, ] -[[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 = "yarl" -version = "1.20.1" +version = "1.22.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/18/893b50efc2350e47a874c5c2d67e55a0ea5df91186b2a6f5ac52eff887cd/yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e", size = 133833, upload-time = "2025-06-10T00:43:07.393Z" }, - { url = "https://files.pythonhosted.org/packages/89/ed/b8773448030e6fc47fa797f099ab9eab151a43a25717f9ac043844ad5ea3/yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b", size = 91070, upload-time = "2025-06-10T00:43:09.538Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e3/409bd17b1e42619bf69f60e4f031ce1ccb29bd7380117a55529e76933464/yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b", size = 89818, upload-time = "2025-06-10T00:43:11.575Z" }, - { url = "https://files.pythonhosted.org/packages/f8/77/64d8431a4d77c856eb2d82aa3de2ad6741365245a29b3a9543cd598ed8c5/yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4", size = 347003, upload-time = "2025-06-10T00:43:14.088Z" }, - { url = "https://files.pythonhosted.org/packages/8d/d2/0c7e4def093dcef0bd9fa22d4d24b023788b0a33b8d0088b51aa51e21e99/yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1", size = 336537, upload-time = "2025-06-10T00:43:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f3/fc514f4b2cf02cb59d10cbfe228691d25929ce8f72a38db07d3febc3f706/yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833", size = 362358, upload-time = "2025-06-10T00:43:18.704Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6d/a313ac8d8391381ff9006ac05f1d4331cee3b1efaa833a53d12253733255/yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d", size = 357362, upload-time = "2025-06-10T00:43:20.888Z" }, - { url = "https://files.pythonhosted.org/packages/00/70/8f78a95d6935a70263d46caa3dd18e1f223cf2f2ff2037baa01a22bc5b22/yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8", size = 348979, upload-time = "2025-06-10T00:43:23.169Z" }, - { url = "https://files.pythonhosted.org/packages/cb/05/42773027968968f4f15143553970ee36ead27038d627f457cc44bbbeecf3/yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf", size = 337274, upload-time = "2025-06-10T00:43:27.111Z" }, - { url = "https://files.pythonhosted.org/packages/05/be/665634aa196954156741ea591d2f946f1b78ceee8bb8f28488bf28c0dd62/yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e", size = 363294, upload-time = "2025-06-10T00:43:28.96Z" }, - { url = "https://files.pythonhosted.org/packages/eb/90/73448401d36fa4e210ece5579895731f190d5119c4b66b43b52182e88cd5/yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389", size = 358169, upload-time = "2025-06-10T00:43:30.701Z" }, - { url = "https://files.pythonhosted.org/packages/c3/b0/fce922d46dc1eb43c811f1889f7daa6001b27a4005587e94878570300881/yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f", size = 362776, upload-time = "2025-06-10T00:43:32.51Z" }, - { url = "https://files.pythonhosted.org/packages/f1/0d/b172628fce039dae8977fd22caeff3eeebffd52e86060413f5673767c427/yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845", size = 381341, upload-time = "2025-06-10T00:43:34.543Z" }, - { url = "https://files.pythonhosted.org/packages/6b/9b/5b886d7671f4580209e855974fe1cecec409aa4a89ea58b8f0560dc529b1/yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1", size = 379988, upload-time = "2025-06-10T00:43:36.489Z" }, - { url = "https://files.pythonhosted.org/packages/73/be/75ef5fd0fcd8f083a5d13f78fd3f009528132a1f2a1d7c925c39fa20aa79/yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e", size = 371113, upload-time = "2025-06-10T00:43:38.592Z" }, - { url = "https://files.pythonhosted.org/packages/50/4f/62faab3b479dfdcb741fe9e3f0323e2a7d5cd1ab2edc73221d57ad4834b2/yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773", size = 81485, upload-time = "2025-06-10T00:43:41.038Z" }, - { url = "https://files.pythonhosted.org/packages/f0/09/d9c7942f8f05c32ec72cd5c8e041c8b29b5807328b68b4801ff2511d4d5e/yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e", size = 86686, upload-time = "2025-06-10T00:43:42.692Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" }, - { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" }, - { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287, upload-time = "2025-06-10T00:43:49.924Z" }, - { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429, upload-time = "2025-06-10T00:43:51.7Z" }, - { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429, upload-time = "2025-06-10T00:43:53.494Z" }, - { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862, upload-time = "2025-06-10T00:43:55.766Z" }, - { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616, upload-time = "2025-06-10T00:43:58.056Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954, upload-time = "2025-06-10T00:43:59.773Z" }, - { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575, upload-time = "2025-06-10T00:44:02.051Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061, upload-time = "2025-06-10T00:44:04.196Z" }, - { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142, upload-time = "2025-06-10T00:44:06.527Z" }, - { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894, upload-time = "2025-06-10T00:44:08.379Z" }, - { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378, upload-time = "2025-06-10T00:44:10.51Z" }, - { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069, upload-time = "2025-06-10T00:44:12.834Z" }, - { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload-time = "2025-06-10T00:44:14.731Z" }, - { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" }, - { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, - { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, - { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, - { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, - { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, - { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, - { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, - { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, - { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, - { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, - { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, - { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, - { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, - { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, - { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, - { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, - { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, - { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, - { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, - { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, - { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, - { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, - { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, - { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, - { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, - { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, - { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, - { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, - { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, ]