diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cd400b61eb26..fe6b004badd3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,6 +16,7 @@ env: RUST_PROFILE: release SLOW_MACHINE: 1 CI_SERVER_URL: "http://35.239.136.52:3170" + GLOBAL_PYTEST_OPTS: --reruns=5 jobs: prebuild: @@ -313,7 +314,7 @@ jobs: run: | env cat config.vars - uv run eatmydata pytest tests/test_downgrade.py -vvv -n ${PYTEST_PAR} ${PYTEST_OPTS} + uv run eatmydata pytest tests/test_downgrade.py -vvv ${GLOBAL_PYTEST_OPTS} -n ${PYTEST_PAR} ${PYTEST_OPTS} integration: name: Test CLN ${{ matrix.name }} @@ -421,7 +422,7 @@ jobs: run: | env cat config.vars - VALGRIND=0 uv run eatmydata pytest tests/ -vvv -n ${PYTEST_PAR} ${PYTEST_OPTS} + VALGRIND=0 uv run eatmydata pytest tests/ -vvv ${GLOBAL_PYTEST_OPTS} -n ${PYTEST_PAR} ${PYTEST_OPTS} integration-valgrind: name: Valgrind Test CLN ${{ matrix.name }} @@ -430,7 +431,7 @@ jobs: env: RUST_PROFILE: release # Has to match the one in the compile step CFG: compile-gcc - PYTEST_OPTS: --test-group-random-seed=42 --timeout=1800 --durations=10 + PYTEST_OPTS: --test-group-random-seed=42 --timeout=1800 --durations=10 --reruns=10 needs: - compile strategy: @@ -501,7 +502,7 @@ jobs: RUST_PROFILE: release SLOW_MACHINE: 1 TEST_DEBUG: 1 - PYTEST_OPTS: --test-group-random-seed=42 --timeout=1800 --durations=10 + PYTEST_OPTS: --test-group-random-seed=42 --timeout=1800 --durations=10 --reruns=10 needs: - compile strategy: @@ -674,27 +675,7 @@ jobs: run: | env cat config.vars - VALGRIND=0 uv run eatmydata pytest tests/ -vvv -n ${PYTEST_PAR} ${PYTEST_OPTS} - - check-flake: - name: Check Nix Flake - runs-on: ubuntu-22.04 - strategy: - fail-fast: true - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - submodules: 'recursive' - - name: Check Nix flake inputs - uses: DeterminateSystems/flake-checker-action@v8 - - name: Install Nix - uses: cachix/install-nix-action@v31 - with: - nix_path: nixpkgs=channel:nixos-unstable - - name: Check flake - run: nix flake check - + VALGRIND=0 uv run eatmydata pytest tests/ -vvv ${GLOBAL_PYTEST_OPTS} -n ${PYTEST_PAR} ${PYTEST_OPTS} gather: # A dummy task that depends on the full matrix of tests, and # signals successful completion. Used for the PR status to pass diff --git a/conftest.py b/conftest.py deleted file mode 100644 index 65b3e3236cd6..000000000000 --- a/conftest.py +++ /dev/null @@ -1,72 +0,0 @@ -import pytest -import subprocess -from urllib import request -import os -import json -from time import time -import unittest - -server = os.environ.get("CI_SERVER_URL", None) - -github_sha = ( - subprocess.check_output(["git", "rev-parse", "HEAD"]).decode("ASCII").strip() -) - -github_ref_name = ( - subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"]) - .decode("ASCII") - .strip() -) - -run_id = os.environ.get("GITHUB_RUN_ID", None) -run_number = os.environ.get("GITHUB_RUN_NUMBER", None) - -result = { - "github_repository": os.environ.get("GITHUB_REPOSITORY", None), - "github_sha": os.environ.get("GITHUB_SHA", github_sha), - "github_ref": os.environ.get("GITHUB_REF", None), - "github_ref_name": github_ref_name, - "github_run_id": int(run_id) if run_id else None, - "github_head_ref": os.environ.get("GITHUB_HEAD_REF", None), - "github_run_number": int(run_number) if run_number else None, - "github_base_ref": os.environ.get("GITHUB_BASE_REF", None), - "github_run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT", None), -} - - -@pytest.hookimpl(hookwrapper=True) -def pytest_pyfunc_call(pyfuncitem): - global result - result = result.copy() - result["testname"] = pyfuncitem.name - result["start_time"] = int(time()) - outcome = yield - result["end_time"] = int(time()) - # outcome.excinfo may be None or a (cls, val, tb) tuple - - if outcome.excinfo is None: - result["outcome"] = "success" - elif outcome.excinfo[0] == unittest.case.SkipTest: - result["outcome"] = "skip" - else: - result["outcome"] = "fail" - - print(result) - - if not server: - return - - try: - req = request.Request(f"{server}/hook/test", method="POST") - req.add_header("Content-Type", "application/json") - - request.urlopen( - req, - data=json.dumps(result).encode("ASCII"), - ) - except ConnectionError as e: - print(f"Could not report testrun: {e}") - except Exception as e: - import warnings - - warnings.warn(f"Error reporting testrun: {e}") diff --git a/contrib/pyln-testing/pyln/testing/db.py b/contrib/pyln-testing/pyln/testing/db.py index 373df50f3742..b732f50f5413 100644 --- a/contrib/pyln-testing/pyln/testing/db.py +++ b/contrib/pyln-testing/pyln/testing/db.py @@ -148,6 +148,56 @@ def stop(self) -> None: pass +class GlobalPostgresDbProvider(object): + """ + Provider that uses pytest-global-fixture for a shared PostgreSQL instance. + This provider coordinates with the global_resource fixture to get databases + from a single globally-shared PostgreSQL instance. + """ + def __init__(self, directory): + self.directory = directory + self.global_resource = None + self.port = None + print("Starting GlobalPostgresDbProvider (using global fixture)") + + def set_global_resource(self, global_resource): + """Called by the db_provider fixture to inject the global_resource.""" + self.global_resource = global_resource + + def start(self): + """No-op: the global service is started by the coordinator.""" + if self.global_resource is None: + raise RuntimeError( + "GlobalPostgresDbProvider requires global_resource fixture. " + "Make sure pytest-global-fixture plugin is loaded." + ) + pass + + def get_db(self, node_directory, testname, node_id): + """Get a database by requesting a tenant from the global service.""" + if self.global_resource is None: + raise RuntimeError( + "global_resource not set. Did you call set_global_resource()?" + ) + + # Request a tenant from the global PostgreSQL service + config = self.global_resource( + "pytest_global_fixture.postgres_service:NativePostgresService" + ) + + # Store port for compatibility + if self.port is None: + self.port = config["port"] + + # Create a PostgresDb instance + db = PostgresDb(config["dbname"], config["port"]) + return db + + def stop(self): + """No-op: global service cleanup is handled by the coordinator.""" + pass + + class PostgresDbProvider(object): def __init__(self, directory): self.directory = directory diff --git a/contrib/pyln-testing/pyln/testing/fixtures.py b/contrib/pyln-testing/pyln/testing/fixtures.py index db4206a1279c..41bcef46f1e3 100644 --- a/contrib/pyln-testing/pyln/testing/fixtures.py +++ b/contrib/pyln-testing/pyln/testing/fixtures.py @@ -1,5 +1,5 @@ from concurrent import futures -from pyln.testing.db import SqliteDbProvider, PostgresDbProvider +from pyln.testing.db import SqliteDbProvider, PostgresDbProvider, GlobalPostgresDbProvider from pyln.testing.utils import NodeFactory, BitcoinD, ElementsD, env, LightningNode, TEST_DEBUG, TEST_NETWORK from pyln.client import Millisatoshi from typing import Dict @@ -699,12 +699,20 @@ def checkMemleak(node): providers = { 'sqlite3': SqliteDbProvider, 'postgres': PostgresDbProvider, + 'gpostgres': GlobalPostgresDbProvider, } @pytest.fixture -def db_provider(test_base_dir): +def db_provider(request, test_base_dir): provider = providers[os.getenv('TEST_DB_PROVIDER', 'sqlite3')](test_base_dir) + + # If using GlobalPostgresDbProvider, inject the global_resource fixture + if isinstance(provider, GlobalPostgresDbProvider): + # Get the global_resource fixture from the same request context + global_resource = request.getfixturevalue('global_resource') + provider.set_global_resource(global_resource) + provider.start() yield provider provider.stop() diff --git a/contrib/pyln-testing/pyproject.toml b/contrib/pyln-testing/pyproject.toml index 87df3faa70e6..737434db4b25 100644 --- a/contrib/pyln-testing/pyproject.toml +++ b/contrib/pyln-testing/pyproject.toml @@ -8,6 +8,8 @@ readme = "README.md" requires-python = ">=3.9,<4.0" dependencies = [ "pytest>=8.0.0", + "pytest-xdist>=3.0.0", + "pytest-global-fixture", "ephemeral-port-reserve>=1.1.4", "psycopg2-binary>=2.9.0", "python-bitcoinlib>=0.11.0", diff --git a/contrib/pytest-global-fixture/README.md b/contrib/pytest-global-fixture/README.md new file mode 100644 index 000000000000..9fbf6901ee7d --- /dev/null +++ b/contrib/pytest-global-fixture/README.md @@ -0,0 +1,248 @@ +# pytest-global-fixture + +A pytest plugin for coordinating globally-shared infrastructure resources across pytest-xdist workers with per-test isolation. + +## The Problem + +When running integration tests that require heavy infrastructure (databases, message queues, caches, etc.), the traditional approach of starting and stopping these services for each test is prohibitively slow: + +```python +# Traditional approach - SLOW! +@pytest.fixture(scope="function") +def postgres(): + container = PostgresContainer() + container.start() # 5-10 seconds + yield container + container.stop() # 2-5 seconds +``` + +With 100 tests, this means 700-1500 seconds just for container lifecycle management! + +## The Solution + +This plugin enables a **shared infrastructure pattern** where: + +1. Heavy resources (Docker containers, VMs, etc.) are started **once** by a coordinator process +2. Each test gets an **isolated tenant** within the shared resource (separate database, schema, vhost, etc.) +3. Tests run in parallel via pytest-xdist without interfering with each other +4. Cleanup happens per-test (tenant removal) and globally at session end + +```python +# With pytest-global-fixture - FAST! +@pytest.fixture(scope="function") +def postgres_db(global_resource): + # Container started once, reused across all tests + db_config = global_resource("tests.resources:PostgresService") + conn = psycopg2.connect(**db_config) + yield conn # Fresh database for this test + conn.close() # Tenant cleaned up automatically +``` + +With 100 tests, the container starts once (~10 seconds) instead of 100 times, saving ~690-1490 seconds! + +## Architecture + +### Components + +1. **InfrastructureService Interface** (`base.py`) + - Abstract base class that all shared resources implement + - Defines lifecycle methods: `start_global()`, `stop_global()`, `create_tenant()`, `remove_tenant()` + +2. **ServiceManager** (`manager.py`) + - Runs exclusively on the coordinator (main) process + - Manages service lifecycle and tenant provisioning via XML-RPC + - Lazy-loads services on first request from any worker + +3. **Pytest Plugin** (`plugin.py`) + - Sets up XML-RPC server on coordinator process + - Provides `global_resource` fixture for tests + - Handles worker-coordinator communication + + +### Request Lifecycle + +1. **Test starts**: Worker calls `global_resource("module:Class")` +2. **RPC call**: Worker sends provision request to coordinator +3. **Lazy start**: If first request, coordinator loads and starts the service +4. **Tenant creation**: Coordinator creates isolated namespace (e.g., new database) +5. **Config returned**: Worker receives connection details (host, port, dbname, etc.) +6. **Test runs**: Test uses isolated tenant +7. **Test cleanup**: Worker calls deprovision, coordinator removes tenant +8. **Session end**: Coordinator tears down all global resources + +## Implementation Guide + +### Step 1: Implement InfrastructureService + +Create a service class implementing the required interface: + +```python +from pytest_global_fixture.base import InfrastructureService +from testcontainers.postgres import PostgresContainer +import psycopg2 + +class PostgresService(InfrastructureService): + def __init__(self): + self.container = None + self.master_config = {} + + def start_global(self) -> None: + """Start the Docker container once.""" + self.container = PostgresContainer("postgres:15-alpine") + self.container.start() + + self.master_config = { + "host": self.container.get_container_host_ip(), + "port": self.container.get_exposed_port(5432), + "user": self.container.username, + "password": self.container.password, + "dbname": self.container.dbname + } + + def stop_global(self) -> None: + """Stop the container at session end.""" + if self.container: + self.container.stop() + + def create_tenant(self, tenant_id: str) -> dict: + """Create an isolated database for this test.""" + conn = psycopg2.connect(**self.master_config) + conn.autocommit = True + with conn.cursor() as cur: + cur.execute(f"CREATE DATABASE {tenant_id}") + conn.close() + + # Return config pointing to the new database + tenant_config = self.master_config.copy() + tenant_config["dbname"] = tenant_id + return tenant_config + + def remove_tenant(self, tenant_id: str) -> None: + """Drop the tenant database.""" + conn = psycopg2.connect(**self.master_config) + conn.autocommit = True + with conn.cursor() as cur: + cur.execute(f"DROP DATABASE IF EXISTS {tenant_id}") + conn.close() +``` + +### Step 2: Create Test Fixture + +Wrap the `global_resource` fixture for convenient test usage: + +```python +# conftest.py +import pytest +import psycopg2 + +@pytest.fixture(scope="function") +def postgres_db(global_resource): + """Provides an isolated PostgreSQL database for each test.""" + # Request the service by its import path + db_config = global_resource("tests.resources:PostgresService") + + # Create connection to the dedicated database + conn = psycopg2.connect(**db_config) + conn.autocommit = True + + yield conn + + conn.close() + # Tenant cleanup happens automatically via global_resource +``` + +### Step 3: Write Tests + +```python +def test_create_table(postgres_db): + """Each test gets its own database.""" + with postgres_db.cursor() as cur: + cur.execute("CREATE TABLE users (id serial PRIMARY KEY, name text)") + cur.execute("INSERT INTO users (name) VALUES ('Alice')") + cur.execute("SELECT count(*) FROM users") + assert cur.fetchone()[0] == 1 + +def test_independent_data(postgres_db): + """This test won't see data from test_create_table.""" + with postgres_db.cursor() as cur: + # This table doesn't exist in our isolated database + cur.execute("CREATE TABLE users (id serial PRIMARY KEY, name text)") + cur.execute("INSERT INTO users (name) VALUES ('Bob')") + cur.execute("SELECT count(*) FROM users") + assert cur.fetchone()[0] == 1 # Only Bob, not Alice +``` + +### Step 4: Run Tests + +```bash +# Run with xdist for parallel execution +pytest -n auto + +# The plugin is automatically loaded if installed +``` + +## When to Use This Pattern + +**Good Use Cases:** +- Databases (PostgreSQL, MySQL, MongoDB) +- Message queues (RabbitMQ, Kafka with topics/partitions) +- Caches (Redis with separate key prefixes or databases) +- Any containerized service with internal tenant isolation + +**Requirements:** +- The resource must support logical isolation (databases, schemas, vhosts, namespaces, etc.) +- Creating/destroying tenants must be faster than restarting the entire service +- Tests must be independent (no shared state between tests) + +**Poor Fit:** +- Services without tenant isolation capabilities +- Tests that require exclusive access to the entire service +- Resources that are fast to start/stop (< 1 second) + +## Configuration + +Add to `pyproject.toml`: + +```toml +[tool.pytest.ini_options] +addopts = "-p pytest_global_fixture.plugin -n auto" +``` + +## How It Works: Technical Details + +### XML-RPC Communication + +The plugin uses XML-RPC for coordinator-worker communication: +- **Coordinator**: Runs `SimpleXMLRPCServer` on ephemeral port +- **Workers**: Connect via `xmlrpc.client.ServerProxy` +- **Methods**: `rpc_provision()` and `rpc_deprovision()` + +### Thread Safety + +The `ServiceManager` uses a `threading.Lock` to ensure: +- Services are started exactly once (even with concurrent worker requests) +- Tenant operations are atomic +- No race conditions during service initialization + +### Dynamic Class Loading + +Services are loaded dynamically from string paths (`module:ClassName`): +- Enables decoupled architecture (plugin doesn't depend on service implementations) +- Supports user-defined services without plugin modification +- Services are imported lazily when first requested + +### Tenant ID Format + +Generated as `{worker_id}_{uuid}`: +- `worker_id`: `gw0`, `gw1`, ..., or `master` for sequential runs +- `uuid`: Short random identifier for uniqueness +- Example: `gw0_a3f2c1` + +## Performance Benefits + +Example with PostgreSQL container: +- **Container startup**: ~8 seconds +- **Container shutdown**: ~3 seconds +- **Database creation**: ~0.1 seconds +- **Database drop**: ~0.1 seconds + diff --git a/contrib/pytest-global-fixture/pyproject.toml b/contrib/pytest-global-fixture/pyproject.toml new file mode 100644 index 000000000000..1f7afe2d5223 --- /dev/null +++ b/contrib/pytest-global-fixture/pyproject.toml @@ -0,0 +1,38 @@ +[project] +name = "pytest-global-fixture" +version = "0.1.0" +description = "A pytest plugin for coordinating global resources across xdist workers" +readme = "README.md" +requires-python = ">=3.9" +dependencies = [ + "pytest>=7.0.0", + "pytest-xdist>=3.0.0", +] + +[project.entry-points.pytest11] +pytest_global_fixture = "pytest_global_fixture.plugin" + +[project.optional-dependencies] +dev = [ + "testcontainers>=3.7.0", + "psycopg2-binary>=2.9.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["pytest_global_fixture"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +# Register the plugin explicitly for local development +addopts = "-p pytest_global_fixture.plugin -n auto" + +[dependency-groups] +dev = [ + "psycopg2-binary>=2.9.10", + "pytest-flakefinder>=1.1.0", + "testcontainers>=4.13.3", +] diff --git a/contrib/pytest-global-fixture/pytest_global_fixture/__init__.py b/contrib/pytest-global-fixture/pytest_global_fixture/__init__.py new file mode 100644 index 000000000000..c7c08e51df91 --- /dev/null +++ b/contrib/pytest-global-fixture/pytest_global_fixture/__init__.py @@ -0,0 +1,11 @@ +""" +pytest-global-fixture: A pytest plugin for globally shared infrastructure resources. +""" + +from .base import InfrastructureService +from .postgres_service import NativePostgresService + +__all__ = [ + 'InfrastructureService', + 'NativePostgresService', +] diff --git a/contrib/pytest-global-fixture/pytest_global_fixture/base.py b/contrib/pytest-global-fixture/pytest_global_fixture/base.py new file mode 100644 index 000000000000..c290f779cb85 --- /dev/null +++ b/contrib/pytest-global-fixture/pytest_global_fixture/base.py @@ -0,0 +1,46 @@ +from abc import ABC, abstractmethod +from typing import Any, Dict + + +class InfrastructureService(ABC): + """ + Interface that all shared resources must implement. + These methods run exclusively on the Coordinator (Main) Process. + """ + + @abstractmethod + def start_global(self) -> None: + """ + Initialize the heavy, global resource (e.g., Docker container). + This is called exactly once, the first time a worker requests this service. + """ + pass + + @abstractmethod + def stop_global(self) -> None: + """ + Teardown the global resource. + Called at the very end of the pytest session. + """ + pass + + @abstractmethod + def create_tenant(self, tenant_id: str) -> Dict[str, Any]: + """ + Create a logical isolation unit (Database, Schema, VHost). + + Args: + tenant_id: A unique string identifier for the requester (e.g., 'gw0_test_uuid'). + + Returns: + A JSON-serializable dictionary containing connection details + (host, port, user, password, db_name, etc.) + """ + pass + + @abstractmethod + def remove_tenant(self, tenant_id: str) -> None: + """ + Clean up the logical isolation unit. + """ + pass diff --git a/contrib/pytest-global-fixture/pytest_global_fixture/manager.py b/contrib/pytest-global-fixture/pytest_global_fixture/manager.py new file mode 100644 index 000000000000..0ab1bde80737 --- /dev/null +++ b/contrib/pytest-global-fixture/pytest_global_fixture/manager.py @@ -0,0 +1,95 @@ +import threading +import importlib +import sys +from typing import Dict +from .base import InfrastructureService + + +class ServiceManager: + """ + Runs on the Master process. + Manages the lifecycle of services and exposes them via XML-RPC. + """ + + def __init__(self): + self._services: Dict[str, InfrastructureService] = {} # Map path -> Instance + self._lock = threading.Lock() + + def _load_class(self, class_path: str) -> InfrastructureService: + """ + Dynamically imports a class from a string 'module.path:ClassName'. + """ + try: + module_name, class_name = class_path.split(":") + except ValueError: + raise ValueError(f"Invalid format '{class_path}'. Expected 'module:Class'") + + try: + # Ensure the current directory is in path so we can import local tests + if "." not in sys.path: + sys.path.insert(0, ".") + + module = importlib.import_module(module_name) + cls = getattr(module, class_name) + return cls() # Instantiate + except (ImportError, AttributeError) as e: + raise RuntimeError(f"Could not load service class '{class_path}': {e}") + + # --- RPC Exposed Methods --- + + def rpc_provision(self, class_path: str, tenant_id: str) -> Dict: + """ + Idempotent method to start a global service (if needed) and create a tenant. + """ + with self._lock: + # 1. Lazy Load & Start Global + if class_path not in self._services: + print(f"[Coordinator] Dynamically loading: {class_path}") + service = self._load_class(class_path) + + print(f"[Coordinator] Starting Global Resource: {class_path}") + try: + service.start_global() + except Exception as e: + print(f"[Coordinator] Failed to start {class_path}: {e}") + raise e + + self._services[class_path] = service + + service = self._services[class_path] + + # 2. Create Tenant + print(f"[Coordinator] Provisioning tenant '{tenant_id}' on {class_path}") + try: + config = service.create_tenant(tenant_id) + return config + except Exception as e: + print(f"[Coordinator] Failed to create tenant {tenant_id}: {e}") + raise e + + def rpc_deprovision(self, class_path: str, tenant_id: str) -> bool: + """ + Removes a tenant. + """ + print(f"[Coordinator] De-Provisioning tenant '{tenant_id}' on {class_path}") + with self._lock: + service = self._services.get(class_path) + if service: + try: + service.remove_tenant(tenant_id) + return True + except Exception as e: + print(f"[Coordinator] Error removing tenant {tenant_id}: {e}") + return False + + def teardown_all(self): + """ + Stop all global services. + """ + print("\n[Coordinator] Shutting down all global resources...") + for name, service in self._services.items(): + try: + print(f"[Coordinator] Stopping {name}") + service.stop_global() + except Exception as e: + print(f"[Coordinator] Error stopping {name}: {e}") diff --git a/contrib/pytest-global-fixture/pytest_global_fixture/plugin.py b/contrib/pytest-global-fixture/pytest_global_fixture/plugin.py new file mode 100644 index 000000000000..75624ab3bbf3 --- /dev/null +++ b/contrib/pytest-global-fixture/pytest_global_fixture/plugin.py @@ -0,0 +1,131 @@ +import pytest +import threading +import uuid +from xmlrpc.server import SimpleXMLRPCServer, SimpleXMLRPCRequestHandler +import xmlrpc.client +from .manager import ServiceManager + +# --- RPC Server Setup (Runs on Master) --- + + +class QuietXMLRPCRequestHandler(SimpleXMLRPCRequestHandler): + """Suppress standard logging from XML-RPC server.""" + + def log_message(self, format, *args): + pass + + +def pytest_configure(config): + """ + If this is the Master/Coordinator process, start the RPC server. + """ + # Check if we are a worker (xdist). If no workerinput, we are Master. + if not hasattr(config, "workerinput"): + print("\n" + "=" * 80) + print("PYTEST-GLOBAL-FIXTURE: Coordinator mode - managing shared resources") + print("=" * 80) + manager = ServiceManager() + + # Bind to port 0 (ephemeral) + server = SimpleXMLRPCServer( + ("localhost", 0), + requestHandler=QuietXMLRPCRequestHandler, + allow_none=True, + logRequests=False, + ) + server.register_instance(manager) + + # Run server in daemon thread + t = threading.Thread(target=server.serve_forever, daemon=True) + t.start() + + host, port = server.server_address + rpc_addr = f"http://{host}:{port}/" + + # Store in config to pass to workers/hooks + config.infra_rpc_addr = rpc_addr + config.infra_manager = manager + + print(f"--- [Coordinator] Infrastructure Manager listening at {rpc_addr} ---") + + +def pytest_configure_node(node): + """ + This runs on Master for each Worker node being created. + Pass the RPC address to the worker. + """ + node.workerinput["infra_rpc_addr"] = node.config.infra_rpc_addr + + +def pytest_unconfigure(config): + """ + Run teardown on Master when session ends. + """ + if hasattr(config, "infra_manager"): + config.infra_manager.teardown_all() + + +# --- Fixture (Runs on Workers) --- + + +@pytest.fixture(scope="session") +def coordinator_client(request): + """ + Returns the XML-RPC client to talk to the manager. + """ + if hasattr(request.config, "workerinput"): + addr = request.config.workerinput["infra_rpc_addr"] + worker_id = request.config.workerinput.get("workerid", "unknown") + print( + f"[{worker_id}] PYTEST-GLOBAL-FIXTURE: Worker connecting to coordinator at {addr}" + ) + else: + # We are running sequentially (no xdist), or we are the master + addr = request.config.infra_rpc_addr + print(f"PYTEST-GLOBAL-FIXTURE: Sequential mode, using coordinator at {addr}") + + return xmlrpc.client.ServerProxy(addr) + + +@pytest.fixture(scope="function") +def global_resource(request): + """ + Factory fixture. + Usage: global_resource("path.to:Class") + """ + + # Get RPC address + if hasattr(request.config, "workerinput"): + addr = request.config.workerinput["infra_rpc_addr"] + else: + addr = request.config.infra_rpc_addr + + # Track resources created in this scope for cleanup + created_resources = [] + + def _provision(class_path): + # Create unique tenant ID: "gwX_testName_UUID" + worker_id = getattr(request.config, "workerinput", {}).get("workerid", "master") + # Short uuid for uniqueness + uid = uuid.uuid4().hex[:6] + tenant_id = f"{worker_id}_{uid}" + + # Create a new ServerProxy for each call to avoid connection reuse issues + # This prevents http.client.CannotSendRequest errors in multi-threaded scenarios + client = xmlrpc.client.ServerProxy(addr) + config = client.rpc_provision(class_path, tenant_id) + + created_resources.append((class_path, tenant_id)) + return config + + yield _provision + + # Teardown logic + for class_path, tenant_id in reversed(created_resources): + try: + # Create a new client for cleanup too + client = xmlrpc.client.ServerProxy(addr) + client.rpc_deprovision(class_path, tenant_id) + except Exception as e: + # We print but don't raise, to avoid masking test failures + print(f"Warning: Failed to deprovision {tenant_id}: {e}") diff --git a/contrib/pytest-global-fixture/pytest_global_fixture/postgres_service.py b/contrib/pytest-global-fixture/pytest_global_fixture/postgres_service.py new file mode 100644 index 000000000000..2ea74e32b59a --- /dev/null +++ b/contrib/pytest-global-fixture/pytest_global_fixture/postgres_service.py @@ -0,0 +1,219 @@ +""" +Production-ready PostgreSQL service using native binaries. +This service starts a real PostgreSQL instance using initdb and postgres binaries, +similar to how pyln-testing's PostgresDbProvider works, but as a globally shared resource. +""" +import itertools +import logging +import os +import psycopg2 +import shutil +import signal +import subprocess +import tempfile +import time +from psycopg2 import sql +from typing import Dict +from .base import InfrastructureService + + +class NativePostgresService(InfrastructureService): + """ + PostgreSQL service using native postgres/initdb binaries. + Starts one PostgreSQL instance globally and creates separate databases per tenant. + """ + + def __init__(self, base_dir: str = None): + """ + Args: + base_dir: Directory to store postgres data. If None, uses tempfile. + """ + self.base_dir = base_dir + self.pgdir = None + self.port = None + self.proc = None + self.conn = None + self.master_config = {} + + def _locate_postgres_binaries(self): + """Find PostgreSQL binaries using pg_config.""" + pg_config = shutil.which('pg_config') + if not pg_config: + raise ValueError( + "Could not find `pg_config` to determine PostgreSQL binaries. " + "Is PostgreSQL installed?" + ) + + bindir = subprocess.check_output([pg_config, '--bindir']).decode().rstrip() + if not os.path.isdir(bindir): + raise ValueError( + f"Error: `pg_config --bindir` didn't return a proper path: {bindir}" + ) + + initdb = os.path.join(bindir, 'initdb') + postgres = os.path.join(bindir, 'postgres') + + if os.path.isfile(initdb) and os.path.isfile(postgres): + if os.access(initdb, os.X_OK) and os.access(postgres, os.X_OK): + logging.info(f"Found `postgres` and `initdb` in {bindir}") + return initdb, postgres + + raise ValueError( + f"Could not find `postgres` and `initdb` binaries in {bindir}" + ) + + def _reserve_port(self): + """Reserve an unused port for PostgreSQL.""" + import socket + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('', 0)) + s.listen(1) + port = s.getsockname()[1] + return port + + def start_global(self) -> None: + """Initialize and start the PostgreSQL instance.""" + print(">> [NativePostgresService] Starting PostgreSQL instance...") + + # Create base directory + if self.base_dir is None: + self.base_dir = tempfile.mkdtemp(prefix='pytest-postgres-') + else: + os.makedirs(self.base_dir, exist_ok=True) + + # Find an unused postgres data directory + for i in itertools.count(): + self.pgdir = os.path.join(self.base_dir, f'pgsql-{i}') + if not os.path.exists(self.pgdir): + break + + # Create password file for initdb + passfile = os.path.join(self.base_dir, "pgpass.txt") + with open(passfile, 'w') as f: + f.write('postgres\n') + + # Initialize the database cluster + initdb, postgres = self._locate_postgres_binaries() + subprocess.check_call([ + initdb, + f'--pwfile={passfile}', + f'--pgdata={self.pgdir}', + '--auth=trust', + '--username=postgres', + ]) + + # Configure postgres for high connection count + conffile = os.path.join(self.pgdir, 'postgresql.conf') + with open(conffile, 'a') as f: + f.write('max_connections = 1000\n') + f.write('shared_buffers = 240MB\n') + + # Reserve a port and start postgres + self.port = self._reserve_port() + self.proc = subprocess.Popen([ + postgres, + '-k', '/tmp/', # Unix socket directory + '-D', self.pgdir, + '-p', str(self.port), + '-F', # No fsync (faster, ok for tests) + '-i', # Listen on TCP + ]) + + # Wait for postgres to be ready + self._wait_for_ready() + + # Connect to template1 for database operations + self.conn = psycopg2.connect( + f"dbname=template1 user=postgres host=localhost port={self.port}" + ) + self.conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT) + + # Store master config for creating databases + self.master_config = { + "host": "localhost", + "port": self.port, + "user": "postgres", + "dbname": "template1" + } + + print(f">> [NativePostgresService] PostgreSQL started on port {self.port}") + + def _wait_for_ready(self): + """Wait for PostgreSQL to be ready to accept connections.""" + for i in range(30): + try: + test_conn = psycopg2.connect( + f"dbname=template1 user=postgres host=localhost port={self.port}" + ) + test_conn.close() + return + except Exception: + time.sleep(0.5) + + raise RuntimeError("PostgreSQL failed to start within timeout") + + def stop_global(self) -> None: + """Stop the PostgreSQL instance and clean up.""" + print(">> [NativePostgresService] Stopping PostgreSQL instance...") + + if self.conn: + self.conn.close() + + if self.proc: + # Fast shutdown: SIGINT + self.proc.send_signal(signal.SIGINT) + self.proc.wait() + + if self.pgdir and os.path.exists(self.pgdir): + shutil.rmtree(self.pgdir) + + print(">> [NativePostgresService] PostgreSQL stopped") + + def create_tenant(self, tenant_id: str) -> Dict: + """ + Create an isolated database for a tenant. + + Args: + tenant_id: Unique identifier for the tenant + + Returns: + Dictionary with connection parameters: host, port, user, dbname + """ + # Sanitize database name (postgres doesn't like dashes in identifiers) + safe_name = tenant_id.replace("-", "_") + + with self.conn.cursor() as cur: + cur.execute( + sql.SQL("CREATE DATABASE {}").format(sql.Identifier(safe_name)) + ) + + # Return connection config for the tenant database + return { + "host": "localhost", + "port": self.port, + "user": "postgres", + "dbname": safe_name + } + + def remove_tenant(self, tenant_id: str) -> None: + """ + Drop the tenant database. + + Args: + tenant_id: Unique identifier for the tenant + """ + safe_name = tenant_id.replace("-", "_") + + with self.conn.cursor() as cur: + # Terminate any active connections to the database + cur.execute(sql.SQL(""" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = {} + AND pid <> pg_backend_pid() + """).format(sql.Literal(safe_name))) + + # Drop the database + cur.execute( + sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(safe_name)) + ) diff --git a/contrib/pytest-global-fixture/tests/conftest.py b/contrib/pytest-global-fixture/tests/conftest.py new file mode 100644 index 000000000000..abdf20dfe1e1 --- /dev/null +++ b/contrib/pytest-global-fixture/tests/conftest.py @@ -0,0 +1,24 @@ +import pytest +import psycopg2 + + +@pytest.fixture(scope="function") +def postgres_db(global_resource): + """ + User-facing fixture. + 1. Tells coordinator to load 'tests.resources:PostgresService' + 2. Coordinator starts Docker (if not running). + 3. Coordinator creates a dedicated DB for this test. + 4. Returns connection to that dedicated DB. + """ + # Request the service by Class Path + db_config = global_resource("tests.resources:PostgresService") + + # Create the actual connection object for the test to use + conn = psycopg2.connect(**db_config) + conn.autocommit = True + + yield conn + + conn.close() + # After yield, 'global_resource' fixture automatically calls remove_tenant diff --git a/contrib/pytest-global-fixture/tests/resources.py b/contrib/pytest-global-fixture/tests/resources.py new file mode 100644 index 000000000000..d024bbcb8473 --- /dev/null +++ b/contrib/pytest-global-fixture/tests/resources.py @@ -0,0 +1,100 @@ +import time +import psycopg2 +from psycopg2 import sql +from testcontainers.postgres import PostgresContainer +from pytest_global_fixture.base import InfrastructureService + + +class PostgresService(InfrastructureService): + def __init__(self): + self.container = None + self.master_config = {} + + def start_global(self) -> None: + """ + Starts a single Postgres Docker container. + """ + print(">> [Service] Starting Postgres Container...") + time.sleep(30) + self.container = PostgresContainer("postgres:15-alpine") + self.container.start() + + # Connection info for the Superuser + self.master_config = { + "host": self.container.get_container_host_ip(), + "port": self.container.get_exposed_port(5432), + "user": self.container.username, + "password": self.container.password, + "dbname": self.container.dbname, + } + + # Wait for readiness + self._wait_for_ready() + + def stop_global(self) -> None: + if self.container: + time.sleep(30) + print(">> [Service] Stopping Postgres Container...") + self.container.stop() + + def create_tenant(self, tenant_id: str) -> dict: + """ + Creates a new DATABASE for the specific test/worker. + """ + # Connect as superuser to create a new DB + conn = psycopg2.connect(**self.master_config) + conn.autocommit = True + try: + with conn.cursor() as cur: + # Sanitize the tenant_id slightly for SQL (simple alphanumeric check is best) + safe_name = tenant_id.replace("-", "_") + cur.execute( + sql.SQL("CREATE DATABASE {}").format(sql.Identifier(safe_name)) + ) + finally: + conn.close() + + # Return config pointing to the NEW database + tenant_config = self.master_config.copy() + tenant_config["dbname"] = tenant_id.replace("-", "_") + return tenant_config + + def remove_tenant(self, tenant_id: str) -> None: + """ + Drops the tenant database. + """ + conn = psycopg2.connect(**self.master_config) + conn.autocommit = True + safe_name = tenant_id.replace("-", "_") + try: + with conn.cursor() as cur: + # Force disconnect users before dropping + cur.execute( + sql.SQL(""" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = {} + AND pid <> pg_backend_pid(); + """).format(sql.Literal(safe_name)) + ) + + cur.execute( + sql.SQL("DROP DATABASE IF EXISTS {}").format( + sql.Identifier(safe_name) + ) + ) + finally: + conn.close() + + def _wait_for_ready(self): + # Basic check + retries = 5 + while retries > 0: + try: + conn = psycopg2.connect(**self.master_config) + conn.close() + return + except Exception: + time.sleep(1) + retries -= 1 + raise RuntimeError("Postgres failed to start") diff --git a/contrib/pytest-global-fixture/tests/test_postgres.py b/contrib/pytest-global-fixture/tests/test_postgres.py new file mode 100644 index 000000000000..d46a9348d5f0 --- /dev/null +++ b/contrib/pytest-global-fixture/tests/test_postgres.py @@ -0,0 +1,50 @@ + + +def test_table_isolation_a(postgres_db, worker_id): + """ + Creates a table 'items'. + If isolation works, test_table_isolation_b won't see this table or data. + """ + with postgres_db.cursor() as cur: + # Get current DB name to verify tenant + cur.execute("SELECT current_database();") + db_name = cur.fetchone()[0] + print(f"Worker {worker_id} connected to {db_name}") + + cur.execute("CREATE TABLE items (id serial PRIMARY KEY, name text);") + cur.execute("INSERT INTO items (name) VALUES ('item_from_a');") + + cur.execute("SELECT count(*) FROM items;") + assert cur.fetchone()[0] == 1 + + +def test_table_isolation_b(postgres_db, worker_id): + """ + Runs in parallel with A. + Should NOT see table 'items' from A because we are in a different DB. + """ + with postgres_db.cursor() as cur: + cur.execute("SELECT current_database();") + db_name = cur.fetchone()[0] + print(f"Worker {worker_id} connected to {db_name}") + + # This should fail if we were sharing the same DB and A ran first without cleanup + # Or, if we are isolated, this table shouldn't exist. + try: + cur.execute("SELECT * FROM items;") + exists = True + except Exception: + exists = False + # Reset transaction if error occurred + postgres_db.rollback() + + if not exists: + # Good, table doesn't exist, let's create our own + cur.execute("CREATE TABLE items (id serial PRIMARY KEY, name text);") + cur.execute("INSERT INTO items (name) VALUES ('item_from_b');") + cur.execute("SELECT count(*) FROM items;") + assert cur.fetchone()[0] == 1 + else: + # If table exists, ensure it doesn't have A's data (if we were reusing DBs) + # But in this architecture, we expect a clean DB, so table shouldn't exist. + pass diff --git a/pyproject.toml b/pyproject.toml index 4003937614da..9b643b976170 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,12 +15,14 @@ dependencies = [ "pyln-client", "pyln-proto", "pyln-grpc-proto", + "pytest-global-fixture", + "pytest-rerunfailures>=16.0.1", ] package-mode = false [dependency-groups] dev = [ # Test dependencies and inherited dependencies belong here - "crc32c>=2.2.post0", # Belongs to lnprototest + "crc32c>=2.2.post0", # Belongs to lnprototest "pytest>=8.0.0", "pytest-xdist>=3.6.0", "pytest-test-groups>=1.2.0", @@ -32,6 +34,8 @@ dev = [ "flask-socketio>=5", "tqdm", "pytest-benchmark", + "pyln-testing", + "pytest-global-fixture", ] [project.optional-dependencies] @@ -50,6 +54,7 @@ members = [ "contrib/pyln-grpc-proto", "plugins/wss-proxy", "contrib/pyln-testing", + "contrib/pytest-global-fixture", "contrib/pyln-spec/bolt1", "contrib/pyln-spec/bolt2", "contrib/pyln-spec/bolt4", @@ -61,8 +66,9 @@ pyln-client = { workspace = true } pyln-proto = { workspace = true } pyln-grpc-proto = { workspace = true } wss-proxy = { workspace = true } -pyln-testing = { workspace = true } pyln-bolt1 = { workspace = true } pyln-bolt2 = { workspace = true } pyln-bolt4 = { workspace = true } pyln-bolt7 = { workspace = true } +pyln-testing = { workspace = true } +pytest-global-fixture = { workspace = true } diff --git a/tests/fixtures.py b/tests/fixtures.py index d3a4a114e471..9efaa3e956de 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,6 +1,7 @@ from utils import TEST_NETWORK, VALGRIND # noqa: F401,F403 from pyln.testing.fixtures import directory, test_base_dir, test_name, chainparams, node_factory, bitcoind, teardown_checks, db_provider, executor, setup_logging, jsonschemas # noqa: F401,F403 from pyln.testing import utils +from pytest_global_fixture.plugin import global_resource, coordinator_client # noqa: F401 from utils import COMPAT from pathlib import Path diff --git a/uv.lock b/uv.lock index 9db2982a1905..73f238befa3e 100644 --- a/uv.lock +++ b/uv.lock @@ -17,6 +17,7 @@ members = [ "pyln-grpc-proto", "pyln-proto", "pyln-testing", + "pytest-global-fixture", ] [[package]] @@ -400,6 +401,9 @@ dependencies = [ { name = "pyln-client" }, { name = "pyln-grpc-proto" }, { name = "pyln-proto" }, + { name = "pytest-global-fixture" }, + { name = "pytest-rerunfailures", version = "16.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest-rerunfailures", version = "16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "websocket-client" }, ] @@ -414,9 +418,11 @@ dev = [ { name = "flake8" }, { name = "flaky" }, { name = "flask-socketio" }, + { name = "pyln-testing" }, { name = "pytest" }, { name = "pytest-benchmark" }, { name = "pytest-custom-exit-code" }, + { name = "pytest-global-fixture" }, { name = "pytest-test-groups" }, { name = "pytest-timeout" }, { name = "pytest-xdist" }, @@ -435,6 +441,8 @@ requires-dist = [ { name = "pyln-grpc-proto", editable = "contrib/pyln-grpc-proto" }, { name = "pyln-proto", editable = "contrib/pyln-proto" }, { name = "pyln-testing", marker = "extra == 'grpc'", editable = "contrib/pyln-testing" }, + { name = "pytest-global-fixture", editable = "contrib/pytest-global-fixture" }, + { name = "pytest-rerunfailures", specifier = ">=16.0.1" }, { name = "websocket-client", specifier = ">=1.2.3" }, ] provides-extras = ["grpc"] @@ -445,9 +453,11 @@ dev = [ { name = "flake8", specifier = ">=7.0" }, { name = "flaky", specifier = ">=3.7.0" }, { name = "flask-socketio", specifier = ">=5" }, + { name = "pyln-testing", editable = "contrib/pyln-testing" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-benchmark" }, { name = "pytest-custom-exit-code", specifier = "==0.3.0" }, + { name = "pytest-global-fixture", editable = "contrib/pytest-global-fixture" }, { name = "pytest-test-groups", specifier = ">=1.2.0" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist", specifier = ">=3.6.0" }, @@ -665,6 +675,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/cd/fe6b65e1117ec7631f6be8951d3db076bac3e1b096e3e12710ed071ffc3c/cryptography-46.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:34f04b7311174469ab3ac2647469743720f8b6c8b046f238e5cb27905695eb2a", size = 3448210, upload-time = "2025-09-17T00:10:30.145Z" }, ] +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + [[package]] name = "ephemeral-port-reserve" version = "1.1.4" @@ -1453,6 +1477,8 @@ dependencies = [ { name = "psycopg2-binary" }, { name = "pyln-client" }, { name = "pytest" }, + { name = "pytest-global-fixture" }, + { name = "pytest-xdist" }, { name = "python-bitcoinlib" }, { name = "requests" }, ] @@ -1481,6 +1507,8 @@ requires-dist = [ { name = "pyln-client", editable = "contrib/pyln-client" }, { name = "pyln-grpc-proto", marker = "extra == 'grpc'", editable = "contrib/pyln-grpc-proto" }, { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-global-fixture", editable = "contrib/pytest-global-fixture" }, + { name = "pytest-xdist", specifier = ">=3.0.0" }, { name = "python-bitcoinlib", specifier = ">=0.11.0" }, { name = "requests", specifier = ">=2.32.0" }, ] @@ -1544,6 +1572,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/a0/effb6cbbccfd1c106c572d3d619b3418d71093afb4cd4f91f51e6a1799d2/pytest_custom_exit_code-0.3.0-py3-none-any.whl", hash = "sha256:6e0ce6e57ce3a583cb7e5023f7d1021e19dfec22be41d9ad345bae2fc61caf3b", size = 4055, upload-time = "2019-08-07T09:45:13.767Z" }, ] +[[package]] +name = "pytest-flakefinder" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/53/69c56a93ea057895b5761c5318455804873a6cd9d796d7c55d41c2358125/pytest-flakefinder-1.1.0.tar.gz", hash = "sha256:e2412a1920bdb8e7908783b20b3d57e9dad590cc39a93e8596ffdd493b403e0e", size = 6795, upload-time = "2022-10-26T18:27:54.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8b/06787150d0fd0cbd3a8054262b56f91631c7778c1bc91bf4637e47f909ad/pytest_flakefinder-1.1.0-py2.py3-none-any.whl", hash = "sha256:741e0e8eea427052f5b8c89c2b3c3019a50c39a59ce4df6a305a2c2d9ba2bd13", size = 4644, upload-time = "2022-10-26T18:27:52.128Z" }, +] + +[[package]] +name = "pytest-global-fixture" +version = "0.1.0" +source = { editable = "contrib/pytest-global-fixture" } +dependencies = [ + { name = "pytest" }, + { name = "pytest-xdist" }, +] + +[package.optional-dependencies] +dev = [ + { name = "psycopg2-binary" }, + { name = "testcontainers" }, +] + +[package.dev-dependencies] +dev = [ + { name = "psycopg2-binary" }, + { name = "pytest-flakefinder" }, + { name = "testcontainers" }, +] + +[package.metadata] +requires-dist = [ + { name = "psycopg2-binary", marker = "extra == 'dev'", specifier = ">=2.9.0" }, + { name = "pytest", specifier = ">=7.0.0" }, + { name = "pytest-xdist", specifier = ">=3.0.0" }, + { name = "testcontainers", marker = "extra == 'dev'", specifier = ">=3.7.0" }, +] +provides-extras = ["dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "psycopg2-binary", specifier = ">=2.9.10" }, + { name = "pytest-flakefinder", specifier = ">=1.1.0" }, + { name = "testcontainers", specifier = ">=4.13.3" }, +] + +[[package]] +name = "pytest-rerunfailures" +version = "16.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pytest", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/53/a543a76f922a5337d10df22441af8bf68f1b421cadf9aedf8a77943b81f6/pytest_rerunfailures-16.0.1.tar.gz", hash = "sha256:ed4b3a6e7badb0a720ddd93f9de1e124ba99a0cb13bc88561b3c168c16062559", size = 27612, upload-time = "2025-09-02T06:48:25.193Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/73/67dc14cda1942914e70fbb117fceaf11e259362c517bdadd76b0dd752524/pytest_rerunfailures-16.0.1-py3-none-any.whl", hash = "sha256:0bccc0e3b0e3388275c25a100f7077081318196569a121217688ed05e58984b9", size = 13610, upload-time = "2025-09-02T06:48:23.615Z" }, +] + +[[package]] +name = "pytest-rerunfailures" +version = "16.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pytest", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/04/71e9520551fc8fe2cf5c1a1842e4e600265b0815f2016b7c27ec85688682/pytest_rerunfailures-16.1.tar.gz", hash = "sha256:c38b266db8a808953ebd71ac25c381cb1981a78ff9340a14bcb9f1b9bff1899e", size = 30889, upload-time = "2025-10-10T07:06:01.238Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/54/60eabb34445e3db3d3d874dc1dfa72751bfec3265bd611cb13c8b290adea/pytest_rerunfailures-16.1-py3-none-any.whl", hash = "sha256:5d11b12c0ca9a1665b5054052fcc1084f8deadd9328962745ef6b04e26382e86", size = 14093, upload-time = "2025-10-10T07:06:00.019Z" }, +] + [[package]] name = "pytest-test-groups" version = "1.2.1" @@ -1590,6 +1700,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/46/b388d0885cf799f5bc66ca9995c83e5b7e17cb737f812a7c2591aa789ea6/python_bitcoinlib-0.12.2-py3-none-any.whl", hash = "sha256:2f29a9f475f21c12169b3a6cc8820f34f11362d7ff1200a5703dce3e4e903a44", size = 106954, upload-time = "2023-06-03T18:37:08.524Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + [[package]] name = "python-engineio" version = "4.12.3" @@ -1615,6 +1734,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/32/b4fb8585d1be0f68bde7e110dffbcf354915f77ad8c778563f0ad9655c02/python_socketio-5.13.0-py3-none-any.whl", hash = "sha256:51f68d6499f2df8524668c24bcec13ba1414117cfb3a90115c559b601ab10caf", size = 77800, upload-time = "2025-04-12T15:46:58.412Z" }, ] +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, + { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, + { url = "https://files.pythonhosted.org/packages/59/42/b86689aac0cdaee7ae1c58d464b0ff04ca909c19bb6502d4973cdd9f9544/pywin32-311-cp39-cp39-win32.whl", hash = "sha256:aba8f82d551a942cb20d4a83413ccbac30790b50efb89a75e4f586ac0bb8056b", size = 8760837, upload-time = "2025-07-14T20:12:59.59Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8a/1403d0353f8c5a2f0829d2b1c4becbf9da2f0a4d040886404fc4a5431e4d/pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91", size = 9590187, upload-time = "2025-07-14T20:13:01.419Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/e0e8d802f124772cec9c75430b01a212f86f9de7546bda715e54140d5aeb/pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d", size = 8778162, upload-time = "2025-07-14T20:13:03.544Z" }, +] + [[package]] name = "referencing" version = "0.36.2" @@ -1827,6 +1971,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, ] +[[package]] +name = "testcontainers" +version = "4.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/b3/c272537f3ea2f312555efeb86398cc382cd07b740d5f3c730918c36e64e1/testcontainers-4.13.3.tar.gz", hash = "sha256:9d82a7052c9a53c58b69e1dc31da8e7a715e8b3ec1c4df5027561b47e2efe646", size = 79064, upload-time = "2025-11-14T05:08:47.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/27/c2f24b19dafa197c514abe70eda69bc031c5152c6b1f1e5b20099e2ceedd/testcontainers-4.13.3-py3-none-any.whl", hash = "sha256:063278c4805ffa6dd85e56648a9da3036939e6c0ac1001e851c9276b19b05970", size = 124784, upload-time = "2025-11-14T05:08:46.053Z" }, +] + [[package]] name = "tomli" version = "2.2.1" @@ -1926,6 +2086,111 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" }, ] +[[package]] +name = "wrapt" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/0d/12d8c803ed2ce4e5e7d5b9f5f602721f9dfef82c95959f3ce97fa584bb5c/wrapt-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:64b103acdaa53b7caf409e8d45d39a8442fe6dcfec6ba3f3d141e0cc2b5b4dbd", size = 77481, upload-time = "2025-11-07T00:43:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/3e/4364ebe221ebf2a44d9fc8695a19324692f7dd2795e64bd59090856ebf12/wrapt-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:91bcc576260a274b169c3098e9a3519fb01f2989f6d3d386ef9cbf8653de1374", size = 60692, upload-time = "2025-11-07T00:43:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ff/ae2a210022b521f86a8ddcdd6058d137c051003812b0388a5e9a03d3fe10/wrapt-2.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ab594f346517010050126fcd822697b25a7031d815bb4fbc238ccbe568216489", size = 61574, upload-time = "2025-11-07T00:43:14.967Z" }, + { url = "https://files.pythonhosted.org/packages/c6/93/5cf92edd99617095592af919cb81d4bff61c5dbbb70d3c92099425a8ec34/wrapt-2.0.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:36982b26f190f4d737f04a492a68accbfc6fa042c3f42326fdfbb6c5b7a20a31", size = 113688, upload-time = "2025-11-07T00:43:18.275Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0a/e38fc0cee1f146c9fb266d8ef96ca39fb14a9eef165383004019aa53f88a/wrapt-2.0.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23097ed8bc4c93b7bf36fa2113c6c733c976316ce0ee2c816f64ca06102034ef", size = 115698, upload-time = "2025-11-07T00:43:19.407Z" }, + { url = "https://files.pythonhosted.org/packages/b0/85/bef44ea018b3925fb0bcbe9112715f665e4d5309bd945191da814c314fd1/wrapt-2.0.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bacfe6e001749a3b64db47bcf0341da757c95959f592823a93931a422395013", size = 112096, upload-time = "2025-11-07T00:43:16.5Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0b/733a2376e413117e497aa1a5b1b78e8f3a28c0e9537d26569f67d724c7c5/wrapt-2.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8ec3303e8a81932171f455f792f8df500fc1a09f20069e5c16bd7049ab4e8e38", size = 114878, upload-time = "2025-11-07T00:43:20.81Z" }, + { url = "https://files.pythonhosted.org/packages/da/03/d81dcb21bbf678fcda656495792b059f9d56677d119ca022169a12542bd0/wrapt-2.0.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:3f373a4ab5dbc528a94334f9fe444395b23c2f5332adab9ff4ea82f5a9e33bc1", size = 111298, upload-time = "2025-11-07T00:43:22.229Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d5/5e623040e8056e1108b787020d56b9be93dbbf083bf2324d42cde80f3a19/wrapt-2.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f49027b0b9503bf6c8cdc297ca55006b80c2f5dd36cecc72c6835ab6e10e8a25", size = 113361, upload-time = "2025-11-07T00:43:24.301Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f3/de535ccecede6960e28c7b722e5744846258111d6c9f071aa7578ea37ad3/wrapt-2.0.1-cp310-cp310-win32.whl", hash = "sha256:8330b42d769965e96e01fa14034b28a2a7600fbf7e8f0cc90ebb36d492c993e4", size = 58035, upload-time = "2025-11-07T00:43:28.96Z" }, + { url = "https://files.pythonhosted.org/packages/21/15/39d3ca5428a70032c2ec8b1f1c9d24c32e497e7ed81aed887a4998905fcc/wrapt-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:1218573502a8235bb8a7ecaed12736213b22dcde9feab115fa2989d42b5ded45", size = 60383, upload-time = "2025-11-07T00:43:25.804Z" }, + { url = "https://files.pythonhosted.org/packages/43/c2/dfd23754b7f7a4dce07e08f4309c4e10a40046a83e9ae1800f2e6b18d7c1/wrapt-2.0.1-cp310-cp310-win_arm64.whl", hash = "sha256:eda8e4ecd662d48c28bb86be9e837c13e45c58b8300e43ba3c9b4fa9900302f7", size = 58894, upload-time = "2025-11-07T00:43:27.074Z" }, + { url = "https://files.pythonhosted.org/packages/98/60/553997acf3939079dab022e37b67b1904b5b0cc235503226898ba573b10c/wrapt-2.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e17283f533a0d24d6e5429a7d11f250a58d28b4ae5186f8f47853e3e70d2590", size = 77480, upload-time = "2025-11-07T00:43:30.573Z" }, + { url = "https://files.pythonhosted.org/packages/2d/50/e5b3d30895d77c52105c6d5cbf94d5b38e2a3dd4a53d22d246670da98f7c/wrapt-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85df8d92158cb8f3965aecc27cf821461bb5f40b450b03facc5d9f0d4d6ddec6", size = 60690, upload-time = "2025-11-07T00:43:31.594Z" }, + { url = "https://files.pythonhosted.org/packages/f0/40/660b2898703e5cbbb43db10cdefcc294274458c3ca4c68637c2b99371507/wrapt-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1be685ac7700c966b8610ccc63c3187a72e33cab53526a27b2a285a662cd4f7", size = 61578, upload-time = "2025-11-07T00:43:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/5b/36/825b44c8a10556957bc0c1d84c7b29a40e05fcf1873b6c40aa9dbe0bd972/wrapt-2.0.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df0b6d3b95932809c5b3fecc18fda0f1e07452d05e2662a0b35548985f256e28", size = 114115, upload-time = "2025-11-07T00:43:35.605Z" }, + { url = "https://files.pythonhosted.org/packages/83/73/0a5d14bb1599677304d3c613a55457d34c344e9b60eda8a737c2ead7619e/wrapt-2.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da7384b0e5d4cae05c97cd6f94faaf78cc8b0f791fc63af43436d98c4ab37bb", size = 116157, upload-time = "2025-11-07T00:43:37.058Z" }, + { url = "https://files.pythonhosted.org/packages/01/22/1c158fe763dbf0a119f985d945711d288994fe5514c0646ebe0eb18b016d/wrapt-2.0.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ec65a78fbd9d6f083a15d7613b2800d5663dbb6bb96003899c834beaa68b242c", size = 112535, upload-time = "2025-11-07T00:43:34.138Z" }, + { url = "https://files.pythonhosted.org/packages/5c/28/4f16861af67d6de4eae9927799b559c20ebdd4fe432e89ea7fe6fcd9d709/wrapt-2.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7de3cc939be0e1174969f943f3b44e0d79b6f9a82198133a5b7fc6cc92882f16", size = 115404, upload-time = "2025-11-07T00:43:39.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8b/7960122e625fad908f189b59c4aae2d50916eb4098b0fb2819c5a177414f/wrapt-2.0.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fb1a5b72cbd751813adc02ef01ada0b0d05d3dcbc32976ce189a1279d80ad4a2", size = 111802, upload-time = "2025-11-07T00:43:40.476Z" }, + { url = "https://files.pythonhosted.org/packages/3e/73/7881eee5ac31132a713ab19a22c9e5f1f7365c8b1df50abba5d45b781312/wrapt-2.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3fa272ca34332581e00bf7773e993d4f632594eb2d1b0b162a9038df0fd971dd", size = 113837, upload-time = "2025-11-07T00:43:42.921Z" }, + { url = "https://files.pythonhosted.org/packages/45/00/9499a3d14e636d1f7089339f96c4409bbc7544d0889f12264efa25502ae8/wrapt-2.0.1-cp311-cp311-win32.whl", hash = "sha256:fc007fdf480c77301ab1afdbb6ab22a5deee8885f3b1ed7afcb7e5e84a0e27be", size = 58028, upload-time = "2025-11-07T00:43:47.369Z" }, + { url = "https://files.pythonhosted.org/packages/70/5d/8f3d7eea52f22638748f74b102e38fdf88cb57d08ddeb7827c476a20b01b/wrapt-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:47434236c396d04875180171ee1f3815ca1eada05e24a1ee99546320d54d1d1b", size = 60385, upload-time = "2025-11-07T00:43:44.34Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/32195e57a8209003587bbbad44d5922f13e0ced2a493bb46ca882c5b123d/wrapt-2.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:837e31620e06b16030b1d126ed78e9383815cbac914693f54926d816d35d8edf", size = 58893, upload-time = "2025-11-07T00:43:46.161Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/8cb252858dc8254baa0ce58ce382858e3a1cf616acebc497cb13374c95c6/wrapt-2.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1fdbb34da15450f2b1d735a0e969c24bdb8d8924892380126e2a293d9902078c", size = 78129, upload-time = "2025-11-07T00:43:48.852Z" }, + { url = "https://files.pythonhosted.org/packages/19/42/44a0db2108526ee6e17a5ab72478061158f34b08b793df251d9fbb9a7eb4/wrapt-2.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d32794fe940b7000f0519904e247f902f0149edbe6316c710a8562fb6738841", size = 61205, upload-time = "2025-11-07T00:43:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/4d/8a/5b4b1e44b791c22046e90d9b175f9a7581a8cc7a0debbb930f81e6ae8e25/wrapt-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:386fb54d9cd903ee0012c09291336469eb7b244f7183d40dc3e86a16a4bace62", size = 61692, upload-time = "2025-11-07T00:43:51.678Z" }, + { url = "https://files.pythonhosted.org/packages/11/53/3e794346c39f462bcf1f58ac0487ff9bdad02f9b6d5ee2dc84c72e0243b2/wrapt-2.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7b219cb2182f230676308cdcacd428fa837987b89e4b7c5c9025088b8a6c9faf", size = 121492, upload-time = "2025-11-07T00:43:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/10b7b0e8841e684c8ca76b462a9091c45d62e8f2de9c4b1390b690eadf16/wrapt-2.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:641e94e789b5f6b4822bb8d8ebbdfc10f4e4eae7756d648b717d980f657a9eb9", size = 123064, upload-time = "2025-11-07T00:43:56.323Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d1/3c1e4321fc2f5ee7fd866b2d822aa89b84495f28676fd976c47327c5b6aa/wrapt-2.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe21b118b9f58859b5ebaa4b130dee18669df4bd111daad082b7beb8799ad16b", size = 117403, upload-time = "2025-11-07T00:43:53.258Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b0/d2f0a413cf201c8c2466de08414a15420a25aa83f53e647b7255cc2fab5d/wrapt-2.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17fb85fa4abc26a5184d93b3efd2dcc14deb4b09edcdb3535a536ad34f0b4dba", size = 121500, upload-time = "2025-11-07T00:43:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/bd/45/bddb11d28ca39970a41ed48a26d210505120f925918592283369219f83cc/wrapt-2.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b89ef9223d665ab255ae42cc282d27d69704d94be0deffc8b9d919179a609684", size = 116299, upload-time = "2025-11-07T00:43:58.877Z" }, + { url = "https://files.pythonhosted.org/packages/81/af/34ba6dd570ef7a534e7eec0c25e2615c355602c52aba59413411c025a0cb/wrapt-2.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a453257f19c31b31ba593c30d997d6e5be39e3b5ad9148c2af5a7314061c63eb", size = 120622, upload-time = "2025-11-07T00:43:59.962Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/693a13b4146646fb03254636f8bafd20c621955d27d65b15de07ab886187/wrapt-2.0.1-cp312-cp312-win32.whl", hash = "sha256:3e271346f01e9c8b1130a6a3b0e11908049fe5be2d365a5f402778049147e7e9", size = 58246, upload-time = "2025-11-07T00:44:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/a7/36/715ec5076f925a6be95f37917b66ebbeaa1372d1862c2ccd7a751574b068/wrapt-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2da620b31a90cdefa9cd0c2b661882329e2e19d1d7b9b920189956b76c564d75", size = 60492, upload-time = "2025-11-07T00:44:01.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3e/62451cd7d80f65cc125f2b426b25fbb6c514bf6f7011a0c3904fc8c8df90/wrapt-2.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:aea9c7224c302bc8bfc892b908537f56c430802560e827b75ecbde81b604598b", size = 58987, upload-time = "2025-11-07T00:44:02.095Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/41af4c46b5e498c90fc87981ab2972fbd9f0bccda597adb99d3d3441b94b/wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9", size = 78132, upload-time = "2025-11-07T00:44:04.628Z" }, + { url = "https://files.pythonhosted.org/packages/1c/92/d68895a984a5ebbbfb175512b0c0aad872354a4a2484fbd5552e9f275316/wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f", size = 61211, upload-time = "2025-11-07T00:44:05.626Z" }, + { url = "https://files.pythonhosted.org/packages/e8/26/ba83dc5ae7cf5aa2b02364a3d9cf74374b86169906a1f3ade9a2d03cf21c/wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218", size = 61689, upload-time = "2025-11-07T00:44:06.719Z" }, + { url = "https://files.pythonhosted.org/packages/cf/67/d7a7c276d874e5d26738c22444d466a3a64ed541f6ef35f740dbd865bab4/wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9", size = 121502, upload-time = "2025-11-07T00:44:09.557Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6b/806dbf6dd9579556aab22fc92908a876636e250f063f71548a8660382184/wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c", size = 123110, upload-time = "2025-11-07T00:44:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/e5/08/cdbb965fbe4c02c5233d185d070cabed2ecc1f1e47662854f95d77613f57/wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db", size = 117434, upload-time = "2025-11-07T00:44:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/6aae2ce39db4cb5216302fa2e9577ad74424dfbe315bd6669725569e048c/wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233", size = 121533, upload-time = "2025-11-07T00:44:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/79/35/565abf57559fbe0a9155c29879ff43ce8bd28d2ca61033a3a3dd67b70794/wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2", size = 116324, upload-time = "2025-11-07T00:44:13.28Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e0/53ff5e76587822ee33e560ad55876d858e384158272cd9947abdd4ad42ca/wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b", size = 120627, upload-time = "2025-11-07T00:44:14.431Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7b/38df30fd629fbd7612c407643c63e80e1c60bcc982e30ceeae163a9800e7/wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7", size = 58252, upload-time = "2025-11-07T00:44:17.814Z" }, + { url = "https://files.pythonhosted.org/packages/85/64/d3954e836ea67c4d3ad5285e5c8fd9d362fd0a189a2db622df457b0f4f6a/wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3", size = 60500, upload-time = "2025-11-07T00:44:15.561Z" }, + { url = "https://files.pythonhosted.org/packages/89/4e/3c8b99ac93527cfab7f116089db120fef16aac96e5f6cdb724ddf286086d/wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8", size = 58993, upload-time = "2025-11-07T00:44:16.65Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f4/eff2b7d711cae20d220780b9300faa05558660afb93f2ff5db61fe725b9a/wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3", size = 82028, upload-time = "2025-11-07T00:44:18.944Z" }, + { url = "https://files.pythonhosted.org/packages/0c/67/cb945563f66fd0f61a999339460d950f4735c69f18f0a87ca586319b1778/wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1", size = 62949, upload-time = "2025-11-07T00:44:20.074Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ca/f63e177f0bbe1e5cf5e8d9b74a286537cd709724384ff20860f8f6065904/wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d", size = 63681, upload-time = "2025-11-07T00:44:21.345Z" }, + { url = "https://files.pythonhosted.org/packages/39/a1/1b88fcd21fd835dca48b556daef750952e917a2794fa20c025489e2e1f0f/wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7", size = 152696, upload-time = "2025-11-07T00:44:24.318Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/d9185500c1960d9f5f77b9c0b890b7fc62282b53af7ad1b6bd779157f714/wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3", size = 158859, upload-time = "2025-11-07T00:44:25.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/60/5d796ed0f481ec003220c7878a1d6894652efe089853a208ea0838c13086/wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b", size = 146068, upload-time = "2025-11-07T00:44:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/04/f8/75282dd72f102ddbfba137e1e15ecba47b40acff32c08ae97edbf53f469e/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10", size = 155724, upload-time = "2025-11-07T00:44:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/fe39c51d1b344caebb4a6a9372157bdb8d25b194b3561b52c8ffc40ac7d1/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf", size = 144413, upload-time = "2025-11-07T00:44:27.939Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/9f6b643fe39d4505c7bf926d7c2595b7cb4b607c8c6b500e56c6b36ac238/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e", size = 150325, upload-time = "2025-11-07T00:44:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b6/20ffcf2558596a7f58a2e69c89597128781f0b88e124bf5a4cadc05b8139/wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c", size = 59943, upload-time = "2025-11-07T00:44:33.211Z" }, + { url = "https://files.pythonhosted.org/packages/87/6a/0e56111cbb3320151eed5d3821ee1373be13e05b376ea0870711f18810c3/wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92", size = 63240, upload-time = "2025-11-07T00:44:30.935Z" }, + { url = "https://files.pythonhosted.org/packages/1d/54/5ab4c53ea1f7f7e5c3e7c1095db92932cc32fd62359d285486d00c2884c3/wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f", size = 60416, upload-time = "2025-11-07T00:44:32.002Z" }, + { url = "https://files.pythonhosted.org/packages/73/81/d08d83c102709258e7730d3cd25befd114c60e43ef3891d7e6877971c514/wrapt-2.0.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5e53b428f65ece6d9dad23cb87e64506392b720a0b45076c05354d27a13351a1", size = 78290, upload-time = "2025-11-07T00:44:34.691Z" }, + { url = "https://files.pythonhosted.org/packages/f6/14/393afba2abb65677f313aa680ff0981e829626fed39b6a7e3ec807487790/wrapt-2.0.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ad3ee9d0f254851c71780966eb417ef8e72117155cff04821ab9b60549694a55", size = 61255, upload-time = "2025-11-07T00:44:35.762Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/a4a1f2fba205a9462e36e708ba37e5ac95f4987a0f1f8fd23f0bf1fc3b0f/wrapt-2.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7b822c61ed04ee6ad64bc90d13368ad6eb094db54883b5dde2182f67a7f22c0", size = 61797, upload-time = "2025-11-07T00:44:37.22Z" }, + { url = "https://files.pythonhosted.org/packages/12/db/99ba5c37cf1c4fad35349174f1e38bd8d992340afc1ff27f526729b98986/wrapt-2.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7164a55f5e83a9a0b031d3ffab4d4e36bbec42e7025db560f225489fa929e509", size = 120470, upload-time = "2025-11-07T00:44:39.425Z" }, + { url = "https://files.pythonhosted.org/packages/30/3f/a1c8d2411eb826d695fc3395a431757331582907a0ec59afce8fe8712473/wrapt-2.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e60690ba71a57424c8d9ff28f8d006b7ad7772c22a4af432188572cd7fa004a1", size = 122851, upload-time = "2025-11-07T00:44:40.582Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8d/72c74a63f201768d6a04a8845c7976f86be6f5ff4d74996c272cefc8dafc/wrapt-2.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3cd1a4bd9a7a619922a8557e1318232e7269b5fb69d4ba97b04d20450a6bf970", size = 117433, upload-time = "2025-11-07T00:44:38.313Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5a/df37cf4042cb13b08256f8e27023e2f9b3d471d553376616591bb99bcb31/wrapt-2.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b4c2e3d777e38e913b8ce3a6257af72fb608f86a1df471cb1d4339755d0a807c", size = 121280, upload-time = "2025-11-07T00:44:41.69Z" }, + { url = "https://files.pythonhosted.org/packages/54/34/40d6bc89349f9931e1186ceb3e5fbd61d307fef814f09fbbac98ada6a0c8/wrapt-2.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3d366aa598d69416b5afedf1faa539fac40c1d80a42f6b236c88c73a3c8f2d41", size = 116343, upload-time = "2025-11-07T00:44:43.013Z" }, + { url = "https://files.pythonhosted.org/packages/70/66/81c3461adece09d20781dee17c2366fdf0cb8754738b521d221ca056d596/wrapt-2.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c235095d6d090aa903f1db61f892fffb779c1eaeb2a50e566b52001f7a0f66ed", size = 119650, upload-time = "2025-11-07T00:44:44.523Z" }, + { url = "https://files.pythonhosted.org/packages/46/3a/d0146db8be8761a9e388cc9cc1c312b36d583950ec91696f19bbbb44af5a/wrapt-2.0.1-cp314-cp314-win32.whl", hash = "sha256:bfb5539005259f8127ea9c885bdc231978c06b7a980e63a8a61c8c4c979719d0", size = 58701, upload-time = "2025-11-07T00:44:48.277Z" }, + { url = "https://files.pythonhosted.org/packages/1a/38/5359da9af7d64554be63e9046164bd4d8ff289a2dd365677d25ba3342c08/wrapt-2.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:4ae879acc449caa9ed43fc36ba08392b9412ee67941748d31d94e3cedb36628c", size = 60947, upload-time = "2025-11-07T00:44:46.086Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3f/96db0619276a833842bf36343685fa04f987dd6e3037f314531a1e00492b/wrapt-2.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:8639b843c9efd84675f1e100ed9e99538ebea7297b62c4b45a7042edb84db03e", size = 59359, upload-time = "2025-11-07T00:44:47.164Z" }, + { url = "https://files.pythonhosted.org/packages/71/49/5f5d1e867bf2064bf3933bc6cf36ade23505f3902390e175e392173d36a2/wrapt-2.0.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:9219a1d946a9b32bb23ccae66bdb61e35c62773ce7ca6509ceea70f344656b7b", size = 82031, upload-time = "2025-11-07T00:44:49.4Z" }, + { url = "https://files.pythonhosted.org/packages/2b/89/0009a218d88db66ceb83921e5685e820e2c61b59bbbb1324ba65342668bc/wrapt-2.0.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fa4184e74197af3adad3c889a1af95b53bb0466bced92ea99a0c014e48323eec", size = 62952, upload-time = "2025-11-07T00:44:50.74Z" }, + { url = "https://files.pythonhosted.org/packages/ae/18/9b968e920dd05d6e44bcc918a046d02afea0fb31b2f1c80ee4020f377cbe/wrapt-2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c5ef2f2b8a53b7caee2f797ef166a390fef73979b15778a4a153e4b5fedce8fa", size = 63688, upload-time = "2025-11-07T00:44:52.248Z" }, + { url = "https://files.pythonhosted.org/packages/a6/7d/78bdcb75826725885d9ea26c49a03071b10c4c92da93edda612910f150e4/wrapt-2.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e042d653a4745be832d5aa190ff80ee4f02c34b21f4b785745eceacd0907b815", size = 152706, upload-time = "2025-11-07T00:44:54.613Z" }, + { url = "https://files.pythonhosted.org/packages/dd/77/cac1d46f47d32084a703df0d2d29d47e7eb2a7d19fa5cbca0e529ef57659/wrapt-2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2afa23318136709c4b23d87d543b425c399887b4057936cd20386d5b1422b6fa", size = 158866, upload-time = "2025-11-07T00:44:55.79Z" }, + { url = "https://files.pythonhosted.org/packages/8a/11/b521406daa2421508903bf8d5e8b929216ec2af04839db31c0a2c525eee0/wrapt-2.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c72328f668cf4c503ffcf9434c2b71fdd624345ced7941bc6693e61bbe36bef", size = 146148, upload-time = "2025-11-07T00:44:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c0/340b272bed297baa7c9ce0c98ef7017d9c035a17a6a71dce3184b8382da2/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3793ac154afb0e5b45d1233cb94d354ef7a983708cc3bb12563853b1d8d53747", size = 155737, upload-time = "2025-11-07T00:44:56.971Z" }, + { url = "https://files.pythonhosted.org/packages/f3/93/bfcb1fb2bdf186e9c2883a4d1ab45ab099c79cbf8f4e70ea453811fa3ea7/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fec0d993ecba3991645b4857837277469c8cc4c554a7e24d064d1ca291cfb81f", size = 144451, upload-time = "2025-11-07T00:44:58.515Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6b/dca504fb18d971139d232652656180e3bd57120e1193d9a5899c3c0b7cdd/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:949520bccc1fa227274da7d03bf238be15389cd94e32e4297b92337df9b7a349", size = 150353, upload-time = "2025-11-07T00:44:59.753Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f6/a1de4bd3653afdf91d250ca5c721ee51195df2b61a4603d4b373aa804d1d/wrapt-2.0.1-cp314-cp314t-win32.whl", hash = "sha256:be9e84e91d6497ba62594158d3d31ec0486c60055c49179edc51ee43d095f79c", size = 60609, upload-time = "2025-11-07T00:45:03.315Z" }, + { url = "https://files.pythonhosted.org/packages/01/3a/07cd60a9d26fe73efead61c7830af975dfdba8537632d410462672e4432b/wrapt-2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:61c4956171c7434634401db448371277d07032a81cc21c599c22953374781395", size = 64038, upload-time = "2025-11-07T00:45:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/99/8a06b8e17dddbf321325ae4eb12465804120f699cd1b8a355718300c62da/wrapt-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:35cdbd478607036fee40273be8ed54a451f5f23121bd9d4be515158f9498f7ad", size = 60634, upload-time = "2025-11-07T00:45:02.087Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1f/5af0ae22368ec69067a577f9e07a0dd2619a1f63aabc2851263679942667/wrapt-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:68424221a2dc00d634b54f92441914929c5ffb1c30b3b837343978343a3512a3", size = 77478, upload-time = "2025-11-07T00:45:16.65Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b7/fd6b563aada859baabc55db6aa71b8afb4a3ceb8bc33d1053e4c7b5e0109/wrapt-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6bd1a18f5a797fe740cb3d7a0e853a8ce6461cc62023b630caec80171a6b8097", size = 60687, upload-time = "2025-11-07T00:45:17.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8c/9ededfff478af396bcd081076986904bdca336d9664d247094150c877dcb/wrapt-2.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fb3a86e703868561c5cad155a15c36c716e1ab513b7065bd2ac8ed353c503333", size = 61563, upload-time = "2025-11-07T00:45:19.109Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a7/d795a1aa2b6ab20ca21157fe03cbfc6aa7e870a88ac3b4ea189e2f6c79f0/wrapt-2.0.1-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5dc1b852337c6792aa111ca8becff5bacf576bf4a0255b0f05eb749da6a1643e", size = 113395, upload-time = "2025-11-07T00:45:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/61/32/56cde2bbf95f2d5698a1850a765520aa86bc7ae0f95b8ec80b6f2e2049bb/wrapt-2.0.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c046781d422f0830de6329fa4b16796096f28a92c8aef3850674442cdcb87b7f", size = 115362, upload-time = "2025-11-07T00:45:22.809Z" }, + { url = "https://files.pythonhosted.org/packages/cf/53/8d3cc433847c219212c133a3e8305bd087b386ef44442ff39189e8fa62ac/wrapt-2.0.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f73f9f7a0ebd0db139253d27e5fc8d2866ceaeef19c30ab5d69dcbe35e1a6981", size = 111766, upload-time = "2025-11-07T00:45:20.294Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d3/14b50c2d0463c0dcef8f388cb1527ed7bbdf0972b9fd9976905f36c77ebf/wrapt-2.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b667189cf8efe008f55bbda321890bef628a67ab4147ebf90d182f2dadc78790", size = 114560, upload-time = "2025-11-07T00:45:24.054Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b8/4f731ff178f77ae55385586de9ff4b4261e872cf2ced4875e6c976fbcb8b/wrapt-2.0.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:a9a83618c4f0757557c077ef71d708ddd9847ed66b7cc63416632af70d3e2308", size = 110999, upload-time = "2025-11-07T00:45:25.596Z" }, + { url = "https://files.pythonhosted.org/packages/fe/bb/5f1bb0f9ae9d12e19f1d71993d052082062603e83fe3e978377f918f054d/wrapt-2.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1e9b121e9aeb15df416c2c960b8255a49d44b4038016ee17af03975992d03931", size = 113164, upload-time = "2025-11-07T00:45:26.8Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f6/f3a3c623d3065c7bf292ee0b73566236b562d5ed894891bd8e435762b618/wrapt-2.0.1-cp39-cp39-win32.whl", hash = "sha256:1f186e26ea0a55f809f232e92cc8556a0977e00183c3ebda039a807a42be1494", size = 58028, upload-time = "2025-11-07T00:45:30.943Z" }, + { url = "https://files.pythonhosted.org/packages/24/78/647c609dfa18063a7fcd5c23f762dd006be401cc9206314d29c9b0b12078/wrapt-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:bf4cb76f36be5de950ce13e22e7fdf462b35b04665a12b64f3ac5c1bbbcf3728", size = 60380, upload-time = "2025-11-07T00:45:28.341Z" }, + { url = "https://files.pythonhosted.org/packages/07/90/0c14b241d18d80ddf4c847a5f52071e126e8a6a9e5a8a7952add8ef0d766/wrapt-2.0.1-cp39-cp39-win_arm64.whl", hash = "sha256:d6cc985b9c8b235bd933990cdbf0f891f8e010b65a3911f7a55179cd7b0fc57b", size = 58895, upload-time = "2025-11-07T00:45:29.527Z" }, + { url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" }, +] + [[package]] name = "wsproto" version = "1.2.0"