diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..b8f59fb9 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,12 @@ +# All files, default reviewers: + +# Serverless and related code requires review from the serverless team: +/vastai/serverless/ @vast-ai/serverless +/vastai/api/deployments.py @vast-ai/serverless +/vastai/api/endpoints.py @vast-ai/serverless +/vastai/data/deployment.py @vast-ai/serverless +/vastai/data/endpoint.py @vast-ai/serverless +/vastai/data/workergroup.py @vast-ai/serverless +/vastai/cli/commands/endpoints.py @vast-ai/serverless +/vastai/cli/commands/deployments.py @vast-ai/serverless +/tests/serverless/ @vast-ai/serverless diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 00000000..619f075c --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,60 @@ +name: PyPI Publish +on: + push: + tags: + - v[0-9]+.[0-9]+.[0-9]+ +jobs: + build_and_publish_vastai: + name: Publish vastai + runs-on: ubuntu-latest + steps: + - name: checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # important for git history with dynamic versioning + - name: install python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: install poetry and plugins + run: | + curl -sSL https://install.python-poetry.org | python3 - + poetry self add "poetry-dynamic-versioning[plugin]" + - name: install dependencies + run: poetry install --no-interaction --no-root + - name: build and publish vastai to PyPI + env: + PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + run: | + poetry config pypi-token.pypi "$PYPI_API_TOKEN" + poetry publish --build + + build_and_publish_vastai_sdk: + name: Publish vastai-sdk + needs: build_and_publish_vastai + runs-on: ubuntu-latest + steps: + - name: checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: install python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: install poetry and plugins + run: | + curl -sSL https://install.python-poetry.org | python3 - + poetry self add "poetry-dynamic-versioning[plugin]" + - name: pin vastai dependency to this release version + working-directory: sdk-wrapper + run: | + VERSION="${GITHUB_REF_NAME#v}" + sed -i "s/vastai = \">=0.1.0\"/vastai = \"==${VERSION}\"/" pyproject.toml + - name: build and publish vastai-sdk to PyPI + env: + PYPI_API_TOKEN_SDK: ${{ secrets.PYPI_API_TOKEN_SDK }} + working-directory: sdk-wrapper + run: | + poetry config pypi-token.pypi "$PYPI_API_TOKEN_SDK" + poetry publish --build diff --git a/.github/workflows/vast-sdk-testing.yml b/.github/workflows/vast-sdk-testing.yml new file mode 100644 index 00000000..c7aacde8 --- /dev/null +++ b/.github/workflows/vast-sdk-testing.yml @@ -0,0 +1,168 @@ +# .github/workflows/vast-sdk-testing.yml +name: Vast SDK Testing + +on: + pull_request: + # Optional, but explicitly define the types of pull requests that should trigger the workflow. + types: [opened, synchronize, reopened, ready_for_review] + # Optional: limit what changes trigger it + # paths: + # - "**/*.py" + # - "pyproject.toml" + # paths-ignore: + # - "docs/**" + +# Auto-cancel older runs for the same PR. This is important to prevent the same PR from being tested multiple times. +concurrency: + group: vast-sdk-testing-${{ github.event.pull_request.number }} + cancel-in-progress: true + + +jobs: + # -------------------------------------------------------------------------- + # Detect Changes + # -------------------------------------------------------------------------- + detect-changes: + runs-on: ubuntu-latest + permissions: + pull-requests: read + outputs: + serverless: ${{ steps.filter.outputs.serverless }} + sdk: ${{ steps.filter.outputs.sdk }} + steps: + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + serverless: + - 'vastai/serverless/**' + - 'tests/serverless/**' + sdk: + - 'vastai/**' + - 'tests/cli/**' + - 'tests/api/**' + - 'tests/sdk/**' + + # -------------------------------------------------------------------------- + # Unit & Integration Tests + # -------------------------------------------------------------------------- + unit-and-integration: + needs: detect-changes + if: needs.detect-changes.outputs.sdk == 'true' + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Poetry and plugins + # snok/install-poetry works on Windows/macOS/Linux; the curl-pipe-python3 + # bootstrap does not (no python3 alias on Windows runners). + uses: snok/install-poetry@v1 + with: + version: "1.8.3" + virtualenvs-create: true + virtualenvs-in-project: false + + # snok/install-poetry adds Poetry to PATH, but PowerShell on Windows + # runners does not pick up the update, so subsequent pwsh steps fail + # with "poetry: not recognized". Force bash (works on all 3 OSes). + - name: Install poetry-dynamic-versioning plugin + shell: bash + run: poetry self add "poetry-dynamic-versioning[plugin]" + + - name: Install test dependencies + shell: bash + working-directory: tests + run: poetry install --no-interaction + + - name: Run CLI, API, and SDK tests + shell: bash + working-directory: tests + run: poetry run pytest -vvv -s --tb=short cli api sdk + + # -------------------------------------------------------------------------- + # Branch protection requires a check literally named `unit-and-integration`, + # but the matrix above produces `unit-and-integration (ubuntu-latest)` etc., + # not the bare name. This aggregator reports the bare name to satisfy the + # rule. Treats `skipped` as passing so the rule is also satisfied when + # detect-changes filters this job out. + # -------------------------------------------------------------------------- + unit-and-integration-check: + name: unit-and-integration + needs: unit-and-integration + if: always() + runs-on: ubuntu-latest + steps: + - name: Aggregate matrix result + run: | + result="${{ needs.unit-and-integration.result }}" + if [ "$result" != "success" ] && [ "$result" != "skipped" ]; then + echo "Matrix job did not succeed (result=$result)" + exit 1 + fi + + # -------------------------------------------------------------------------- + # Install Smoke (package installs + CLI entrypoint runs) + # -------------------------------------------------------------------------- + # Catches packaging bugs (wheel missing files, entry-point misregistered, + # broken pyproject config) that unit tests don't, because the unit tests + # import the source tree directly rather than the installed package. + install-smoke: + needs: detect-changes + if: needs.detect-changes.outputs.sdk == 'true' + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install package + env: + POETRY_DYNAMIC_VERSIONING_BYPASS: "0.0.0" + run: pip install . + - name: Invoke CLI entrypoint + run: vastai --help + + # -------------------------------------------------------------------------- + # Serverless Tests + # -------------------------------------------------------------------------- + serverless-testing: + needs: detect-changes + if: needs.detect-changes.outputs.serverless == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Poetry and plugins + run: | + curl -sSL https://install.python-poetry.org | python3 - + poetry self add "poetry-dynamic-versioning[plugin]" + + - name: Install test dependencies + run: poetry install -C tests --no-interaction + + - name: Run serverless tests + run: poetry run -C tests pytest -vvv -s --tb=short --log-cli-level=INFO serverless diff --git a/.gitignore b/.gitignore index 8a159373..72d7eb71 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,23 @@ gpu_names_cache.json passed_machines.txt failed_machines.txt Pass_testresults.log +dist/ +__pycache__/ +build/ +*.egg-info/ +env/ +*egg* +.venv +poetry.lock +# Keep tests lock file for reproducible CI +!tests/poetry.lock +local +.env +venv/ + +# coverage.py (SQLite DB; machine-specific paths; regenerated by pytest --cov) +.coverage +.coverage.* + +# Claude Code +.claude diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..b1b4894c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ +# vast-ai/vast-cli + +CLI and SDK for the Vast.ai GPU cloud marketplace. + +## AI Agents + +- **Using the CLI in an agent task?** Read [`vastai/SKILL.md`](vastai/SKILL.md) — install, auth, commands, common errors, and API quirks. +- **Writing Python code with the SDK?** Read [`vastai_sdk/SKILL.md`](vastai_sdk/SKILL.md) — VastAI class, SyncClient, AsyncClient, Serverless. diff --git a/README.md b/README.md index 809aac22..df84fe25 100644 --- a/README.md +++ b/README.md @@ -1,231 +1,120 @@ -# Welcome to Vast.ai’s documentation! +# Vast.ai Python SDK & CLI +[![PyPI version](https://badge.fury.io/py/vastai.svg)](https://badge.fury.io/py/vastai) -## Overview -This repository contains the open source python command line interface for vast.ai. -This CLI has all of the main functionality of the vast.ai website GUI and uses the -same underlying REST API. Most of the functionality is self-contained in the single -script file `vast.py`, although the invoice generating commands -require installing an additional second script called `vast_pdf.py`. +The official Vast.ai Python package — provides both the CLI and SDK for managing Vast.ai GPU cloud resources, plus a serverless client for endpoint inference. -[![PyPI version](https://badge.fury.io/py/vastai.svg)](https://badge.fury.io/py/vastai) +## Install + +```bash +pip install vastai +``` + +> **Note:** `pip install vastai-sdk` also works and installs the same package. Both package names are supported for backward compatibility. ## Quickstart -You should probably create a subdirectory in which to put this script and related files if you -haven't already. You can call it whatever you like but I'll refer to it as "vid" for "Vast Install Directory". -So just enter `mkdir vid` to create the directory. Once you've created the directory just change your working directory to it with `cd vid`. After you've -done that the quickest way to get started is to download the `vast.py` script using the `wget` command. +1. Get your API key from [https://cloud.vast.ai/manage-keys/](https://cloud.vast.ai/manage-keys/) -```wget https://raw.githubusercontent.com/vast-ai/vast-python/master/vast.py; chmod +x vast.py;``` +2. Set your API key: +```bash +vastai set api-key YOUR_API_KEY +``` + +3. Test a search: +```bash +vastai search offers --limit 3 +``` +You should see a short list of available GPU offers. -You can verify that the script is working by doing `./vast.py --help`. You should see a list of the available -commands. In order to proceed further you will need to login to the vast.ai website and get your api-key. -Go to [https://vast.ai/console/cli/](https://vast.ai/console/cli/). Copy the command under -the heading "Login / Set API Key" and run it. The command will be something like +## CLI Usage -```./vast.py set api-key xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx``` +The `vastai` command provides full access to the Vast.ai platform from your terminal: -where the `xxxx...` is your api-key (a long hexadecimal number). Note that if the script is -named "vast" in this command on the website and your installed script is named "vast.py" -you will need to change the name of the script in the command you run. The `set api-key` -command saves your api-key in a hidden file in your home directory. Do not share your -api-key with anyone as it authenticates your other vast commands to your account. +```bash +vastai search offers 'gpu_name=RTX_4090 num_gpus>=4' +vastai create instance 12345 --image pytorch/pytorch --disk 32 --ssh --direct +vastai show instances +vastai stop instance 12345 +vastai destroy instance 12345 +``` -## Usage +Run `vastai --help` for a full list of commands. You can also use `--help` on any subcommand: -To see how the API works you can use it to find machines for rent. `vast.py search offers`. In this -form the command will show all available offers. To get more specific results try narrowing the search. -There is a large online help page on how to do this. Bring up the help by doing `vast.py search offers --help`. -There are many parameters that can be used to filter the results. The search command supports -all of the filters and sort options that the website GUI uses. To find Turing GPU instances -(compute capability 7.0 or higher): +```bash +vastai search offers --help +vastai create instance --help +``` -```./vast.py search offers 'compute_cap > 700 '``` +## SDK Usage -To find instances with a reliability score >= 0.99 and at least 4 gpus, ordering by num of gpus -descending: +```python +from vastai import VastAI -```./vast.py search offers 'reliability > 0.99 num_gpus>=4' -o 'num_gpus-'``` +vast = VastAI() # uses VAST_API_KEY env var, or pass api_key="..." -The output of this command at the time of this writing is -``` -ID CUDA Num Model PCIE_BW vCPUs RAM Storage $/hr DLPerf DLP/$ Nvidia Driver Version Net_up Net_down R Max_Days machine_id -1596177 11.4 10x GTX_1080 5.5 48.0 257.9 4628 2.0000 73.0 36.5 470.63.01 653.3 854.5 99.5 - 638 -2459430 11.5 8x RTX_A5000 9.1 128.0 515.8 3094 4.0000 209.4 52.3 495.46 1844.2 2669.6 99.7 12.0 4384 -2459380 11.4 8x RTX_3070 6.3 12.0 64.0 710 1.4200 67.2 47.3 470.86 0.0 0.0 99.8 - 4102 -2456624 11.4 8x RTX_2080_Ti 10.7 32.0 257.9 1653 2.8000 126.4 45.2 470.82.00 14.6 214.2 99.8 28.7 3047 -2456622 11.4 8x RTX_2080_Ti 10.8 32.0 128.9 1651 2.8000 127.1 45.4 470.82.00 14.9 214.7 99.1 28.7 1569 -2456600 11.5 8x RTX_2080_Ti 10.9 48.0 256.6 1704 2.4000 125.5 52.3 495.29.05 169.0 169.8 99.7 25.7 4058 -2455617 11.2 8x RTX_3090 21.7 64.0 515.8 6165 6.4000 261.1 40.8 460.67 477.6 707.2 99.8 28.7 2980 -2454397 11.2 8x A100_SXM4 22.4 128.0 2064.1 21568 13.2000 300.1 22.7 460.106.00 708.7 1119.8 99.2 - 4762 -2405590 11.4 8x RTX_2080_Ti 11.2 48.0 257.9 1629 3.8000 125.5 33.0 470.82.00 389.4 608.8 100.0 1.8 2776 -2364579 11.4 8x A100_PCIE 18.5 128.0 515.8 4813 14.8000 278.8 18.8 470.74 472.4 699.0 99.9 28.7 3459 -2281839 11.2 8x Tesla_V100 11.8 72.0 483.1 1171 5.6000 193.6 34.6 460.67 493.0 697.8 100.0 28.7 2744 -2281832 11.2 8x A100_PCIE 17.7 64.0 515.9 5821 14.8000 276.7 18.7 460.91.03 478.2 655.5 99.9 28.7 2901 -2452630 11.4 7x RTX_3090 6.3 28.0 64.0 61 3.5000 165.5 47.3 470.86 84.6 84.4 99.3 3.8 4420 -2342561 11.4 7x RTX_3090 6.1 96.0 257.6 1664 4.5500 149.2 32.8 470.82.00 476.9 671.7 99.4 1.7 4202 -2237983 11.4 7x RTX_3090 12.5 32.0 257.6 3228 3.1500 204.5 64.9 470.86 194.4 183.8 99.1 - 4207 -2459511 11.4 6x RTX_3090 6.2 - 128.8 812 2.8200 150.2 53.2 470.94 374.4 271.4 99.0 6.7 3129 -2448342 11.5 6x RTX_A6000 12.4 64.0 515.7 6695 3.6000 169.8 47.2 495.29.05 668.6 1082.6 99.6 - 3624 -2437565 11.4 6x RTX_3090 23.0 16.0 128.8 1676 5.4000 196.8 36.5 470.94 34.1 131.5 99.4 - 4238 -2332973 11.2 6x RTX_3090 11.9 48.0 193.3 1671 3.3000 180.3 54.6 460.84 582.1 737.6 99.9 25.6 3552 -2459459 11.5 4x RTX_3090 23.1 32.0 257.8 1363 2.0000 131.2 65.6 495.46 1954.7 2725.8 99.6 12.0 3059 -2459428 11.5 4x RTX_A5000 24.6 64.0 515.8 1547 2.0000 104.9 52.4 495.46 1844.2 2669.6 99.7 12.0 4384 -2459368 11.4 4x RTX_3090 25.3 48.0 64.2 133 1.3967 130.5 93.4 470.86 0.0 0.0 99.4 - 4637 -2458968 11.6 4x RTX_3090 11.7 16.0 128.5 752 1.4000 79.8 57.0 510.39.01 797.8 842.7 99.9 4.0 2555 -2458878 11.6 4x RTX_3090 11.6 36.0 128.5 1531 1.4000 81.9 58.5 510.39.01 757.1 807.6 99.9 4.0 3646 -2458845 11.6 4x RTX_3090 3.1 12.0 128.5 369 1.4000 92.4 66.0 510.39.01 725.7 852.2 99.8 4.0 700 -2458838 11.6 4x RTX_3090 5.7 48.0 128.9 624 1.4000 85.3 60.9 510.39.01 574.9 731.7 99.8 4.0 2217 -2454395 11.2 4x A100_SXM4 22.9 64.0 2064.1 10784 6.6000 150.0 22.7 460.106.00 708.7 1119.8 99.2 - 4762 -2452632 11.4 4x RTX_3090 6.3 16.0 64.0 35 2.0000 123.5 61.8 470.86 84.6 84.4 99.3 3.8 4420 -2450275 11.4 4x RTX_3080_Ti 12.5 32.0 128.7 817 1.8000 128.8 71.6 470.82.00 278.3 350.4 99.7 - 4260 -2449210 11.5 4x RTX_3090 11.2 48.0 128.9 324 2.0000 89.7 44.9 495.29.05 688.3 775.4 99.8 - 2764 -2445175 11.4 4x RTX_3090 11.9 32.0 257.6 1530 2.0000 135.4 67.7 470.86 868.6 887.1 99.7 25.9 3055 -2444916 11.4 4x RTX_3090 11.9 16.0 128.7 1576 1.4000 131.8 94.2 470.82.00 39.4 402.3 99.9 - 3759 -2437188 11.4 4x Tesla_P100 11.7 24.0 95.2 2945 0.7200 44.8 62.2 470.82.00 10.9 76.2 99.5 0.1 3969 -2437179 11.4 4x Tesla_P100 11.7 32.0 192.1 3070 0.7200 44.8 62.3 470.82.00 11.1 66.0 99.2 0.0 4159 -2431606 11.4 4x RTX_3090 17.9 32.0 110.7 330 1.8400 134.3 73.0 470.82.01 584.6 813.4 99.7 4.4 4079 -2419191 11.4 4x RTX_2080_Ti 6.3 32.0 64.4 837 2.0000 64.7 32.4 470.63.01 40.5 205.9 99.7 - 162 -2405589 11.4 4x RTX_2080_Ti 10.8 24.0 257.9 815 1.9000 62.8 33.0 470.82.00 389.4 608.8 100.0 1.8 2776 -2392087 11.4 4x RTX_A6000 10.8 32.0 515.9 1247 1.8000 64.5 35.8 470.94 669.9 705.4 99.1 10.9 4782 -2377227 11.2 4x RTX_3090 6.3 24.0 64.3 1638 2.0000 128.3 64.1 460.32.03 37.8 145.0 99.7 3.0 2672 -2349173 11.4 4x RTX_3090 23.2 48.0 128.7 1475 2.0000 107.4 53.7 470.86 33.2 84.2 99.8 47.3 3949 -2338635 11.4 4x RTX_3090 23.0 32.0 128.5 3151 1.6000 108.8 68.0 470.86 33.8 86.4 99.6 47.4 3948 -2303959 11.2 4x RTX_3090 11.7 28.0 128.8 791 2.1200 131.3 61.9 460.32.03 519.7 570.7 99.5 - 3042 -2281830 11.2 4x A100_PCIE 18.1 32.0 515.9 2910 7.4000 143.6 19.4 460.91.03 478.2 655.5 99.9 28.7 2901 -2193726 11.4 4x RTX_3090 12.4 32.0 128.8 1646 3.6000 153.9 42.8 470.82.01 33.3 137.5 99.5 - 3434 -1737692 11.2 4x RTX_3070 6.3 28.0 128.5 656 2.8000 37.5 13.4 460.91.03 452.6 703.2 99.6 - 3510 +vast.search_offers(query='gpu_name=RTX_4090 num_gpus>=4') +vast.show_instances() +vast.start_instance(id=12345) +vast.stop_instance(id=12345) ``` -#### Launching Instances -To create an instance of type 2459368 (using an ID from the search) with the vastai/tensorflow image -and 32 GB of disk storage +Use `help(vast.search_offers)` to view documentation for any method. -```./vast.py create instance 2459368 --image vastai/tensorflow --disk 32``` +> **Migrating from `vastai-sdk`?** The old import still works: `from vastai_sdk import VastAI` -## Install +## Using the Serverless Client + +1. Create the client +```python +from vastai import Serverless +serverless = Serverless() # or, Serverless("YOUR_API_KEY") +``` +2. Get an endpoint +```python +endpoint = await serverless.get_endpoint("my-endpoint") +``` +3. Make a request +```python +request_body = { + "model": "Qwen/Qwen3-8B", + "prompt" : "Who are you?", + "max_tokens" : 100, + "temperature" : 0.7 +} +response = await serverless.request("/v1/completions", request_body) +``` +4. Read the response +```python +text = response["response"]["choices"][0]["text"] +print(text) +``` -If you followed the instructions in [Quickstart](#Quickstart) you have already installed the script that contains -most of the CLI functionality. If you wish to print PDF format invoices you will need a few other -things. First, you'll need the vast_pdf.py script. This can be found in this repository in the main -directory at [vast_pdf.py](vast_pdf.py). This script should be present in the same directory as the -`vast.py` script. It makes use of a third party library called Borb to create the PDF invoices. To install -this run the command `pip3 install borb` +Find more examples in the `examples/` directory. -## Commands +## Tab Completion -The CLI API is all contained in a python script called `vast.py`. -This script can be called with various commands as arguments. Commands follow -a simple "verb-object" pattern. As an example, consider "show machines". To run this -command we type `./vast.py show machines` +Tab completion is supported in Bash and Zsh via [argcomplete](https://github.com/kislyuk/argcomplete) (installed automatically). To enable it: -## List of commands and associated help message +```bash +activate-global-python-argcomplete +``` + +Or for a single session: +```bash +eval "$(register-python-argcomplete vastai)" ``` -usage: vast.py [-h] [--url URL] [--retry RETRY] [--raw] [--explain] [--api-key API_KEY] command ... - -positional arguments: - command command to run. one of: - help print this help message - attach ssh Attach an ssh key to an instance. This will allow you to connect to the instance with the ssh key. - cancel copy Cancel a remote copy in progress, specified by DST id - cancel sync Cancel a remote copy in progress, specified by DST id - change bid Change the bid price for a spot/interruptible instance - copy Copy directories between instances and/or local - cloud copy Copy files/folders to and from cloud providers - create api-key Create a new api-key with restricted permissions. Can be sent to other users and teammates - create ssh-key Create a new ssh-key - create autoscaler Create a new autoscale group - create endpoint Create a new endpoint group - create instance Create a new instance - create env-var Create a new user environment variable - create subaccount Create a subaccount - create team Create a new team - create team-role Add a new role to your - create template Create a new template - delete api-key Remove an api-key - delete env-var Delete a user environment variable - delete ssh-key Remove an ssh-key - delete autoscaler Delete an autoscaler group - delete endpoint Delete an endpoint group - destroy instance Destroy an instance (irreversible, deletes data) - destroy instances Destroy a list of instances (irreversible, deletes data) - destroy team Destroy your team - detach ssh Detach an ssh key from an instance - execute Execute a (constrained) remote command on a machine - invite team-member Invite a team member - label instance Assign a string label to an instance - logs Get the logs for an instance - prepay instance Deposit credits into reserved instance. - reboot instance Reboot (stop/start) an instance - recycle instance Recycle (destroy/create) an instance - remove team-member Remove a team member - remove team-role Remove a role from your team - reports Get the user reports for a given machine - reset api-key Reset your api-key (get new key from website). - start instance Start a stopped instance - start instances Start a list of instances - stop instance Stop a running instance - stop instances Stop a list of instances - search benchmarks Search for benchmark results using custom query - search invoices Search for benchmark results using custom query - search offers Search for instance types using custom query - search templates Search for template results using custom query - set api-key Set api-key (get your api-key from the console/CLI) - set user Update user data from json file - ssh-url ssh url helper - scp-url scp url helper - show api-key Show an api-key - show api-keys List your api-keys associated with your account - show ssh-keys List your ssh keys associated with your account - show autoscalers Display user's current autoscaler groups - show endpoints Display user's current endpoint groups - show connections Displays user's cloud connections - show deposit Display reserve deposit info for an instance - show earnings Get machine earning history reports - show invoices Get billing history reports - show instance Display user's current instances - show instances Display user's current instances - show ipaddrs Display user's history of ip addresses - show user Get current user data - show subaccounts Get current subaccounts - show env-vars Show user environment variables - show team-members Show your team members - show team-role Show your team role - show team-roles Show roles for a team - transfer credit Transfer credits to another account - update autoscaler Update an existing autoscale group - update endpoint Update an existing endpoint group - update team-role Update an existing team role - update env-var Update an existing user environment variable - update ssh-key Update an existing ssh key - generate pdf-invoices - cleanup machine [Host] Remove all expired storage instances from the machine, freeing up space. - delete machine [Host] Delete machine if the machine is not being used by clients - list machine [Host] list a machine for rent - list machines [Host] list machines for rent - remove defjob [Host] Delete default jobs - set defjob [Host] Create default jobs for a machine - set min-bid [Host] Set the minimum bid/rental price for a machine - schedule maint [Host] Schedule upcoming maint window - cancel maint [Host] Cancel maint window - show machines [Host] Show hosted machines - show maints [Host] Show maintenance information for host machines - unlist machine [Host] Unlist a listed machine - launch instance Launch the top instance from the search offers based on the given parameters - -options: - -h, --help show this help message and exit - --url URL server REST api url - --retry RETRY retry limit - --raw output machine-readable json - --explain output verbose explanation of mapping of CLI calls to HTTPS API endpoints - --api-key API_KEY api key. defaults to using the one stored in ~/.vast_api_key - -Use 'vast COMMAND --help' for more info about a command + +## AI Agents + +Vast.ai has a skill for AI coding agents (Claude Code, Cursor, Windsurf, Codex, etc.): + +```bash +npx skills add vast-ai/vast-cli ``` -## Tab-Completion -Vast.py has optional tab completion in both the Bash and Zsh shell if the [argcomplete](https://github.com/kislyuk/argcomplete) package is installed. To enable this first install the `argcomplete` pip then either run `activate-global-python-argcomplete` to install global handlers or, for a local shell instance, `eval "$(register-python-argcomplete vast.py)"`. If necessary, change `vast.py` to whatever name you've assigned to invoke the tool as you are instrumenting the shell to autocomplete upon a certain command. +This installs the Vast.ai skill so your agent can search offers, create instances, and manage GPU workflows directly. See [CLI SKILL.md](vastai/SKILL.md) or [SDK SKILL.md](vastai_sdk/SKILL.md) for the full reference. + +## Contributing -As a caveat, although we haven't seen it in the wild, as api calls may be executed with the tab complete, invoking it too rapidly could trigger a rate limit. Please report it in the github issues tab if you encounter it or other unexpected behavior. +This [repository](https://github.com/vast-ai/vast-cli) is open source. If you find a bug, please [open an issue](https://github.com/vast-ai/vast-cli/issues). PRs are welcome. diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 52d77458..00000000 --- a/TODO.md +++ /dev/null @@ -1,24 +0,0 @@ - -## CLI Tools ## - -* Invoices - * ~~Get real invoice #~~ - * ~~Pagination system for long invoices.~~ - * ~~Sum charges the way the existing invoice does.~~ - * ~~Clean out dead code and standardize coding style~~ - * ~~Start and end date filter~~ - * ~~Totals only at top and end~~ - * ~~Filter for credit events and instance charges, default is both~~ - * ~~Null guards for all user info~~ - - - -* Filter on Driver Version - * Document new search option 'driver_version == xxx.xx.xxx' - * Find way to sort correctly for comparators like '>=' - - -* Documentation in General - * Research doc systems that can make mulitiple doc targets from same source - (i.e. man pages, HTML, pdf, etc...) - * Begin doc project for CLI tools. \ No newline at end of file diff --git a/__init__.py b/__init__.py deleted file mode 100644 index 426f99d8..00000000 --- a/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .vastai_sdk import VastAI \ No newline at end of file diff --git a/borb_invoice_example.pdf b/borb_invoice_example.pdf deleted file mode 100644 index 8c65e372..00000000 Binary files a/borb_invoice_example.pdf and /dev/null differ diff --git a/examples/client/ace_example.py b/examples/client/ace_example.py new file mode 100644 index 00000000..5d98cb88 --- /dev/null +++ b/examples/client/ace_example.py @@ -0,0 +1,156 @@ +from vastai import Serverless +import asyncio + + +async def main(): + async with Serverless() as client: + endpoint = await client.get_endpoint(name="my-ace-endpoint") + + # ComfyUI API compatible json workflow for ACE Step + workflow = { + "14": { + "inputs": { + "tags": "funk, pop, soul, rock, melodic, guitar, drums, bass, keyboard, percussion, 105 BPM, energetic, upbeat, groovy, vibrant, dynamic", + "lyrics": "[verse]\nNeon lights they flicker bright\nCity hums in dead of night\nRhythms pulse through concrete veins\nLost in echoes of refrains\n\n[verse]\nBassline groovin in my chest\nHeartbeats match the citys zest\nElectric whispers fill the air\nSynthesized dreams everywhere\n\n[chorus]\nTurn it up and let it flow\nFeel the fire let it grow\nIn this rhythm we belong\nHear the night sing out our song", + "lyrics_strength": 0.99, + "clip": ["40", 1] + }, + "class_type": "TextEncodeAceStepAudio", + "_meta": { + "title": "TextEncodeAceStepAudio" + } + }, + "17": { + "inputs": { + "seconds": 180, + "batch_size": 1 + }, + "class_type": "EmptyAceStepLatentAudio", + "_meta": { + "title": "EmptyAceStepLatentAudio" + } + }, + "18": { + "inputs": { + "samples": ["52", 0], + "vae": ["40", 2] + }, + "class_type": "VAEDecodeAudio", + "_meta": { + "title": "VAE Decode Audio" + } + }, + "40": { + "inputs": { + "ckpt_name": "ace_step_v1_3.5b.safetensors" + }, + "class_type": "CheckpointLoaderSimple", + "_meta": { + "title": "Load Checkpoint" + } + }, + "44": { + "inputs": { + "conditioning": ["14", 0] + }, + "class_type": "ConditioningZeroOut", + "_meta": { + "title": "ConditioningZeroOut" + } + }, + "49": { + "inputs": { + "model": ["51", 0], + "operation": ["50", 0] + }, + "class_type": "LatentApplyOperationCFG", + "_meta": { + "title": "LatentApplyOperationCFG" + } + }, + "50": { + "inputs": { + "multiplier": 1.15 + }, + "class_type": "LatentOperationTonemapReinhard", + "_meta": { + "title": "LatentOperationTonemapReinhard" + } + }, + "51": { + "inputs": { + "shift": 6, + "model": ["40", 0] + }, + "class_type": "ModelSamplingSD3", + "_meta": { + "title": "ModelSamplingSD3" + } + }, + "52": { + "inputs": { + "seed": "__RANDOM_INT__", + "steps": 65, + "cfg": 4, + "sampler_name": "er_sde", + "scheduler": "linear_quadratic", + "denoise": 1, + "model": ["49", 0], + "positive": ["14", 0], + "negative": ["44", 0], + "latent_image": ["17", 0] + }, + "class_type": "KSampler", + "_meta": { + "title": "KSampler" + } + }, + "59": { + "inputs": { + "filename_prefix": "audio/ComfyUI", + "quality": "V0", + "audioUI": "", + "audio": ["18", 0] + }, + "class_type": "SaveAudioMP3", + "_meta": { + "title": "Save Audio (MP3)" + } + } + } + + payload = { + "input": { + "request_id": "", + "workflow_json": workflow, + "s3": { + "access_key_id": "", + "secret_access_key": "", + "endpoint_url": "", + "bucket_name": "", + "region": "" + }, + "webhook": { + "url": "", + "extra_params": { + "user_id": "12345", + "project_id": "abc-def" + } + } + } + } + + try: + result = await endpoint.request("/generate/sync", payload) + if result["ok"]: + # Success path + print(result["response"]) + else: + # Request failed (HTTP error) + print(f"Request failed. Status={result.get('status')}, Msg={result.get('text')}") + except Exception as ex: + # Exception raised (transport error, invalid JSON, etc.) + print(f"Request failed with exception: {ex}") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/client/callback_example.py b/examples/client/callback_example.py new file mode 100644 index 00000000..99329039 --- /dev/null +++ b/examples/client/callback_example.py @@ -0,0 +1,44 @@ +import asyncio +from vastai import Serverless, ServerlessRequest + +MAX_TOKENS = 128 + +async def main(): + async with Serverless() as client: + endpoint = await client.get_endpoint(name="my-vllm-endpoint") + + payload = { + "input" : { + "model": "Qwen/Qwen3-8B", + "prompt" : "Who are you?", + "max_tokens" : MAX_TOKENS, + "temperature" : 0.7 + } + } + + # Create a ServerlessRequest object to attach callbacks before submitting the request + req = ServerlessRequest() + + # Attach a callback to run when the machine finished work on the request + def work_finished_callback(response): + if response.get("ok"): + print(f"Request finished. Got response of length {len(response['response']['choices'][0]['text'])}") + else: + print(f"Request failed in callback. Status={response.get('status')}") + + req.then(work_finished_callback) + + try: + result = await endpoint.request(route="/v1/completions", payload=payload, serverless_request=req, cost=MAX_TOKENS) + if result["ok"]: + # Success path + print(result["response"]["choices"][0]["text"]) + else: + # Request failed (HTTP error) + print(f"Request failed. Status={result.get('status')}, Msg={result.get('text')}") + except Exception as ex: + # Exception raised (transport error, invalid JSON, etc.) + print(f"Request failed with exception: {ex}") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/client/comfy_example.py b/examples/client/comfy_example.py new file mode 100644 index 00000000..13a3ca65 --- /dev/null +++ b/examples/client/comfy_example.py @@ -0,0 +1,31 @@ +import asyncio +from vastai import Serverless +import random + +async def main(): + async with Serverless() as client: + endpoint = await client.get_endpoint(name="my-comfy-endpoint") + + payload = { + "input": { + "modifier": "Text2Image", + "modifications": { + "prompt": "Generate a page from a peanuts comic strip.", + "width": 512, + "height": 512, + "steps": 10, + "seed": random.randint(1, 1000) + } + } + } + try: + result = await endpoint.request("/generate/sync", payload) + if result["ok"]: + print(result["response"]["output"][0]["local_path"]) + else: + print(f"Request failed. Status={result.get("status")}, Msg={result.get("text")}") + except Exception as ex: + print(f"Request failed with exception: {ex}") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/client/comfy_load_example.py b/examples/client/comfy_load_example.py new file mode 100644 index 00000000..db23c6ed --- /dev/null +++ b/examples/client/comfy_load_example.py @@ -0,0 +1,41 @@ +import asyncio +from vastai import Serverless, ServerlessRequest +import random + +COST_PER_REQUEST = 100 + +async def main(): + async with Serverless() as client: + endpoint = await client.get_endpoint(name="my-comfy-endpoint") + + payload = { + "input": { + "modifier": "Text2Image", + "modifications": { + "prompt": "Generate a page from a peanuts comic strip.", + "width": 512, + "height": 512, + "steps": 10, + "seed": random.randint(1, 1000) + } + } + } + + responses = [] + + CUR_LOAD = 300 + while True: + # Create a ServerlessRequest object to attach callbacks before submitting the request + req = ServerlessRequest() + # Attach a callback to run when the machine finished work on the request + def work_finished_callback(response): + if response.get("ok"): + print(f"{len([x for x in responses if x.status != 'Complete'])} in flight") + else: + print(f"Request failed in callback. Status={response.get('status')}") + req.then(work_finished_callback) + responses.append(endpoint.request(route="/generate/sync", payload=payload, serverless_request=req, cost=COST_PER_REQUEST)) + await asyncio.sleep(COST_PER_REQUEST / CUR_LOAD) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/client/comfy_session.py b/examples/client/comfy_session.py new file mode 100644 index 00000000..9e58eefb --- /dev/null +++ b/examples/client/comfy_session.py @@ -0,0 +1,56 @@ +import asyncio +from vastai import Serverless +import random + +async def main(): + async with Serverless(instance="alpha", debug=True) as client: + endpoint = await client.get_endpoint(name="my-comfy-endpoint") + session = await endpoint.session(cost=100, lifetime=30) + payload = lambda : { + "input": { + "modifier": "Text2Image", + "modifications": { + "prompt": "Generate a page from a peanuts comic strip.", + "width": 512, + "height": 512, + "steps": 10, + "seed": random.randint(1, 1000) + }, + "webhook": { + "url": "http://localhost:3001/session/end", + "extra_params": { + "session_id": session.session_id, + "session_auth" : session.auth_data + } + } + } + } + # This test allows us to test: + # - Session creation + # - Session async request handlign + # - Closing sessions with webhooks + # - Error handling for requests on closed sessions + # The expected result is to see the first request succeed, + # and the second request fail due to the closed session. + try: + response = await session.request("/generate", payload()) + if not response.get("ok"): + print(f"Request failed: {response.get('text')}") + else: + print("Request succeeded") + except Exception as ex: + print(f"Request failed: {ex}") + + await asyncio.sleep(5) + try: + response = await session.request("/generate", payload()) + if not response.get("ok"): + print(f"Request failed: {response.get('text')}") + else: + print("Request succeeded") + except Exception as ex: + print(f"Request failed: {ex}") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/client/comfy_sine_load.py b/examples/client/comfy_sine_load.py new file mode 100644 index 00000000..97116d24 --- /dev/null +++ b/examples/client/comfy_sine_load.py @@ -0,0 +1,49 @@ +import asyncio +import math +import time +from vastai import Serverless, ServerlessRequest +import random + +COST_PER_REQUEST = 100 +PERIOD_SECONDS = 10 * 60 +LOAD_MIN = 50 +LOAD_MAX = 300 + +async def main(): + async with Serverless() as client: + endpoint = await client.get_endpoint(name="my-comfy-endpoint") + + payload = { + "input": { + "modifier": "Text2Image", + "modifications": { + "prompt": "Generate a page from a peanuts comic strip.", + "width": 512, + "height": 512, + "steps": 10, + "seed": random.randint(1, 1000) + } + } + } + + responses = [] + + start = time.monotonic() + while True: + phase = 2 * math.pi * (time.monotonic() - start) / PERIOD_SECONDS + CUR_LOAD = (LOAD_MIN + LOAD_MAX) / 2 + (LOAD_MAX - LOAD_MIN) / 2 * math.sin(phase) + + # Create a ServerlessRequest object to attach callbacks before submitting the request + req = ServerlessRequest() + # Attach a callback to run when the machine finished work on the request + def work_finished_callback(response): + if response.get("ok"): + print(f"{len([x for x in responses if x.status != 'Complete'])} in flight") + else: + print(f"Request failed in callback. Status={response.get('status')}") + req.then(work_finished_callback) + responses.append(endpoint.request(route="/generate/sync", payload=payload, serverless_request=req, cost=COST_PER_REQUEST)) + await asyncio.sleep(COST_PER_REQUEST / CUR_LOAD) + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/client/remote_example.py b/examples/client/remote_example.py new file mode 100644 index 00000000..9ff883ae --- /dev/null +++ b/examples/client/remote_example.py @@ -0,0 +1,20 @@ +import asyncio +from vastai import Serverless +import random + +async def main(): + async with Serverless() as client: + endpoint = await client.get_endpoint(name="my-comfy-endpoint") + + payload = { + "a": 1 + } + + response = await endpoint.request("/add", payload) + + # Get the file from the path on the local machine using SCP or SFTP + # or configure S3 to upload to cloud storage. + print(response["response"]["result"]) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/client/request_report.py b/examples/client/request_report.py new file mode 100644 index 00000000..674bd341 --- /dev/null +++ b/examples/client/request_report.py @@ -0,0 +1,158 @@ +""" +Send N requests to a Vast.ai Serverless endpoint and generate a success/failure report. + +Usage: + python3 examples/client/request_report.py --endpoint [--count 100] [--concurrency 50] [--instance prod] +""" + +import asyncio +import argparse +import time +import statistics +from collections import Counter +from vastai import Serverless, ServerlessRequest + + +async def main(): + parser = argparse.ArgumentParser(description="Serverless endpoint request report") + parser.add_argument("--endpoint", required=True, help="Endpoint name") + parser.add_argument("--route", default="/generate/sync", help="Worker route (default: /generate/sync)") + parser.add_argument("--count", type=int, default=100, help="Number of requests to send (default: 100)") + parser.add_argument("--concurrency", type=int, default=50, help="Max concurrent requests (default: 50)") + parser.add_argument("--instance", default="prod", choices=["prod", "alpha", "candidate"], help="Environment (default: prod)") + parser.add_argument("--cost", type=int, default=100, help="Cost per request (default: 100)") + parser.add_argument("--timeout", type=float, default=120.0, help="Per-request timeout in seconds (default: 120)") + args = parser.parse_args() + + payload = { + "input": { + "modifier": "Text2Image", + "modifications": { + "prompt": "A simple test image.", + "width": 512, + "height": 512, + "steps": 10, + "seed": 42, + } + } + } + + print(f"Connecting to {args.instance} environment...") + async with Serverless(instance=args.instance, debug=False) as client: + endpoint = await client.get_endpoint(name=args.endpoint) + print(f"Found endpoint: {args.endpoint}") + print(f"Sending {args.count} requests (concurrency={args.concurrency}, cost={args.cost}, timeout={args.timeout}s)") + print("-" * 60) + + semaphore = asyncio.Semaphore(args.concurrency) + results = [] + wall_start = time.time() + + async def send_one(idx: int): + async with semaphore: + req = ServerlessRequest() + future = endpoint.request( + route=args.route, + payload=payload, + serverless_request=req, + cost=args.cost, + ) + try: + result = await asyncio.wait_for(future, timeout=args.timeout) + results.append({ + "idx": idx, + "ok": result.get("ok", False), + "status": result.get("status"), + "latency": result.get("latency"), + "request_status": req.status, + "error": None, + }) + except asyncio.TimeoutError: + results.append({ + "idx": idx, + "ok": False, + "status": None, + "latency": None, + "request_status": req.status, + "error": "TimeoutError", + }) + except Exception as ex: + results.append({ + "idx": idx, + "ok": False, + "status": None, + "latency": None, + "request_status": req.status, + "error": f"{type(ex).__name__}: {ex}", + }) + + tasks = [send_one(i) for i in range(args.count)] + await asyncio.gather(*tasks) + wall_elapsed = time.time() - wall_start + + # ── Report ────────────────────────────────────────────── + total = len(results) + successes = [r for r in results if r["ok"]] + failures = [r for r in results if not r["ok"]] + success_latencies = [r["latency"] for r in successes if r["latency"] is not None] + + print() + print("=" * 60) + print(" REQUEST REPORT") + print("=" * 60) + print(f" Endpoint: {args.endpoint}") + print(f" Environment: {args.instance}") + print(f" Route: {args.route}") + print(f" Total requests: {total}") + print(f" Wall time: {wall_elapsed:.2f}s") + print(f" Throughput: {total / wall_elapsed:.2f} req/s") + print() + + print(" ── Outcomes ──") + print(f" Succeeded: {len(successes):>6} ({len(successes)/total*100:.1f}%)") + print(f" Failed: {len(failures):>6} ({len(failures)/total*100:.1f}%)") + print() + + if success_latencies: + print(" ── Latency (successful requests) ──") + print(f" Min: {min(success_latencies):>8.3f}s") + print(f" Max: {max(success_latencies):>8.3f}s") + print(f" Mean: {statistics.mean(success_latencies):>8.3f}s") + print(f" Median: {statistics.median(success_latencies):>8.3f}s") + if len(success_latencies) >= 2: + print(f" Stdev: {statistics.stdev(success_latencies):>8.3f}s") + p90 = sorted(success_latencies)[int(len(success_latencies) * 0.9)] + p99 = sorted(success_latencies)[min(int(len(success_latencies) * 0.99), len(success_latencies) - 1)] + print(f" P90: {p90:>8.3f}s") + print(f" P99: {p99:>8.3f}s") + print() + + if failures: + print(" ── Failure Breakdown ──") + + # By HTTP status + status_counts = Counter(r["status"] for r in failures if r["status"] is not None) + if status_counts: + print(" HTTP status codes:") + for code, count in status_counts.most_common(): + print(f" {code}: {count}") + + # By error type + error_counts = Counter(r["error"] for r in failures if r["error"] is not None) + if error_counts: + print(" Exceptions:") + for err, count in error_counts.most_common(): + print(f" {err}: {count}") + + # By request status + req_status_counts = Counter(r["request_status"] for r in failures) + print(" Request status at failure:") + for status, count in req_status_counts.most_common(): + print(f" {status}: {count}") + print() + + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/client/session_example.py b/examples/client/session_example.py new file mode 100644 index 00000000..5057470c --- /dev/null +++ b/examples/client/session_example.py @@ -0,0 +1,41 @@ +import asyncio +import random +from vastai import Serverless + +NUM_SESSIONS = 5 + + +async def run_with_session(endpoint, session_id): + """Run a single session with one request.""" + session = await endpoint.session(cost=100, lifetime=30) + try: + payload = { + "input": { + "modifier": "Text2Image", + "modifications": { + "prompt": "Generate a page from a peanuts comic strip.", + "width": 512, + "height": 512, + "steps": 10, + "seed": random.randint(1, 1000), + }, + } + } + response = await session.request("/generate/sync", payload, cost=100) + print(f"[Session {session_id}] {response['response']['output'][0]['local_path']}") + finally: + await session.close() + + +async def main(): + async with Serverless() as client: + endpoint = await client.get_endpoint(name="my-comfy-endpoint") + + # Launch all sessions concurrently and gather results + await asyncio.gather(*( + run_with_session(endpoint, i) for i in range(NUM_SESSIONS) + )) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/client/tgi_simple_example.py b/examples/client/tgi_simple_example.py new file mode 100644 index 00000000..035f5d27 --- /dev/null +++ b/examples/client/tgi_simple_example.py @@ -0,0 +1,34 @@ +import asyncio +from vastai import Serverless + +MAX_TOKENS = 128 + +async def main(): + async with Serverless() as client: + endpoint = await client.get_endpoint(name="my-tgi-endpoint") + + prompt = "Who are you?" + + payload = { + "inputs": prompt, + "parameters": { + "max_new_tokens": MAX_TOKENS, + "temperature": 0.7, + "return_full_text": False + } + } + + try: + result = await endpoint.request("/generate", payload, cost=MAX_TOKENS) + if result["ok"]: + # Success path + print(result["response"]["generated_text"]) + else: + # Request failed (HTTP error) + print(f"Request failed. Status={result.get('status')}, Msg={result.get('text')}") + except Exception as ex: + # Exception raised (transport error, invalid JSON, etc.) + print(f"Request failed with exception: {ex}") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/client/tgi_stream_example.py b/examples/client/tgi_stream_example.py new file mode 100644 index 00000000..b656c507 --- /dev/null +++ b/examples/client/tgi_stream_example.py @@ -0,0 +1,64 @@ +import asyncio +from vastai import Serverless + +MAX_TOKENS = 1024 + +def build_prompt(system_prompt: str, user_prompt: str) -> str: + return ( + f"<>\n{system_prompt.strip()}\n<>\n\n" + f"User: {user_prompt.strip()}\n" + f"Assistant:" + ) + +async def main(): + async with Serverless() as client: + endpoint = await client.get_endpoint(name="my-tgi-endpoint") + + system_prompt = ( + "You are Qwen.\n" + "You are to only speak in English.\n" + ) + user_prompt = """ + Critically analyze the extent to which hotdogs are sandwiches. + """ + + prompt = build_prompt(system_prompt, user_prompt) + + payload = { + "inputs": prompt, + "parameters": { + "max_new_tokens": MAX_TOKENS, + "temperature": 0.7, + "do_sample": True, + "return_full_text": False, + } + } + + try: + result = await endpoint.request( + "/generate_stream", + payload, + cost=MAX_TOKENS, + stream=True, + ) + if result["ok"]: + # Success path - process the stream + stream = result["response"] + + printed_answer = False + async for event in stream: + tok = (event.get("token") or {}).get("text") + if tok: + if not printed_answer: + printed_answer = True + print("Answer:\n", end="", flush=True) + print(tok, end="", flush=True) + else: + # Request failed (HTTP error) + print(f"Request failed. Status={result.get('status')}, Msg={result.get('text')}") + except Exception as ex: + # Exception raised (transport error, invalid JSON, etc.) + print(f"Request failed with exception: {ex}") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/client/vllm_load_example.py b/examples/client/vllm_load_example.py new file mode 100644 index 00000000..53510eb8 --- /dev/null +++ b/examples/client/vllm_load_example.py @@ -0,0 +1,34 @@ +import asyncio +from vastai import Serverless, ServerlessRequest + +MAX_TOKENS = 500 + +async def main(): + async with Serverless() as client: + endpoint = await client.get_endpoint(name="my-vllm-endpoint") + + payload = { + "model": "Qwen/Qwen3-8B", + "prompt" : "Who are you?", + "max_tokens" : MAX_TOKENS, + "temperature" : 0.7 + } + + responses = [] + + CUR_LOAD = 16000 + while True: + # Create a ServerlessRequest object to attach callbacks before submitting the request + req = ServerlessRequest() + # Attach a callback to run when the machine finished work on the request + def work_finished_callback(response): + if response.get("ok"): + print(response["response"]["choices"][0]["text"]) + else: + print(f"Request failed in callback. Status={response.get('status')}") + req.then(work_finished_callback) + responses.append(endpoint.request(route="/v1/completions", payload=payload, serverless_request=req, cost=MAX_TOKENS)) + await asyncio.sleep(MAX_TOKENS / CUR_LOAD) + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/client/vllm_simple_example.py b/examples/client/vllm_simple_example.py new file mode 100644 index 00000000..c26bbf94 --- /dev/null +++ b/examples/client/vllm_simple_example.py @@ -0,0 +1,30 @@ +import asyncio +from vastai import Serverless + +MAX_TOKENS = 128 + +async def main(): + async with Serverless() as client: + endpoint = await client.get_endpoint(name="my-vllm-endpoint") + + payload = { + "model": "Qwen/Qwen3-8B", + "prompt" : "Who are you?", + "max_tokens" : MAX_TOKENS, + "temperature" : 0.7 + } + + try: + result = await endpoint.request("/v1/completions", payload, cost=MAX_TOKENS) + if result["ok"]: + # Success path + print(result["response"]["choices"][0]["text"]) + else: + # Request failed (HTTP error) + print(f"Request failed. Status={result.get('status')}, Msg={result.get('text')}") + except Exception as ex: + # Exception raised (transport error, invalid JSON, etc.) + print(f"Request failed with exception: {ex}") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/client/vllm_stream_convo_example.py b/examples/client/vllm_stream_convo_example.py new file mode 100644 index 00000000..8a882fcb --- /dev/null +++ b/examples/client/vllm_stream_convo_example.py @@ -0,0 +1,67 @@ +import asyncio +from vastai import Serverless + +MAX_TOKENS = 1024 + +async def main(): + async with Serverless() as client: + endpoint = await client.get_endpoint(name="my-vllm-endpoint") + + system_prompt = ( + "You are Qwen.\n" + "You are to only speak in English.\n" + ) + + user_prompt = "What is the integral of 2x^2 from 0 to 5?" + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + + payload = { + "model": "Qwen/Qwen3-8B", + "messages": messages, + "stream": True, + "max_tokens": MAX_TOKENS, + "temperature": 0.7, + } + + try: + result = await endpoint.request("/v1/chat/completions", payload, cost=MAX_TOKENS, stream=True) + if result["ok"]: + # Success path - process the stream + stream = result["response"] + + printed_reasoning = False + printed_answer = False + + async for chunk in stream: + delta = chunk["choices"][0].get("delta", {}) + + rc = delta.get("reasoning_content", None) + if rc: + if not printed_reasoning: + printed_reasoning = True + print("Reasoning:\n", end="", flush=True) + print(rc, end="", flush=True) + + content = delta.get("content", None) + if content: + if not printed_answer: + printed_answer = True + if printed_reasoning: + print("\n\nAnswer:\n", end="", flush=True) + else: + print("Answer:\n", end="", flush=True) + print(content, end="", flush=True) + else: + # Request failed (HTTP error) + print(f"Request failed. Status={result.get('status')}, Msg={result.get('text')}") + except Exception as ex: + # Exception raised (transport error, invalid JSON, etc.) + print(f"Request failed with exception: {ex}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/client/vllm_streaming_example.py b/examples/client/vllm_streaming_example.py new file mode 100644 index 00000000..15701e1a --- /dev/null +++ b/examples/client/vllm_streaming_example.py @@ -0,0 +1,46 @@ +import asyncio +from vastai import Serverless + +MAX_TOKENS = 1024 + +async def main(): + async with Serverless() as client: + endpoint = await client.get_endpoint(name="my-vllm-endpoint") + + system_prompt = ( + "You are Qwen, a helpful AI assistant.\n" + "You are to only speak in English.\n" + "Please answer the users response.\n" + "When you are done, use the token.\n" + ) + + + user_prompt = """ + What is the 118th element in the periodic table? + """ + + payload = { + "model": "Qwen/Qwen3-8B", + "prompt" : f"{system_prompt}\n{user_prompt}\n", + "max_tokens" : MAX_TOKENS, + "temperature" : 0.8, + "stop" : [""], + "stream" : True, + } + + try: + result = await endpoint.request("/v1/completions", payload, cost=MAX_TOKENS, stream=True) + if result["ok"]: + # Success path - process the stream + stream = result["response"] + async for event in stream: + print(event["choices"][0]["text"], end="", flush=True) + else: + # Request failed (HTTP error) + print(f"Request failed. Status={result.get('status')}, Msg={result.get('text')}") + except Exception as ex: + # Exception raised (transport error, invalid JSON, etc.) + print(f"Request failed with exception: {ex}") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/client/wan_example.py b/examples/client/wan_example.py new file mode 100644 index 00000000..4b890d4c --- /dev/null +++ b/examples/client/wan_example.py @@ -0,0 +1,212 @@ +from vastai import Serverless +import asyncio + +async def main(): + async with Serverless() as client: + endpoint = await client.get_endpoint(name="my-wan-endpoint") + + # ComfyUI API compatible json workflow for Wan 2.2 T2V + workflow = { + "90": { + "inputs": { + "clip_name": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "type": "wan", + "device": "default" + }, + "class_type": "CLIPLoader", + "_meta": { + "title": "Load CLIP" + } + }, + "91": { + "inputs": { + "text": "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走,裸露,NSFW", + "clip": ["90", 0] + }, + "class_type": "CLIPTextEncode", + "_meta": { + "title": "CLIP Text Encode (Negative Prompt)" + } + }, + "92": { + "inputs": { + "vae_name": "wan_2.1_vae.safetensors" + }, + "class_type": "VAELoader", + "_meta": { + "title": "Load VAE" + } + }, + "93": { + "inputs": { + "shift": 8.000000000000002, + "model": ["101", 0] + }, + "class_type": "ModelSamplingSD3", + "_meta": { + "title": "ModelSamplingSD3" + } + }, + "94": { + "inputs": { + "shift": 8, + "model": ["102", 0] + }, + "class_type": "ModelSamplingSD3", + "_meta": { + "title": "ModelSamplingSD3" + } + }, + "95": { + "inputs": { + "add_noise": "disable", + "noise_seed": 0, + "steps": 20, + "cfg": 3.5, + "sampler_name": "euler", + "scheduler": "simple", + "start_at_step": 10, + "end_at_step": 10000, + "return_with_leftover_noise": "disable", + "model": ["94", 0], + "positive": ["99", 0], + "negative": ["91", 0], + "latent_image": ["96", 0] + }, + "class_type": "KSamplerAdvanced", + "_meta": { + "title": "KSampler (Advanced)" + } + }, + "96": { + "inputs": { + "add_noise": "enable", + "noise_seed": "__RANDOM_INT__", + "steps": 20, + "cfg": 3.5, + "sampler_name": "euler", + "scheduler": "simple", + "start_at_step": 0, + "end_at_step": 10, + "return_with_leftover_noise": "enable", + "model": ["93", 0], + "positive": ["99", 0], + "negative": ["91", 0], + "latent_image": ["104", 0] + }, + "class_type": "KSamplerAdvanced", + "_meta": { + "title": "KSampler (Advanced)" + } + }, + "97": { + "inputs": { + "samples": ["95", 0], + "vae": ["92", 0] + }, + "class_type": "VAEDecode", + "_meta": { + "title": "VAE Decode" + } + }, + "98": { + "inputs": { + "filename_prefix": "video/ComfyUI", + "format": "auto", + "codec": "auto", + "video": ["100", 0] + }, + "class_type": "SaveVideo", + "_meta": { + "title": "Save Video" + } + }, + "99": { + "inputs": { + "text": "Beautiful young European woman with honey blonde hair gracefully turning her head back over shoulder, gentle smile, bright eyes looking at camera. Hair flowing in slow motion as she turns. Soft natural lighting, clean background, cinematic portrait.", + "clip": ["90", 0] + }, + "class_type": "CLIPTextEncode", + "_meta": { + "title": "CLIP Text Encode (Positive Prompt)" + } + }, + "100": { + "inputs": { + "fps": 16, + "images": ["97", 0] + }, + "class_type": "CreateVideo", + "_meta": { + "title": "Create Video" + } + }, + "101": { + "inputs": { + "unet_name": "wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors", + "weight_dtype": "default" + }, + "class_type": "UNETLoader", + "_meta": { + "title": "Load Diffusion Model" + } + }, + "102": { + "inputs": { + "unet_name": "wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors", + "weight_dtype": "default" + }, + "class_type": "UNETLoader", + "_meta": { + "title": "Load Diffusion Model" + } + }, + "104": { + "inputs": { + "width": 640, + "height": 640, + "length": 81, + "batch_size": 1 + }, + "class_type": "EmptyHunyuanLatentVideo", + "_meta": { + "title": "EmptyHunyuanLatentVideo" + } + } + } + + payload = { + "input": { + "request_id": "", + "workflow_json": workflow, + "s3": { + "access_key_id": "", + "secret_access_key": "", + "endpoint_url": "", + "bucket_name": "", + "region": "" + }, + "webhook": { + "url": "", + "extra_params": { + "user_id": "12345", + "project_id": "abc-def" + } + } + } + } + + try: + result = await endpoint.request("/generate/sync", payload) + if result["ok"]: + # Success path + print(result["response"]) + else: + # Request failed (HTTP error) + print(f"Request failed. Status={result.get('status')}, Msg={result.get('text')}") + except Exception as ex: + # Exception raised (transport error, invalid JSON, etc.) + print(f"Request failed with exception: {ex}") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/client/worker_example.py b/examples/client/worker_example.py new file mode 100644 index 00000000..59654382 --- /dev/null +++ b/examples/client/worker_example.py @@ -0,0 +1,11 @@ +import asyncio +from vastai import Serverless + + +async def main(): + async with Serverless() as client: + endpoint = await client.get_endpoint(name="my-comfy-endpoint") + workers = await endpoint.get_workers() + print(workers) +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/deployments/image_gen/deploy.py b/examples/deployments/image_gen/deploy.py new file mode 100644 index 00000000..7c47587e --- /dev/null +++ b/examples/deployments/image_gen/deploy.py @@ -0,0 +1,235 @@ +"""Minimal ComfyUI-style image generation deployment. + +Exposes a node-based workflow as composable remote functions: + - txt2img: text prompt -> image bytes + - img2img: image bytes + prompt -> image bytes + - upscale: image bytes -> upscaled image bytes + +Each function returns PNG bytes directly, so callers can chain them +client-side just like wiring nodes in ComfyUI: + + base = await txt2img("a cat in space", steps=30) + refined = await img2img(base, "a cat in space, highly detailed", strength=0.4) + final = await upscale(refined, scale_factor=2) + Path("output.png").write_bytes(final) +""" + +import random +from vastai import Deployment +from vastai.data.query import gpu_name, RTX_4090, RTX_5090 + +app = Deployment(name="image-gen", tag="v2") + +MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0" +REFINER_ID = "stabilityai/stable-diffusion-xl-refiner-1.0" + +# --------------------------------------------------------------------------- +# Context: load models once per worker, keep them warm on GPU +# --------------------------------------------------------------------------- + +@app.context() +class DiffusionModels: + async def __aenter__(self): + import torch, sys, logging + from diffusers import StableDiffusionXLPipeline, StableDiffusionXLImg2ImgPipeline, StableDiffusionUpscalePipeline + from diffusers.utils import logging as diffusers_logging + + # Enable verbose download & loading logs + diffusers_logging.set_verbosity_info() + logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s] %(name)s %(levelname)s: %(message)s", + stream=sys.stdout, + ) + # huggingface_hub download progress + try: + import huggingface_hub + huggingface_hub.utils.logging.set_verbosity_info() + except Exception: + pass + + dtype = torch.float16 + device = "cuda" + + print(f"[image-gen] Downloading & loading base model: {MODEL_ID}", flush=True) + self.txt2img_pipe = StableDiffusionXLPipeline.from_pretrained( + MODEL_ID, torch_dtype=dtype, variant="fp16", use_safetensors=True, + ) + print(f"[image-gen] Moving base model to {device} ...", flush=True) + self.txt2img_pipe = self.txt2img_pipe.to(device) + print(f"[image-gen] Base model loaded on {device}.", flush=True) + + # Reuse base model components for img2img to save VRAM + print("[image-gen] Creating img2img pipeline (shared weights) ...", flush=True) + self.img2img_pipe = StableDiffusionXLImg2ImgPipeline( + vae=self.txt2img_pipe.vae, + text_encoder=self.txt2img_pipe.text_encoder, + text_encoder_2=self.txt2img_pipe.text_encoder_2, + tokenizer=self.txt2img_pipe.tokenizer, + tokenizer_2=self.txt2img_pipe.tokenizer_2, + unet=self.txt2img_pipe.unet, + scheduler=self.txt2img_pipe.scheduler, + ) + + # Warmup: single-step generation to trigger CUDA graph compilation + print("[image-gen] Warming up (1-step latent generation) ...", flush=True) + self.txt2img_pipe( + "warmup", num_inference_steps=1, output_type="latent", + ) + + # SD x4 upscaler (separate model, shares nothing with SDXL) + UPSCALER_ID = "stabilityai/stable-diffusion-x4-upscaler" + print(f"[image-gen] Downloading & loading upscaler: {UPSCALER_ID}", flush=True) + self.upscale_pipe = StableDiffusionUpscalePipeline.from_pretrained( + UPSCALER_ID, torch_dtype=dtype, + ).to(device) + self.upscale_pipe.set_progress_bar_config(disable=True) + self.upscale_pipe.enable_vae_tiling() + self.upscale_pipe.enable_attention_slicing() + print(f"[image-gen] Upscaler loaded on {device}.", flush=True) + + self.device = device + self.dtype = dtype + print("[image-gen] All models ready. Accepting requests.", flush=True) + return self + + async def __aexit__(self, *exc): + pass + + +# --------------------------------------------------------------------------- +# Remote functions (the "nodes") +# --------------------------------------------------------------------------- + +BENCHMARK_PROMPTS = [ + {"prompt": "a photograph of an astronaut riding a horse"}, + {"prompt": "oil painting of a sunset over mountains"}, + {"prompt": "cyberpunk cityscape at night, neon lights"}, +] + + +@app.remote(benchmark_dataset=BENCHMARK_PROMPTS) +async def txt2img( + prompt: str, + negative_prompt: str = "", + width: int = 1024, + height: int = 1024, + steps: int = 30, + guidance_scale: float = 7.5, + seed: int = -1, +) -> bytes: + """Generate an image from a text prompt. Returns PNG bytes.""" + import torch, io + from PIL import Image + + ctx = app.get_context(DiffusionModels) + + generator = torch.Generator(device=ctx.device) + if seed >= 0: + generator.manual_seed(seed) + else: + generator.manual_seed(random.randint(0, 2**32 - 1)) + + result = ctx.txt2img_pipe( + prompt=prompt, + negative_prompt=negative_prompt or None, + width=width, + height=height, + num_inference_steps=steps, + guidance_scale=guidance_scale, + generator=generator, + ) + + buf = io.BytesIO() + result.images[0].save(buf, format="PNG") + return buf.getvalue() + + +@app.remote() +async def img2img( + image_bytes: bytes, + prompt: str, + negative_prompt: str = "", + strength: float = 0.5, + steps: int = 30, + guidance_scale: float = 7.5, + seed: int = -1, +) -> bytes: + """Refine an existing image with a text prompt. Returns PNG bytes.""" + import torch, io + from PIL import Image + + ctx = app.get_context(DiffusionModels) + + input_image = Image.open(io.BytesIO(image_bytes)).convert("RGB") + + generator = torch.Generator(device=ctx.device) + if seed >= 0: + generator.manual_seed(seed) + else: + generator.manual_seed(random.randint(0, 2**32 - 1)) + + result = ctx.img2img_pipe( + prompt=prompt, + negative_prompt=negative_prompt or None, + image=input_image, + strength=strength, + num_inference_steps=steps, + guidance_scale=guidance_scale, + generator=generator, + ) + + buf = io.BytesIO() + result.images[0].save(buf, format="PNG") + return buf.getvalue() + + +@app.remote() +async def upscale( + image_bytes: bytes, + prompt: str = "", + steps: int = 20, + seed: int = -1, +) -> bytes: + """4x upscale using Stable Diffusion x4 upscaler. Returns PNG bytes.""" + import torch, io + from PIL import Image + + ctx = app.get_context(DiffusionModels) + + low_res = Image.open(io.BytesIO(image_bytes)).convert("RGB") + + generator = torch.Generator(device=ctx.device) + if seed >= 0: + generator.manual_seed(seed) + else: + generator.manual_seed(random.randint(0, 2**32 - 1)) + + result = ctx.upscale_pipe( + prompt=prompt or "", + image=low_res, + num_inference_steps=steps, + generator=generator, + ) + + buf = io.BytesIO() + result.images[0].save(buf, format="PNG") + return buf.getvalue() + + +# --------------------------------------------------------------------------- +# Image & deployment config +# --------------------------------------------------------------------------- + +image = app.image("vastai/pytorch:@vastai-automatic-tag", 50) +image.venv("/venv/main") +image.pip_install( + "diffusers>=0.30.0", + "transformers>=4.40.0", + "accelerate", + "safetensors", + "invisible-watermark>=0.2.0", +) +image.require(gpu_name.in_([RTX_4090, RTX_5090])) +app.configure_autoscaling(min_load=10, max_workers=5) #!VAST_IGNORE_CHANGES +app.ensure_ready() diff --git a/examples/deployments/image_gen/run.py b/examples/deployments/image_gen/run.py new file mode 100644 index 00000000..27d78f2d --- /dev/null +++ b/examples/deployments/image_gen/run.py @@ -0,0 +1,44 @@ +"""Client script: chain image-gen nodes like a ComfyUI workflow. + +Usage: + python3 examples/deployments/image_gen/run.py +""" + +import asyncio +from pathlib import Path +from deploy import txt2img, img2img, upscale + + +async def main(): + prompt = "a corgi astronaut floating in space, earth in background, cinematic lighting" + negative = "blurry, low quality, watermark, text" + + # --- Node 1: txt2img --- + print("Generating 1024x1024 base image ...") + base_png = await txt2img(prompt, negative_prompt=negative, steps=30, seed=42) + Path("01_base.png").write_bytes(base_png) + print(f" -> 01_base.png ({len(base_png)} bytes)") + + # --- Node 2: img2img style transfer --- + print("Applying style with img2img ...") + styled_png = await img2img( + base_png, + prompt=f"{prompt}, watercolor", + negative_prompt=negative, + strength=0.8, + steps=25, + ) + Path("02_styled.png").write_bytes(styled_png) + print(f" -> 02_styled.png ({len(styled_png)} bytes)") + + # --- Node 3: 4x upscale (1024x1024 -> 4096x4096) --- + print("Upscaling 4x ...") + upscaled_png = await upscale(styled_png, prompt=prompt) + Path("03_upscaled.png").write_bytes(upscaled_png) + print(f" -> 03_upscaled.png ({len(upscaled_png)} bytes)") + + print("Done! Pipeline: txt2img -> img2img -> upscale 4x") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/deployments/square/client.py b/examples/deployments/square/client.py new file mode 100644 index 00000000..a02268c2 --- /dev/null +++ b/examples/deployments/square/client.py @@ -0,0 +1,10 @@ +import asyncio +from deploy import app, square + +async def main(): + for x in range(1, 10): + result = await square(x) + print(f"square({x}) = {result}") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/deployments/square/deploy.py b/examples/deployments/square/deploy.py new file mode 100644 index 00000000..8129dd35 --- /dev/null +++ b/examples/deployments/square/deploy.py @@ -0,0 +1,15 @@ +from vastai import Deployment +from vastai.data.query import gpu_name, RTX_4090, RTX_5090 + +app = Deployment(name="square") + + +@app.remote(benchmark_dataset=[{"x": 2}]) +async def square(x): + return x * x + + +app.configure_autoscaling(min_load=1000) +image = app.image("vastai/base-image:@vastai-automatic-tag", 16) +image.require(gpu_name.in_([RTX_4090, RTX_5090])) +app.ensure_ready() diff --git a/examples/deployments/train_mnist/client.py b/examples/deployments/train_mnist/client.py new file mode 100644 index 00000000..0b5cf088 --- /dev/null +++ b/examples/deployments/train_mnist/client.py @@ -0,0 +1,21 @@ +import asyncio +import random +from deploy import app, infer + +async def main(): + from torchvision import datasets, transforms + + test_data = datasets.MNIST("/tmp/mnist", train=False, download=True, transform=transforms.ToTensor()) + idx = random.randint(0, len(test_data) - 1) + image_tensor, true_label = test_data[idx] + + # Convert to 28x28 nested list of floats (raw pixel values, no normalization) + pixel_values = image_tensor.squeeze(0).tolist() + + result = await infer(pixel_values) + print(f"True label: {true_label}") + print(f"Predicted: {result['digit']}") + print(f"Confidence: {result['probability']:.4f}") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/deployments/train_mnist/deploy.py b/examples/deployments/train_mnist/deploy.py new file mode 100644 index 00000000..7dc7bb7d --- /dev/null +++ b/examples/deployments/train_mnist/deploy.py @@ -0,0 +1,100 @@ +from vastai import Deployment +from vastai.data.query import gpu_name, RTX_4090, RTX_5090 + +app = Deployment(name="train-mnist") + + +@app.context() +class MNISTModel: + async def __aenter__(self): + import torch + import torch.nn as nn + import torch.optim as optim + from torchvision import datasets, transforms + + class CNN(nn.Module): + def __init__(self): + super().__init__() + self.conv1 = nn.Conv2d(1, 32, 3, padding=1) + self.conv2 = nn.Conv2d(32, 64, 3, padding=1) + self.pool = nn.MaxPool2d(2) + self.fc1 = nn.Linear(64 * 7 * 7, 128) + self.fc2 = nn.Linear(128, 10) + self.relu = nn.ReLU() + + def forward(self, x): + x = self.pool(self.relu(self.conv1(x))) + x = self.pool(self.relu(self.conv2(x))) + x = x.view(-1, 64 * 7 * 7) + x = self.relu(self.fc1(x)) + return self.fc2(x) + + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + model = CNN().to(device) + optimizer = optim.Adam(model.parameters(), lr=1e-3) + loss_fn = nn.CrossEntropyLoss() + + transform = transforms.Compose([ + transforms.ToTensor(), + transforms.Normalize((0.1307,), (0.3081,)), + ]) + train_data = datasets.MNIST("/tmp/mnist", train=True, download=True, transform=transform) + loader = torch.utils.data.DataLoader(train_data, batch_size=64, shuffle=True) + + print("Training MNIST classifier...") + model.train() + for epoch in range(3): + total_loss = 0.0 + for images, labels in loader: + images, labels = images.to(device), labels.to(device) + optimizer.zero_grad() + loss = loss_fn(model(images), labels) + loss.backward() + optimizer.step() + total_loss += loss.item() + print(f" Epoch {epoch + 1}/3 loss={total_loss / len(loader):.4f}") + + model.eval() + self.model = model + self.device = device + print("Training complete. Model ready for inference.") + return self + + async def __aexit__(self, *exc): + pass + + +@app.remote(benchmark_dataset=[{"pixel_values": [[0.0] * 28] * 28}]) +async def infer(pixel_values: list[list[float]]) -> dict: + """Classify a 28x28 grayscale MNIST image. + + Args: + pixel_values: 28x28 nested list of floats (0.0=black, 1.0=white), + raw pixel intensities before normalization. + + Returns: + dict with "digit" (predicted class) and "probability" (confidence). + """ + import torch + + ctx = app.get_context(MNISTModel) + + tensor = torch.tensor(pixel_values, dtype=torch.float32) + # Normalize the same way training data was normalized + tensor = (tensor - 0.1307) / 0.3081 + tensor = tensor.unsqueeze(0).unsqueeze(0).to(ctx.device) # (1, 1, 28, 28) + tensor = torch.flip(tensor, dims=[2]) # flip vertically + + with torch.no_grad(): + logits = ctx.model(tensor) + probs = torch.softmax(logits, dim=1) + prob, digit = probs.max(dim=1) + + return {"digit": digit.item(), "probability": prob.item()} + + +image = app.image("vastai/pytorch:@vastai-automatic-tag", 16) +image.venv("/venv/main") +image.require(gpu_name.in_([RTX_4090, RTX_5090])) +app.configure_autoscaling(min_load=100, max_workers=3) #!VAST_IGNORE_CHANGES +app.ensure_ready() diff --git a/examples/deployments/vllm/client.py b/examples/deployments/vllm/client.py new file mode 100644 index 00000000..700b0d1f --- /dev/null +++ b/examples/deployments/vllm/client.py @@ -0,0 +1,8 @@ +import asyncio +from deploy import app, generate + +async def main(): + print(f"Response: {await generate("Explain quantum computing in one sentence.")}") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/deployments/vllm/deploy.py b/examples/deployments/vllm/deploy.py new file mode 100644 index 00000000..a7b5ef2b --- /dev/null +++ b/examples/deployments/vllm/deploy.py @@ -0,0 +1,46 @@ +from vastai import Deployment +from vastai.data.query import gpu_name, RTX_4090, RTX_5090 + +app = Deployment(name="vllm") + +MODEL = "Qwen/Qwen3-0.6B" + +@app.context() +class VLLMEngine: + async def __aenter__(self): + from vllm import AsyncLLMEngine, AsyncEngineArgs, SamplingParams + + args = AsyncEngineArgs(model=MODEL, max_model_len=512) + self.engine = AsyncLLMEngine.from_engine_args(args) + # Warmup: run a dummy generation to ensure model is fully loaded + # and KV cache is allocated before serving real requests. + async for _ in self.engine.generate( + "warmup", SamplingParams(max_tokens=1), request_id="warmup" + ): + pass + return self + + async def __aexit__(self, *exc): + self.engine.shutdown_background_loop() + + +@app.remote(benchmark_dataset=[{"prompt": "Hello"}]) +async def generate(prompt: str, max_tokens: int = 128) -> str: + from vllm import SamplingParams + import uuid + + engine = app.get_context(VLLMEngine) + params = SamplingParams(max_tokens=max_tokens, temperature=0.7) + request_id = str(uuid.uuid4()) + result = None + async for output in engine.engine.generate(prompt, params, request_id=request_id): + result = output + return result.outputs[0].text + + +image = app.image("vastai/vllm:v0.11.0-cuda-12.8-mvc-cuda-12.0", 32) +image.use_system_python() +image.pip_install("vllm==0.11.0", "transformers==4.57.0") +image.require(gpu_name.in_([RTX_4090, RTX_5090])) +app.configure_autoscaling(min_load=100) +app.ensure_ready() diff --git a/examples/server/ace_worker.py b/examples/server/ace_worker.py new file mode 100644 index 00000000..11524a62 --- /dev/null +++ b/examples/server/ace_worker.py @@ -0,0 +1,184 @@ +import random +import sys + +from vastai import Worker, WorkerConfig, HandlerConfig, LogActionConfig, BenchmarkConfig + +# ComyUI model configuration +MODEL_SERVER_URL = 'http://127.0.0.1' +MODEL_SERVER_PORT = 18288 +MODEL_LOG_FILE = '/var/log/portal/comfyui.log' +MODEL_HEALTHCHECK_ENDPOINT = "/health" + +# ComyUI-specific log messages +MODEL_LOAD_LOG_MSG = [ + "To see the GUI go to: " +] + +MODEL_ERROR_LOG_MSGS = [ + "MetadataIncompleteBuffer", + "Value not in list: ", + "[ERROR] Provisioning Script failed" +] + +MODEL_INFO_LOG_MSGS = [ + '"message":"Downloading' +] + +benchmark_lyrics = [ + "[verse]\nGuardian cloaked in twilight hue\nShadows melt where he breaks through\nEchoes swirl in mystic flight\nHooded hero owns the night\n\n[verse]\nThrough the chaos shapes arise\nFeral whispers, glowing eyes\nOrcs and creatures side by side\nMarch within the inky tide\n\n[chorus]\nRise above the fear and gloom\nLet your courage fully bloom\nIn the darkness stand your ground\nHear the night proclaim your sound", + "[verse]\nMorning sun on fields of gold\nGentle stories unfold\nEvery breeze a quiet song\nWhere the peaceful hearts belong\n\n[verse]\nLanterns glow at stable doors\nRustling leaves on orchard floors\nSimple joys in every hand\nLife grows soft in fertile land\n\n[chorus]\nLet the day drift slow and free\nRoot your soul where you can be\nIn this haven warm and bright\nFeel the earth breathe pure delight", + "[verse]\nLittle feet on dusty ground\nChasing dreams without a sound\nSoccer ball in morning light\nHopes take wing in youthful flight\n\n[verse]\nChrome reflections paint the day\nSwagger in the steps that play\nCopper tones in shining air\nChildhood gleaming everywhere\n\n[chorus]\nKick the world with boundless cheer\nHold the magic close and near\nIn each moment bold and true\nLet the sky belong to you", + "[verse]\nSunset bleeds across the street\nGilded calm in summer heat\nLow-rise towers rimmed with fire\nDreams ignite as lights climb higher\n\n[verse]\nFootsteps scatter through the haze\nFutures shimmer in the blaze\nEvery window tells a tale\nFloating through a tangerine veil\n\n[chorus]\nLet the neon softly glow\nLet your restless heartbeat slow\nIn this city forged in light\nCarry hope into the night", + "[verse]\nOcean breathes in rolling arcs\nSprays of diamond, glowing sparks\nWaves unfold a perfect line\nNature’s rhythm feels divine\n\n[verse]\nSun above in golden sweep\nPaints the rise of every deep\nShimmer drifting through the blue\nWorld reborn in every view\n\n[chorus]\nLet the tide pull you along\nHear the water’s ancient song\nIn the cresting waves you’ll find\nQuiet peace for heart and mind", + "[verse]\nGlass aglow with swirling light\nFruits and mints in colors bright\nIcy whispers clink and chime\nFlowing forms suspend in time\n\n[verse]\nCreamy spirals drift within\nGentle currents slowly spin\nWarm reflections lingering sweet\nMixing flavors at your feet\n\n[chorus]\nSip the glow and let it rise\nTaste the sunset in disguise\nIn this moment clear and true\nLet the warmth flow into you", + "[verse]\nEngines rumble down the lane\nCopper clouds of steam and rain\nOilpunk dreams in metal shine\nRider drifting down the line\n\n[verse]\nLeather jacket, steady glare\nStories sparking in the air\nMagazine lights frame his face\nKing of roads in timeless grace\n\n[chorus]\nThrottle up beyond the bend\nFeel the force of steel ascend\nRide the night and hold on tight\nClaim the world in streaks of light", + "[verse]\nCut-out shapes in swirling play\nTextures dance in bold array\nCats in denim, grinning wide\nStrut across the patterned tide\n\n[verse]\nPosters hum with neon glow\nSurreal scenes begin to grow\nColors crisp as folded art\nPatchwork beating like a heart\n\n[chorus]\nLet the collage come alive\nWatch the vibrant pieces thrive\nIn this joyful, crafted space\nEvery shape finds its own place", + "[verse]\nTiny world in crystal glass\nAncient tales behind the mass\nVillage lights in winter gleam\nFrozen in a mystic dream\n\n[verse]\nLantern beams in swirling air\nSoft enchantment everywhere\nShadows drift with gentle grace\nMagic sealed within the space\n\n[chorus]\nHold the sphere and you will see\nEchoes of a memory\nIn the glow of fragile light\nLives a realm of pure delight", + "[verse]\nArmor hums with power bright\nChopping sparks in jungle night\nMecha spirits shift and scream\nThrough the ferns like shattered beams\n\n[verse]\nAxes blaze in glowing arcs\nLighting up the shadowed marks\nNature roars in trembling air\nClash of steel and cosmic flare\n\n[chorus]\nRaise the fire, strike the ground\nLet your legend shake the sound\nIn the wild where echoes roam\nForge the fight and carve your home", + "[verse]\nCrowds ignite in vibrant flare\nBeats explode through smoky air\nDJ robes replaced with flame\nPope on decks in holy frame\n\n[verse]\nLeather gleams in blinding light\nTurntables spin with sacred might\nChoirs echo in the bass\nHeaven pulses through the place\n\n[chorus]\nLift the roof and shake the floor\nSacred rhythm evermore\nLet the music take control\nFeel the blessing in your soul", +] + +benchmark_dataset = [ + { + "input": { + "request_id": "", + "workflow_json": { + "14": { + "inputs": { + "tags": "funk, pop, soul, rock, melodic, guitar, drums, bass, keyboard, percussion, 105 BPM, energetic, upbeat, groovy, vibrant, dynamic", + "lyrics": lyrics, + "lyrics_strength": 0.99, + "clip": ["40", 1] + }, + "class_type": "TextEncodeAceStepAudio", + "_meta": { + "title": "TextEncodeAceStepAudio" + } + }, + "17": { + "inputs": { + "seconds": 180, + "batch_size": 1 + }, + "class_type": "EmptyAceStepLatentAudio", + "_meta": { + "title": "EmptyAceStepLatentAudio" + } + }, + "18": { + "inputs": { + "samples": ["52", 0], + "vae": ["40", 2] + }, + "class_type": "VAEDecodeAudio", + "_meta": { + "title": "VAE Decode Audio" + } + }, + "40": { + "inputs": { + "ckpt_name": "ace_step_v1_3.5b.safetensors" + }, + "class_type": "CheckpointLoaderSimple", + "_meta": { + "title": "Load Checkpoint" + } + }, + "44": { + "inputs": { + "conditioning": ["14", 0] + }, + "class_type": "ConditioningZeroOut", + "_meta": { + "title": "ConditioningZeroOut" + } + }, + "49": { + "inputs": { + "model": ["51", 0], + "operation": ["50", 0] + }, + "class_type": "LatentApplyOperationCFG", + "_meta": { + "title": "LatentApplyOperationCFG" + } + }, + "50": { + "inputs": { + "multiplier": 1.15 + }, + "class_type": "LatentOperationTonemapReinhard", + "_meta": { + "title": "LatentOperationTonemapReinhard" + } + }, + "51": { + "inputs": { + "shift": 6, + "model": ["40", 0] + }, + "class_type": "ModelSamplingSD3", + "_meta": { + "title": "ModelSamplingSD3" + } + }, + "52": { + "inputs": { + "seed": "__RANDOM_INT__", + "steps": 65, + "cfg": 4, + "sampler_name": "er_sde", + "scheduler": "linear_quadratic", + "denoise": 1, + "model": ["49", 0], + "positive": ["14", 0], + "negative": ["44", 0], + "latent_image": ["17", 0] + }, + "class_type": "KSampler", + "_meta": { + "title": "KSampler" + } + }, + "59": { + "inputs": { + "filename_prefix": "audio/ComfyUI", + "quality": "V0", + "audioUI": "", + "audio": ["18", 0] + }, + "class_type": "SaveAudioMP3", + "_meta": { + "title": "Save Audio (MP3)" + } + } + } + } + } for lyrics in benchmark_lyrics +] + +worker_config = WorkerConfig( + model_server_url=MODEL_SERVER_URL, + model_server_port=MODEL_SERVER_PORT, + model_log_file=MODEL_LOG_FILE, + model_healthcheck_url=MODEL_HEALTHCHECK_ENDPOINT, + handlers=[ + HandlerConfig( + route="/generate/sync", + allow_parallel_requests=False, + max_queue_time=10.0, + benchmark_config=BenchmarkConfig( + dataset=benchmark_dataset, + runs=1 + ), + workload_calculator= lambda _ : 1000.0 + ) + ], + log_action_config=LogActionConfig( + on_load=MODEL_LOAD_LOG_MSG, + on_error=MODEL_ERROR_LOG_MSGS, + on_info=MODEL_INFO_LOG_MSGS + ) +) + +Worker(worker_config).run() \ No newline at end of file diff --git a/examples/server/comfy_worker.py b/examples/server/comfy_worker.py new file mode 100644 index 00000000..ddb7da62 --- /dev/null +++ b/examples/server/comfy_worker.py @@ -0,0 +1,81 @@ +import random +import sys + +from vastai import Worker, WorkerConfig, HandlerConfig, LogActionConfig, BenchmarkConfig + +# ComyUI model configuration +MODEL_SERVER_URL = 'http://127.0.0.1' +MODEL_SERVER_PORT = 18288 +MODEL_LOG_FILE = '/var/log/portal/comfyui.log' +MODEL_HEALTHCHECK_ENDPOINT = "/health" + +# ComyUI-specific log messages +MODEL_LOAD_LOG_MSG = [ + "To see the GUI go to: " +] + +MODEL_ERROR_LOG_MSGS = [ + "MetadataIncompleteBuffer", + "Value not in list: ", + "[ERROR] Provisioning Script failed" +] + +MODEL_INFO_LOG_MSGS = [ + '"message":"Downloading' +] + +benchmark_prompts = [ + "Cartoon hoodie hero; orc, anime cat, bunny; black goo; buff; vector on white.", + "Cozy farming-game scene with fine details.", + "2D vector child with soccer ball; airbrush chrome; swagger; antique copper.", + "Realistic futuristic downtown of low buildings at sunset.", + "Perfect wave front view; sunny seascape; ultra-detailed water; artful feel.", + "Clear cup with ice, fruit, mint; creamy swirls; fluid-sim CGI; warm glow.", + "Male biker with backpack on motorcycle; oilpunk; award-worthy magazine cover.", + "Collage for textile; surreal cartoon cat in cap/jeans before poster; crisp.", + "Medieval village inside glass sphere; volumetric light; macro focus.", + "Iron Man with glowing axe; mecha sci-fi; jungle scene; dynamic light.", + "Pope Francis DJ in leather jacket, mixing on giant console; dramatic.", +] + + + +benchmark_dataset = [ + { + "input": { + "request_id": f"test-{random.randint(1000, 99999)}", + "modifier": "Text2Image", + "modifications": { + "prompt": prompt, + "width": 512, + "height": 512, + "steps": 20, + "seed": random.randint(0, sys.maxsize) + } + } + } for prompt in benchmark_prompts +] + +worker_config = WorkerConfig( + model_server_url=MODEL_SERVER_URL, + model_server_port=MODEL_SERVER_PORT, + model_log_file=MODEL_LOG_FILE, + model_healthcheck_url=MODEL_HEALTHCHECK_ENDPOINT, + handlers=[ + HandlerConfig( + route="/generate/sync", + allow_parallel_requests=False, + max_queue_time=10.0, + benchmark_config=BenchmarkConfig( + dataset=benchmark_dataset, + ) + ) + ], + log_action_config=LogActionConfig( + on_load=MODEL_LOAD_LOG_MSG, + on_error=MODEL_ERROR_LOG_MSGS, + on_info=MODEL_INFO_LOG_MSGS + ) +) + +Worker(worker_config).run() \ No newline at end of file diff --git a/examples/server/tgi_worker.py b/examples/server/tgi_worker.py new file mode 100644 index 00000000..f8084ab2 --- /dev/null +++ b/examples/server/tgi_worker.py @@ -0,0 +1,76 @@ +import nltk +import random + +from vastai import Worker, WorkerConfig, HandlerConfig, LogActionConfig, BenchmarkConfig + +# TGI model configuration +MODEL_SERVER_URL = 'http://0.0.0.0' +MODEL_SERVER_PORT = 5001 +MODEL_LOG_FILE = "/workspace/infer.log" +MODEL_HEALTHCHECK_ENDPOINT = "/health" + +# TGI-specific log messages +MODEL_LOAD_LOG_MSG = [ + '"message":"Connected","target":"text_generation_router"', + '"message":"Connected","target":"text_generation_router::server"', +] + +MODEL_ERROR_LOG_MSGS = [ + "Error: WebserverFailed", + "Error: DownloadError", + "Error: ShardCannotStart", +] + +MODEL_INFO_LOG_MSGS = [ + '"message":"Download' +] + +nltk.download("words") +WORD_LIST = nltk.corpus.words.words() + + +def benchmark_generator() -> dict: + prompt = " ".join(random.choices(WORD_LIST, k=int(250))) + + benchmark_data = { + "inputs": prompt, + "parameters": { + "max_new_tokens": 128, + "temperature": 0.7, + "return_full_text": False + } + } + + return benchmark_data + +worker_config = WorkerConfig( + model_server_url=MODEL_SERVER_URL, + model_server_port=MODEL_SERVER_PORT, + model_log_file=MODEL_LOG_FILE, + model_healthcheck_url=MODEL_HEALTHCHECK_ENDPOINT, + handlers=[ + HandlerConfig( + route="/generate", + allow_parallel_requests=True, + max_queue_time=60.0, + benchmark_config=BenchmarkConfig( + generator=benchmark_generator, + concurrency=50 + ), + workload_calculator= lambda x: x["parameters"]["max_new_tokens"] + ), + HandlerConfig( + route="/generate_stream", + allow_parallel_requests=True, + max_queue_time=60.0, + workload_calculator= lambda x: x["parameters"]["max_new_tokens"] + ) + ], + log_action_config=LogActionConfig( + on_load=MODEL_LOAD_LOG_MSG, + on_error=MODEL_ERROR_LOG_MSGS, + on_info=MODEL_INFO_LOG_MSGS + ) +) + +Worker(worker_config).run() \ No newline at end of file diff --git a/examples/server/tutorial_worker.py b/examples/server/tutorial_worker.py new file mode 100644 index 00000000..4cb86223 --- /dev/null +++ b/examples/server/tutorial_worker.py @@ -0,0 +1,92 @@ +from vastai import Worker, WorkerConfig, HandlerConfig, BenchmarkConfig, LogActionConfig + +# We define a WorkerConfig object to configure our PyWorker +# Here, we can implement handlers for different routes our +# endpoint may serve +worker_config = WorkerConfig( + # --- Model Config --- + # The local URL of your model + model_server_url="http://127.0.0.1", + # The port your model is running on + model_server_port=18000, + # The file your model writes logs to + model_log_file="/var/model/out.log", + # If your model responds to a healthcheck, you can specify it here + model_healthcheck_url="/health", + + # --- Handler Config --- + # Here, we define potentially multiple endpoint handlers for our endpoint, + # each of which services a different route on our endpoint + handlers=[ + HandlerConfig( + # The route on our endpoint that we are handling + route="/my/route", + # Enable this if the model backend supports handling multiple requests at once + # If 'False', the worker will enforce one request at a time on the + # model backend with strict FIFO ordering. + allow_parallel_requests=False, + # --- Benchmark config --- + # One endpoint handler must implement a BenchmarkConfig + # The BenchmarkConfig defines sample payloads we use for + # measuring the performance of any given machine. + # This is essential for correct optimal autoscaling behavior. + benchmark_config=BenchmarkConfig( + # A list of possible request payloads to benchmark on + dataset=[ + { "prompt" : "some" }, + { "prompt" : "sample" }, + { "prompt" : "data" } + ], + # You may also implement a `generator` function, which + # returns a benchmark payload dictionary + # generator= lambda: { "prompt" : "a" * random.randint(60) } + + # How many times you should run the benchmark + runs= 5, + + # If `allow_parallel_requests` == True, how many concurrent payloads per run + concurrency=10 + ), + # A function that calculates the workload per request + # Example: the length of the input data + workload_calculator= lambda request: len(request["prompt"]) + ) + ], + + # --- Log Config --- + # Here, we define various LogActions, which inform our worker + # of model start, model error, or useful model information. + # It's important that your model outputs logs to the file + # specified in `model_log_file`, so the worker knows the state + # of the model and can react accordingly. + log_action_config=LogActionConfig( + # A log line from our model that indicates + # the model has completed loading and is ready + # to recieve requests + on_load=[ + "Application startup complete.", + ], + # The log lines from our model that indicate + # the model has suffered an irrecoverable error + # and our worker must be restarted + on_error=[ + "INFO exited: vllm", + "RuntimeError: Engine", + "Traceback (most recent call last):" + ], + # A log line the model may emit + # containing relevant information + on_info=[ + '"message":"Download' + ] + ) +) + +# --- Running the Worker --- +# Run the worker synchronously +Worker(worker_config).run() + +# Or, if you wish to continue executing Python from this entrypoint, +# you can run your PyWorker in an asyncio background task +# pyworker_task = asyncio.run(Worker(worker_config).run_async()) +# ... more python here ... \ No newline at end of file diff --git a/examples/server/vllm_worker.py b/examples/server/vllm_worker.py new file mode 100644 index 00000000..6cf17f0e --- /dev/null +++ b/examples/server/vllm_worker.py @@ -0,0 +1,78 @@ +import nltk +import random +import os + +from vastai import Worker, WorkerConfig, HandlerConfig, LogActionConfig, BenchmarkConfig + +# vLLM model configuration +MODEL_SERVER_URL = 'http://127.0.0.1' +MODEL_SERVER_PORT = 18000 +MODEL_LOG_FILE = '/var/log/portal/vllm.log' +MODEL_HEALTHCHECK_ENDPOINT = "/health" + +# vLLM-specific log messages +MODEL_LOAD_LOG_MSG = [ + "Application startup complete.", +] + +MODEL_ERROR_LOG_MSGS = [ + "INFO exited: vllm", + "RuntimeError: Engine", + "Traceback (most recent call last):" +] + +MODEL_INFO_LOG_MSGS = [ + '"message":"Download' +] + +nltk.download("words") +WORD_LIST = nltk.corpus.words.words() + + +def completions_benchmark_generator() -> dict: + prompt = " ".join(random.choices(WORD_LIST, k=int(250))) + model = os.environ.get("MODEL_NAME") + if not model: + raise ValueError("MODEL_NAME environment variable not set") + + benchmark_data = { + "model": model, + "prompt": prompt, + "temperature": 0.7, + "max_tokens": 500, + } + + return benchmark_data + +worker_config = WorkerConfig( + model_server_url=MODEL_SERVER_URL, + model_server_port=MODEL_SERVER_PORT, + model_log_file=MODEL_LOG_FILE, + model_healthcheck_url=MODEL_HEALTHCHECK_ENDPOINT, + handlers=[ + HandlerConfig( + route="/v1/completions", + workload_calculator= lambda data: data.get("max_tokens", 0), + allow_parallel_requests=True, + max_queue_time=60.0, + benchmark_config=BenchmarkConfig( + generator=completions_benchmark_generator, + concurrency=100, + runs=2 + ) + ), + HandlerConfig( + route="/v1/chat/completions", + workload_calculator= lambda data: data.get("max_tokens", 0), + allow_parallel_requests=True, + max_queue_time=60.0, + ) + ], + log_action_config=LogActionConfig( + on_load=MODEL_LOAD_LOG_MSG, + on_error=MODEL_ERROR_LOG_MSGS, + on_info=MODEL_INFO_LOG_MSGS + ) +) + +Worker(worker_config).run() \ No newline at end of file diff --git a/examples/server/wan_example.py b/examples/server/wan_example.py new file mode 100644 index 00000000..174b5f4f --- /dev/null +++ b/examples/server/wan_example.py @@ -0,0 +1,288 @@ +import random +import sys + +from vastai import Worker, WorkerConfig, HandlerConfig, LogActionConfig, BenchmarkConfig + +# ComyUI model configuration +MODEL_SERVER_URL = 'http://127.0.0.1' +MODEL_SERVER_PORT = 18288 +MODEL_LOG_FILE = '/var/log/portal/comfyui.log' +MODEL_HEALTHCHECK_ENDPOINT = "/health" + +# ComyUI-specific log messages +MODEL_LOAD_LOG_MSG = [ + "To see the GUI go to: " +] + +MODEL_ERROR_LOG_MSGS = [ + "MetadataIncompleteBuffer", + "Value not in list: ", + "[ERROR] Provisioning Script failed" +] + +MODEL_INFO_LOG_MSGS = [ + '"message":"Downloading' +] + +benchmark_prompts = [ + "Cartoon hoodie hero; orc, anime cat, bunny; black goo; buff; vector on white.", + "Cozy farming-game scene with fine details.", + "2D vector child with soccer ball; airbrush chrome; swagger; antique copper.", + "Realistic futuristic downtown of low buildings at sunset.", + "Perfect wave front view; sunny seascape; ultra-detailed water; artful feel.", + "Clear cup with ice, fruit, mint; creamy swirls; fluid-sim CGI; warm glow.", + "Male biker with backpack on motorcycle; oilpunk; award-worthy magazine cover.", + "Collage for textile; surreal cartoon cat in cap/jeans before poster; crisp.", + "Medieval village inside glass sphere; volumetric light; macro focus.", + "Iron Man with glowing axe; mecha sci-fi; jungle scene; dynamic light.", + "Pope Francis DJ in leather jacket, mixing on giant console; dramatic.", +] + +benchmark_dataset = [ + { + "input": { + "workflow_json": { + "90": { + "inputs": { + "clip_name": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "type": "wan", + "device": "default" + }, + "class_type": "CLIPLoader", + "_meta": { + "title": "Load CLIP" + } + }, + "91": { + "inputs": { + "text": "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走,裸露,NSFW", + "clip": [ + "90", + 0 + ] + }, + "class_type": "CLIPTextEncode", + "_meta": { + "title": "CLIP Text Encode (Negative Prompt)" + } + }, + "92": { + "inputs": { + "vae_name": "wan_2.1_vae.safetensors" + }, + "class_type": "VAELoader", + "_meta": { + "title": "Load VAE" + } + }, + "93": { + "inputs": { + "shift": 8.000000000000002, + "model": [ + "101", + 0 + ] + }, + "class_type": "ModelSamplingSD3", + "_meta": { + "title": "ModelSamplingSD3" + } + }, + "94": { + "inputs": { + "shift": 8, + "model": [ + "102", + 0 + ] + }, + "class_type": "ModelSamplingSD3", + "_meta": { + "title": "ModelSamplingSD3" + } + }, + "95": { + "inputs": { + "add_noise": "disable", + "noise_seed": 0, + "steps": 20, + "cfg": 3.5, + "sampler_name": "euler", + "scheduler": "simple", + "start_at_step": 10, + "end_at_step": 10000, + "return_with_leftover_noise": "disable", + "model": [ + "94", + 0 + ], + "positive": [ + "99", + 0 + ], + "negative": [ + "91", + 0 + ], + "latent_image": [ + "96", + 0 + ] + }, + "class_type": "KSamplerAdvanced", + "_meta": { + "title": "KSampler (Advanced)" + } + }, + "96": { + "inputs": { + "add_noise": "enable", + "noise_seed": "__RANDOM_INT__", + "steps": 20, + "cfg": 3.5, + "sampler_name": "euler", + "scheduler": "simple", + "start_at_step": 0, + "end_at_step": 10, + "return_with_leftover_noise": "enable", + "model": [ + "93", + 0 + ], + "positive": [ + "99", + 0 + ], + "negative": [ + "91", + 0 + ], + "latent_image": [ + "104", + 0 + ] + }, + "class_type": "KSamplerAdvanced", + "_meta": { + "title": "KSampler (Advanced)" + } + }, + "97": { + "inputs": { + "samples": [ + "95", + 0 + ], + "vae": [ + "92", + 0 + ] + }, + "class_type": "VAEDecode", + "_meta": { + "title": "VAE Decode" + } + }, + "98": { + "inputs": { + "filename_prefix": "video/ComfyUI", + "format": "auto", + "codec": "auto", + "video": [ + "100", + 0 + ] + }, + "class_type": "SaveVideo", + "_meta": { + "title": "Save Video" + } + }, + "99": { + "inputs": { + "text":prompt, + "clip": [ + "90", + 0 + ] + }, + "class_type": "CLIPTextEncode", + "_meta": { + "title": "CLIP Text Encode (Positive Prompt)" + } + }, + "100": { + "inputs": { + "fps": 16, + "images": [ + "97", + 0 + ] + }, + "class_type": "CreateVideo", + "_meta": { + "title": "Create Video" + } + }, + "101": { + "inputs": { + "unet_name": "wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors", + "weight_dtype": "default" + }, + "class_type": "UNETLoader", + "_meta": { + "title": "Load Diffusion Model" + } + }, + "102": { + "inputs": { + "unet_name": "wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors", + "weight_dtype": "default" + }, + "class_type": "UNETLoader", + "_meta": { + "title": "Load Diffusion Model" + } + }, + "104": { + "inputs": { + "width": 640, + "height": 640, + "length": 81, + "batch_size": 1 + }, + "class_type": "EmptyHunyuanLatentVideo", + "_meta": { + "title": "EmptyHunyuanLatentVideo" + } + } + } + } + } for prompt in benchmark_prompts +] + +worker_config = WorkerConfig( + model_server_url=MODEL_SERVER_URL, + model_server_port=MODEL_SERVER_PORT, + model_log_file=MODEL_LOG_FILE, + model_healthcheck_url=MODEL_HEALTHCHECK_ENDPOINT, + handlers=[ + HandlerConfig( + route="/generate/sync", + allow_parallel_requests=False, + max_queue_time=10.0, + benchmark_config=BenchmarkConfig( + dataset=benchmark_dataset, + runs=1 + ), + workload_calculator= lambda _ : 10000.0 + ) + ], + log_action_config=LogActionConfig( + on_load=MODEL_LOAD_LOG_MSG, + on_error=MODEL_ERROR_LOG_MSGS, + on_info=MODEL_INFO_LOG_MSGS + ) +) + +Worker(worker_config).run() \ No newline at end of file diff --git a/examples/session/client.py b/examples/session/client.py new file mode 100644 index 00000000..21db6191 --- /dev/null +++ b/examples/session/client.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +""" +Demo client for Vast.ai serverless PyTorch training endpoints. + +Usage: + python client.py --endpoint my-pytorch-endpoint --sessions 3 --epochs 5 +""" + +import argparse +import asyncio +import time +from dataclasses import dataclass +from typing import Any, Optional + +import vastai + + +@dataclass +class SessionResult: + session_id: str + success: bool + start_time: float + end_time: float + final_status: Optional[dict] = None + error: Optional[str] = None + + @property + def duration(self) -> float: + return self.end_time - self.start_time + + +async def close_sessions(sessions: list[Any]) -> None: + """Close all open sessions.""" + for session in sessions: + try: + if await session.is_open(): + print(f" Closing session {session.session_id[:8]}...") + await session.close() + except Exception as e: + print(f" Failed to close {session.session_id[:8]}: {e}") + + +async def run_training_session( + endpoint, + session: Any, + epochs: int, + max_train_batches: int, + poll_interval: float = 1.0, + debug: bool = False, +) -> SessionResult: + """ + Run a training session: start the task, poll for status, return results. + """ + session_id = session.session_id + start_time = time.time() + + payload = { + "epochs": epochs, + "max_train_batches_per_epoch": max_train_batches, + "session_id": session_id, + } + + # Start the task and check the initial response + try: + resp = await session.request(route="/start_task", payload=payload) + if debug: + print(f" [{session_id[:8]}] /start_task response: {resp}") + + # SDK wraps the model response in a "response" key + inner = resp.get("response", resp) + status = inner.get("status", {}) + state = status.get("state", "unknown") + print(f" [{session_id[:8]}] Task started, initial state={state}") + + # If task already completed in the start call + if state in ("completed", "failed", "canceled"): + return SessionResult( + session_id=session_id, + success=(state == "completed"), + start_time=start_time, + end_time=time.time(), + final_status=status, + error=status.get("error") if state == "failed" else None, + ) + except Exception as e: + return SessionResult( + session_id=session_id, + success=False, + start_time=start_time, + end_time=time.time(), + error=f"Failed to start task: {e}", + ) + + final_status = status + last_state = state + + # Poll until completion or session closes + while await session.is_open(): + await asyncio.sleep(poll_interval) + try: + resp = await session.request(route="/status", payload={}, retry=False) + if debug: + print(f" [{session_id[:8]}] /status response: {resp}") + + inner = resp.get("response", resp) + status = inner.get("status", {}) + state = status.get("state", "unknown") + epoch = status.get("epoch", 0) + step = status.get("step", 0) + total = status.get("total_steps", 0) + msg = status.get("message", "") + + print(f" [{session_id[:8]}] state={state} epoch={epoch} step={step}/{total} - {msg}") + + final_status = status + last_state = state + + if state in ("completed", "failed", "canceled"): + break + + except Exception: + print(f" [{session_id[:8]}] Session closed (training likely completed)") + break + + success = last_state in ("completed", "running") + error = None + if final_status and final_status.get("state") == "failed": + success = False + error = final_status.get("error", "Unknown error") + + return SessionResult( + session_id=session_id, + success=success, + start_time=start_time, + end_time=time.time(), + final_status=final_status, + error=error, + ) + + +def print_summary(results: list[SessionResult]) -> None: + """Print a summary of all training session results.""" + print("\n" + "=" * 60) + print("TRAINING SUMMARY") + print("=" * 60) + + successful = [r for r in results if r.success] + failed = [r for r in results if not r.success] + + print(f"Total sessions: {len(results)}") + print(f"Successful: {len(successful)}") + print(f"Failed: {len(failed)}") + + if successful: + durations = [r.duration for r in successful] + print(f"\nSuccessful session durations:") + print(f" Min: {min(durations):.2f}s") + print(f" Max: {max(durations):.2f}s") + print(f" Avg: {sum(durations) / len(durations):.2f}s") + + if failed: + print(f"\nFailed sessions:") + for r in failed: + print(f" [{r.session_id[:8]}] {r.error}") + + print("\nPer-session details:") + for r in results: + status_str = "OK" if r.success else "FAILED" + print(f" [{r.session_id[:8]}] {status_str} - {r.duration:.2f}s") + if r.final_status: + val_acc = r.final_status.get("val_acc") + val_loss = r.final_status.get("val_loss") + train_acc = r.final_status.get("train_acc") + if val_acc is not None: + print(f" Final val_acc: {val_acc:.4f}") + if val_loss is not None: + print(f" Final val_loss: {val_loss:.4f}") + if train_acc is not None: + print(f" Final train_acc: {train_acc:.4f}") + + print("=" * 60) + + +async def main( + endpoint_name: str, + num_sessions: int, + epochs: int, + max_train_batches: int, + session_cost: float, + debug: bool = False, +) -> None: + print(f"Starting {num_sessions} training session(s) on endpoint '{endpoint_name}'") + print(f"Config: epochs={epochs}, max_train_batches={max_train_batches}") + print() + + sessions = [] + results = [] + + async with vastai.Serverless(max_poll_interval=0.1) as client: + endpoint = await client.get_endpoint(endpoint_name) + + try: + # Step 1: Create all sessions + print("Creating sessions...") + for i in range(num_sessions): + session = await endpoint.session(cost=session_cost, on_close_route="/cancel_task") + sessions.append(session) + print(f" Created session {i+1}/{num_sessions}: {session.session_id[:8]}") + + print(f"\nCreated {len(sessions)} session(s)\n") + + # Step 2: Run training on all sessions concurrently + print("Starting training runs...") + training_tasks = [ + run_training_session( + endpoint=endpoint, + session=s, + epochs=epochs, + max_train_batches=max_train_batches, + debug=debug, + ) + for s in sessions + ] + + results = await asyncio.gather(*training_tasks, return_exceptions=True) + results = [r for r in results if isinstance(r, SessionResult)] + + except KeyboardInterrupt: + print("\n\nInterrupted.") + finally: + print("\nCleaning up sessions...") + await close_sessions(sessions) + + if results: + print_summary(results) + else: + print("\nNo results to report.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Demo client for Vast.ai serverless training") + parser.add_argument("--endpoint", type=str, required=True, help="Name of the serverless endpoint") + parser.add_argument("--sessions", type=int, default=3, help="Number of concurrent sessions") + parser.add_argument("--epochs", type=int, default=5, help="Number of training epochs") + parser.add_argument("--max-train-batches", type=int, default=10, help="Max batches per epoch") + parser.add_argument("--session-cost", type=float, default=10.0, help="Cost budget per session") + parser.add_argument("--debug", action="store_true", help="Print raw API responses") + + args = parser.parse_args() + + asyncio.run( + main( + endpoint_name=args.endpoint, + num_sessions=args.sessions, + epochs=args.epochs, + max_train_batches=args.max_train_batches, + session_cost=args.session_cost, + debug=args.debug, + ) + ) \ No newline at end of file diff --git a/examples/session/model.py b/examples/session/model.py new file mode 100644 index 00000000..a06c45de --- /dev/null +++ b/examples/session/model.py @@ -0,0 +1,630 @@ +#!/usr/bin/env python3 +""" +Single-worker PyTorch trainer + aiohttp webserver. + +Endpoints (all POST, JSON): + 1) /start_task -> starts a new MNIST training run (CPU), returns task_id + 2) /status -> returns current task state + metrics + 3) /cancel_task -> cancels the current task (best-effort), returns state + +Notes: +- This backend manages exactly one active task at a time. +- Training runs in a background thread, while the webserver remains responsive. +- Status is kept in a shared state object guarded by a lock. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import signal +import threading +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, Dict, Optional, Tuple +import urllib.request +import urllib.error +from aiohttp import web +import ssl + +# ---- PyTorch / TorchVision ---- +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch.utils.data import DataLoader +from torchvision import datasets, transforms + + + +class SmallCNN(nn.Module): + def __init__(self, num_classes: int = 10): + super().__init__() + self.conv1 = nn.Conv2d(1, 16, 3, padding=1) + self.conv2 = nn.Conv2d(16, 32, 3, padding=1) + self.fc1 = nn.Linear(32 * 7 * 7, 128) + self.fc2 = nn.Linear(128, num_classes) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + x = F.relu(self.conv1(x)) + x = F.max_pool2d(x, 2) # 14x14 + x = F.relu(self.conv2(x)) + x = F.max_pool2d(x, 2) # 7x7 + x = torch.flatten(x, 1) + x = F.relu(self.fc1(x)) + return self.fc2(x) + + +def accuracy(logits: torch.Tensor, y: torch.Tensor) -> float: + preds = logits.argmax(dim=1) + return (preds == y).float().mean().item() + + +def now_s() -> float: + return time.time() + +async def on_startup(app: web.Application) -> None: + """ + Runs after the app is created and just before the server starts accepting requests. + Put any real readiness work here (warmups, checks, etc.). + """ + # Example: do small CPU torch warmup so first request isn't "cold" + # (optional; remove if you don't want it) + try: + import torch + x = torch.randn(1, 1, 28, 28) + m = SmallCNN().eval() + with torch.no_grad(): + _ = m(x) + except Exception: + # If warmup fails, you can decide to raise to fail-fast, + # or just continue. I'd usually fail-fast: + raise + + # Signal readiness + app["ready_event"].set() + +# ----------------------------- +# Training model + helpers +# ----------------------------- + + +# ----------------------------- +# Task lifecycle + status +# ----------------------------- + +@dataclass +class TaskConfig: + epochs: int = 2 + batch_size: int = 64 + lr: float = 1e-3 + max_train_batches_per_epoch: int = 200 + max_val_batches: int = 50 + seed: int = 1337 + data_dir: str = "./data" + num_workers: int = 2 # GPU input pipeline benefits from workers (tune per box) + device: str = "auto" # "auto" | "cuda" | "cpu" + pin_memory: bool = True + task_id: str = None + + +@dataclass +class TaskStatus: + task_id: Optional[str] = None + state: str = "idle" # idle|running|completed|failed|canceled + message: str = "" + created_at: Optional[float] = None + started_at: Optional[float] = None + finished_at: Optional[float] = None + + # Progress / metrics + epoch: int = 0 + step: int = 0 + total_steps: int = 0 + train_loss: Optional[float] = None + train_acc: Optional[float] = None + val_loss: Optional[float] = None + val_acc: Optional[float] = None + + # Last update + config snapshot + last_update_at: Optional[float] = None + config: Dict[str, Any] = field(default_factory=dict) + + # Error details (if failed) + error_type: Optional[str] = None + error: Optional[str] = None + + +class TaskManager: + """ + Single-worker task manager: + - at most one active training task at a time + - training runs in a background thread + - cancellation via threading.Event + """ + def __init__(self): + self._lock = threading.Lock() + self._status = TaskStatus() + self._thread: Optional[threading.Thread] = None + self._cancel_event: Optional[threading.Event] = None + + def snapshot(self) -> Dict[str, Any]: + with self._lock: + return json.loads(json.dumps(self._status, default=lambda o: o.__dict__)) + + def can_start(self) -> Tuple[bool, str]: + with self._lock: + if self._status.state == "running": + return False, "A task is already running" + return True, "" + + def start(self, cfg: TaskConfig) -> str: + if cfg.task_id is None: + raise RuntimeError("Cannot start task without session_id") + task_id = cfg.task_id + cancel_event = threading.Event() + + with self._lock: + if self._status.state == "running": + raise RuntimeError("A task is already running") + + self._status = TaskStatus( + task_id=task_id, + state="running", + message="Task started", + created_at=now_s(), + started_at=now_s(), + epoch=0, + step=0, + total_steps=0, + last_update_at=now_s(), + config=cfg.__dict__.copy(), + ) + self._cancel_event = cancel_event + + t = threading.Thread( + target=self._train_entrypoint, + name=f"trainer-{task_id}", + args=(task_id, cfg, cancel_event), + daemon=True, + ) + self._thread = t + t.start() + return task_id + + def cancel(self) -> Dict[str, Any]: + with self._lock: + if self._status.state != "running": + # return a snapshot WITHOUT re-locking + return json.loads(json.dumps(self._status, default=lambda o: o.__dict__)) + + if self._cancel_event is not None: + self._cancel_event.set() + self._status.message = "Cancellation requested" + self._status.last_update_at = now_s() + + return self.snapshot() + + def _set_status_update(self, **kwargs: Any) -> None: + with self._lock: + for k, v in kwargs.items(): + setattr(self._status, k, v) + self._status.last_update_at = now_s() + + def end_session(self, task_id: str) -> None: + """ + Best-effort HTTPS POST to the local worker session server. + + Assumes the session server is running with aiohttp + USE_SSL=true + and is bound on WORKER_PORT in the same environment. + """ + port = int(os.environ.get("WORKER_PORT", "3000")) + use_ssl = os.environ.get("USE_SSL", "true") == "true" + scheme = "https" if use_ssl else "http" + url = f"{scheme}://127.0.0.1:{port}/session/end" + + payload = json.dumps({"session_id": task_id}).encode("utf-8") + req = urllib.request.Request( + url=url, + data=payload, + method="POST", + headers={"Content-Type": "application/json"}, + ) + + # Simplest: internal call, TLS on, skip cert verification. + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + try: + with urllib.request.urlopen(req, timeout=2.0, context=ctx) as resp: + _ = resp.read() + except Exception as e: + # Best-effort: don't fail training if session end fails. + try: + self._set_status_update( + message=f"{self._status.message} | end_session failed: {type(e).__name__}: {e}" + ) + except Exception: + pass + + + def _train_entrypoint(self, task_id: str, cfg: TaskConfig, cancel_event: threading.Event) -> None: + try: + run_training(task_id=task_id, cfg=cfg, cancel_event=cancel_event, report=self._set_status_update) + + # IMPORTANT: We call this function to tell our worker that the session has ended. + self.end_session(task_id) + + if cancel_event.is_set(): + self._set_status_update( + state="canceled", + message="Task canceled", + finished_at=now_s(), + ) + else: + self._set_status_update( + state="completed", + message="Task completed", + finished_at=now_s(), + ) + except Exception as e: + self._set_status_update( + state="failed", + message="Task failed", + finished_at=now_s(), + error_type=type(e).__name__, + error=str(e), + ) + + +# ----------------------------- +# Training loop +# ----------------------------- + +def run_training(task_id: str, cfg: TaskConfig, cancel_event: threading.Event, report) -> None: + torch.manual_seed(cfg.seed) + + # ---- Device selection ---- + if cfg.device == "cuda": + if not torch.cuda.is_available(): + raise RuntimeError("device='cuda' requested but CUDA is not available") + device = torch.device("cuda") + elif cfg.device == "cpu": + device = torch.device("cpu") + else: # "auto" + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + use_cuda = (device.type == "cuda") + + # Optional: faster convs on fixed-size inputs + if use_cuda: + torch.backends.cudnn.benchmark = True + + tfm = transforms.Compose([ + transforms.ToTensor(), + transforms.Normalize((0.1307,), (0.3081,)) + ]) + + train_ds = datasets.MNIST(cfg.data_dir, train=True, download=True, transform=tfm) + val_ds = datasets.MNIST(cfg.data_dir, train=False, download=True, transform=tfm) + + train_loader = DataLoader( + train_ds, + batch_size=cfg.batch_size, + shuffle=True, + num_workers=cfg.num_workers, + pin_memory=(cfg.pin_memory and use_cuda), + persistent_workers=(cfg.num_workers > 0), + ) + val_loader = DataLoader( + val_ds, + batch_size=cfg.batch_size, + shuffle=False, + num_workers=cfg.num_workers, + pin_memory=(cfg.pin_memory and use_cuda), + persistent_workers=(cfg.num_workers > 0), + ) + + model = SmallCNN().to(device) + opt = torch.optim.Adam(model.parameters(), lr=cfg.lr) + loss_fn = nn.CrossEntropyLoss() + + steps_per_epoch = min(cfg.max_train_batches_per_epoch, len(train_loader)) + total_steps = steps_per_epoch * cfg.epochs + + report( + total_steps=total_steps, + message=f"Training initialized on {device.type}", + epoch=0, + step=0, + train_loss=None, + train_acc=None, + val_loss=None, + val_acc=None, + ) + + global_step = 0 + + for epoch in range(1, cfg.epochs + 1): + if cancel_event.is_set(): + report(message=f"Canceled before epoch {epoch}", epoch=epoch, step=global_step) + return + + model.train() + running_loss = 0.0 + running_acc = 0.0 + n_batches = 0 + + for batch_idx, (x, y) in enumerate(train_loader, start=1): + if batch_idx > cfg.max_train_batches_per_epoch: + break + if cancel_event.is_set(): + report(message=f"Canceled during epoch {epoch}", epoch=epoch, step=global_step) + return + + # ---- Move batch to GPU (non_blocking only matters with pin_memory=True) ---- + x = x.to(device, non_blocking=use_cuda) + y = y.to(device, non_blocking=use_cuda) + + opt.zero_grad(set_to_none=True) + logits = model(x) + loss = loss_fn(logits, y) + loss.backward() + opt.step() + + b_loss = loss.item() + b_acc = accuracy(logits.detach(), y) + + running_loss += b_loss + running_acc += b_acc + n_batches += 1 + global_step += 1 + + if batch_idx % 10 == 0 or batch_idx == steps_per_epoch: + report( + epoch=epoch, + step=global_step, + message=f"Training epoch {epoch}/{cfg.epochs} batch {batch_idx}/{steps_per_epoch}", + train_loss=running_loss / max(1, n_batches), + train_acc=running_acc / max(1, n_batches), + ) + + model.eval() + v_loss_sum = 0.0 + v_acc_sum = 0.0 + v_batches = 0 + with torch.no_grad(): + for v_idx, (x, y) in enumerate(val_loader, start=1): + if v_idx > cfg.max_val_batches: + break + if cancel_event.is_set(): + report(message=f"Canceled during validation epoch {epoch}", epoch=epoch, step=global_step) + return + + x = x.to(device, non_blocking=use_cuda) + y = y.to(device, non_blocking=use_cuda) + + logits = model(x) + loss = loss_fn(logits, y) + + v_loss_sum += loss.item() + v_acc_sum += accuracy(logits, y) + v_batches += 1 + + report( + epoch=epoch, + step=global_step, + message=f"Validation epoch {epoch}/{cfg.epochs} complete", + val_loss=v_loss_sum / max(1, v_batches), + val_acc=v_acc_sum / max(1, v_batches), + ) + +# ----------------------------- +# aiohttp server +# ----------------------------- + +async def json_request(request: web.Request) -> Dict[str, Any]: + if request.content_type and "application/json" in request.content_type: + try: + return await request.json() + except Exception: + return {} + return {} + + +def make_app(manager: TaskManager) -> web.Application: + app = web.Application() + + app["ready_event"] = asyncio.Event() + app.on_startup.append(on_startup) + + app["sync_lock"] = asyncio.Lock() + + async def start_task(request: web.Request) -> web.Response: + payload = await json_request(request) + + # Allow overrides via JSON; keep defaults safe. + cfg = TaskConfig( + epochs=int(payload.get("epochs", 2)), + batch_size=int(payload.get("batch_size", 64)), + lr=float(payload.get("lr", 1e-3)), + max_train_batches_per_epoch=int(payload.get("max_train_batches_per_epoch", 200)), + max_val_batches=int(payload.get("max_val_batches", 50)), + seed=int(payload.get("seed", 1337)), + data_dir=str(payload.get("data_dir", "./data")), + num_workers=int(payload.get("num_workers", 2)), + device=str(payload.get("device", "auto")), + pin_memory=bool(payload.get("pin_memory", True)), + task_id=str(payload.get("session_id")) + ) + + try: + task_id = manager.start(cfg) + return web.json_response({"ok": True, "task_id": task_id, "status": manager.snapshot()}) + except Exception as e: + return web.json_response({"ok": False, "error": str(e), "status": manager.snapshot()}, status=409) + + async def status(request: web.Request) -> web.Response: + # No body required; POST for uniformity. + _ = await json_request(request) + return web.json_response({"ok": True, "status": manager.snapshot()}) + + async def cancel_task(request: web.Request) -> web.Response: + _ = await json_request(request) + st = manager.cancel() + return web.json_response({"ok": True, "status": st}) + + async def start_sync_task(request: web.Request) -> web.Response: + payload = await json_request(request) + + # Build config (same defaults as /start_task) + session_id = payload.get("session_id") + if not session_id: + session_id = str(uuid.uuid4()) + + cfg = TaskConfig( + epochs=int(payload.get("epochs", 2)), + batch_size=int(payload.get("batch_size", 64)), + lr=float(payload.get("lr", 1e-3)), + max_train_batches_per_epoch=int(payload.get("max_train_batches_per_epoch", 200)), + max_val_batches=int(payload.get("max_val_batches", 50)), + seed=int(payload.get("seed", 1337)), + data_dir=str(payload.get("data_dir", "./data")), + num_workers=int(payload.get("num_workers", 2)), + device=str(payload.get("device", "auto")), + pin_memory=bool(payload.get("pin_memory", True)), + task_id=str(session_id), + ) + + # Disallow if an async task is already running + can, reason = manager.can_start() + if not can: + return web.json_response( + {"ok": False, "error": reason, "status": manager.snapshot()}, + status=409, + ) + + # Disallow concurrent sync runs + sync_lock: asyncio.Lock = request.app["sync_lock"] + if sync_lock.locked(): + return web.json_response( + {"ok": False, "error": "A sync task is already running"}, + status=409, + ) + + # Local status (not TaskManager-backed) + st_lock = threading.Lock() + st = TaskStatus( + task_id=cfg.task_id, + state="running", + message="Sync task started", + created_at=now_s(), + started_at=now_s(), + last_update_at=now_s(), + epoch=0, + step=0, + total_steps=0, + config=cfg.__dict__.copy(), + ) + + def report(**kwargs: Any) -> None: + with st_lock: + for k, v in kwargs.items(): + setattr(st, k, v) + st.last_update_at = now_s() + + dummy_cancel = threading.Event() # never set; no canceling + + async with sync_lock: + try: + loop = asyncio.get_running_loop() + # Run training off the event loop, but await completion = synchronous API + await loop.run_in_executor( + None, + run_training, + cfg.task_id, + cfg, + dummy_cancel, + report, + ) + + with st_lock: + st.state = "completed" + st.message = "Sync task completed" + st.finished_at = now_s() + st.last_update_at = now_s() + + return web.json_response( + {"ok": True, "task_id": cfg.task_id, "status": json.loads(json.dumps(st, default=lambda o: o.__dict__))}, + ) + + except Exception as e: + with st_lock: + st.state = "failed" + st.message = "Sync task failed" + st.finished_at = now_s() + st.error_type = type(e).__name__ + st.error = str(e) + st.last_update_at = now_s() + + return web.json_response( + {"ok": False, "error": str(e), "task_id": cfg.task_id, "status": json.loads(json.dumps(st, default=lambda o: o.__dict__))}, + status=500, + ) + + app.router.add_post("/start_task", start_task) + app.router.add_post("/start_sync_task", start_sync_task) + app.router.add_post("/status", status) + app.router.add_post("/cancel_task", cancel_task) + + # Basic liveness + async def health(request: web.Request) -> web.Response: + return web.json_response({"ok": True}) + + app.router.add_get("/health", health) + return app + + +async def _run_server(host: str, port: int) -> None: + manager = TaskManager() + app = make_app(manager) + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, host=host, port=port) + await site.start() + + await app["ready_event"].wait() + + print("Model Server Running") + + stop_event = asyncio.Event() + + def _handle_sig(*_args: Any) -> None: + stop_event.set() + + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, _handle_sig) + except NotImplementedError: + # Windows fallback + signal.signal(sig, lambda *_: stop_event.set()) + + await stop_event.wait() + + # Best-effort cancel on shutdown + manager.cancel() + await runner.cleanup() + + +def main() -> None: + host = os.environ.get("HOST", "0.0.0.0") + port = int(os.environ.get("PORT", "8080")) + asyncio.run(_run_server(host, port)) + + +if __name__ == "__main__": + main() diff --git a/examples/session/worker.py b/examples/session/worker.py new file mode 100644 index 00000000..e7978c62 --- /dev/null +++ b/examples/session/worker.py @@ -0,0 +1,45 @@ +from vastai import Worker, WorkerConfig, HandlerConfig, LogActionConfig, BenchmarkConfig + +# vLLM model configuration +MODEL_SERVER_URL = 'http://127.0.0.1' +MODEL_SERVER_PORT = 8080 +MODEL_LOG_FILE = '/var/log/model.log' +MODEL_HEALTHCHECK_ENDPOINT = "/health" + +# vLLM-specific log messages +MODEL_LOAD_LOG_MSG = [ + "Model Server Running", +] + +MODEL_ERROR_LOG_MSGS = [ + "Traceback (most recent call last):" +] + +benchmark_dataset = [ + { + "max_train_batches_per_epoch" : 10 + } +] + +worker_config = WorkerConfig( + model_server_url=MODEL_SERVER_URL, + model_server_port=MODEL_SERVER_PORT, + model_log_file=MODEL_LOG_FILE, + model_healthcheck_url=MODEL_HEALTHCHECK_ENDPOINT, + handlers=[ + HandlerConfig( + route="/start_task", + benchmark_config=BenchmarkConfig( + dataset=benchmark_dataset, runs=1 + ) + ), + HandlerConfig(route="/status"), + HandlerConfig(route="/cancel_task"), + ], + log_action_config=LogActionConfig( + on_load=MODEL_LOAD_LOG_MSG, + on_error=MODEL_ERROR_LOG_MSGS + ) +) + +Worker(worker_config).run() \ No newline at end of file diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 40b3a335..00000000 --- a/mypy.ini +++ /dev/null @@ -1,9 +0,0 @@ -[mypy] -ignore_missing_imports = True - -[mypy-borb.*] -ignore_missing_imports = True - -[mypy-PIL.*] -ignore_missing_imports = True - diff --git a/openapi/README.md b/openapi/README.md new file mode 100644 index 00000000..b52ef763 --- /dev/null +++ b/openapi/README.md @@ -0,0 +1,13 @@ +# API docs moved + +The OpenAPI spec source files (per-endpoint YAMLs and the build script) have moved to the `vast-ai/docs` repo. + +**New location:** [`api-reference/openapi/`](https://github.com/vast-ai/docs/tree/main/api-reference/openapi) + +To update the API docs: + +1. Edit the relevant file in `vast-ai/docs` at `api-reference/openapi/yaml/.yaml`. +2. From the docs repo root, run `npm run build-openapi` to regenerate `api-reference/openapi.yaml`. +3. Open a PR in `vast-ai/docs`. CI verifies the spec, Mintlify deploys to docs.vast.ai on merge. + +See the [docs repo README](https://github.com/vast-ai/docs/blob/main/api-reference/openapi/README.md) for full instructions. diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 00000000..c3761dfc --- /dev/null +++ b/poetry.lock @@ -0,0 +1,2119 @@ +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. + +[[package]] +name = "aiodns" +version = "3.6.1" +description = "Simple DNS resolver for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiodns-3.6.1-py3-none-any.whl", hash = "sha256:46233ccad25f2037903828c5d05b64590eaa756e51d12b4a5616e2defcbc98c7"}, + {file = "aiodns-3.6.1.tar.gz", hash = "sha256:b0e9ce98718a5b8f7ca8cd16fc393163374bc2412236b91f6c851d066e3324b6"}, +] + +[package.dependencies] +pycares = ">=4.9.0,<5" + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +description = "Happy Eyeballs for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, + {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7"}, + {file = "aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821"}, + {file = "aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11"}, + {file = "aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd"}, + {file = "aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c"}, + {file = "aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b"}, + {file = "aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64"}, + {file = "aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29"}, + {file = "aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239"}, + {file = "aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f"}, + {file = "aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c"}, + {file = "aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168"}, + {file = "aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a"}, + {file = "aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046"}, + {file = "aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57"}, + {file = "aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c"}, + {file = "aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9"}, + {file = "aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591"}, + {file = "aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf"}, + {file = "aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e"}, + {file = "aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808"}, + {file = "aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415"}, + {file = "aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43"}, + {file = "aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1"}, + {file = "aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984"}, + {file = "aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c"}, + {file = "aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592"}, + {file = "aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa"}, + {file = "aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767"}, + {file = "aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344"}, + {file = "aiohttp-3.13.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31a83ea4aead760dfcb6962efb1d861db48c34379f2ff72db9ddddd4cda9ea2e"}, + {file = "aiohttp-3.13.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:988a8c5e317544fdf0d39871559e67b6341065b87fceac641108c2096d5506b7"}, + {file = "aiohttp-3.13.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9b174f267b5cfb9a7dba9ee6859cecd234e9a681841eb85068059bc867fb8f02"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:947c26539750deeaee933b000fb6517cc770bbd064bad6033f1cff4803881e43"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9ebf57d09e131f5323464bd347135a88622d1c0976e88ce15b670e7ad57e4bd6"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4ae5b5a0e1926e504c81c5b84353e7a5516d8778fbbff00429fe7b05bb25cbce"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ba0eea45eb5cc3172dbfc497c066f19c41bac70963ea1a67d51fc92e4cf9a80"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bae5c2ed2eae26cc382020edad80d01f36cb8e746da40b292e68fec40421dc6a"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a60e60746623925eab7d25823329941aee7242d559baa119ca2b253c88a7bd6"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e50a2e1404f063427c9d027378472316201a2290959a295169bcf25992d04558"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:9a9dc347e5a3dc7dfdbc1f82da0ef29e388ddb2ed281bfce9dd8248a313e62b7"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b46020d11d23fe16551466c77823df9cc2f2c1e63cc965daf67fa5eec6ca1877"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:69c56fbc1993fa17043e24a546959c0178fe2b5782405ad4559e6c13975c15e3"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:b99281b0704c103d4e11e72a76f1b543d4946fea7dd10767e7e1b5f00d4e5704"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:40c5e40ecc29ba010656c18052b877a1c28f84344825efa106705e835c28530f"}, + {file = "aiohttp-3.13.3-cp39-cp39-win32.whl", hash = "sha256:56339a36b9f1fc708260c76c87e593e2afb30d26de9ae1eb445b5e051b98a7a1"}, + {file = "aiohttp-3.13.3-cp39-cp39-win_amd64.whl", hash = "sha256:c6b8568a3bb5819a0ad087f16d40e5a3fb6099f39ea1d5625a3edc1e923fc538"}, + {file = "aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88"}, +] + +[package.dependencies] +aiohappyeyeballs = ">=2.5.0" +aiosignal = ">=1.4.0" +async-timeout = {version = ">=4.0,<6.0", markers = "python_version < \"3.11\""} +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +propcache = ">=0.2.0" +yarl = ">=1.17.0,<2.0" + +[package.extras] +speedups = ["Brotli (>=1.2) ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "backports.zstd ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "brotlicffi (>=1.2) ; platform_python_implementation != \"CPython\""] + +[[package]] +name = "aiosignal" +version = "1.4.0" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, + {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" +typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} + +[[package]] +name = "anyio" +version = "4.13.0" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708"}, + {file = "anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.32.0)"] + +[[package]] +name = "argcomplete" +version = "3.5.1" +description = "Bash tab completion for argparse" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "argcomplete-3.5.1-py3-none-any.whl", hash = "sha256:1a1d148bdaa3e3b93454900163403df41448a248af01b6e849edc5ac08e6c363"}, + {file = "argcomplete-3.5.1.tar.gz", hash = "sha256:eb1ee355aa2557bd3d0145de7b06b2a45b0ce461e1e7813f5d066039ab4177b4"}, +] + +[package.extras] +test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] + +[[package]] +name = "async-timeout" +version = "5.0.1" +description = "Timeout context manager for asyncio programs" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version == \"3.10\"" +files = [ + {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, + {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, +] + +[[package]] +name = "attrs" +version = "26.1.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, + {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, +] + +[[package]] +name = "borb" +version = "2.1.25" +description = "borb is a library for reading, creating and manipulating PDF files in python." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "borb-2.1.25-py3-none-any.whl", hash = "sha256:708c6b14d298890d75567cda15027d874d818e533089d88637831e495f489088"}, + {file = "borb-2.1.25.tar.gz", hash = "sha256:813a25227b96f471d29244bf3c07a7b3df36d61d62bcbecf45b14944b8011ef4"}, +] + +[package.dependencies] +cryptography = ">=37.0.4" +fonttools = ">=4.22.1" +lxml = ">=4.9.1" +Pillow = ">=7.1.0" +python-barcode = ">=0.13.1" +qrcode = {version = ">=6.1", extras = ["pil"]} +requests = ">=2.24.0" +setuptools = ">=51.1.1" + +[[package]] +name = "certifi" +version = "2025.1.31" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, + {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, +] + +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, + {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, + {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "cryptography" +version = "46.0.5" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.8" +groups = ["main"] +files = [ + {file = "cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731"}, + {file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82"}, + {file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1"}, + {file = "cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48"}, + {file = "cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4"}, + {file = "cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663"}, + {file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826"}, + {file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d"}, + {file = "cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a"}, + {file = "cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4"}, + {file = "cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c"}, + {file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4"}, + {file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9"}, + {file = "cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72"}, + {file = "cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7"}, + {file = "cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} +typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox[uv] (>=2024.4.15)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==46.0.5)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "curlify" +version = "2.2.1" +description = "Library to convert python requests object to curl command." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "curlify-2.2.1.tar.gz", hash = "sha256:0d3f02e7235faf952de8ef45ef469845196d30632d5838bcd5aee217726ddd6d"}, +] + +[package.dependencies] +requests = "*" + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "fonttools" +version = "4.60.2" +description = "Tools to manipulate font files" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "fonttools-4.60.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e36fadcf7e8ca6e34d490eef86ed638d6fd9c55d2f514b05687622cfc4a7050"}, + {file = "fonttools-4.60.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e500fc9c04bee749ceabfc20cb4903f6981c2139050d85720ea7ada61b75d5c"}, + {file = "fonttools-4.60.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22efea5e784e1d1cd8d7b856c198e360a979383ebc6dea4604743b56da1cbc34"}, + {file = "fonttools-4.60.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:677aa92d84d335e4d301d8ba04afca6f575316bc647b6782cb0921943fcb6343"}, + {file = "fonttools-4.60.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:edd49d3defbf35476e78b61ff737ff5efea811acff68d44233a95a5a48252334"}, + {file = "fonttools-4.60.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:126839492b69cecc5baf2bddcde60caab2ffafd867bbae2a88463fce6078ca3a"}, + {file = "fonttools-4.60.2-cp310-cp310-win32.whl", hash = "sha256:ffcab6f5537136046ca902ed2491ab081ba271b07591b916289b7c27ff845f96"}, + {file = "fonttools-4.60.2-cp310-cp310-win_amd64.whl", hash = "sha256:9c68b287c7ffcd29dd83b5f961004b2a54a862a88825d52ea219c6220309ba45"}, + {file = "fonttools-4.60.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a2aed0a7931401b3875265717a24c726f87ecfedbb7b3426c2ca4d2812e281ae"}, + {file = "fonttools-4.60.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dea6868e9d2b816c9076cfea77754686f3c19149873bdbc5acde437631c15df1"}, + {file = "fonttools-4.60.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fa27f34950aa1fe0f0b1abe25eed04770a3b3b34ad94e5ace82cc341589678a"}, + {file = "fonttools-4.60.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:13a53d479d187b09bfaa4a35ffcbc334fc494ff355f0a587386099cb66674f1e"}, + {file = "fonttools-4.60.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fac5e921d3bd0ca3bb8517dced2784f0742bc8ca28579a68b139f04ea323a779"}, + {file = "fonttools-4.60.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:648f4f9186fd7f1f3cd57dbf00d67a583720d5011feca67a5e88b3a491952cfb"}, + {file = "fonttools-4.60.2-cp311-cp311-win32.whl", hash = "sha256:3274e15fad871bead5453d5ce02658f6d0c7bc7e7021e2a5b8b04e2f9e40da1a"}, + {file = "fonttools-4.60.2-cp311-cp311-win_amd64.whl", hash = "sha256:91d058d5a483a1525b367803abb69de0923fbd45e1f82ebd000f5c8aa65bc78e"}, + {file = "fonttools-4.60.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e0164b7609d2b5c5dd4e044b8085b7bd7ca7363ef8c269a4ab5b5d4885a426b2"}, + {file = "fonttools-4.60.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1dd3d9574fc595c1e97faccae0f264dc88784ddf7fbf54c939528378bacc0033"}, + {file = "fonttools-4.60.2-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98d0719f1b11c2817307d2da2e94296a3b2a3503f8d6252a101dca3ee663b917"}, + {file = "fonttools-4.60.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d3ea26957dd07209f207b4fff64c702efe5496de153a54d3b91007ec28904dd"}, + {file = "fonttools-4.60.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ee301273b0850f3a515299f212898f37421f42ff9adfc341702582ca5073c13"}, + {file = "fonttools-4.60.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6eb4694cc3b9c03b7c01d65a9cf35b577f21aa6abdbeeb08d3114b842a58153"}, + {file = "fonttools-4.60.2-cp312-cp312-win32.whl", hash = "sha256:57f07b616c69c244cc1a5a51072eeef07dddda5ebef9ca5c6e9cf6d59ae65b70"}, + {file = "fonttools-4.60.2-cp312-cp312-win_amd64.whl", hash = "sha256:310035802392f1fe5a7cf43d76f6ff4a24c919e4c72c0352e7b8176e2584b8a0"}, + {file = "fonttools-4.60.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2bb5fd231e56ccd7403212636dcccffc96c5ae0d6f9e4721fa0a32cb2e3ca432"}, + {file = "fonttools-4.60.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:536b5fab7b6fec78ccf59b5c59489189d9d0a8b0d3a77ed1858be59afb096696"}, + {file = "fonttools-4.60.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b9288fc38252ac86a9570f19313ecbc9ff678982e0f27c757a85f1f284d3400"}, + {file = "fonttools-4.60.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93fcb420791d839ef592eada2b69997c445d0ce9c969b5190f2e16828ec10607"}, + {file = "fonttools-4.60.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7916a381b094db4052ac284255186aebf74c5440248b78860cb41e300036f598"}, + {file = "fonttools-4.60.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58c8c393d5e16b15662cfc2d988491940458aa87894c662154f50c7b49440bef"}, + {file = "fonttools-4.60.2-cp313-cp313-win32.whl", hash = "sha256:19c6e0afd8b02008caa0aa08ab896dfce5d0bcb510c49b2c499541d5cb95a963"}, + {file = "fonttools-4.60.2-cp313-cp313-win_amd64.whl", hash = "sha256:6a500dc59e11b2338c2dba1f8cf11a4ae8be35ec24af8b2628b8759a61457b76"}, + {file = "fonttools-4.60.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9387c532acbe323bbf2a920f132bce3c408a609d5f9dcfc6532fbc7e37f8ccbb"}, + {file = "fonttools-4.60.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6f1c824185b5b8fb681297f315f26ae55abb0d560c2579242feea8236b1cfef"}, + {file = "fonttools-4.60.2-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:55a3129d1e4030b1a30260f1b32fe76781b585fb2111d04a988e141c09eb6403"}, + {file = "fonttools-4.60.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b196e63753abc33b3b97a6fd6de4b7c4fef5552c0a5ba5e562be214d1e9668e0"}, + {file = "fonttools-4.60.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:de76c8d740fb55745f3b154f0470c56db92ae3be27af8ad6c2e88f1458260c9a"}, + {file = "fonttools-4.60.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ba6303225c95998c9fda2d410aa792c3d2c1390a09df58d194b03e17583fa25"}, + {file = "fonttools-4.60.2-cp314-cp314-win32.whl", hash = "sha256:0a89728ce10d7c816fedaa5380c06d2793e7a8a634d7ce16810e536c22047384"}, + {file = "fonttools-4.60.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa8446e6ab8bd778b82cb1077058a2addba86f30de27ab9cc18ed32b34bc8667"}, + {file = "fonttools-4.60.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4063bc81ac5a4137642865cb63dd270e37b3cd1f55a07c0d6e41d072699ccca2"}, + {file = "fonttools-4.60.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebfdb66fa69732ed604ab8e2a0431e6deff35e933a11d73418cbc7823d03b8e1"}, + {file = "fonttools-4.60.2-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50b10b3b1a72d1d54c61b0e59239e1a94c0958f4a06a1febf97ce75388dd91a4"}, + {file = "fonttools-4.60.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:beae16891a13b4a2ddec9b39b4de76092a3025e4d1c82362e3042b62295d5e4d"}, + {file = "fonttools-4.60.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:522f017fdb3766fd5d2d321774ef351cc6ce88ad4e6ac9efe643e4a2b9d528db"}, + {file = "fonttools-4.60.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82cceceaf9c09a965a75b84a4b240dd3768e596ffb65ef53852681606fe7c9ba"}, + {file = "fonttools-4.60.2-cp314-cp314t-win32.whl", hash = "sha256:bbfbc918a75437fe7e6d64d1b1e1f713237df1cf00f3a36dedae910b2ba01cee"}, + {file = "fonttools-4.60.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0e5cd9b0830f6550d58c84f3ab151a9892b50c4f9d538c5603c0ce6fff2eb3f1"}, + {file = "fonttools-4.60.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a3c75b8b42f7f93906bdba9eb1197bb76aecbe9a0a7cf6feec75f7605b5e8008"}, + {file = "fonttools-4.60.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0f86c8c37bc0ec0b9c141d5e90c717ff614e93c187f06d80f18c7057097f71bc"}, + {file = "fonttools-4.60.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe905403fe59683b0e9a45f234af2866834376b8821f34633b1c76fb731b6311"}, + {file = "fonttools-4.60.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38ce703b60a906e421e12d9e3a7f064883f5e61bb23e8961f4be33cfe578500b"}, + {file = "fonttools-4.60.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9e810c06f3e79185cecf120e58b343ea5a89b54dd695fd644446bcf8c026da5e"}, + {file = "fonttools-4.60.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:38faec8cc1d12122599814d15a402183f5123fb7608dac956121e7c6742aebc5"}, + {file = "fonttools-4.60.2-cp39-cp39-win32.whl", hash = "sha256:80a45cf7bf659acb7b36578f300231873daba67bd3ca8cce181c73f861f14a37"}, + {file = "fonttools-4.60.2-cp39-cp39-win_amd64.whl", hash = "sha256:c355d5972071938e1b1e0f5a1df001f68ecf1a62f34a3407dc8e0beccf052501"}, + {file = "fonttools-4.60.2-py3-none-any.whl", hash = "sha256:73cf92eeda67cf6ff10c8af56fc8f4f07c1647d989a979be9e388a49be26552a"}, + {file = "fonttools-4.60.2.tar.gz", hash = "sha256:d29552e6b155ebfc685b0aecf8d429cb76c14ab734c22ef5d3dea6fdf800c92c"}, +] + +[package.extras] +all = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\"", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.45.0)", "unicodedata2 (>=17.0.0) ; python_version <= \"3.14\"", "xattr ; sys_platform == \"darwin\"", "zopfli (>=0.1.4)"] +graphite = ["lz4 (>=1.7.4.2)"] +interpolatable = ["munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\""] +lxml = ["lxml (>=4.0)"] +pathops = ["skia-pathops (>=0.5.0)"] +plot = ["matplotlib"] +repacker = ["uharfbuzz (>=0.45.0)"] +symfont = ["sympy"] +type1 = ["xattr ; sys_platform == \"darwin\""] +unicode = ["unicodedata2 (>=17.0.0) ; python_version <= \"3.14\""] +woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"] + +[[package]] +name = "frozenlist" +version = "1.8.0" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7"}, + {file = "frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967"}, + {file = "frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa"}, + {file = "frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed"}, + {file = "frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7"}, + {file = "frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda"}, + {file = "frozenlist-1.8.0-cp39-cp39-win32.whl", hash = "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087"}, + {file = "frozenlist-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a"}, + {file = "frozenlist-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103"}, + {file = "frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d"}, + {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"}, +] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "lxml" +version = "5.3.2" +description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "lxml-5.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c4b84d6b580a9625dfa47269bf1fd7fbba7ad69e08b16366a46acb005959c395"}, + {file = "lxml-5.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b4c08ecb26e4270a62f81f81899dfff91623d349e433b126931c9c4577169666"}, + {file = "lxml-5.3.2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef926e9f11e307b5a7c97b17c5c609a93fb59ffa8337afac8f89e6fe54eb0b37"}, + {file = "lxml-5.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:017ceeabe739100379fe6ed38b033cd244ce2da4e7f6f07903421f57da3a19a2"}, + {file = "lxml-5.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dae97d9435dc90590f119d056d233c33006b2fd235dd990d5564992261ee7ae8"}, + {file = "lxml-5.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:910f39425c6798ce63c93976ae5af5fff6949e2cb446acbd44d6d892103eaea8"}, + {file = "lxml-5.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9780de781a0d62a7c3680d07963db3048b919fc9e3726d9cfd97296a65ffce1"}, + {file = "lxml-5.3.2-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:1a06b0c6ba2e3ca45a009a78a4eb4d6b63831830c0a83dcdc495c13b9ca97d3e"}, + {file = "lxml-5.3.2-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:4c62d0a34d1110769a1bbaf77871a4b711a6f59c4846064ccb78bc9735978644"}, + {file = "lxml-5.3.2-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:8f961a4e82f411b14538fe5efc3e6b953e17f5e809c463f0756a0d0e8039b700"}, + {file = "lxml-5.3.2-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:3dfc78f5f9251b6b8ad37c47d4d0bfe63ceb073a916e5b50a3bf5fd67a703335"}, + {file = "lxml-5.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10e690bc03214d3537270c88e492b8612d5e41b884f232df2b069b25b09e6711"}, + {file = "lxml-5.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aa837e6ee9534de8d63bc4c1249e83882a7ac22bd24523f83fad68e6ffdf41ae"}, + {file = "lxml-5.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:da4c9223319400b97a2acdfb10926b807e51b69eb7eb80aad4942c0516934858"}, + {file = "lxml-5.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dc0e9bdb3aa4d1de703a437576007d366b54f52c9897cae1a3716bb44fc1fc85"}, + {file = "lxml-5.3.2-cp310-cp310-win32.win32.whl", hash = "sha256:dd755a0a78dd0b2c43f972e7b51a43be518ebc130c9f1a7c4480cf08b4385486"}, + {file = "lxml-5.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:d64ea1686474074b38da13ae218d9fde0d1dc6525266976808f41ac98d9d7980"}, + {file = "lxml-5.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d61a7d0d208ace43986a92b111e035881c4ed45b1f5b7a270070acae8b0bfb4"}, + {file = "lxml-5.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:856dfd7eda0b75c29ac80a31a6411ca12209183e866c33faf46e77ace3ce8a79"}, + {file = "lxml-5.3.2-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a01679e4aad0727bedd4c9407d4d65978e920f0200107ceeffd4b019bd48529"}, + {file = "lxml-5.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6b37b4c3acb8472d191816d4582379f64d81cecbdce1a668601745c963ca5cc"}, + {file = "lxml-5.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3df5a54e7b7c31755383f126d3a84e12a4e0333db4679462ef1165d702517477"}, + {file = "lxml-5.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c09a40f28dcded933dc16217d6a092be0cc49ae25811d3b8e937c8060647c353"}, + {file = "lxml-5.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1ef20f1851ccfbe6c5a04c67ec1ce49da16ba993fdbabdce87a92926e505412"}, + {file = "lxml-5.3.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f79a63289dbaba964eb29ed3c103b7911f2dce28c36fe87c36a114e6bd21d7ad"}, + {file = "lxml-5.3.2-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:75a72697d95f27ae00e75086aed629f117e816387b74a2f2da6ef382b460b710"}, + {file = "lxml-5.3.2-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:b9b00c9ee1cc3a76f1f16e94a23c344e0b6e5c10bec7f94cf2d820ce303b8c01"}, + {file = "lxml-5.3.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:77cbcab50cbe8c857c6ba5f37f9a3976499c60eada1bf6d38f88311373d7b4bc"}, + {file = "lxml-5.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:29424058f072a24622a0a15357bca63d796954758248a72da6d512f9bd9a4493"}, + {file = "lxml-5.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7d82737a8afe69a7c80ef31d7626075cc7d6e2267f16bf68af2c764b45ed68ab"}, + {file = "lxml-5.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:95473d1d50a5d9fcdb9321fdc0ca6e1edc164dce4c7da13616247d27f3d21e31"}, + {file = "lxml-5.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2162068f6da83613f8b2a32ca105e37a564afd0d7009b0b25834d47693ce3538"}, + {file = "lxml-5.3.2-cp311-cp311-win32.whl", hash = "sha256:f8695752cf5d639b4e981afe6c99e060621362c416058effd5c704bede9cb5d1"}, + {file = "lxml-5.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:d1a94cbb4ee64af3ab386c2d63d6d9e9cf2e256ac0fd30f33ef0a3c88f575174"}, + {file = "lxml-5.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:16b3897691ec0316a1aa3c6585f61c8b7978475587c5b16fc1d2c28d283dc1b0"}, + {file = "lxml-5.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a8d4b34a0eeaf6e73169dcfd653c8d47f25f09d806c010daf074fba2db5e2d3f"}, + {file = "lxml-5.3.2-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cd7a959396da425022e1e4214895b5cfe7de7035a043bcc2d11303792b67554"}, + {file = "lxml-5.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cac5eaeec3549c5df7f8f97a5a6db6963b91639389cdd735d5a806370847732b"}, + {file = "lxml-5.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29b5f7d77334877c2146e7bb8b94e4df980325fab0a8af4d524e5d43cd6f789d"}, + {file = "lxml-5.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13f3495cfec24e3d63fffd342cc8141355d1d26ee766ad388775f5c8c5ec3932"}, + {file = "lxml-5.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e70ad4c9658beeff99856926fd3ee5fde8b519b92c693f856007177c36eb2e30"}, + {file = "lxml-5.3.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:507085365783abd7879fa0a6fa55eddf4bdd06591b17a2418403bb3aff8a267d"}, + {file = "lxml-5.3.2-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:5bb304f67cbf5dfa07edad904732782cbf693286b9cd85af27059c5779131050"}, + {file = "lxml-5.3.2-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:3d84f5c093645c21c29a4e972b84cb7cf682f707f8706484a5a0c7ff13d7a988"}, + {file = "lxml-5.3.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:bdc13911db524bd63f37b0103af014b7161427ada41f1b0b3c9b5b5a9c1ca927"}, + {file = "lxml-5.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ec944539543f66ebc060ae180d47e86aca0188bda9cbfadff47d86b0dc057dc"}, + {file = "lxml-5.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:59d437cc8a7f838282df5a199cf26f97ef08f1c0fbec6e84bd6f5cc2b7913f6e"}, + {file = "lxml-5.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e275961adbd32e15672e14e0cc976a982075208224ce06d149c92cb43db5b93"}, + {file = "lxml-5.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:038aeb6937aa404480c2966b7f26f1440a14005cb0702078c173c028eca72c31"}, + {file = "lxml-5.3.2-cp312-cp312-win32.whl", hash = "sha256:3c2c8d0fa3277147bff180e3590be67597e17d365ce94beb2efa3138a2131f71"}, + {file = "lxml-5.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:77809fcd97dfda3f399102db1794f7280737b69830cd5c961ac87b3c5c05662d"}, + {file = "lxml-5.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:77626571fb5270ceb36134765f25b665b896243529eefe840974269b083e090d"}, + {file = "lxml-5.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78a533375dc7aa16d0da44af3cf6e96035e484c8c6b2b2445541a5d4d3d289ee"}, + {file = "lxml-5.3.2-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6f62b2404b3f3f0744bbcabb0381c5fe186fa2a9a67ecca3603480f4846c585"}, + {file = "lxml-5.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ea918da00091194526d40c30c4996971f09dacab032607581f8d8872db34fbf"}, + {file = "lxml-5.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c35326f94702a7264aa0eea826a79547d3396a41ae87a70511b9f6e9667ad31c"}, + {file = "lxml-5.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3bef90af21d31c4544bc917f51e04f94ae11b43156356aff243cdd84802cbf2"}, + {file = "lxml-5.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52fa7ba11a495b7cbce51573c73f638f1dcff7b3ee23697467dc063f75352a69"}, + {file = "lxml-5.3.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ad131e2c4d2c3803e736bb69063382334e03648de2a6b8f56a878d700d4b557d"}, + {file = "lxml-5.3.2-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:00a4463ca409ceacd20490a893a7e08deec7870840eff33dc3093067b559ce3e"}, + {file = "lxml-5.3.2-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:87e8d78205331cace2b73ac8249294c24ae3cba98220687b5b8ec5971a2267f1"}, + {file = "lxml-5.3.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:bf6389133bb255e530a4f2f553f41c4dd795b1fbb6f797aea1eff308f1e11606"}, + {file = "lxml-5.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b3709fc752b42fb6b6ffa2ba0a5b9871646d97d011d8f08f4d5b3ee61c7f3b2b"}, + {file = "lxml-5.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:abc795703d0de5d83943a4badd770fbe3d1ca16ee4ff3783d7caffc252f309ae"}, + {file = "lxml-5.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:98050830bb6510159f65d9ad1b8aca27f07c01bb3884ba95f17319ccedc4bcf9"}, + {file = "lxml-5.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6ba465a91acc419c5682f8b06bcc84a424a7aa5c91c220241c6fd31de2a72bc6"}, + {file = "lxml-5.3.2-cp313-cp313-win32.whl", hash = "sha256:56a1d56d60ea1ec940f949d7a309e0bff05243f9bd337f585721605670abb1c1"}, + {file = "lxml-5.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:1a580dc232c33d2ad87d02c8a3069d47abbcdce974b9c9cc82a79ff603065dbe"}, + {file = "lxml-5.3.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:1a59f7fe888d0ec1916d0ad69364c5400cfa2f885ae0576d909f342e94d26bc9"}, + {file = "lxml-5.3.2-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d67b50abc2df68502a26ed2ccea60c1a7054c289fb7fc31c12e5e55e4eec66bd"}, + {file = "lxml-5.3.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2cb08d2cb047c98d6fbbb2e77d6edd132ad6e3fa5aa826ffa9ea0c9b1bc74a84"}, + {file = "lxml-5.3.2-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:495ddb7e10911fb4d673d8aa8edd98d1eadafb3b56e8c1b5f427fd33cadc455b"}, + {file = "lxml-5.3.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:884d9308ac7d581b705a3371185282e1b8eebefd68ccf288e00a2d47f077cc51"}, + {file = "lxml-5.3.2-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:37f3d7cf7f2dd2520df6cc8a13df4c3e3f913c8e0a1f9a875e44f9e5f98d7fee"}, + {file = "lxml-5.3.2-cp36-cp36m-win32.whl", hash = "sha256:e885a1bf98a76dff0a0648850c3083b99d9358ef91ba8fa307c681e8e0732503"}, + {file = "lxml-5.3.2-cp36-cp36m-win_amd64.whl", hash = "sha256:b45f505d0d85f4cdd440cd7500689b8e95110371eaa09da0c0b1103e9a05030f"}, + {file = "lxml-5.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b53cd668facd60b4f0dfcf092e01bbfefd88271b5b4e7b08eca3184dd006cb30"}, + {file = "lxml-5.3.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5dea998c891f082fe204dec6565dbc2f9304478f2fc97bd4d7a940fec16c873"}, + {file = "lxml-5.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d46bc3e58b01e4f38d75e0d7f745a46875b7a282df145aca9d1479c65ff11561"}, + {file = "lxml-5.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:661feadde89159fd5f7d7639a81ccae36eec46974c4a4d5ccce533e2488949c8"}, + {file = "lxml-5.3.2-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:43af2a69af2cacc2039024da08a90174e85f3af53483e6b2e3485ced1bf37151"}, + {file = "lxml-5.3.2-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:1539f962d82436f3d386eb9f29b2a29bb42b80199c74a695dff51b367a61ec0a"}, + {file = "lxml-5.3.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:6673920bf976421b5fac4f29b937702eef4555ee42329546a5fc68bae6178a48"}, + {file = "lxml-5.3.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:9fa722a9cd8845594593cce399a49aa6bfc13b6c83a7ee05e2ab346d9253d52f"}, + {file = "lxml-5.3.2-cp37-cp37m-win32.whl", hash = "sha256:2eadd4efa487f4710755415aed3d6ae9ac8b4327ea45226ffccb239766c8c610"}, + {file = "lxml-5.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:83d8707b1b08cd02c04d3056230ec3b771b18c566ec35e723e60cdf037064e08"}, + {file = "lxml-5.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bc6e8678bfa5ccba370103976ccfcf776c85c83da9220ead41ea6fd15d2277b4"}, + {file = "lxml-5.3.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0bed509662f67f719119ad56006cd4a38efa68cfa74383060612044915e5f7ad"}, + {file = "lxml-5.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e3925975fadd6fd72a6d80541a6ec75dfbad54044a03aa37282dafcb80fbdfa"}, + {file = "lxml-5.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83c0462dedc5213ac586164c6d7227da9d4d578cf45dd7fbab2ac49b63a008eb"}, + {file = "lxml-5.3.2-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:53e3f9ca72858834688afa17278649d62aa768a4b2018344be00c399c4d29e95"}, + {file = "lxml-5.3.2-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:32ba634ef3f1b20f781019a91d78599224dc45745dd572f951adbf1c0c9b0d75"}, + {file = "lxml-5.3.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:1b16504c53f41da5fcf04868a80ac40a39d3eec5329caf761114caec6e844ad1"}, + {file = "lxml-5.3.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:1f9682786138549da44ca4c49b20e7144d063b75f2b2ba611f4cff9b83db1062"}, + {file = "lxml-5.3.2-cp38-cp38-win32.whl", hash = "sha256:d8f74ef8aacdf6ee5c07566a597634bb8535f6b53dc89790db43412498cf6026"}, + {file = "lxml-5.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:49f1cee0fa27e1ee02589c696a9bdf4027e7427f184fa98e6bef0c6613f6f0fa"}, + {file = "lxml-5.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:741c126bcf9aa939e950e64e5e0a89c8e01eda7a5f5ffdfc67073f2ed849caea"}, + {file = "lxml-5.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ab6e9e6aca1fd7d725ffa132286e70dee5b9a4561c5ed291e836440b82888f89"}, + {file = "lxml-5.3.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58e8c9b9ed3c15c2d96943c14efc324b69be6352fe5585733a7db2bf94d97841"}, + {file = "lxml-5.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7811828ddfb8c23f4f1fbf35e7a7b2edec2f2e4c793dee7c52014f28c4b35238"}, + {file = "lxml-5.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72968623efb1e12e950cbdcd1d0f28eb14c8535bf4be153f1bfffa818b1cf189"}, + {file = "lxml-5.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ebfceaa2ea588b54efb6160e3520983663d45aed8a3895bb2031ada080fb5f04"}, + {file = "lxml-5.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d685d458505b2bfd2e28c812749fe9194a2b0ce285a83537e4309a187ffa270b"}, + {file = "lxml-5.3.2-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:334e0e414dab1f5366ead8ca34ec3148415f236d5660e175f1d640b11d645847"}, + {file = "lxml-5.3.2-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:02e56f7de72fa82561eae69628a7d6febd7891d72248c7ff7d3e7814d4031017"}, + {file = "lxml-5.3.2-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:638d06b4e1d34d1a074fa87deed5fb55c18485fa0dab97abc5604aad84c12031"}, + {file = "lxml-5.3.2-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:354dab7206d22d7a796fa27c4c5bffddd2393da2ad61835355a4759d435beb47"}, + {file = "lxml-5.3.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d9d9f82ff2c3bf9bb777cb355149f7f3a98ec58f16b7428369dc27ea89556a4c"}, + {file = "lxml-5.3.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:95ad58340e3b7d2b828efc370d1791856613c5cb62ae267158d96e47b3c978c9"}, + {file = "lxml-5.3.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:30fe05f4b7f6e9eb32862745512e7cbd021070ad0f289a7f48d14a0d3fc1d8a9"}, + {file = "lxml-5.3.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:34c688fef86f73dbca0798e0a61bada114677006afa524a8ce97d9e5fabf42e6"}, + {file = "lxml-5.3.2-cp39-cp39-win32.whl", hash = "sha256:4d6d3d1436d57f41984920667ec5ef04bcb158f80df89ac4d0d3f775a2ac0c87"}, + {file = "lxml-5.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:2996e1116bbb3ae2a1fbb2ba4da8f92742290b4011e7e5bce2bd33bbc9d9485a"}, + {file = "lxml-5.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:521ab9c80b98c30b2d987001c3ede2e647e92eeb2ca02e8cb66ef5122d792b24"}, + {file = "lxml-5.3.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f1231b0f9810289d41df1eacc4ebb859c63e4ceee29908a0217403cddce38d0"}, + {file = "lxml-5.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271f1a4d5d2b383c36ad8b9b489da5ea9c04eca795a215bae61ed6a57cf083cd"}, + {file = "lxml-5.3.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:6fca8a5a13906ba2677a5252752832beb0f483a22f6c86c71a2bb320fba04f61"}, + {file = "lxml-5.3.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ea0c3b7922209160faef194a5b6995bfe7fa05ff7dda6c423ba17646b7b9de10"}, + {file = "lxml-5.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0a006390834603e5952a2ff74b9a31a6007c7cc74282a087aa6467afb4eea987"}, + {file = "lxml-5.3.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:eae4136a3b8c4cf76f69461fc8f9410d55d34ea48e1185338848a888d71b9675"}, + {file = "lxml-5.3.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d48e06be8d8c58e7feaedd8a37897a6122637efb1637d7ce00ddf5f11f9a92ad"}, + {file = "lxml-5.3.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4b83aed409134093d90e114007034d2c1ebcd92e501b71fd9ec70e612c8b2eb"}, + {file = "lxml-5.3.2-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7a0e77edfe26d3703f954d46bed52c3ec55f58586f18f4b7f581fc56954f1d84"}, + {file = "lxml-5.3.2-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:19f6fcfd15b82036b4d235749d78785eb9c991c7812012dc084e0d8853b4c1c0"}, + {file = "lxml-5.3.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:d49919c95d31ee06eefd43d8c6f69a3cc9bdf0a9b979cc234c4071f0eb5cb173"}, + {file = "lxml-5.3.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2d0a60841410123c533990f392819804a8448853f06daf412c0f383443925e89"}, + {file = "lxml-5.3.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b7f729e03090eb4e3981f10efaee35e6004b548636b1a062b8b9a525e752abc"}, + {file = "lxml-5.3.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:579df6e20d8acce3bcbc9fb8389e6ae00c19562e929753f534ba4c29cfe0be4b"}, + {file = "lxml-5.3.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2abcf3f3b8367d6400b908d00d4cd279fc0b8efa287e9043820525762d383699"}, + {file = "lxml-5.3.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:348c06cb2e3176ce98bee8c397ecc89181681afd13d85870df46167f140a305f"}, + {file = "lxml-5.3.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:617ecaccd565cbf1ac82ffcaa410e7da5bd3a4b892bb3543fb2fe19bd1c4467d"}, + {file = "lxml-5.3.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c3eb4278dcdb9d86265ed2c20b9ecac45f2d6072e3904542e591e382c87a9c00"}, + {file = "lxml-5.3.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:258b6b53458c5cbd2a88795557ff7e0db99f73a96601b70bc039114cd4ee9e02"}, + {file = "lxml-5.3.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0a9d8d25ed2f2183e8471c97d512a31153e123ac5807f61396158ef2793cb6e"}, + {file = "lxml-5.3.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:73bcb635a848c18a3e422ea0ab0092f2e4ef3b02d8ebe87ab49748ebc8ec03d8"}, + {file = "lxml-5.3.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1545de0a69a16ced5767bae8cca1801b842e6e49e96f5e4a8a5acbef023d970b"}, + {file = "lxml-5.3.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:165fcdc2f40fc0fe88a3c3c06c9c2a097388a90bda6a16e6f7c9199c903c9b8e"}, + {file = "lxml-5.3.2.tar.gz", hash = "sha256:773947d0ed809ddad824b7b14467e1a481b8976e87278ac4a730c2f7c7fcddc1"}, +] + +[package.extras] +cssselect = ["cssselect (>=0.7)"] +html-clean = ["lxml_html_clean"] +html5 = ["html5lib"] +htmlsoup = ["BeautifulSoup4"] +source = ["Cython (>=3.0.11,<3.1.0)"] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "multidict" +version = "6.7.1" +description = "multidict implementation" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5"}, + {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8"}, + {file = "multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505"}, + {file = "multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122"}, + {file = "multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df"}, + {file = "multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db"}, + {file = "multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d"}, + {file = "multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e"}, + {file = "multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa"}, + {file = "multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a"}, + {file = "multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b"}, + {file = "multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6"}, + {file = "multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172"}, + {file = "multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd"}, + {file = "multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba"}, + {file = "multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511"}, + {file = "multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19"}, + {file = "multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf"}, + {file = "multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23"}, + {file = "multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2"}, + {file = "multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33"}, + {file = "multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3"}, + {file = "multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5"}, + {file = "multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df"}, + {file = "multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1"}, + {file = "multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963"}, + {file = "multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108"}, + {file = "multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32"}, + {file = "multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8"}, + {file = "multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118"}, + {file = "multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee"}, + {file = "multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2"}, + {file = "multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b"}, + {file = "multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d"}, + {file = "multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f"}, + {file = "multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5"}, + {file = "multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581"}, + {file = "multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a"}, + {file = "multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2"}, + {file = "multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7"}, + {file = "multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5"}, + {file = "multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2"}, + {file = "multidict-6.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f"}, + {file = "multidict-6.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358"}, + {file = "multidict-6.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5"}, + {file = "multidict-6.7.1-cp39-cp39-win32.whl", hash = "sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0"}, + {file = "multidict-6.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4"}, + {file = "multidict-6.7.1-cp39-cp39-win_arm64.whl", hash = "sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9"}, + {file = "multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56"}, + {file = "multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} + +[[package]] +name = "packaging" +version = "24.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, +] + +[[package]] +name = "pillow" +version = "12.2.0" +description = "Python Imaging Library (fork)" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f"}, + {file = "pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c"}, + {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3"}, + {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa"}, + {file = "pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032"}, + {file = "pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5"}, + {file = "pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024"}, + {file = "pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab"}, + {file = "pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176"}, + {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b"}, + {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909"}, + {file = "pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808"}, + {file = "pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60"}, + {file = "pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe"}, + {file = "pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5"}, + {file = "pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780"}, + {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5"}, + {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5"}, + {file = "pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940"}, + {file = "pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5"}, + {file = "pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c"}, + {file = "pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795"}, + {file = "pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3"}, + {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9"}, + {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795"}, + {file = "pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e"}, + {file = "pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b"}, + {file = "pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06"}, + {file = "pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b"}, + {file = "pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4"}, + {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4"}, + {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea"}, + {file = "pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24"}, + {file = "pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98"}, + {file = "pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295"}, + {file = "pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed"}, + {file = "pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286"}, + {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50"}, + {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104"}, + {file = "pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7"}, + {file = "pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150"}, + {file = "pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1"}, + {file = "pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463"}, + {file = "pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e"}, + {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06"}, + {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43"}, + {file = "pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354"}, + {file = "pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1"}, + {file = "pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e"}, + {file = "pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] +xmp = ["defusedxml"] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "propcache" +version = "0.4.1" +description = "Accelerated property cache" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db"}, + {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8"}, + {file = "propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c"}, + {file = "propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb"}, + {file = "propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37"}, + {file = "propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f"}, + {file = "propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1"}, + {file = "propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6"}, + {file = "propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75"}, + {file = "propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8"}, + {file = "propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db"}, + {file = "propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66"}, + {file = "propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81"}, + {file = "propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e"}, + {file = "propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1"}, + {file = "propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717"}, + {file = "propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37"}, + {file = "propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144"}, + {file = "propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f"}, + {file = "propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153"}, + {file = "propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455"}, + {file = "propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85"}, + {file = "propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1"}, + {file = "propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3d233076ccf9e450c8b3bc6720af226b898ef5d051a2d145f7d765e6e9f9bcff"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:357f5bb5c377a82e105e44bd3d52ba22b616f7b9773714bff93573988ef0a5fb"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cbc3b6dfc728105b2a57c06791eb07a94229202ea75c59db644d7d496b698cac"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:182b51b421f0501952d938dc0b0eb45246a5b5153c50d42b495ad5fb7517c888"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b536b39c5199b96fc6245eb5fb796c497381d3942f169e44e8e392b29c9ebcc"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db65d2af507bbfbdcedb254a11149f894169d90488dd3e7190f7cdcb2d6cd57a"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd2dbc472da1f772a4dae4fa24be938a6c544671a912e30529984dd80400cd88"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:daede9cd44e0f8bdd9e6cc9a607fc81feb80fae7a5fc6cecaff0e0bb32e42d00"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:71b749281b816793678ae7f3d0d84bd36e694953822eaad408d682efc5ca18e0"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fe49d0a85038f36ba9e3ffafa1103e61170b28e95b16622e11be0a0ea07c6781"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:99d43339c83aaf4d32bda60928231848eee470c6bda8d02599cc4cebe872d183"}, + {file = "propcache-0.4.1-cp39-cp39-win32.whl", hash = "sha256:a129e76735bc792794d5177069691c3217898b9f5cee2b2661471e52ffe13f19"}, + {file = "propcache-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:948dab269721ae9a87fd16c514a0a2c2a1bdb23a9a61b969b0f9d9ee2968546f"}, + {file = "propcache-0.4.1-cp39-cp39-win_arm64.whl", hash = "sha256:5fd37c406dd6dc85aa743e214cef35dc54bbdd1419baac4f6ae5e5b1a2976938"}, + {file = "propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237"}, + {file = "propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d"}, +] + +[[package]] +name = "psutil" +version = "6.1.1" +description = "Cross-platform lib for process and system monitoring in Python." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +groups = ["main"] +files = [ + {file = "psutil-6.1.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:9ccc4316f24409159897799b83004cb1e24f9819b0dcf9c0b68bdcb6cefee6a8"}, + {file = "psutil-6.1.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ca9609c77ea3b8481ab005da74ed894035936223422dc591d6772b147421f777"}, + {file = "psutil-6.1.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:8df0178ba8a9e5bc84fed9cfa61d54601b371fbec5c8eebad27575f1e105c0d4"}, + {file = "psutil-6.1.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:1924e659d6c19c647e763e78670a05dbb7feaf44a0e9c94bf9e14dfc6ba50468"}, + {file = "psutil-6.1.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:018aeae2af92d943fdf1da6b58665124897cfc94faa2ca92098838f83e1b1bca"}, + {file = "psutil-6.1.1-cp27-none-win32.whl", hash = "sha256:6d4281f5bbca041e2292be3380ec56a9413b790579b8e593b1784499d0005dac"}, + {file = "psutil-6.1.1-cp27-none-win_amd64.whl", hash = "sha256:c777eb75bb33c47377c9af68f30e9f11bc78e0f07fbf907be4a5d70b2fe5f030"}, + {file = "psutil-6.1.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fc0ed7fe2231a444fc219b9c42d0376e0a9a1a72f16c5cfa0f68d19f1a0663e8"}, + {file = "psutil-6.1.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0bdd4eab935276290ad3cb718e9809412895ca6b5b334f5a9111ee6d9aff9377"}, + {file = "psutil-6.1.1-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6e06c20c05fe95a3d7302d74e7097756d4ba1247975ad6905441ae1b5b66003"}, + {file = "psutil-6.1.1-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97f7cb9921fbec4904f522d972f0c0e1f4fabbdd4e0287813b21215074a0f160"}, + {file = "psutil-6.1.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33431e84fee02bc84ea36d9e2c4a6d395d479c9dd9bba2376c1f6ee8f3a4e0b3"}, + {file = "psutil-6.1.1-cp36-cp36m-win32.whl", hash = "sha256:384636b1a64b47814437d1173be1427a7c83681b17a450bfc309a1953e329603"}, + {file = "psutil-6.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8be07491f6ebe1a693f17d4f11e69d0dc1811fa082736500f649f79df7735303"}, + {file = "psutil-6.1.1-cp37-abi3-win32.whl", hash = "sha256:eaa912e0b11848c4d9279a93d7e2783df352b082f40111e078388701fd479e53"}, + {file = "psutil-6.1.1-cp37-abi3-win_amd64.whl", hash = "sha256:f35cfccb065fff93529d2afb4a2e89e363fe63ca1e4a5da22b603a85833c2649"}, + {file = "psutil-6.1.1.tar.gz", hash = "sha256:cf8496728c18f2d0b45198f06895be52f36611711746b7f30c464b422b50e2f5"}, +] + +[package.extras] +dev = ["abi3audit", "black", "check-manifest", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pytest-cov", "requests", "rstcheck", "ruff", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "virtualenv", "vulture", "wheel"] +test = ["pytest", "pytest-xdist", "setuptools"] + +[[package]] +name = "pycares" +version = "4.11.0" +description = "Python interface for c-ares" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pycares-4.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87dab618fe116f1936f8461df5970fcf0befeba7531a36b0a86321332ff9c20b"}, + {file = "pycares-4.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3db6b6439e378115572fa317053f3ee6eecb39097baafe9292320ff1a9df73e3"}, + {file = "pycares-4.11.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:742fbaa44b418237dbd6bf8cdab205c98b3edb334436a972ad341b0ea296fb47"}, + {file = "pycares-4.11.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:d2a3526dbf6cb01b355e8867079c9356a8df48706b4b099ac0bf59d4656e610d"}, + {file = "pycares-4.11.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:3d5300a598ad48bbf169fba1f2b2e4cf7ab229e7c1a48d8c1166f9ccf1755cb3"}, + {file = "pycares-4.11.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:066f3caa07c85e1a094aebd9e7a7bb3f3b2d97cff2276665693dd5c0cc81cf84"}, + {file = "pycares-4.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dcd4a7761fdfb5aaac88adad0a734dd065c038f5982a8c4b0dd28efa0bd9cc7c"}, + {file = "pycares-4.11.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:83a7401d7520fa14b00d85d68bcca47a0676c69996e8515d53733972286f9739"}, + {file = "pycares-4.11.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:66c310773abe42479302abf064832f4a37c8d7f788f4d5ee0d43cbad35cf5ff4"}, + {file = "pycares-4.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:95bc81f83fadb67f7f87914f216a0e141555ee17fd7f56e25aa0cc165e99e53b"}, + {file = "pycares-4.11.0-cp310-cp310-win32.whl", hash = "sha256:1dbbf0cfb39be63598b4cdc2522960627bf2f523e49c4349fb64b0499902ec7c"}, + {file = "pycares-4.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dde02314eefb85dce3cfdd747e8b44c69a94d442c0d7221b7de151ee4c93f0f5"}, + {file = "pycares-4.11.0-cp310-cp310-win_arm64.whl", hash = "sha256:9518514e3e85646bac798d94d34bf5b8741ee0cb580512e8450ce884f526b7cf"}, + {file = "pycares-4.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c2971af3a4094280f7c24293ff4d361689c175c1ebcbea6b3c1560eaff7cb240"}, + {file = "pycares-4.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d69e2034160e1219665decb8140e439afc7a7afcfd4adff08eb0f6142405c3e"}, + {file = "pycares-4.11.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:3bd81ad69f607803f531ff5cfa1262391fa06e78488c13495cee0f70d02e0287"}, + {file = "pycares-4.11.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:0aed0974eab3131d832e7e84a73ddb0dddbc57393cd8c0788d68a759a78c4a7b"}, + {file = "pycares-4.11.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:30d197180af626bb56f17e1fa54640838d7d12ed0f74665a3014f7155435b199"}, + {file = "pycares-4.11.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:cb711a66246561f1cae51244deef700eef75481a70d99611fd3c8ab5bd69ab49"}, + {file = "pycares-4.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7aba9a312a620052133437f2363aae90ae4695ee61cb2ee07cbb9951d4c69ddd"}, + {file = "pycares-4.11.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c2af7a9d3afb63da31df1456d38b91555a6c147710a116d5cc70ab1e9f457a4f"}, + {file = "pycares-4.11.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d5fe089be67bc5927f0c0bd60c082c79f22cf299635ee3ddd370ae2a6e8b4ae0"}, + {file = "pycares-4.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:35ff1ec260372c97ed688efd5b3c6e5481f2274dea08f6c4ea864c195a9673c6"}, + {file = "pycares-4.11.0-cp311-cp311-win32.whl", hash = "sha256:ff3d25883b7865ea34c00084dd22a7be7c58fd3131db6b25c35eafae84398f9d"}, + {file = "pycares-4.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:f4695153333607e63068580f2979b377b641a03bc36e02813659ffbea2b76fe2"}, + {file = "pycares-4.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:dc54a21586c096df73f06f9bdf594e8d86d7be84e5d4266358ce81c04c3cc88c"}, + {file = "pycares-4.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b93d624560ba52287873bacff70b42c99943821ecbc810b959b0953560f53c36"}, + {file = "pycares-4.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:775d99966e28c8abd9910ddef2de0f1e173afc5a11cea9f184613c747373ab80"}, + {file = "pycares-4.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:84fde689557361764f052850a2d68916050adbfd9321f6105aca1d8f1a9bd49b"}, + {file = "pycares-4.11.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:30ceed06f3bf5eff865a34d21562c25a7f3dad0ed336b9dd415330e03a6c50c4"}, + {file = "pycares-4.11.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:97d971b3a88a803bb95ff8a40ea4d68da59319eb8b59e924e318e2560af8c16d"}, + {file = "pycares-4.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:2d5cac829da91ade70ce1af97dad448c6cd4778b48facbce1b015e16ced93642"}, + {file = "pycares-4.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee1ea367835eb441d246164c09d1f9703197af4425fc6865cefcde9e2ca81f85"}, + {file = "pycares-4.11.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3139ec1f4450a4b253386035c5ecd2722582ae3320a456df5021ffe3f174260a"}, + {file = "pycares-4.11.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5d70324ca1d82c6c4b00aa678347f7560d1ef2ce1d181978903459a97751543a"}, + {file = "pycares-4.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e2f8d9cfe0eb3a2997fde5df99b1aaea5a46dabfcfcac97b2d05f027c2cd5e28"}, + {file = "pycares-4.11.0-cp312-cp312-win32.whl", hash = "sha256:1571a7055c03a95d5270c914034eac7f8bfa1b432fc1de53d871b821752191a4"}, + {file = "pycares-4.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:7570e0b50db619b2ee370461c462617225dc3a3f63f975c6f117e2f0c94f82ca"}, + {file = "pycares-4.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:f199702740f3b766ed8c70efb885538be76cb48cd0cb596b948626f0b825e07a"}, + {file = "pycares-4.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c296ab94d1974f8d2f76c499755a9ce31ffd4986e8898ef19b90e32525f7d84"}, + {file = "pycares-4.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0fcd3a8bac57a0987d9b09953ba0f8703eb9dca7c77f7051d8c2ed001185be8"}, + {file = "pycares-4.11.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:bac55842047567ddae177fb8189b89a60633ac956d5d37260f7f71b517fd8b87"}, + {file = "pycares-4.11.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:4da2e805ed8c789b9444ef4053f6ef8040cd13b0c1ca6d3c4fe6f9369c458cb4"}, + {file = "pycares-4.11.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:ea785d1f232b42b325578f0c8a2fa348192e182cc84a1e862896076a4a2ba2a7"}, + {file = "pycares-4.11.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:aa160dc9e785212c49c12bb891e242c949758b99542946cc8e2098ef391f93b0"}, + {file = "pycares-4.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7830709c23bbc43fbaefbb3dde57bdd295dc86732504b9d2e65044df8fd5e9fb"}, + {file = "pycares-4.11.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ef1ab7abbd238bb2dbbe871c3ea39f5a7fc63547c015820c1e24d0d494a1689"}, + {file = "pycares-4.11.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a4060d8556c908660512d42df1f4a874e4e91b81f79e3a9090afedc7690ea5ba"}, + {file = "pycares-4.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a98fac4a3d4f780817016b6f00a8a2c2f41df5d25dfa8e5b1aa0d783645a6566"}, + {file = "pycares-4.11.0-cp313-cp313-win32.whl", hash = "sha256:faa8321bc2a366189dcf87b3823e030edf5ac97a6b9a7fc99f1926c4bf8ef28e"}, + {file = "pycares-4.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:6f74b1d944a50fa12c5006fd10b45e1a45da0c5d15570919ce48be88e428264c"}, + {file = "pycares-4.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f7581793d8bb3014028b8397f6f80b99db8842da58f4409839c29b16397ad"}, + {file = "pycares-4.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:df0a17f4e677d57bca3624752bbb515316522ad1ce0de07ed9d920e6c4ee5d35"}, + {file = "pycares-4.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3b44e54cad31d3c3be5e8149ac36bc1c163ec86e0664293402f6f846fb22ad00"}, + {file = "pycares-4.11.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:80752133442dc7e6dd9410cec227c49f69283c038c316a8585cca05ec32c2766"}, + {file = "pycares-4.11.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:84b0b402dd333403fdce0e204aef1ef834d839c439c0c1aa143dc7d1237bb197"}, + {file = "pycares-4.11.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:c0eec184df42fc82e43197e073f9cc8f93b25ad2f11f230c64c2dc1c80dbc078"}, + {file = "pycares-4.11.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ee751409322ff10709ee867d5aea1dc8431eec7f34835f0f67afd016178da134"}, + {file = "pycares-4.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1732db81e348bfce19c9bf9448ba660aea03042eeeea282824da1604a5bd4dcf"}, + {file = "pycares-4.11.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:702d21823996f139874aba5aa9bb786d69e93bde6e3915b99832eb4e335d31ae"}, + {file = "pycares-4.11.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:218619b912cef7c64a339ab0e231daea10c994a05699740714dff8c428b9694a"}, + {file = "pycares-4.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:719f7ddff024fdacde97b926b4b26d0cc25901d5ef68bb994a581c420069936d"}, + {file = "pycares-4.11.0-cp314-cp314-win32.whl", hash = "sha256:d552fb2cb513ce910d1dc22dbba6420758a991a356f3cd1b7ec73a9e31f94d01"}, + {file = "pycares-4.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:23d50a0842e8dbdddf870a7218a7ab5053b68892706b3a391ecb3d657424d266"}, + {file = "pycares-4.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:836725754c32363d2c5d15b931b3ebd46b20185c02e850672cb6c5f0452c1e80"}, + {file = "pycares-4.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c9d839b5700542b27c1a0d359cbfad6496341e7c819c7fea63db9588857065ed"}, + {file = "pycares-4.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:31b85ad00422b38f426e5733a71dfb7ee7eb65a99ea328c508d4f552b1760dc8"}, + {file = "pycares-4.11.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cdac992206756b024b371760c55719eb5cd9d6b2cb25a8d5a04ae1b0ff426232"}, + {file = "pycares-4.11.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:ffb22cee640bc12ee0e654eba74ecfb59e2e0aebc5bccc3cc7ef92f487008af7"}, + {file = "pycares-4.11.0-cp314-cp314t-manylinux_2_28_s390x.whl", hash = "sha256:00538826d2eaf4a0e4becb0753b0ac8d652334603c445c9566c9eb273657eb4c"}, + {file = "pycares-4.11.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:29daa36548c04cdcd1a78ae187a4b7b003f0b357a2f4f1f98f9863373eedc759"}, + {file = "pycares-4.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cf306f3951740d7bed36149a6d8d656a7d5432dd4bbc6af3bb6554361fc87401"}, + {file = "pycares-4.11.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:386da2581db4ea2832629e275c061103b0be32f9391c5dfaea7f6040951950ad"}, + {file = "pycares-4.11.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:45d3254a694459fdb0640ef08724ca9d4b4f6ff6d7161c9b526d7d2e2111379e"}, + {file = "pycares-4.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eddf5e520bb88b23b04ac1f28f5e9a7c77c718b8b4af3a4a7a2cc4a600f34502"}, + {file = "pycares-4.11.0-cp314-cp314t-win32.whl", hash = "sha256:8a75a406432ce39ce0ca41edff7486df6c970eb0fe5cfbe292f195a6b8654461"}, + {file = "pycares-4.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3784b80d797bcc2ff2bf3d4b27f46d8516fe1707ff3b82c2580dc977537387f9"}, + {file = "pycares-4.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:afc6503adf8b35c21183b9387be64ca6810644ef54c9ef6c99d1d5635c01601b"}, + {file = "pycares-4.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5e1ab899bb0763dea5d6569300aab3a205572e6e2d0ef1a33b8cf2b86d1312a4"}, + {file = "pycares-4.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9d0c543bdeefa4794582ef48f3c59e5e7a43d672a4bfad9cbbd531e897911690"}, + {file = "pycares-4.11.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:5344d52efa37df74728505a81dd52c15df639adffd166f7ddca7a6318ecdb605"}, + {file = "pycares-4.11.0-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:b50ca218a3e2e23cbda395fd002d030385202fbb8182aa87e11bea0a568bd0b8"}, + {file = "pycares-4.11.0-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:30feeab492ac609f38a0d30fab3dc1789bd19c48f725b2955bcaaef516e32a21"}, + {file = "pycares-4.11.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:6195208b16cce1a7b121727710a6f78e8403878c1017ab5a3f92158b048cec34"}, + {file = "pycares-4.11.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:77bf82dc0beb81262bf1c7f546e1c1fde4992e5c8a2343b867ca201b85f9e1aa"}, + {file = "pycares-4.11.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:aca981fc00c8af8d5b9254ea5c2f276df8ece089b081af1ef4856fbcfc7c698a"}, + {file = "pycares-4.11.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:96e07d5a8b733d753e37d1f7138e7321d2316bb3f0f663ab4e3d500fabc82807"}, + {file = "pycares-4.11.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9a00408105901ede92e318eecb46d0e661d7d093d0a9b1224c71b5dd94f79e83"}, + {file = "pycares-4.11.0-cp39-cp39-win32.whl", hash = "sha256:910ce19a549f493fb55cfd1d7d70960706a03de6bfc896c1429fc5d6216df77e"}, + {file = "pycares-4.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:6f751f5a0e4913b2787f237c2c69c11a53f599269012feaa9fb86d7cef3aec26"}, + {file = "pycares-4.11.0-cp39-cp39-win_arm64.whl", hash = "sha256:f6c602c5e3615abbf43dbdf3c6c64c65e76e5aa23cb74e18466b55d4a2095468"}, + {file = "pycares-4.11.0.tar.gz", hash = "sha256:c863d9003ca0ce7df26429007859afd2a621d3276ed9fef154a9123db9252557"}, +] + +[package.dependencies] +cffi = [ + {version = ">=1.5.0", markers = "python_version < \"3.14\""}, + {version = ">=2.0.0b1", markers = "python_version >= \"3.14\""}, +] + +[package.extras] +idna = ["idna (>=2.1)"] + +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +description = "Cryptographic library for Python" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +files = [ + {file = "pycryptodome-3.23.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a176b79c49af27d7f6c12e4b178b0824626f40a7b9fed08f712291b6d54bf566"}, + {file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:573a0b3017e06f2cffd27d92ef22e46aa3be87a2d317a5abf7cc0e84e321bd75"}, + {file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:63dad881b99ca653302b2c7191998dd677226222a3f2ea79999aa51ce695f720"}, + {file = "pycryptodome-3.23.0-cp27-cp27m-win32.whl", hash = "sha256:b34e8e11d97889df57166eda1e1ddd7676da5fcd4d71a0062a760e75060514b4"}, + {file = "pycryptodome-3.23.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:7ac1080a8da569bde76c0a104589c4f414b8ba296c0b3738cf39a466a9fb1818"}, + {file = "pycryptodome-3.23.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:6fe8258e2039eceb74dfec66b3672552b6b7d2c235b2dfecc05d16b8921649a8"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39"}, + {file = "pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27"}, + {file = "pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843"}, + {file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490"}, + {file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575"}, + {file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b"}, + {file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a"}, + {file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f"}, + {file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa"}, + {file = "pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886"}, + {file = "pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2"}, + {file = "pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c"}, + {file = "pycryptodome-3.23.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:350ebc1eba1da729b35ab7627a833a1a355ee4e852d8ba0447fafe7b14504d56"}, + {file = "pycryptodome-3.23.0-pp27-pypy_73-win32.whl", hash = "sha256:93837e379a3e5fd2bb00302a47aee9fdf7940d83595be3915752c74033d17ca7"}, + {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379"}, + {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4"}, + {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630"}, + {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353"}, + {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5"}, + {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:865d83c906b0fc6a59b510deceee656b6bc1c4fa0d82176e2b77e97a420a996a"}, + {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d4d56153efc4d81defe8b65fd0821ef8b2d5ddf8ed19df31ba2f00872b8002"}, + {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3f2d0aaf8080bda0587d58fc9fe4766e012441e2eed4269a77de6aea981c8be"}, + {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64093fc334c1eccfd3933c134c4457c34eaca235eeae49d69449dc4728079339"}, + {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ce64e84a962b63a47a592690bdc16a7eaf709d2c2697ababf24a0def566899a6"}, + {file = "pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef"}, +] + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pyparsing" +version = "3.3.2" +description = "pyparsing - Classes and methods to define and execute parsing grammars" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d"}, + {file = "pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc"}, +] + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"}, + {file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + +[[package]] +name = "python-barcode" +version = "0.15.1" +description = "Create standard barcodes with Python. No external modules needed. (optional Pillow support included)." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "python-barcode-0.15.1.tar.gz", hash = "sha256:3b1825fbdb11e597466dff4286b4ea9b1e86a57717b59e563ae679726fc854de"}, + {file = "python_barcode-0.15.1-py3-none-any.whl", hash = "sha256:057636fba37369c22852410c8535b36adfbeb965ddfd4e5b6924455d692e0886"}, +] + +[package.extras] +images = ["pillow"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "qrcode" +version = "8.1" +description = "QR Code image generator" +optional = false +python-versions = "<4.0,>=3.9" +groups = ["main"] +files = [ + {file = "qrcode-8.1-py3-none-any.whl", hash = "sha256:9beba317d793ab8b3838c52af72e603b8ad2599c4e9bbd5c3da37c7dcc13c5cf"}, + {file = "qrcode-8.1.tar.gz", hash = "sha256:e8df73caf72c3bace3e93d9fa0af5aa78267d4f3f5bc7ab1b208f271605a5e48"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +pillow = {version = ">=9.1.0", optional = true, markers = "extra == \"pil\" or extra == \"all\""} + +[package.extras] +all = ["pillow (>=9.1.0)", "pypng"] +pil = ["pillow (>=9.1.0)"] +png = ["pypng"] + +[[package]] +name = "requests" +version = "2.32.4" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, + {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "rich" +version = "14.2.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"}, + {file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "setuptools" +version = "78.1.1" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "setuptools-78.1.1-py3-none-any.whl", hash = "sha256:c3a9c4211ff4c309edb8b8c4f1cbfa7ae324c4ba9f91ff254e3d305b9fd54561"}, + {file = "setuptools-78.1.1.tar.gz", hash = "sha256:fcc17fd9cd898242f6b4adfaca46137a9edef687f43e6f78469692a5e70d851d"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "tomli" +version = "2.4.0" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"}, + {file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"}, + {file = "tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95"}, + {file = "tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76"}, + {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d"}, + {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576"}, + {file = "tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a"}, + {file = "tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa"}, + {file = "tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614"}, + {file = "tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1"}, + {file = "tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8"}, + {file = "tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a"}, + {file = "tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1"}, + {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b"}, + {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51"}, + {file = "tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729"}, + {file = "tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da"}, + {file = "tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3"}, + {file = "tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0"}, + {file = "tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e"}, + {file = "tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4"}, + {file = "tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e"}, + {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c"}, + {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f"}, + {file = "tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86"}, + {file = "tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87"}, + {file = "tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132"}, + {file = "tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6"}, + {file = "tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc"}, + {file = "tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66"}, + {file = "tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d"}, + {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702"}, + {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8"}, + {file = "tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776"}, + {file = "tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475"}, + {file = "tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2"}, + {file = "tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9"}, + {file = "tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0"}, + {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df"}, + {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d"}, + {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f"}, + {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b"}, + {file = "tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087"}, + {file = "tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd"}, + {file = "tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4"}, + {file = "tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a"}, + {file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] +markers = {main = "python_version < \"3.13\"", dev = "python_version == \"3.10\""} + +[[package]] +name = "urllib3" +version = "2.6.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, +] + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[[package]] +name = "xdg" +version = "6.0.0" +description = "Variables defined by the XDG Base Directory Specification" +optional = false +python-versions = ">=3.7,<4.0" +groups = ["main"] +files = [ + {file = "xdg-6.0.0-py3-none-any.whl", hash = "sha256:df3510755b4395157fc04fc3b02467c777f3b3ca383257397f09ab0d4c16f936"}, + {file = "xdg-6.0.0.tar.gz", hash = "sha256:24278094f2d45e846d1eb28a2ebb92d7b67fc0cab5249ee3ce88c95f649a1c92"}, +] + +[[package]] +name = "yarl" +version = "1.23.0" +description = "Yet another URL library" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "yarl-1.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cff6d44cb13d39db2663a22b22305d10855efa0fa8015ddeacc40bc59b9d8107"}, + {file = "yarl-1.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4c53f8347cd4200f0d70a48ad059cabaf24f5adc6ba08622a23423bc7efa10d"}, + {file = "yarl-1.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a6940a074fb3c48356ed0158a3ca5699c955ee4185b4d7d619be3c327143e05"}, + {file = "yarl-1.23.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed5f69ce7be7902e5c70ea19eb72d20abf7d725ab5d49777d696e32d4fc1811d"}, + {file = "yarl-1.23.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:389871e65468400d6283c0308e791a640b5ab5c83bcee02a2f51295f95e09748"}, + {file = "yarl-1.23.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dda608c88cf709b1d406bdfcd84d8d63cff7c9e577a403c6108ce8ce9dcc8764"}, + {file = "yarl-1.23.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c4fe09e0780c6c3bf2b7d4af02ee2394439d11a523bbcf095cf4747c2932007"}, + {file = "yarl-1.23.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31c9921eb8bd12633b41ad27686bbb0b1a2a9b8452bfdf221e34f311e9942ed4"}, + {file = "yarl-1.23.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5f10fd85e4b75967468af655228fbfd212bdf66db1c0d135065ce288982eda26"}, + {file = "yarl-1.23.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dbf507e9ef5688bada447a24d68b4b58dd389ba93b7afc065a2ba892bea54769"}, + {file = "yarl-1.23.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:85e9beda1f591bc73e77ea1c51965c68e98dafd0fec72cdd745f77d727466716"}, + {file = "yarl-1.23.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0e1fdaa14ef51366d7757b45bde294e95f6c8c049194e793eedb8387c86d5993"}, + {file = "yarl-1.23.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:75e3026ab649bf48f9a10c0134512638725b521340293f202a69b567518d94e0"}, + {file = "yarl-1.23.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:80e6d33a3d42a7549b409f199857b4fb54e2103fc44fb87605b6663b7a7ff750"}, + {file = "yarl-1.23.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5ec2f42d41ccbd5df0270d7df31618a8ee267bfa50997f5d720ddba86c4a83a6"}, + {file = "yarl-1.23.0-cp310-cp310-win32.whl", hash = "sha256:debe9c4f41c32990771be5c22b56f810659f9ddf3d63f67abfdcaa2c6c9c5c1d"}, + {file = "yarl-1.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:ab5f043cb8a2d71c981c09c510da013bc79fd661f5c60139f00dd3c3cc4f2ffb"}, + {file = "yarl-1.23.0-cp310-cp310-win_arm64.whl", hash = "sha256:263cd4f47159c09b8b685890af949195b51d1aa82ba451c5847ca9bc6413c220"}, + {file = "yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99"}, + {file = "yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c"}, + {file = "yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432"}, + {file = "yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a"}, + {file = "yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05"}, + {file = "yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83"}, + {file = "yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c"}, + {file = "yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598"}, + {file = "yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b"}, + {file = "yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c"}, + {file = "yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788"}, + {file = "yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222"}, + {file = "yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb"}, + {file = "yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc"}, + {file = "yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2"}, + {file = "yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5"}, + {file = "yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46"}, + {file = "yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928"}, + {file = "yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860"}, + {file = "yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069"}, + {file = "yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25"}, + {file = "yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8"}, + {file = "yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072"}, + {file = "yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8"}, + {file = "yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7"}, + {file = "yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51"}, + {file = "yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67"}, + {file = "yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7"}, + {file = "yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d"}, + {file = "yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760"}, + {file = "yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2"}, + {file = "yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86"}, + {file = "yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34"}, + {file = "yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d"}, + {file = "yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e"}, + {file = "yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9"}, + {file = "yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e"}, + {file = "yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5"}, + {file = "yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b"}, + {file = "yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035"}, + {file = "yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5"}, + {file = "yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735"}, + {file = "yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401"}, + {file = "yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4"}, + {file = "yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f"}, + {file = "yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a"}, + {file = "yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2"}, + {file = "yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f"}, + {file = "yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b"}, + {file = "yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a"}, + {file = "yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543"}, + {file = "yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957"}, + {file = "yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3"}, + {file = "yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3"}, + {file = "yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa"}, + {file = "yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120"}, + {file = "yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59"}, + {file = "yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512"}, + {file = "yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4"}, + {file = "yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1"}, + {file = "yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea"}, + {file = "yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9"}, + {file = "yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123"}, + {file = "yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24"}, + {file = "yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de"}, + {file = "yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b"}, + {file = "yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6"}, + {file = "yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6"}, + {file = "yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5"}, + {file = "yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595"}, + {file = "yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090"}, + {file = "yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144"}, + {file = "yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912"}, + {file = "yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474"}, + {file = "yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719"}, + {file = "yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319"}, + {file = "yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434"}, + {file = "yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723"}, + {file = "yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039"}, + {file = "yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52"}, + {file = "yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c"}, + {file = "yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae"}, + {file = "yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e"}, + {file = "yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85"}, + {file = "yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd"}, + {file = "yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6"}, + {file = "yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe"}, + {file = "yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169"}, + {file = "yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70"}, + {file = "yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e"}, + {file = "yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679"}, + {file = "yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412"}, + {file = "yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4"}, + {file = "yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c"}, + {file = "yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4"}, + {file = "yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94"}, + {file = "yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28"}, + {file = "yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6"}, + {file = "yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277"}, + {file = "yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4"}, + {file = "yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a"}, + {file = "yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb"}, + {file = "yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41"}, + {file = "yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2"}, + {file = "yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4"}, + {file = "yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4"}, + {file = "yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2"}, + {file = "yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25"}, + {file = "yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f"}, + {file = "yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" +propcache = ">=0.2.1" + +[metadata] +lock-version = "2.1" +python-versions = ">=3.10, <4.0" +content-hash = "f8e854efa9755265ecfc1604609fd1f5196b2e9901f78725d7716c7ab1c3a106" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..b4fcd39e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,80 @@ +[build-system] +requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] +build-backend = "poetry_dynamic_versioning.backend" + +[project] +name = "vastai" +description = "CLI and SDK for Vast.ai GPU Cloud Service" +authors = [ + { name = "jake cannell", email = "jake@vast.ai" }, + { name = "chris mckenzie", email = "chris@vast.ai" }, + { name = "anthony benjamin", email = "anthony@vast.ai" }, + { name = "liam weldon", email = "liam@vast.ai" }, + { name = "edgar lin", email = "edgar@vast.ai" }, + { name = "nader arbabian", email = "nader@vast.ai" }, + { name = "marco hernandez", email = "marco@vast.ai" }, + { name = "karthik pillalamarri", email = "karthik@vast.ai" }, + { name = "sammy javed", email = "sammy@vast.ai" }, + { name = "rob ballantyne", email = "rob@vast.ai" }, + { name = "lucas armand", email = "lucas@vast.ai" }, + { name = "zuby javed", email = "zuby@vast.ai" }, +] +readme = "README.md" +requires-python = ">=3.10, <4.0" +license = { text = "MIT" } +dynamic = ["version"] +dependencies = [ + "xdg>=1.0.0", + "borb~=2.1.25", + "requests>=2.32.3", + "python-dateutil>=2.8.2", + "urllib3>=2.0,<3.0", + "pyparsing>=3.1,<4.0", + "aiohttp>=3.9.1", + "aiodns>=3.6.0", + "pycares==4.11.0", + "anyio~=4.4", + "psutil~=6.0", + "pycryptodome~=3.20", + "argcomplete>=2.0", + "curlify>=2.2", + "rich>=13.0", + "cryptography==46.0.5", + "pillow==12.2.0" +] + +[tool.poetry] +packages = [ + { include = "vastai" }, + { include = "vastai_sdk" }, +] +version = "0.0.0" + +[project.scripts] +vastai = "vastai.cli.main:main" +serve-vast-deployment = "vastai.serverless.remote.serve_deployment:main" + +[project.urls] +Homepage = "https://vast.ai" +Repository = "https://github.com/vast-ai/vast-cli" + +[tool.poetry-dynamic-versioning] +enable = true +vcs = "git" +fix-shallow-repository = true +style = "semver" + +[tool.poetry.requires-plugins] +poetry-dynamic-versioning = { version = ">=1.0.0,<2.0.0", extras = ["plugin"] } + +[tool.poetry.group.dev.dependencies] +pytest = "^8.0.0" +pytest-mock = "^3.12" + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "live: tests that hit the real Vast.ai API (require VAST_API_KEY)", + "integration: integration tests requiring API credentials and fixtures", +] +addopts = "-m 'not live and not integration' --tb=short -q" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 311d2760..00000000 --- a/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -xdg -argcomplete==3.5.1 -requests==2.32.3 -borb==2.0.17 -python-dateutil==2.6.1 -pytz -urllib3==2.2.3 - diff --git a/make_command_docs.py b/scripts/make_command_docs.py similarity index 83% rename from make_command_docs.py rename to scripts/make_command_docs.py index ad5f323b..da9995f1 100755 --- a/make_command_docs.py +++ b/scripts/make_command_docs.py @@ -3,9 +3,9 @@ #################################################################################################### # Title: make_command_docs.py; Author Greg Propf; Date 2022-02-01 #################################################################################################### -# Usage: './make_command_docs' This script runs the 'vast.py' command -# with the --help option to generate the list of commands and then again -# for each command shown by help. It does some minimal formatting on +# Usage: 'python scripts/make_command_docs.py' This script runs the 'vastai' +# command with the --help option to generate the list of commands and then +# again for each command shown by help. It does some minimal formatting on # the results and produces a Markdown version of the commands and their # options. #################################################################################################### @@ -50,24 +50,24 @@ def run_cmd_and_capture_output(verb: str, obj: str = None, direct_obj: str = Non os.system(f'stty cols {columns} rows {rows} < /dev/pts/{os.minor(os.fstat(slave).st_rdev)}') # Execute the command - #proc = subprocess.Popen(["./vast.py", "--help"], stdout=slave, stderr=slave) + #proc = subprocess.Popen(["vastai", "--help"], stdout=slave, stderr=slave) if verb: if direct_obj: - cmd_output = subprocess.run(["./vast.py", verb, obj, direct_obj, "--help"], stdout=subprocess.PIPE) - #proc = subprocess.Popen(["./vast.py", verb, obj, direct_obj, "--help"], stdout=slave, stderr=slave) + cmd_output = subprocess.run(["vastai", verb, obj, direct_obj, "--help"], stdout=subprocess.PIPE) + #proc = subprocess.Popen(["vastai", verb, obj, direct_obj, "--help"], stdout=slave, stderr=slave) elif obj: - cmd_output = subprocess.run(["./vast.py", verb, obj, "--help"], stdout=subprocess.PIPE) - #proc = subprocess.Popen(["./vast.py", verb, obj, "--help"], stdout=slave, stderr=slave) + cmd_output = subprocess.run(["vastai", verb, obj, "--help"], stdout=subprocess.PIPE) + #proc = subprocess.Popen(["vastai", verb, obj, "--help"], stdout=slave, stderr=slave) else: - cmd_output = subprocess.run(["./vast.py", verb, "--help"], stdout=subprocess.PIPE) - #proc = subprocess.Popen(["./vast.py", verb, "--help"], stdout=slave, stderr=slave) + cmd_output = subprocess.run(["vastai", verb, "--help"], stdout=subprocess.PIPE) + #proc = subprocess.Popen(["vastai", verb, "--help"], stdout=slave, stderr=slave) else: - cmd_output = subprocess.run(["./vast.py", "--help"], stdout=subprocess.PIPE) - #cmd_output = subprocess.Popen(["./vast.py", "--help"], stdout=subprocess.PIPE) + cmd_output = subprocess.run(["vastai", "--help"], stdout=subprocess.PIPE) + #cmd_output = subprocess.Popen(["vastai", "--help"], stdout=subprocess.PIPE) - command = "./vast.py --help" + command = "vastai --help" os.system("resize -s 128 128") res = os.popen(command).read() # get all content as text #res = list(os.popen(command)) # get lines as array elements diff --git a/scripts/start_server.sh b/scripts/start_server.sh new file mode 100644 index 00000000..129031c7 --- /dev/null +++ b/scripts/start_server.sh @@ -0,0 +1,253 @@ +#!/bin/bash + +set -e -o pipefail + +WORKSPACE_DIR="${WORKSPACE_DIR:-/workspace}" + +SERVER_DIR="$WORKSPACE_DIR/vast-pyworker" +ENV_PATH="$WORKSPACE_DIR/worker-env" +DEBUG_LOG="$WORKSPACE_DIR/debug.log" +PYWORKER_LOG="$WORKSPACE_DIR/pyworker.log" + +REPORT_ADDR="${REPORT_ADDR:-https://run.vast.ai}" +USE_SSL="${USE_SSL:-true}" +WORKER_PORT="${WORKER_PORT:-3000}" +mkdir -p "$WORKSPACE_DIR" +cd "$WORKSPACE_DIR" + +exec &> >(tee -a "$DEBUG_LOG") + +function echo_var(){ + echo "$1: ${!1}" +} + +function report_error_and_exit(){ + local error_msg="$1" + echo "ERROR: $error_msg" + + MTOKEN="${MASTER_TOKEN:-}" + VERSION="${PYWORKER_VERSION:-0}" + + IFS=',' read -r -a REPORT_ADDRS <<< "${REPORT_ADDR}" + for addr in "${REPORT_ADDRS[@]}"; do + curl -sS -X POST -H 'Content-Type: application/json' \ + -d "$(cat <> "$MODEL_LOG.old"; then + report_error_and_exit "Failed to rotate model log" + fi + if ! : > "$MODEL_LOG"; then + report_error_and_exit "Failed to truncate model log" + fi +fi + +# Populate /etc/environment with quoted values +if ! grep -q "VAST" /etc/environment; then + if ! env -0 | grep -zEv "^(HOME=|SHLVL=)|CONDA" | while IFS= read -r -d '' line; do + name=${line%%=*} + value=${line#*=} + printf '%s="%s"\n' "$name" "$value" + done > /etc/environment; then + echo "WARNING: Failed to populate /etc/environment, continuing anyway" + fi +fi + +if [ ! -d "$ENV_PATH" ] +then + echo "setting up venv" + if ! which uv; then + if ! curl -LsSf https://astral.sh/uv/install.sh | sh; then + report_error_and_exit "Failed to install uv package manager" + fi + if [[ -f ~/.local/bin/env ]]; then + if ! source ~/.local/bin/env; then + report_error_and_exit "Failed to source uv environment" + fi + else + echo "WARNING: ~/.local/bin/env not found after uv installation" + fi + fi + + if [[ ! -d $SERVER_DIR ]]; then + if ! git clone "${PYWORKER_REPO:-https://github.com/vast-ai/pyworker}" "$SERVER_DIR"; then + report_error_and_exit "Failed to clone pyworker repository" + fi + fi + if [[ -n ${PYWORKER_REF:-} ]]; then + if ! (cd "$SERVER_DIR" && git checkout "$PYWORKER_REF"); then + report_error_and_exit "Failed to checkout pyworker reference: $PYWORKER_REF" + fi + fi + + if ! uv venv --python-preference only-managed "$ENV_PATH" -p 3.10; then + report_error_and_exit "Failed to create virtual environment" + fi + + if ! source "$ENV_PATH/bin/activate"; then + report_error_and_exit "Failed to activate virtual environment" + fi + + if ! uv pip install -r "${SERVER_DIR}/requirements.txt"; then + report_error_and_exit "Failed to install Python requirements" + fi + + install_vastai_sdk + + if ! touch ~/.no_auto_tmux; then + report_error_and_exit "Failed to create ~/.no_auto_tmux" + fi +else + if [[ -f ~/.local/bin/env ]]; then + if ! source ~/.local/bin/env; then + report_error_and_exit "Failed to source uv environment" + fi + fi + if ! source "$WORKSPACE_DIR/worker-env/bin/activate"; then + report_error_and_exit "Failed to activate existing virtual environment" + fi + echo "environment activated" + echo "venv: $VIRTUAL_ENV" +fi + +if [ "$USE_SSL" = true ]; then + + if ! cat << EOF > /etc/openssl-san.cnf + [req] + default_bits = 2048 + distinguished_name = req_distinguished_name + req_extensions = v3_req + + [req_distinguished_name] + countryName = US + stateOrProvinceName = CA + organizationName = Vast.ai Inc. + commonName = vast.ai + + [v3_req] + basicConstraints = CA:FALSE + keyUsage = nonRepudiation, digitalSignature, keyEncipherment + subjectAltName = @alt_names + + [alt_names] + IP.1 = 0.0.0.0 +EOF + then + report_error_and_exit "Failed to write OpenSSL config" + fi + + if ! openssl req -newkey rsa:2048 -subj "/C=US/ST=CA/CN=pyworker.vast.ai/" \ + -nodes \ + -sha256 \ + -keyout /etc/instance.key \ + -out /etc/instance.csr \ + -config /etc/openssl-san.cnf; then + report_error_and_exit "Failed to generate SSL certificate request" + fi + + if ! curl --header 'Content-Type: application/octet-stream' \ + --data-binary @/etc/instance.csr \ + -X \ + POST "https://console.vast.ai/api/v0/sign_cert/?instance_id=$CONTAINER_ID" > /etc/instance.crt; then + report_error_and_exit "Failed to sign SSL certificate" + fi +fi + +export REPORT_ADDR WORKER_PORT USE_SSL UNSECURED + +if ! cd "$SERVER_DIR"; then + report_error_and_exit "Failed to cd into SERVER_DIR: $SERVER_DIR" +fi + +echo "launching PyWorker server" + +set +e + +PY_STATUS=1 + +if [ -f "$SERVER_DIR/worker.py" ]; then + echo "trying worker.py" + python3 -m "worker" |& tee -a "$PYWORKER_LOG" + PY_STATUS=${PIPESTATUS[0]} +fi + +if [ "${PY_STATUS}" -ne 0 ] && [ -f "$SERVER_DIR/workers/$BACKEND/worker.py" ]; then + echo "trying workers.${BACKEND}.worker" + python3 -m "workers.${BACKEND}.worker" |& tee -a "$PYWORKER_LOG" + PY_STATUS=${PIPESTATUS[0]} +fi + +if [ "${PY_STATUS}" -ne 0 ] && [ -f "$SERVER_DIR/workers/$BACKEND/server.py" ]; then + echo "trying workers.${BACKEND}.server" + python3 -m "workers.${BACKEND}.server" |& tee -a "$PYWORKER_LOG" + PY_STATUS=${PIPESTATUS[0]} +fi + +set -e + +if [ "${PY_STATUS}" -ne 0 ]; then + if [ ! -f "$SERVER_DIR/worker.py" ] && [ ! -f "$SERVER_DIR/workers/$BACKEND/worker.py" ] && [ ! -f "$SERVER_DIR/workers/$BACKEND/server.py" ]; then + report_error_and_exit "Failed to find PyWorker" + fi + report_error_and_exit "PyWorker exited with status ${PY_STATUS}" +fi + +echo "launching PyWorker server done" diff --git a/sdk-wrapper/README.md b/sdk-wrapper/README.md new file mode 100644 index 00000000..51ee7aa7 --- /dev/null +++ b/sdk-wrapper/README.md @@ -0,0 +1,82 @@ +# Vast.ai Python SDK + +> **This package is deprecated.** It has been merged into [vast-ai/vast-cli](https://github.com/vast-ai/vast-cli). `pip install vastai` now installs both the SDK and CLI in a single package. `pip install vastai-sdk` still works and installs the same package. For issues and PRs, go to [vast-ai/vast-cli](https://github.com/vast-ai/vast-cli). + +## Install + +```bash +pip install vastai-sdk +``` + +## Quickstart + +1. Get your API key from [https://cloud.vast.ai/manage-keys/](https://cloud.vast.ai/manage-keys/) + +2. Set your API key: +```python +from vastai_sdk import VastAI +vast = VastAI(api_key="YOUR_API_KEY") +``` + +Or set the `VAST_API_KEY` environment variable and just use: +```python +vast = VastAI() +``` + +## SDK Usage + +Both of these work identically: +```python +from vastai_sdk import VastAI +from vastai import VastAI +``` + +Both of these also work identically: +```python +from vastai import Serverless +from vastai_sdk.serverless.client.client import Serverless +``` + +```python +vast = VastAI() + +vast.search_offers(query='gpu_name=RTX_4090 num_gpus>=4') +vast.show_instances() +vast.start_instance(id=12345) +vast.stop_instance(id=12345) +``` + +Use `help(vast.search_offers)` to view documentation for any method. + +## Using the Serverless Client + +1. Create the client +```python +from vastai import Serverless +serverless = Serverless() # or, Serverless("YOUR_API_KEY") +``` +2. Get an endpoint +```python +endpoint = await serverless.get_endpoint("my-endpoint") +``` +3. Make a request +```python +request_body = { + "model": "Qwen/Qwen3-8B", + "prompt" : "Who are you?", + "max_tokens" : 100, + "temperature" : 0.7 +} +response = await serverless.request("/v1/completions", request_body) +``` +4. Read the response +```python +text = response["response"]["choices"][0]["text"] +print(text) +``` + +Find more examples in the `examples/` directory. + +## Contributing + +This repo is deprecated. For issues, PRs, and documentation, go to [vast-ai/vast-cli](https://github.com/vast-ai/vast-cli). diff --git a/sdk-wrapper/pyproject.toml b/sdk-wrapper/pyproject.toml new file mode 100644 index 00000000..0af8a71e --- /dev/null +++ b/sdk-wrapper/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] +name = "vastai-sdk" +version = "0.0.0" +description = "DEPRECATED — use 'pip install vastai' instead. This package is a compatibility wrapper that installs vastai." +readme = "README.md" +authors = [ + "Chris McKenzie ", + "Lucas Armand ", + "Zuby Javed " +] +homepage = "https://vast.ai" +repository = "https://github.com/vast-ai/vast-cli" +packages = [ + { include = "vastai_sdk" } +] + +[tool.poetry-dynamic-versioning] +enable = true +style = "pep440" +vcs = "git" + +[tool.poetry.dependencies] +python = ">=3.9" +vastai = ">=0.1.0" + +[tool.poetry.urls] +Homepage = "https://vast.ai" +Source = "https://github.com/vast-ai/vast-cli" diff --git a/sdk-wrapper/vastai_sdk/__init__.py b/sdk-wrapper/vastai_sdk/__init__.py new file mode 100644 index 00000000..309cf752 --- /dev/null +++ b/sdk-wrapper/vastai_sdk/__init__.py @@ -0,0 +1,11 @@ +# vastai_sdk/__init__.py + +# Backward-compatibility shim: allow "import vastai_sdk" to reference "vastai" +import sys +import importlib + +# Import the real package +_vastai = importlib.import_module("vastai") + +# Register it under the old name +sys.modules[__name__] = _vastai diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..74b59c28 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,97 @@ +# Vast SDK Tests + +## Configuration + +### API Key + +Tests that hit the Vast API require an API key. Resolved in order: + +1. `VAST_API_KEY` environment variable +2. `$XDG_CONFIG_HOME/vastai/vast_api_key` (typically `~/.config/vastai/vast_api_key`) +3. `~/.vast_api_key` (legacy) + +### Server + +By default, tests run against `https://console.vast.ai`. Override with: + +``` +VAST_SERVER=https://alpha.vast.ai pytest tests/ +``` + +### Serverless Instance + +Serverless tests target the `prod` autoscaler by default. Override with: + +``` +VAST_SERVERLESS_INSTANCE=alpha pytest tests/ +``` + +### Template Hash + +Serverless integration tests use the vLLM template by default +(`490c0ed717a7da3bc5e2677a80f9c4c2`). Override with: + +``` +VAST_TEST_TEMPLATE_HASH= pytest tests/ +``` + +## Running Tests + +```bash +# All tests +pytest tests/ + +# Only unit tests (no API calls) +pytest tests/test_data_objects.py + +# Only integration tests +pytest tests/ -m integration + +# Serverless tests (creates real resources — costs money) +pytest tests/ -m serverless +``` + +## Test Files + +### `test_data_objects.py` — Unit tests for data objects + +Pure unit tests with no API calls. Tests Query, Column, EndpointConfig, +WorkergroupConfig, DeploymentConfig, Offer, and their serialization. + +**Setup/teardown:** None. + +### `test_search.py` — Search API integration tests + +Tests SyncClient.search() and AsyncClient.search() against the live API. +Read-only — no resources created or modified. + +**Setup/teardown:** None. + +### `test_serverless_lifecycle.py` — Serverless endpoint lifecycle tests + +Tests the full serverless flow: create endpoint, add workergroup, send +request, delete workergroup, delete endpoint. Uses the vLLM template. + +**Setup/teardown:** +- **Setup:** Creates an endpoint (`POST /api/v0/endptjobs/`) and a + workergroup (`POST /api/v0/workergroups/`) with the configured template + hash. These are created once per test session as a session-scoped fixture. +- **Teardown:** Deletes the workergroup (`DELETE /api/v0/workergroups/{id}/`) + and endpoint (`DELETE /api/v0/endptjobs/{id}/`) in `finally` blocks. + If teardown fails, resource IDs are printed for manual cleanup. + +**Cost note:** Creating a workergroup provisions GPU instances via the +autoscaler. Tests in this file will incur real costs. + +### `conftest.py` — Shared fixtures + +Provides: +- `api_key` — resolved API key +- `vast_server` — target server URL +- `serverless_instance` — autoscaler instance name +- `template_hash` — template hash for serverless tests +- `sync_client` — `SyncClient` instance +- `async_client` — `AsyncClient` instance (session-scoped, closed after tests) +- `serverless_client` — `CoroutineServerless` instance (session-scoped) +- `managed_endpoint` — session-scoped `ManagedEndpoint` with a workergroup + attached, torn down after the test session diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..15e3c864 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Vast SDK test package diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/api/test_client.py b/tests/api/test_client.py new file mode 100644 index 00000000..c647f79b --- /dev/null +++ b/tests/api/test_client.py @@ -0,0 +1,298 @@ +"""Tests for vastai/api/client.py — VastClient URL building, headers, retry logic.""" + +import json +import pytest +import requests +from unittest.mock import patch, MagicMock +from vastai.api.client import VastClient + + +class TestBuildUrl: + def test_adds_api_v0_prefix(self): + c = VastClient(api_key=None) + url = c._build_url("/instances") + assert "/api/v0/instances" in url + + def test_preserves_api_v1_prefix(self): + c = VastClient(api_key=None) + url = c._build_url("/api/v1/invoices/") + assert "/api/v1/invoices/" in url + assert "/api/v0" not in url + + def test_appends_api_key(self): + c = VastClient(api_key="mykey123") + url = c._build_url("/instances") + assert "api_key=mykey123" in url + + def test_no_query_when_no_args_and_no_key(self): + c = VastClient(api_key=None) + url = c._build_url("/instances") + assert "?" not in url + + def test_url_encodes_query_args(self): + c = VastClient(api_key=None) + url = c._build_url("/test", query_args={"q": "hello world"}) + assert "q=hello+world" in url + + def test_json_encodes_dict_args(self): + c = VastClient(api_key=None) + url = c._build_url("/test", query_args={"data": {"key": "val"}}) + # json.dumps({"key": "val"}) URL-encoded + assert "data=" in url + + def test_server_url_default(self): + c = VastClient(api_key=None) + url = c._build_url("/test") + assert url.startswith("https://console.vast.ai") + + def test_custom_server_url(self): + c = VastClient(api_key=None, server_url="https://custom.api.com") + url = c._build_url("/test") + assert url.startswith("https://custom.api.com") + + +class TestBuildHeaders: + def test_includes_bearer_auth(self): + c = VastClient(api_key="mykey") + h = c._build_headers() + assert h["Authorization"] == "Bearer mykey" + + def test_empty_when_no_key(self): + c = VastClient(api_key=None) + h = c._build_headers() + assert h == {} + + +class TestHttpMethods: + """Test that get/post/put/delete call _request with the correct method string.""" + + @patch.object(VastClient, "_request") + @patch.object(VastClient, "_build_headers", return_value={}) + @patch.object(VastClient, "_build_url", return_value="https://example.com/api/v0/test") + def test_get_calls_request(self, mock_url, mock_headers, mock_req): + c = VastClient(api_key=None) + c.get("/test") + mock_req.assert_called_once_with("GET", "https://example.com/api/v0/test", {}, None, timeout=None) + + @patch.object(VastClient, "_request") + @patch.object(VastClient, "_build_headers", return_value={}) + @patch.object(VastClient, "_build_url", return_value="https://example.com/api/v0/test") + def test_post_calls_request(self, mock_url, mock_headers, mock_req): + c = VastClient(api_key=None) + c.post("/test", json_data={"a": 1}) + mock_req.assert_called_once_with("POST", "https://example.com/api/v0/test", {}, {"a": 1}, timeout=None) + + @patch.object(VastClient, "_request") + @patch.object(VastClient, "_build_headers", return_value={}) + @patch.object(VastClient, "_build_url", return_value="https://example.com/api/v0/test") + def test_put_calls_request(self, mock_url, mock_headers, mock_req): + c = VastClient(api_key=None) + c.put("/test", json_data={"b": 2}) + mock_req.assert_called_once_with("PUT", "https://example.com/api/v0/test", {}, {"b": 2}, timeout=None) + + @patch.object(VastClient, "_request") + @patch.object(VastClient, "_build_headers", return_value={}) + @patch.object(VastClient, "_build_url", return_value="https://example.com/api/v0/test") + def test_delete_calls_request(self, mock_url, mock_headers, mock_req): + c = VastClient(api_key=None) + c.delete("/test") + mock_req.assert_called_once_with("DELETE", "https://example.com/api/v0/test", {}, {}, timeout=None) + + @patch.object(VastClient, "_request") + @patch.object(VastClient, "_build_headers", return_value={}) + @patch.object(VastClient, "_build_url", return_value="https://example.com/api/v0/test") + def test_post_defaults_json_to_empty_dict(self, mock_url, mock_headers, mock_req): + c = VastClient(api_key=None) + c.post("/test") + mock_req.assert_called_once_with("POST", "https://example.com/api/v0/test", {}, {}, timeout=None) + + +class TestRetryLogic: + @patch("vastai.api.client.time.sleep") + @patch("vastai.api.client.requests.Session") + def test_retries_on_429(self, mock_session_cls, mock_sleep): + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_prep = MagicMock() + mock_session.prepare_request.return_value = mock_prep + + resp_429 = MagicMock() + resp_429.status_code = 429 + resp_200 = MagicMock() + resp_200.status_code = 200 + + mock_session.send.side_effect = [resp_429, resp_200] + + c = VastClient(api_key=None, retry=3) + result = c._request("GET", "https://example.com", {}) + + assert result.status_code == 200 + assert mock_sleep.call_count == 1 + + @patch("vastai.api.client.time.sleep") + @patch("vastai.api.client.requests.Session") + def test_stops_on_non_429(self, mock_session_cls, mock_sleep): + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_prep = MagicMock() + mock_session.prepare_request.return_value = mock_prep + + resp_500 = MagicMock() + resp_500.status_code = 500 + + mock_session.send.return_value = resp_500 + + c = VastClient(api_key=None, retry=3) + result = c._request("GET", "https://example.com", {}) + + assert result.status_code == 500 + mock_sleep.assert_not_called() + + @patch("vastai.api.client.time.sleep") + @patch("vastai.api.client.requests.Session") + def test_exhausts_retry_count(self, mock_session_cls, mock_sleep): + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_prep = MagicMock() + mock_session.prepare_request.return_value = mock_prep + + resp_429 = MagicMock() + resp_429.status_code = 429 + mock_session.send.return_value = resp_429 + + c = VastClient(api_key=None, retry=2) + result = c._request("GET", "https://example.com", {}) + + assert result.status_code == 429 + assert mock_session.send.call_count == 2 + + @patch("vastai.api.client.time.sleep") + @patch("vastai.api.client.requests.Session") + def test_retries_on_503(self, mock_session_cls, mock_sleep): + """503 (transient upstream error) should retry the same way 429 does.""" + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_session.prepare_request.return_value = MagicMock() + + resp_503 = MagicMock(status_code=503) + resp_200 = MagicMock(status_code=200) + mock_session.send.side_effect = [resp_503, resp_200] + + c = VastClient(api_key=None, retry=3) + result = c._request("GET", "https://example.com", {}) + + assert result.status_code == 200 + assert mock_sleep.call_count == 1 + + @patch("vastai.api.client.time.sleep") + @patch("vastai.api.client.requests.Session") + def test_retries_on_502_and_504(self, mock_session_cls, mock_sleep): + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_session.prepare_request.return_value = MagicMock() + + resp_502 = MagicMock(status_code=502) + resp_504 = MagicMock(status_code=504) + resp_200 = MagicMock(status_code=200) + mock_session.send.side_effect = [resp_502, resp_504, resp_200] + + c = VastClient(api_key=None, retry=3) + result = c._request("GET", "https://example.com", {}) + + assert result.status_code == 200 + assert mock_session.send.call_count == 3 + + @patch("vastai.api.client.time.sleep") + @patch("vastai.api.client.requests.Session") + def test_retries_on_connection_error(self, mock_session_cls, mock_sleep): + """First attempt raises ConnectionError; second succeeds.""" + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_session.prepare_request.return_value = MagicMock() + + resp_200 = MagicMock(status_code=200) + mock_session.send.side_effect = [ + requests.exceptions.ConnectionError("connection reset"), + resp_200, + ] + + c = VastClient(api_key=None, retry=3) + result = c._request("GET", "https://example.com", {}) + + assert result.status_code == 200 + assert mock_session.send.call_count == 2 + assert mock_sleep.call_count == 1 + + @patch("vastai.api.client.time.sleep") + @patch("vastai.api.client.requests.Session") + def test_timeout_exhausts_retries_raises(self, mock_session_cls, mock_sleep): + """All attempts raise Timeout; the exception propagates (doesn't hang or return None).""" + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_session.prepare_request.return_value = MagicMock() + + mock_session.send.side_effect = requests.exceptions.ReadTimeout("read timed out") + + c = VastClient(api_key=None, retry=3) + with pytest.raises(requests.exceptions.ReadTimeout): + c._request("GET", "https://example.com", {}) + + assert mock_session.send.call_count == 3 + + @patch("vastai.api.client.requests.Session") + def test_non_retryable_exception_propagates_immediately(self, mock_session_cls): + """InvalidURL etc. must not be retried — retrying them burns time for no reason.""" + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_session.prepare_request.return_value = MagicMock() + mock_session.send.side_effect = requests.exceptions.InvalidURL("bad url") + + c = VastClient(api_key=None, retry=3) + with pytest.raises(requests.exceptions.InvalidURL): + c._request("GET", "https://example.com", {}) + + assert mock_session.send.call_count == 1 + + @patch("vastai.api.client.requests.Session") + def test_timeout_is_passed_to_send(self, mock_session_cls): + """The per-request timeout must actually reach session.send().""" + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_session.prepare_request.return_value = MagicMock() + mock_session.send.return_value = MagicMock(status_code=200) + + c = VastClient(api_key=None, retry=1, timeout=45) + c._request("GET", "https://example.com", {}) + + _, kwargs = mock_session.send.call_args + assert kwargs.get("timeout") == 45 + + @patch("vastai.api.client.requests.Session") + def test_per_call_timeout_overrides_default(self, mock_session_cls): + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_session.prepare_request.return_value = MagicMock() + mock_session.send.return_value = MagicMock(status_code=200) + + c = VastClient(api_key=None, retry=1, timeout=120) + c._request("GET", "https://example.com", {}, timeout=5) + + _, kwargs = mock_session.send.call_args + assert kwargs.get("timeout") == 5 + + +class TestClientInit: + def test_default_values(self): + c = VastClient() + assert c.api_key is None + assert c.retry == 3 + assert c.explain is False + assert c.curl is False + + def test_custom_values(self): + c = VastClient(api_key="k", server_url="http://x", retry=5, explain=True, curl=True) + assert c.api_key == "k" + assert c.server_url == "http://x" + assert c.retry == 5 + assert c.explain is True + assert c.curl is True diff --git a/tests/api/test_price_increase.py b/tests/api/test_price_increase.py new file mode 100644 index 00000000..154bd7e5 --- /dev/null +++ b/tests/api/test_price_increase.py @@ -0,0 +1,116 @@ +"""Tests for vastai/api/price_increase.py. + +Backend contract (source of truth: ``vast/web/views/instance.py:257-312``, +``vast/web/views/pydantic/models/instance.py``): + + PUT /instances/accept-price-increase/ body {"pending_price_increase_id": int} + PUT /instances/reject-price-increase/ body {"pending_price_increase_id": int} + +Both bodies are enforced by ``_Base.Config.extra='forbid'`` — any extra key +returns HTTP 400, so we assert *exact* body equality. +""" + +import pytest +from requests.exceptions import HTTPError + +from vastai.api import price_increase + + +SAMPLE_ROW = { + "pending_price_increase_id": 999, + "contract_id": 123, + "host_id": 7, + "new_gpu_costpersec": 0.0002, + "old_gpu_costpersec": 0.0001, + "new_disk_ram_costpersec": None, + "old_disk_ram_costpersec": None, + "new_bwu_cost": 0.02, + "old_bwu_cost": 0.01, + "new_bwd_cost": 0.02, + "old_bwd_cost": 0.01, + "new_platform_fee": 0.15, + "old_platform_fee": 0.10, + "contract_end_date": 1_700_000_000.0, + "ask_end_date": 1_700_500_000.0, + "created_at": 1_699_990_000.0, +} + + +class TestListPending: + def test_list_pending_returns_envelope(self, mock_client, mock_response): + envelope = { + "success": True, + "count": 1, + "truncated": False, + "pending_price_increases": [SAMPLE_ROW], + } + mock_client.get.return_value = mock_response(200, envelope) + result = price_increase.list_pending(mock_client) + assert result == envelope + mock_client.get.assert_called_once_with("/instances/pending-price-increases/") + + def test_list_pending_raises_on_http_error(self, mock_client, mock_response): + mock_client.get.return_value = mock_response(500, {"msg": "boom"}) + with pytest.raises(HTTPError): + price_increase.list_pending(mock_client) + + +class TestAccept: + def test_accept_sends_only_pending_id(self, mock_client, mock_response): + mock_client.put.return_value = mock_response( + 200, {"success": True, "pending_price_increase_id": 999, "contract_id": 123}, + ) + result = price_increase.accept(mock_client, 999) + mock_client.put.assert_called_once() + url = mock_client.put.call_args[0][0] + body = mock_client.put.call_args[1]["json_data"] + assert url == "/instances/accept-price-increase/" + # Exact equality matters: extra='forbid' on the backend returns 400 + # for any additional key. + assert body == {"pending_price_increase_id": 999} + assert result["contract_id"] == 123 + + def test_accept_propagates_404_no_pending(self, mock_client, mock_response): + mock_client.put.return_value = mock_response( + 404, {"success": False, "error": price_increase.NO_PENDING_PRICE_INCREASE}, + ) + with pytest.raises(HTTPError) as excinfo: + price_increase.accept(mock_client, 999) + assert excinfo.value.response.status_code == 404 + assert excinfo.value.response.json()["error"] == "no_pending_price_increase" + + def test_accept_propagates_legacy_409(self, mock_client, mock_response): + mock_client.put.return_value = mock_response(409, {"msg": "conflict"}) + with pytest.raises(HTTPError) as excinfo: + price_increase.accept(mock_client, 999) + assert excinfo.value.response.status_code == 409 + + def test_accept_coerces_pending_id_to_int(self, mock_client, mock_response): + mock_client.put.return_value = mock_response( + 200, {"success": True, "pending_price_increase_id": 5, "contract_id": 1}, + ) + price_increase.accept(mock_client, "5") + body = mock_client.put.call_args[1]["json_data"] + assert body == {"pending_price_increase_id": 5} + assert isinstance(body["pending_price_increase_id"], int) + + +class TestReject: + def test_reject_sends_only_pending_id(self, mock_client, mock_response): + mock_client.put.return_value = mock_response( + 200, {"success": True, "pending_price_increase_id": 999, "contract_id": 123}, + ) + result = price_increase.reject(mock_client, 999) + mock_client.put.assert_called_once() + url = mock_client.put.call_args[0][0] + body = mock_client.put.call_args[1]["json_data"] + assert url == "/instances/reject-price-increase/" + assert body == {"pending_price_increase_id": 999} + assert result["contract_id"] == 123 + + def test_reject_propagates_404_no_pending(self, mock_client, mock_response): + mock_client.put.return_value = mock_response( + 404, {"success": False, "error": price_increase.NO_PENDING_PRICE_INCREASE}, + ) + with pytest.raises(HTTPError): + price_increase.reject(mock_client, 999) diff --git a/tests/api/test_query.py b/tests/api/test_query.py new file mode 100644 index 00000000..06bec778 --- /dev/null +++ b/tests/api/test_query.py @@ -0,0 +1,211 @@ +"""Tests for vastai/api/query.py — parse_query, field definitions, numeric_version.""" + +import os +import time + +import pytest +from vastai.api.query import ( + parse_query, numeric_version, string_to_unix_epoch, fix_date_fields, + offers_fields, offers_alias, offers_mult, + benchmarks_fields, templates_fields, invoices_fields, +) + + +@pytest.fixture +def tz_los_angeles(monkeypatch): + """Force the process timezone to UTC-7/-8 so local/UTC drift shows up.""" + if not hasattr(time, "tzset"): + pytest.skip("tzset unavailable on this platform") + monkeypatch.setenv("TZ", "America/Los_Angeles") + time.tzset() + yield + time.tzset() + + +class TestNumericVersion: + def test_standard(self): + assert numeric_version("1.2.3") == 1002003 + + def test_zero_padded(self): + assert numeric_version("12.0.1") == 12000001 + + def test_large(self): + assert numeric_version("535.129.3") == 535129003 + + def test_invalid_returns_none(self, capsys): + result = numeric_version("bad") + assert result is None + + +class TestStringToUnixEpoch: + def test_none_returns_none(self): + assert string_to_unix_epoch(None) is None + + def test_float_string(self): + assert string_to_unix_epoch("1700000000.0") == 1700000000.0 + + def test_date_string(self): + result = string_to_unix_epoch("01/15/2024") + assert isinstance(result, float) + assert result > 0 + + def test_date_string_is_utc_midnight(self): + # 2024-01-15 00:00:00 UTC = 1705276800 + assert string_to_unix_epoch("01/15/2024") == 1705276800.0 + + def test_date_string_unaffected_by_local_timezone(self, tz_los_angeles): + # Under PST the old time.mktime(dt.timetuple()) implementation produced + # 1705305600 (2024-01-15 00:00 PST = 08:00 UTC). Must now match UTC. + assert string_to_unix_epoch("01/15/2024") == 1705276800.0 + + +class TestFixDateFields: + def test_converts_date_fields(self): + query = {"when": {"gte": "1700000000"}, "name": {"eq": "test"}} + result = fix_date_fields(query, ["when"]) + assert result["when"]["gte"] == 1700000000.0 + assert result["name"]["eq"] == "test" + + def test_leaves_non_date_fields(self): + query = {"score": {"gt": "5"}} + result = fix_date_fields(query, ["when"]) + assert result["score"]["gt"] == "5" + + +class TestParseQuery: + def test_equality(self): + result = parse_query("num_gpus=1", fields=offers_fields) + assert result["num_gpus"]["eq"] == "1" + + def test_double_equals(self): + result = parse_query("num_gpus==1", fields=offers_fields) + assert result["num_gpus"]["eq"] == "1" + + def test_gt(self): + result = parse_query("reliability>0.98", fields=offers_fields) + assert result["reliability"]["gt"] == "0.98" + + def test_gte(self): + result = parse_query("reliability>=0.98", fields=offers_fields) + assert result["reliability"]["gte"] == "0.98" + + def test_lt(self): + result = parse_query("dph_total<1.0", fields=offers_fields) + assert result["dph_total"]["lt"] == "1.0" + + def test_lte(self): + result = parse_query("dph_total<=1.0", fields=offers_fields) + assert result["dph_total"]["lte"] == "1.0" + + def test_neq(self): + result = parse_query("gpu_name!=RTX_3090", fields=offers_fields) + assert result["gpu_name"]["neq"] == "RTX 3090" + + def test_boolean_true(self): + result = parse_query("rentable=true", fields=offers_fields) + assert result["rentable"]["eq"] is True + + def test_boolean_false(self): + result = parse_query("rented=false", fields=offers_fields) + assert result["rented"]["eq"] is False + + def test_null_value(self): + result = parse_query("external=None", fields=offers_fields) + assert result["external"]["eq"] is None + + def test_wildcard_any_deletes_field(self): + result = parse_query("gpu_name=any", res={"gpu_name": {"eq": "RTX_3090"}}, fields=offers_fields) + assert "gpu_name" not in result + + def test_wildcard_star(self): + result = parse_query("gpu_name=*", res={"gpu_name": {"eq": "RTX_3090"}}, fields=offers_fields) + assert "gpu_name" not in result + + def test_field_alias(self): + result = parse_query("cuda_vers>=12.0", fields=offers_fields, field_alias=offers_alias) + assert "cuda_max_good" in result + + def test_field_alias_preserves_prior_constraint(self): + """Aliased fields used alongside a prior constraint on the canonical name + (or on a prior use of the same alias) must not drop the existing op.""" + # Pre-seed a constraint on the alias; parsing another op on the same alias + # must merge, not clobber. + result = parse_query( + "cuda_vers>=12.0 cuda_vers<14.0", + fields=offers_fields, field_alias=offers_alias, + ) + assert result["cuda_max_good"]["gte"] == "12.0" + assert result["cuda_max_good"]["lt"] == "14.0" + + def test_field_multiplier(self): + result = parse_query("cpu_ram>=16", fields=offers_fields, field_multiplier=offers_mult) + assert result["cpu_ram"]["gte"] == 16000.0 + + def test_duration_multiplier(self): + result = parse_query("duration>=1", fields=offers_fields, field_multiplier=offers_mult) + assert result["duration"]["gte"] == 86400.0 + + def test_list_input(self): + result = parse_query(["num_gpus=1", "gpu_name=RTX_3090"], fields=offers_fields) + assert "num_gpus" in result + assert "gpu_name" in result + + def test_res_accumulation(self): + res = {"verified": {"eq": True}} + result = parse_query("num_gpus=1", res=res, fields=offers_fields) + assert result["verified"]["eq"] is True + assert result["num_gpus"]["eq"] == "1" + + def test_unconsumed_text_raises(self): + with pytest.raises(ValueError, match="Unconsumed"): + parse_query("bad query ^^^ stuff", fields=offers_fields) + + def test_driver_version_converted(self): + result = parse_query("driver_version>=535.129.3", fields=offers_fields) + assert result["driver_version"]["gte"] == 535129003 + + def test_quoted_string(self): + result = parse_query('gpu_name="RTX 4090"', fields=offers_fields) + assert result["gpu_name"]["eq"] == "RTX 4090" + + def test_none_returns_res(self): + res = {"x": {"eq": 1}} + assert parse_query(None, res=res) is res + + def test_empty_string(self): + result = parse_query("", fields=offers_fields) + assert result == {} + + def test_underscore_replaced_with_space_in_value(self): + result = parse_query("gpu_name=RTX_4090", fields=offers_fields) + assert result["gpu_name"]["eq"] == "RTX 4090" + + +class TestFieldSets: + def test_offers_fields_not_empty(self): + assert len(offers_fields) > 0 + + def test_offers_fields_has_expected(self): + assert "gpu_name" in offers_fields + assert "num_gpus" in offers_fields + assert "dph_total" in offers_fields + + def test_benchmarks_fields_not_empty(self): + assert len(benchmarks_fields) > 0 + assert "score" in benchmarks_fields + + def test_templates_fields_not_empty(self): + assert len(templates_fields) > 0 + assert "image" in templates_fields + + def test_invoices_fields_not_empty(self): + assert len(invoices_fields) > 0 + assert "amount_cents" in invoices_fields + + def test_offers_alias_maps_correctly(self): + assert offers_alias["cuda_vers"] == "cuda_max_good" + assert offers_alias["dph"] == "dph_total" + + def test_offers_mult_values(self): + assert offers_mult["cpu_ram"] == 1000 + assert offers_mult["duration"] == 86400.0 diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/test_auth_commands.py b/tests/cli/test_auth_commands.py new file mode 100644 index 00000000..e075fa0d --- /dev/null +++ b/tests/cli/test_auth_commands.py @@ -0,0 +1,153 @@ +"""Integration tests for auth CLI commands with mocked HTTP.""" + +import argparse +from unittest.mock import patch + +import pytest +from requests.exceptions import HTTPError + + +class TestShowAuditLogs: + def test_show_audit_logs_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, [ + {"ip_address": "1.2.3.4", "api_key_id": 1, "created_at": "2024-01-01", "api_route": "/test", "args": "{}"} + ]) + args = parse_argv(["show", "audit-logs", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/audit_logs/" in call_args[0][0] + + def test_show_audit_logs_display(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, [ + {"ip_address": "1.2.3.4", "api_key_id": 1, "created_at": "2024-01-01", "api_route": "/test", "args": "{}"} + ]) + args = parse_argv(["show", "audit-logs"]) + args.func(args) + captured = capsys.readouterr() + assert "ip_address" in captured.out or "1.2.3.4" in captured.out + + +class TestShowEnvVars: + def test_show_env_vars_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, { + "secrets": {"MY_VAR": "my_value", "OTHER": "secret"} + }) + args = parse_argv(["show", "env-vars", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/secrets/" in call_args[0][0] + # Values should be masked when not using --show-values + assert result["MY_VAR"] == "*****" + + def test_show_env_vars_with_values(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, { + "secrets": {"MY_VAR": "my_value"} + }) + args = parse_argv(["show", "env-vars", "--raw", "--show-values"]) + result = args.func(args) + assert result["MY_VAR"] == "my_value" + + +class TestCreateEnvVar: + def test_create_env_var(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.post.return_value = mock_response(200, {"success": True, "msg": "Created"}) + args = parse_argv(["create", "env-var", "MY_NAME", "MY_VALUE"]) + args.func(args) + patch_get_client.post.assert_called_once() + call_args = patch_get_client.post.call_args + assert "/secrets/" in call_args[0][0] + assert call_args[1]["json_data"]["key"] == "MY_NAME" + assert call_args[1]["json_data"]["value"] == "MY_VALUE" + + +class TestUpdateEnvVar: + def test_update_env_var(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.put.return_value = mock_response(200, {"success": True, "msg": "Updated"}) + args = parse_argv(["update", "env-var", "MY_NAME", "NEW_VALUE"]) + args.func(args) + patch_get_client.put.assert_called_once() + call_args = patch_get_client.put.call_args + assert "/secrets/" in call_args[0][0] + assert call_args[1]["json_data"]["key"] == "MY_NAME" + assert call_args[1]["json_data"]["value"] == "NEW_VALUE" + + +class TestDeleteEnvVar: + def test_delete_env_var(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.delete.return_value = mock_response(200, {"success": True, "msg": "Deleted"}) + args = parse_argv(["delete", "env-var", "MY_NAME"]) + args.func(args) + patch_get_client.delete.assert_called_once() + call_args = patch_get_client.delete.call_args + assert "/secrets/" in call_args[0][0] + assert call_args[1]["json_data"]["key"] == "MY_NAME" + + +class TestSetApiKey: + def test_writes_byte_exact_no_text_mode_translation(self, tmp_path, monkeypatch): + # `open(path, "w").write(key)` on Windows turns `\n` -> `\r\n`; the key + # file must contain exactly the key bytes and nothing else, so trailing + # whitespace bugs are also caught. + key_file = tmp_path / "vast_api_key" + legacy_file = tmp_path / ".vast_api_key" + monkeypatch.setattr("vastai.cli.util.APIKEY_FILE", str(key_file)) + monkeypatch.setattr("vastai.cli.util.APIKEY_FILE_HOME", str(legacy_file)) + from vastai.cli.commands.auth import set__api_key + set__api_key(argparse.Namespace(new_api_key="test-key-abc-123")) + assert key_file.read_bytes() == b"test-key-abc-123" + + def test_sdk_reads_back_key_written_by_cli(self, tmp_path, monkeypatch): + # End-to-end: `set api-key` writes the file; VastAI() picks it up. + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) # Path.home() on Windows + monkeypatch.delenv("VAST_API_KEY", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + xdg_dir = tmp_path / ".config" / "vastai" + xdg_dir.mkdir(parents=True) + key_file = xdg_dir / "vast_api_key" + monkeypatch.setattr("vastai.cli.util.APIKEY_FILE", str(key_file)) + monkeypatch.setattr("vastai.cli.util.APIKEY_FILE_HOME", str(tmp_path / ".vast_api_key")) + + from vastai.cli.commands.auth import set__api_key + set__api_key(argparse.Namespace(new_api_key="round-trip-key")) + + from vastai import VastAI + with patch("vastai.sdk.VastClient") as MockClient: + VastAI() + assert MockClient.call_args[0][0] == "round-trip-key" + + +class TestTfaStatus: + def test_tfa_status_raw(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, { + "tfa_enabled": True, "methods": [], "backup_codes_remaining": 5 + }) + args = parse_argv(["tfa", "status", "--raw"]) + args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/tfa/status/" in call_args[0][0] + + +class TestTfaMethodFieldsFormatUtc: + def test_created_at_formats_in_utc(self, monkeypatch): + import time as _time + if not hasattr(_time, "tzset"): + pytest.skip("tzset unavailable on this platform") + monkeypatch.setenv("TZ", "America/Los_Angeles") + _time.tzset() + try: + from vastai.cli.commands.auth import TFA_METHOD_FIELDS + created_formatter = dict((f[0], f[3]) for f in TFA_METHOD_FIELDS)["created_at"] + # 1705276800 = 2024-01-15 00:00:00 UTC; in LA would be 2024-01-14 16:00 + assert created_formatter(1705276800) == "2024-01-15 00:00:00" + finally: + _time.tzset() + + def test_falsy_value_renders_na(self): + from vastai.cli.commands.auth import TFA_METHOD_FIELDS + created_formatter = dict((f[0], f[3]) for f in TFA_METHOD_FIELDS)["created_at"] + assert created_formatter(None) == "N/A" + assert created_formatter(0) == "N/A" diff --git a/tests/cli/test_benchmarks_commands.py b/tests/cli/test_benchmarks_commands.py new file mode 100644 index 00000000..2e6899f9 --- /dev/null +++ b/tests/cli/test_benchmarks_commands.py @@ -0,0 +1,333 @@ +"""Tests for ``vastai run benchmarks``. + +Covers the two things that actually matter: + 1. Cleanup invariant — the workergroup and endpoint are deleted on every + exit path (success, timeout, exception, Ctrl-C). + 2. Happy-path shape — the command provisions, polls, extracts + measured_perf, and returns rows. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from vastai.cli.commands import benchmarks as bench + + +# --------------------------------------------------------------------------- +# _benchmark_gpu — cleanup invariant +# --------------------------------------------------------------------------- + + +def _mk_vast(*, create_endpoint=None, create_workergroup=None, + get_endpoint_workers=None, delete_workergroup=None, + delete_endpoint=None, show_instance=None, + create_workergroup_raises=None, delete_workergroup_raises=None): + """Build a MagicMock VastAI with serverless methods configured.""" + v = MagicMock() + v.client = MagicMock() + v.client.api_key = "k" + v.create_endpoint.return_value = (create_endpoint + if create_endpoint is not None + else {"success": True, "result": 11}) + if create_workergroup_raises: + v.create_workergroup.side_effect = create_workergroup_raises + else: + v.create_workergroup.return_value = (create_workergroup + if create_workergroup is not None + else {"success": True, "result": 999}) + if isinstance(get_endpoint_workers, list): + v.get_endpoint_workers.side_effect = get_endpoint_workers + elif get_endpoint_workers is not None: + v.get_endpoint_workers.return_value = get_endpoint_workers + else: + v.get_endpoint_workers.return_value = [] + if delete_workergroup_raises: + v.delete_workergroup.side_effect = delete_workergroup_raises + else: + v.delete_workergroup.return_value = (delete_workergroup + if delete_workergroup is not None + else {"success": True}) + v.delete_endpoint.return_value = (delete_endpoint + if delete_endpoint is not None + else {"success": True}) + v.show_instance.return_value = (show_instance + if show_instance is not None + else {"dph_total": 0.5}) + return v + + +class TestBenchmarkOne: + def test_happy_path_returns_ok(self): + vast = _mk_vast(get_endpoint_workers=[ + [{"id": 1, "measured_perf": 42.0, "status": "idle"}], + ]) + with patch.object(bench.time, "sleep", return_value=None), \ + patch.object(bench.time, "monotonic", + side_effect=[0, 0, 1, 2, 3, 4, 5, 6]): + active_wgs = set() + active_eps = set() + gpu, num_gpus, status, perf, err, price = bench._benchmark_gpu( + vast, + gpu_name="RTX 4080", num_gpus=1, timeout=60, + workergroups=active_wgs, endpoints=active_eps, + template_id=99999, + ) + assert status == "ok" + assert perf == 42.0 + assert err is None + vast.delete_workergroup.assert_called_once_with(id=999) + assert active_wgs == set() + assert active_eps == set() + call = vast.create_workergroup.call_args + assert call.kwargs["template_id"] == 99999 + assert "gpu_name=RTX_4080" in call.kwargs["search_params"] + assert "num_gpus=1" in call.kwargs["search_params"] + assert "verified" not in call.kwargs["search_params"] + + def test_template_id_wins_over_hash(self): + vast = _mk_vast(get_endpoint_workers=[ + [{"id": 1, "measured_perf": 1.0, "status": "idle"}], + ]) + with patch.object(bench.time, "sleep", return_value=None), \ + patch.object(bench.time, "monotonic", + side_effect=[0, 0, 1, 2, 3, 4, 5, 6]): + bench._benchmark_gpu( + vast, + gpu_name="RTX 3060", num_gpus=1, timeout=60, + workergroups=set(), endpoints=set(), + template_id=12345, template_hash="abc", + ) + call = vast.create_workergroup.call_args + assert call.kwargs.get("template_id") == 12345 + # When --template_id is provided, hash must NOT be passed (id is the canonical key). + assert "template_hash" not in call.kwargs + + def test_template_hash_used_when_no_id(self): + vast = _mk_vast(get_endpoint_workers=[ + [{"id": 1, "measured_perf": 1.0, "status": "idle"}], + ]) + with patch.object(bench.time, "sleep", return_value=None), \ + patch.object(bench.time, "monotonic", + side_effect=[0, 0, 1, 2, 3, 4, 5, 6]): + bench._benchmark_gpu( + vast, + gpu_name="RTX 3060", num_gpus=1, timeout=60, + workergroups=set(), endpoints=set(), + template_hash="abc123", + ) + call = vast.create_workergroup.call_args + assert call.kwargs.get("template_hash") == "abc123" + assert "template_id" not in call.kwargs + + def test_timeout_still_tears_down(self): + vast = _mk_vast(create_workergroup={"success": True, "result": 777}, + get_endpoint_workers=[[]]) + with patch.object(bench.time, "sleep"), \ + patch.object(bench.time, "monotonic", + side_effect=[0, 0, 2, 3, 4, 5]): + active_wgs = set() + active_eps = set() + gpu, num_gpus, status, perf, err, price = bench._benchmark_gpu( + vast, + gpu_name="RTX 3060", num_gpus=1, timeout=1, + workergroups=active_wgs, + endpoints=active_eps, + ) + assert status == "timeout" + assert perf is None + vast.delete_workergroup.assert_called_once() + assert active_wgs == set() + assert active_eps == set() + + def test_create_failure_records_error_and_no_teardown(self): + # create_endpoint succeeds, but create_workergroup raises. The + # workergroup never came into existence (no delete_workergroup), + # but the endpoint did and must be torn down by finally. + vast = _mk_vast(create_workergroup_raises=RuntimeError("boom")) + with patch.object(bench.time, "monotonic", return_value=0): + active_wgs = set() + active_eps = set() + with pytest.raises(RuntimeError): + bench._benchmark_gpu( + vast, + gpu_name="RTX 3060", num_gpus=1, timeout=10, + workergroups=active_wgs, + endpoints=active_eps, + ) + vast.delete_workergroup.assert_not_called() + vast.delete_endpoint.assert_called_once() + assert active_wgs == set() + assert active_eps == set() + + def test_create_returns_no_id_reports_error(self): + vast = _mk_vast(create_workergroup={"success": False}) + with patch.object(bench.time, "monotonic", return_value=0): + gpu, num_gpus, status, perf, err, price = bench._benchmark_gpu( + vast, + gpu_name="RTX 3060", num_gpus=1, timeout=10, + workergroups=set(), endpoints=set(), + ) + assert status == "error" + assert "no id" in err + vast.delete_workergroup.assert_not_called() + + def test_all_workers_terminal_bails_fast(self): + # All workers in a terminal state (stopped) for >_TERMINAL_DEBOUNCE + # without producing measured_perf -> fail fast. ``error`` is no longer + # treated as terminal because the autoscaler restarts errored workers + # via error -> rebooting -> model_loading. + poll = [{"id": 1, "status": "stopped"}] + vast = _mk_vast(create_workergroup={"success": True, "result": 1}, + get_endpoint_workers=[poll, poll]) + with patch.object(bench.time, "sleep"), \ + patch.object(bench.time, "monotonic", + side_effect=[0, 0, 0, 5, 5, 50, 50]): + gpu, num_gpus, status, perf, err, price = bench._benchmark_gpu( + vast, + gpu_name="RTX 3060", num_gpus=1, timeout=600, + workergroups=set(), endpoints=set(), + ) + assert status == "failed" + assert "terminal" in err + vast.delete_workergroup.assert_called_once() + + def test_delete_failure_does_not_raise(self): + # delete_workergroup raises in finally; should be caught and logged. + poll = [{"id": 1, "measured_perf": 5.0, "status": "idle"}] + vast = _mk_vast(create_workergroup={"success": True, "result": 1}, + get_endpoint_workers=[poll], + delete_workergroup_raises=RuntimeError("delete failed"), + show_instance={"dph_total": 1.0}) + with patch.object(bench.time, "sleep"), \ + patch.object(bench.time, "monotonic", side_effect=[0, 0, 1]): + gpu, num_gpus, status, perf, err, price = bench._benchmark_gpu( + vast, + gpu_name="RTX 3060", num_gpus=1, timeout=60, + workergroups=set(), endpoints=set(), + ) + assert status == "ok" + + +# --------------------------------------------------------------------------- +# CLI integration: args.func(args) with a patched VastAI class +# --------------------------------------------------------------------------- + + +_FAKE_TEMPLATE = {"id": 99999, "hash_id": "x", "extra_filters": "{}"} + + +def _run_cli(parse_argv, argv, *, create_resp=None, workers_seq=None, + rental_dph=None, template=None, preflight_offers=1, + create_workergroup_raises=None): + """Parse argv and invoke the command with a mocked VastAI.""" + template = template if template is not None else _FAKE_TEMPLATE + fake_offer_list = [{"id": i} for i in range(preflight_offers)] + instance_resp = {"dph_total": rental_dph} if rental_dph is not None else {} + + vast = MagicMock() + vast.client = MagicMock(api_key="k") + vast.search_templates.return_value = [template] + vast.search_offers.return_value = fake_offer_list + vast.show_instance.return_value = instance_resp + vast.create_endpoint.return_value = {"success": True, "result": 11} + vast.delete_endpoint.return_value = {} + if create_workergroup_raises: + vast.create_workergroup.side_effect = create_workergroup_raises + else: + vast.create_workergroup.return_value = (create_resp + if create_resp is not None + else {"success": True, "result": 500}) + vast.get_endpoint_workers.side_effect = list(workers_seq or [[]]) + vast.delete_workergroup.return_value = {} + + with patch.object(bench, "VastAI", return_value=vast), \ + patch.object(bench.time, "sleep"): + args = parse_argv(argv) + return args.func(args), vast + + +class TestBenchmarkRunCLI: + def test_happy_path_returns_rows(self, parse_argv): + result, _ = _run_cli( + parse_argv, + ["run", "benchmarks", "--template_id", "99999", + "--gpus", "RTX_4080", "--timeout", "60", "-y", "--raw"], + workers_seq=[[{"id": 1, "measured_perf": 100.0, "status": "idle"}]], + rental_dph=0.5, + ) + assert isinstance(result, list) + assert len(result) == 1 + row = result[0] + assert row["gpu_name"] == "RTX 4080" + assert row["measured_perf"] == 100.0 + assert row["status"] == "ok" + assert row["rental_dph"] == 0.5 + assert row["perf_per_dollar"] == 200.0 + + def test_missing_template_flag_errors(self, parse_argv, capsys): + args = parse_argv([ + "run", "benchmarks", "--gpus", "RTX_3060", + "--timeout", "60", "-y", "--raw", + ]) + rc = args.func(args) + assert rc == 1 + assert "template_id" in capsys.readouterr().err + + def test_endpoint_name_includes_gpu_spec(self, parse_argv): + _, vast = _run_cli( + parse_argv, + ["run", "benchmarks", "--template_id", "99999", + "--gpus", "RTX_3060", "--timeout", "60", "-y", "--raw"], + create_resp={"result": 2}, + workers_seq=[[{"id": 1, "measured_perf": 1.0, "status": "idle"}]], + rental_dph=0.5, + ) + name = vast.create_endpoint.call_args.kwargs["endpoint_name"] + assert name.startswith("benchmark 1x RTX 3060 (") + assert name.endswith(")") + + def test_endpoint_deleted_even_on_exception(self, parse_argv): + rows, vast = _run_cli( + parse_argv, + ["run", "benchmarks", "--template_id", "99999", + "--gpus", "RTX_3060", "--timeout", "60", "-y", "--raw"], + create_workergroup_raises=RuntimeError("boom"), + ) + assert rows[0]["status"] == "error" + vast.delete_endpoint.assert_called() + + def test_template_id_threaded_to_workergroup(self, parse_argv): + _, vast = _run_cli( + parse_argv, + ["run", "benchmarks", "--template_id", "12345", + "--gpus", "RTX_3060", "--timeout", "60", "-y", "--raw"], + create_resp={"result": 2}, + workers_seq=[[{"id": 7, "measured_perf": 1, "status": "idle"}]], + rental_dph=0.5, + ) + assert vast.create_workergroup.call_args.kwargs["template_id"] == 12345 + + def test_no_offers_skips_class(self, parse_argv): + # Pre-flight returns 0 offers — class should be skipped without ever + # creating a workergroup. + rows, vast = _run_cli( + parse_argv, + ["run", "benchmarks", "--template_id", "99999", + "--gpus", "RTX_3060", "--timeout", "60", "-y", "--raw"], + preflight_offers=0, + ) + assert rows[0]["status"] == "skipped" + vast.create_workergroup.assert_not_called() + + def test_template_not_found_errors(self, parse_argv, capsys): + vast = MagicMock() + vast.search_templates.return_value = [] + with patch.object(bench, "VastAI", return_value=vast): + args = parse_argv([ + "run", "benchmarks", "--template_id", "99999", + "--gpus", "RTX_3060", "--timeout", "60", "-y", "--raw", + ]) + rc = args.func(args) + assert rc == 1 + assert "not found" in capsys.readouterr().err diff --git a/tests/cli/test_billing_commands.py b/tests/cli/test_billing_commands.py new file mode 100644 index 00000000..e740f653 --- /dev/null +++ b/tests/cli/test_billing_commands.py @@ -0,0 +1,112 @@ +"""Integration tests for billing CLI commands with mocked HTTP.""" + +import pytest +from requests.exceptions import HTTPError + + +class TestShowUser: + def test_show_user_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, { + "email": "test@test.com", "id": 1, "balance": 10.0, "api_key": "secret" + }) + args = parse_argv(["show", "user", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/users/current" in call_args[0][0] + + def test_show_user_display(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, { + "email": "test@test.com", "id": 1, "balance": 10.0, + }) + args = parse_argv(["show", "user"]) + args.func(args) + captured = capsys.readouterr() + assert "Email" in captured.out or "test@test.com" in captured.out + + +class TestShowInvoices: + def test_show_invoices_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, { + "invoices": [{"id": 1, "amount": 5.0, "type": "charge", "timestamp": 1700000000}], + "current": {"total": 5.0}, + }) + args = parse_argv(["show", "invoices", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/users/me/invoices" in call_args[0][0] + + +class TestShowEarnings: + def test_show_earnings_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, {"earnings": []}) + args = parse_argv(["show", "earnings", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/users/me/machine-earnings" in call_args[0][0] + + +class TestShowDeposit: + def test_show_deposit(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, {"balance": 100.0}) + args = parse_argv(["show", "deposit", "123"]) + args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "123" in call_args[0][0] + + +class TestShowSubaccounts: + def test_show_subaccounts_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, { + "users": [{"id": 2, "email": "sub@test.com"}] + }) + args = parse_argv(["show", "subaccounts", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/subaccounts" in call_args[0][0] + + +class TestShowIpaddrs: + def test_show_ipaddrs_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, { + "results": [{"ip": "1.2.3.4", "first_seen": "2024-01-01"}] + }) + args = parse_argv(["show", "ipaddrs", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/users/me/ipaddrs" in call_args[0][0] + + +class TestShowScheduledJobs: + def test_show_scheduled_jobs_display(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, [ + { + "id": 1, "instance_id": 100, "api_endpoint": "/api/v0/instances/reboot/100/", + "start_time": 1700000000, "end_time": 1700100000, + "day_of_the_week": None, "hour_of_the_day": None, + "min_of_the_hour": None, "frequency": "HOURLY", + } + ]) + args = parse_argv(["show", "scheduled-jobs"]) + args.func(args) + captured = capsys.readouterr() + assert "Scheduled Job ID" in captured.out or "HOURLY" in captured.out or "Everyday" in captured.out + + +class TestHttpErrors: + def test_401_raises(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(401, {"msg": "Unauthorized"}) + args = parse_argv(["show", "user", "--raw"]) + with pytest.raises(HTTPError): + args.func(args) + + def test_500_raises(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(500, {"msg": "Server error"}) + args = parse_argv(["show", "user", "--raw"]) + with pytest.raises(HTTPError): + args.func(args) diff --git a/tests/cli/test_clusters_commands.py b/tests/cli/test_clusters_commands.py new file mode 100644 index 00000000..1a48aeb0 --- /dev/null +++ b/tests/cli/test_clusters_commands.py @@ -0,0 +1,59 @@ +"""Integration tests for cluster/overlay CLI commands with mocked HTTP.""" + +import pytest + + +class TestShowClusters: + def test_show_clusters_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, { + "clusters": { + "1": { + "subnet": "10.0.0.0/24", + "nodes": [ + {"machine_id": 100, "is_cluster_manager": True, "local_ip": "10.0.0.1"}, + {"machine_id": 101, "is_cluster_manager": False, "local_ip": "10.0.0.2"}, + ] + } + } + }) + args = parse_argv(["show", "clusters", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/clusters/" in call_args[0][0] + + +class TestCreateCluster: + def test_create_cluster(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.post.return_value = mock_response(200, {"success": True, "msg": "Cluster created"}) + args = parse_argv(["create", "cluster", "10.0.0.0/24", "100"]) + args.func(args) + patch_get_client.post.assert_called_once() + call_args = patch_get_client.post.call_args + assert "/cluster/" in call_args[0][0] + json_data = call_args[1]["json_data"] + assert json_data["subnet"] == "10.0.0.0/24" + assert json_data["manager_id"] == 100 + + +class TestDeleteCluster: + def test_delete_cluster(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.delete.return_value = mock_response(200, {"success": True, "msg": "Cluster deleted"}) + args = parse_argv(["delete", "cluster", "1"]) + args.func(args) + patch_get_client.delete.assert_called_once() + call_args = patch_get_client.delete.call_args + assert "/cluster/" in call_args[0][0] + + +class TestShowOverlays: + def test_show_overlays_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, [ + {"overlay_id": 1, "name": "test-overlay", "subnet": "10.0.0.0/24", + "cluster_id": 1, "instance_count": 2, "instances": [1, 2]} + ]) + args = parse_argv(["show", "overlays", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/overlay/" in call_args[0][0] diff --git a/tests/cli/test_deployments_commands.py b/tests/cli/test_deployments_commands.py new file mode 100644 index 00000000..b2c9dbfe --- /dev/null +++ b/tests/cli/test_deployments_commands.py @@ -0,0 +1,122 @@ +"""Unit tests for deployment CLI commands with mocked HTTP.""" + +import pytest +from requests.exceptions import HTTPError + + +class TestShowDeployments: + def test_show_deployments_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, { + "deployments": [{"id": 1, "name": "dep-1"}, {"id": 2, "name": "dep-2"}] + }) + args = parse_argv(["show", "deployments", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/deployments" in call_args[0][0] + + def test_show_deployments_table(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, { + "deployments": [{"id": 1, "name": "dep-1", "tag": "latest"}] + }) + args = parse_argv(["show", "deployments"]) + args.func(args) + patch_get_client.get.assert_called_once() + + def test_show_deployments_error(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(401, {"error": "unauthorized"}) + args = parse_argv(["show", "deployments", "--raw"]) + with pytest.raises(HTTPError): + args.func(args) + + +class TestShowDeployment: + def test_show_deployment_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, { + "deployment": {"id": 42, "name": "dep-42", "state": "running"} + }) + args = parse_argv(["show", "deployment", "42", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/deployment/42/" in call_args[0][0] + + def test_show_deployment_table(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, { + "deployment": {"id": 42, "name": "dep-42", "state": "running"} + }) + args = parse_argv(["show", "deployment", "42"]) + args.func(args) + patch_get_client.get.assert_called_once() + + def test_show_deployment_not_found(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(404, {"error": "not found"}) + args = parse_argv(["show", "deployment", "999", "--raw"]) + with pytest.raises(HTTPError): + args.func(args) + + +class TestStopDeployment: + def test_stop_deployment(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.post.return_value = mock_response(200, {"success": True, "endpoint_state": "stopped"}) + args = parse_argv(["stop", "deployment", "42"]) + args.func(args) + patch_get_client.post.assert_called_once() + call_args = patch_get_client.post.call_args + assert "/deployment/42/stop/" in call_args[0][0] + + def test_stop_deployment_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.post.return_value = mock_response(200, {"success": True, "endpoint_state": "stopped"}) + args = parse_argv(["stop", "deployment", "42", "--raw"]) + result = args.func(args) + assert result["success"] is True + + def test_stop_deployment_error(self, parse_argv, patch_get_client, mock_response): + patch_get_client.post.return_value = mock_response(404, {"error": "not found"}) + args = parse_argv(["stop", "deployment", "999"]) + with pytest.raises(HTTPError): + args.func(args) + + +class TestStartDeployment: + def test_start_deployment(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.post.return_value = mock_response(200, {"success": True, "endpoint_state": "active"}) + args = parse_argv(["start", "deployment", "42"]) + args.func(args) + patch_get_client.post.assert_called_once() + call_args = patch_get_client.post.call_args + assert "/deployment/42/start/" in call_args[0][0] + + def test_start_deployment_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.post.return_value = mock_response(200, {"success": True, "endpoint_state": "active"}) + args = parse_argv(["start", "deployment", "42", "--raw"]) + result = args.func(args) + assert result["success"] is True + + def test_start_deployment_error(self, parse_argv, patch_get_client, mock_response): + patch_get_client.post.return_value = mock_response(404, {"error": "not found"}) + args = parse_argv(["start", "deployment", "999"]) + with pytest.raises(HTTPError): + args.func(args) + + +class TestDeleteDeployment: + def test_delete_deployment(self, parse_argv, patch_get_client, mock_response): + patch_get_client.delete.return_value = mock_response(200, {"success": True}) + args = parse_argv(["delete", "deployment", "42"]) + args.func(args) + patch_get_client.delete.assert_called_once() + call_args = patch_get_client.delete.call_args + assert "/deployment/42/" in call_args[0][0] + + def test_delete_deployment_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.delete.return_value = mock_response(200, {"success": True}) + args = parse_argv(["delete", "deployment", "42", "--raw"]) + result = args.func(args) + patch_get_client.delete.assert_called_once() + + def test_delete_deployment_error(self, parse_argv, patch_get_client, mock_response): + patch_get_client.delete.return_value = mock_response(404, {"error": "not found"}) + args = parse_argv(["delete", "deployment", "999"]) + with pytest.raises(HTTPError): + args.func(args) diff --git a/tests/cli/test_display.py b/tests/cli/test_display.py new file mode 100644 index 00000000..94c6393e --- /dev/null +++ b/tests/cli/test_display.py @@ -0,0 +1,129 @@ +"""Tests for vastai/cli/display.py — display_table, field tuple validation, deindent.""" + +import pytest +from vastai.cli.display import ( + deindent, display_table, translate_null_strings_to_blanks, strip_strings, + displayable_fields, instance_fields, machine_fields, volume_fields, + cluster_fields, overlay_fields, audit_log_fields, scheduled_jobs_fields, + invoice_fields, user_fields, connection_fields, +) + + +class TestDeindent: + def test_basic_deindent(self): + msg = """ + Hello + World + """ + result = deindent(msg, add_separator=False) + assert "Hello" in result + assert "World" in result + # Leading whitespace should be removed + lines = result.strip().split("\n") + for line in lines: + if line.strip(): + assert not line.startswith(" ") + + def test_with_separator(self): + msg = """ + Hello + """ + result = deindent(msg, add_separator=True) + assert "_" in result # separator line + + +class TestTranslateNullStrings: + def test_empty_string_becomes_space(self): + d = {"a": "", "b": "hello"} + result = translate_null_strings_to_blanks(d) + assert result["a"] == " " + assert result["b"] == "hello" + + +class TestStripStrings: + def test_strips_string(self): + assert strip_strings(" hello ") == "hello" + + def test_strips_in_dict(self): + result = strip_strings({"k": " v "}) + assert result["k"] == "v" + + def test_strips_in_list(self): + result = strip_strings([" a ", " b "]) + assert result == ["a", "b"] + + def test_leaves_non_string(self): + assert strip_strings(42) == 42 + + +class TestDisplayTable: + def test_runs_without_error(self, capsys): + rows = [ + {"id": 1, "gpu_name": "RTX_3090", "num_gpus": 2}, + ] + fields = ( + ("id", "ID", "{}", None, True), + ("gpu_name", "GPU", "{}", None, True), + ("num_gpus", "N", "{}", None, False), + ) + display_table(rows, fields) + captured = capsys.readouterr() + assert "ID" in captured.out + assert "GPU" in captured.out + + def test_handles_missing_fields(self, capsys): + rows = [{"id": 1}] + fields = ( + ("id", "ID", "{}", None, True), + ("missing_field", "Missing", "{}", None, True), + ) + display_table(rows, fields) + captured = capsys.readouterr() + assert "-" in captured.out + + def test_applies_conversion_functions(self, capsys): + rows = [{"ram": 16000}] + fields = ( + ("ram", "RAM_GB", "{:0.1f}", lambda x: x / 1000, False), + ) + display_table(rows, fields) + captured = capsys.readouterr() + assert "16.0" in captured.out + + def test_empty_rows(self, capsys): + fields = ( + ("id", "ID", "{}", None, True), + ) + display_table([], fields) + captured = capsys.readouterr() + assert "ID" in captured.out + + +class TestFieldDefinitions: + """All field definition tuples are 5-tuples with correct types.""" + + ALL_FIELD_DEFS = [ + ("displayable_fields", displayable_fields), + ("instance_fields", instance_fields), + ("machine_fields", machine_fields), + ("volume_fields", volume_fields), + ("cluster_fields", cluster_fields), + ("overlay_fields", overlay_fields), + ("audit_log_fields", audit_log_fields), + ("scheduled_jobs_fields", scheduled_jobs_fields), + ("invoice_fields", invoice_fields), + ("user_fields", user_fields), + ("connection_fields", connection_fields), + ] + + @pytest.mark.parametrize("name,fields", ALL_FIELD_DEFS) + def test_field_tuple_structure(self, name, fields): + assert len(fields) > 0, f"{name} should not be empty" + for i, field in enumerate(fields): + assert len(field) == 5, f"{name}[{i}] should be a 5-tuple, got {len(field)}" + key, label, fmt, conv, ljust = field + assert isinstance(key, str), f"{name}[{i}].key should be str" + assert isinstance(label, str), f"{name}[{i}].label should be str" + assert isinstance(fmt, str), f"{name}[{i}].fmt should be str" + assert conv is None or callable(conv), f"{name}[{i}].conv should be None or callable" + assert isinstance(ljust, bool), f"{name}[{i}].ljust should be bool" diff --git a/tests/cli/test_endpoints_commands.py b/tests/cli/test_endpoints_commands.py new file mode 100644 index 00000000..036b9480 --- /dev/null +++ b/tests/cli/test_endpoints_commands.py @@ -0,0 +1,49 @@ +"""Integration tests for endpoint/workergroup CLI commands with mocked HTTP.""" + +import pytest + + +class TestShowEndpoints: + def test_show_endpoints_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, { + "success": True, + "results": [{"id": 1, "endpoint_name": "my-endpoint"}] + }) + args = parse_argv(["show", "endpoints", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/endptjobs/" in call_args[0][0] + + +class TestCreateEndpoint: + def test_create_endpoint(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.post.return_value = mock_response(200, {"success": True, "id": 1}) + args = parse_argv(["create", "endpoint", "--endpoint_name", "test-ep"]) + args.func(args) + patch_get_client.post.assert_called_once() + call_args = patch_get_client.post.call_args + assert "/endptjobs/" in call_args[0][0] + + +class TestDeleteEndpoint: + def test_delete_endpoint(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.delete.return_value = mock_response(200, {"success": True}) + args = parse_argv(["delete", "endpoint", "1"]) + args.func(args) + patch_get_client.delete.assert_called_once() + call_args = patch_get_client.delete.call_args + assert "/endptjobs/1/" in call_args[0][0] + + +class TestShowWorkergroups: + def test_show_workergroups_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, { + "success": True, + "results": [{"id": 1, "name": "wg1"}] + }) + args = parse_argv(["show", "workergroups", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/autojobs/" in call_args[0][0] diff --git a/tests/cli/test_instances_commands.py b/tests/cli/test_instances_commands.py new file mode 100644 index 00000000..db49a93c --- /dev/null +++ b/tests/cli/test_instances_commands.py @@ -0,0 +1,157 @@ +"""Integration tests for instance CLI commands with mocked HTTP.""" + +import time +import pytest +from requests.exceptions import HTTPError + + +class TestShowInstances: + def test_show_instances_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, { + "instances": [ + {"id": 1, "gpu_name": "RTX_3090", "actual_status": "running", + "start_date": time.time() - 3600, "extra_env": [["KEY", "VAL"]]} + ] + }) + args = parse_argv(["show", "instances", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/instances" in call_args[0][0] + assert isinstance(result, list) + + def test_show_instances_raw_null_instances(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, {"instances": None}) + args = parse_argv(["show", "instances", "--raw"]) + + result = args.func(args) + + assert result == [] + + def test_show_instances_display(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, { + "instances": [ + {"id": 1, "gpu_name": "RTX_3090", "actual_status": "running", + "start_date": time.time() - 3600, "extra_env": []} + ] + }) + args = parse_argv(["show", "instances"]) + args.func(args) + captured = capsys.readouterr() + assert "ID" in captured.out + + +class TestShowInstance: + def test_show_instance_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, { + "instances": {"id": 123, "gpu_name": "RTX_4090", "start_date": time.time() - 100, "extra_env": []} + }) + args = parse_argv(["show", "instance", "123", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "123" in call_args[0][0] + + def test_show_instance_raw_deleted_instance(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, {"instances": None}) + args = parse_argv(["show", "instance", "123", "--raw"]) + + result = args.func(args) + + assert result == {"instances": None} + + def test_show_instance_display_deleted_instance(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, {"instances": None}) + args = parse_argv(["show", "instance", "123"]) + + result = args.func(args) + + assert result == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "Instance 123 not found or no longer exists." in captured.err + + +class TestDestroyInstance: + def test_destroy_instance(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.delete.return_value = mock_response(200, {"success": True}) + args = parse_argv(["destroy", "instance", "123", "--raw", "--yes"]) + result = args.func(args) + patch_get_client.delete.assert_called_once() + call_args = patch_get_client.delete.call_args + assert "/instances/123/" in call_args[0][0] + + def test_destroy_instance_confirm_yes(self, parse_argv, patch_get_client, mock_response, capsys, monkeypatch): + patch_get_client.delete.return_value = mock_response(200, {"success": True}) + monkeypatch.setattr("builtins.input", lambda _: "y") + args = parse_argv(["destroy", "instance", "123"]) + args.func(args) + patch_get_client.delete.assert_called_once() + captured = capsys.readouterr() + assert "destroying instance 123" in captured.out + + def test_destroy_instance_confirm_no(self, parse_argv, patch_get_client, mock_response, capsys, monkeypatch): + patch_get_client.delete.return_value = mock_response(200, {"success": True}) + monkeypatch.setattr("builtins.input", lambda _: "n") + args = parse_argv(["destroy", "instance", "123"]) + args.func(args) + patch_get_client.delete.assert_not_called() + captured = capsys.readouterr() + assert "Aborted" in captured.out + + +class TestStartInstance: + def test_start_instance(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.put.return_value = mock_response(200, {"success": True}) + args = parse_argv(["start", "instance", "123"]) + args.func(args) + patch_get_client.put.assert_called_once() + call_args = patch_get_client.put.call_args + assert "/instances/123/" in call_args[0][0] + assert call_args[1]["json_data"]["state"] == "running" + + +class TestStopInstance: + def test_stop_instance(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.put.return_value = mock_response(200, {"success": True}) + args = parse_argv(["stop", "instance", "123"]) + args.func(args) + patch_get_client.put.assert_called_once() + call_args = patch_get_client.put.call_args + assert "/instances/123/" in call_args[0][0] + assert call_args[1]["json_data"]["state"] == "stopped" + + +class TestRebootInstance: + def test_reboot_instance(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.put.return_value = mock_response(200, {"success": True}) + args = parse_argv(["reboot", "instance", "123"]) + args.func(args) + patch_get_client.put.assert_called_once() + call_args = patch_get_client.put.call_args + assert "/instances/reboot/123/" in call_args[0][0] + + +class TestRecycleInstance: + def test_recycle_instance(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.put.return_value = mock_response(200, {"success": True}) + args = parse_argv(["recycle", "instance", "123"]) + args.func(args) + patch_get_client.put.assert_called_once() + call_args = patch_get_client.put.call_args + assert "/instances/recycle/123/" in call_args[0][0] + + +class TestLabelInstance: + def test_label_instance(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.put.return_value = mock_response(200, {"success": True}) + args = parse_argv(["label", "instance", "123", "my-label"]) + args.func(args) + patch_get_client.put.assert_called_once() + call_args = patch_get_client.put.call_args + assert call_args[1]["json_data"]["label"] == "my-label" + + +# TestAcceptPriceIncrease was rewritten against the per-row backend and moved +# to tests/cli/test_price_increase_commands.py alongside the show + reject +# command tests. diff --git a/tests/cli/test_keys_commands.py b/tests/cli/test_keys_commands.py new file mode 100644 index 00000000..9a633f5a --- /dev/null +++ b/tests/cli/test_keys_commands.py @@ -0,0 +1,69 @@ +"""Integration tests for SSH/API key CLI commands with mocked HTTP.""" + +import pytest + + +class TestShowSshKeys: + def test_show_ssh_keys_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, { + "ssh_keys": [{"id": 1, "ssh_key": "ssh-rsa AAAA..."}] + }) + args = parse_argv(["show", "ssh-keys", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/ssh/" in call_args[0][0] + + +class TestShowApiKeys: + def test_show_api_keys_raw(self, parse_argv, patch_get_client, mock_response): + # Backend returns {"apikeys": [...]} (no underscore), not "api_keys". + patch_get_client.get.return_value = mock_response(200, { + "apikeys": [{"id": 1, "name": "test-key"}] + }) + args = parse_argv(["show", "api-keys", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/auth/apikeys/" in call_args[0][0] + + +class TestShowApiKey: + def test_show_api_key(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, {"id": 1, "name": "my-key"}) + args = parse_argv(["show", "api-key", "1"]) + args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/auth/apikeys/1/" in call_args[0][0] + + +class TestCreateSshKey: + def test_create_ssh_key(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.post.return_value = mock_response(200, {"id": 1, "success": True}) + args = parse_argv(["create", "ssh-key", "ssh-rsa AAAA... user@host"]) + args.func(args) + patch_get_client.post.assert_called_once() + call_args = patch_get_client.post.call_args + assert "/ssh/" in call_args[0][0] + assert "ssh_key" in call_args[1]["json_data"] + + +class TestDeleteSshKey: + def test_delete_ssh_key(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.delete.return_value = mock_response(200, {"success": True}) + args = parse_argv(["delete", "ssh-key", "1"]) + args.func(args) + patch_get_client.delete.assert_called_once() + call_args = patch_get_client.delete.call_args + assert "/ssh/1/" in call_args[0][0] + + +class TestDeleteApiKey: + def test_delete_api_key(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.delete.return_value = mock_response(200, {"success": True}) + args = parse_argv(["delete", "api-key", "1"]) + args.func(args) + patch_get_client.delete.assert_called_once() + call_args = patch_get_client.delete.call_args + assert "/auth/apikeys/1/" in call_args[0][0] diff --git a/tests/cli/test_machines_commands.py b/tests/cli/test_machines_commands.py new file mode 100644 index 00000000..2adcb48a --- /dev/null +++ b/tests/cli/test_machines_commands.py @@ -0,0 +1,278 @@ +"""Integration tests for machine CLI commands with mocked HTTP.""" + +import json +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + + +class TestShowMachines: + def test_show_machines_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, { + "machines": [ + {"id": 1, "gpu_name": "RTX_3090", "num_gpus": 4, "hostname": "host1"} + ] + }) + args = parse_argv(["show", "machines", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/machines" in call_args[0][0] + + def test_show_machines_display(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, { + "machines": [ + {"id": 1, "gpu_name": "RTX_3090", "num_gpus": 4, "hostname": "host1", + "disk_space": 100, "driver_version": "535.0", "reliability2": 0.99, + "verification": "verified", "public_ipaddr": "1.2.3.4", + "geolocation": "US", "num_reports": 0, "listed_gpu_cost": 0.5, + "min_bid_price": 0.3, "credit_discount_max": 0.1, + "listed_inet_up_cost": 0.01, "listed_inet_down_cost": 0.01, + "gpu_occupancy": "2/4"} + ] + }) + args = parse_argv(["show", "machines"]) + args.func(args) + captured = capsys.readouterr() + assert "ID" in captured.out + + +class TestShowMachine: + def test_show_machine_raw(self, parse_argv, patch_get_client, mock_response): + # Backend returns a bare one-element list for GET /machines/{id} + patch_get_client.get.return_value = mock_response(200, [{"id": 1, "gpu_name": "RTX_3090"}]) + args = parse_argv(["show", "machine", "1", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/machines/" in call_args[0][0] + assert result == [{"id": 1, "gpu_name": "RTX_3090"}] + + +class TestListMachineMinChunkDefault: + # Backend web/views/machines.py:53 does `int(params.get("min_chunk", 1))` — if the + # key is present but null, int(None) raises and is caught as HTTPBadRequest + # ("Invalid machine id or min_chunk"). The CLI must send 1 when --min_chunk is + # omitted, matching the backend's implicit default. + def test_list_machine_defaults_min_chunk_to_one(self, parse_argv, patch_get_client, mock_response): + patch_get_client.put.return_value = mock_response(200, {"success": True}) + args = parse_argv(["list", "machine", "42", "-g", "0.5"]) + args.func(args) + patch_get_client.put.assert_called_once() + body = patch_get_client.put.call_args[1]["json_data"] + assert body["min_chunk"] == 1, f"min_chunk={body['min_chunk']!r} would trigger backend 400" + + def test_list_machines_defaults_min_chunk_to_one(self, parse_argv, patch_get_client, mock_response): + patch_get_client.put.return_value = mock_response(200, {"success": True}) + args = parse_argv(["list", "machines", "11", "22", "-g", "0.5"]) + args.func(args) + assert patch_get_client.put.call_count == 2 + for call in patch_get_client.put.call_args_list: + assert call[1]["json_data"]["min_chunk"] == 1 + + def test_list_machine_explicit_min_chunk_wins(self, parse_argv, patch_get_client, mock_response): + patch_get_client.put.return_value = mock_response(200, {"success": True}) + args = parse_argv(["list", "machine", "42", "-g", "0.5", "-m", "4"]) + args.func(args) + body = patch_get_client.put.call_args[1]["json_data"] + assert body["min_chunk"] == 4 + + +class TestSelfTestMachineCleanup: + def test_successful_destroy_does_not_warn_when_instance_is_already_gone( + self, parse_argv, monkeypatch, capsys + ): + from vastai.cli.commands import machines + + offer = { + "id": 777, + "cuda_max_good": "13.0", + "compute_cap": 860, + "dlperf": 1, + "reliability": 0.99, + "direct_port_count": 4, + "pcie_bw": 3.0, + "gpu_total_ram": 12288, + "inet_down": 500, + "inet_up": 500, + "gpu_ram": 8, + "cpu_ram": 16000, + "cpu_cores": 4, + "num_gpus": 1, + } + running_instance = { + "id": 123, + "actual_status": "running", + "intended_status": "running", + "public_ipaddr": "127.0.0.1", + "ports": {"5000/tcp": [{"HostPort": "5000"}]}, + "status_msg": "", + } + + monkeypatch.setattr(machines.offers_api, "search_offers", Mock(return_value=[offer])) + monkeypatch.setattr(machines.instances_api, "create_instance", Mock(return_value={"new_contract": 123})) + monkeypatch.setattr(machines.instances_api, "show_instance", Mock(side_effect=[ + running_instance, + running_instance, + None, + ])) + destroy_instance = Mock(return_value={"success": True}) + monkeypatch.setattr(machines.instances_api, "destroy_instance", destroy_instance) + monkeypatch.setattr(machines.requests, "get", lambda *_, **__: SimpleNamespace(status_code=200, text="DONE")) + monkeypatch.setattr(machines.time, "sleep", lambda *_: None) + + args = parse_argv(["self-test", "machine", "46368"]) + with pytest.raises(SystemExit) as exit_info: + args.func(args) + + assert exit_info.value.code == 0 + assert destroy_instance.call_count == 1 + captured = capsys.readouterr() + assert "Instance 123 destroyed successfully on attempt 1." in captured.out + assert "WARNING: failed to destroy test instance 123" not in captured.out + + +class TestListMachineEpilogDoesNotPromiseEmail: + """Regression: epilogs must not reference the email that no longer carries details.""" + + def _epilog_lower(self, cli_parser, command): + # The two-word command name keys into the registered subparser tree. + sp = cli_parser.subparsers().choices[command] + return (sp.epilog or "").lower() + + def test_list_machine_epilog_no_email(self, cli_parser): + assert "email" not in self._epilog_lower(cli_parser, "list machine") + + def test_list_machines_epilog_no_email(self, cli_parser): + assert "email" not in self._epilog_lower(cli_parser, "list machines") + + +class TestSelfTestMachineIgnoreRequirements: + def test_ignore_requirements_warns_on_success(self, parse_argv, monkeypatch, capsys): + from vastai.cli.commands import machines + + offer = { + "id": 202, + "dlperf": 1.0, + "cuda_max_good": 13.0, + "compute_cap": 1200, + "reliability": 0.99, + "direct_port_count": 10, + "pcie_bw": 4.0, + "gpu_total_ram": 32 * 1024, + "inet_down": 200.0, + "inet_up": 200.0, + "gpu_ram": 32, + "cpu_ram": 64 * 1024, + "cpu_cores": 8, + "num_gpus": 1, + } + instance = { + "intended_status": "running", + "actual_status": "running", + "public_ipaddr": "203.0.113.10", + "ports": {"5000/tcp": [{"HostPort": "5000"}]}, + } + + monkeypatch.setattr(machines.offers_api, "search_offers", Mock(return_value=[offer])) + monkeypatch.setattr(machines.instances_api, "create_instance", Mock(return_value={"new_contract": 303})) + monkeypatch.setattr(machines.instances_api, "show_instance", Mock(return_value=instance)) + monkeypatch.setattr(machines.instances_api, "destroy_instance", Mock(return_value={"success": True})) + monkeypatch.setattr(machines.requests, "get", lambda *_, **__: SimpleNamespace(status_code=200, text="DONE")) + monkeypatch.setattr(machines.time, "sleep", lambda *_: None) + + args = parse_argv(["self-test", "machine", "123", "--ignore-requirements"]) + with pytest.raises(SystemExit) as exc_info: + args.func(args) + + assert exc_info.value.code == 0 + out = capsys.readouterr().out + assert "WARNING: --ignore-requirements is set." in out + assert "Requirement checks are skipped as a pass/fail gate" in out + assert "does not qualify this machine for verification" in out + assert out.count("does not qualify this machine for verification") >= 2 + assert "Test passed." in out + + def test_ignore_requirements_warning_in_raw_summary(self, parse_argv, monkeypatch, capsys): + from vastai.cli.commands import machines + + monkeypatch.setattr(machines.offers_api, "search_offers", Mock(return_value=[])) + + args = parse_argv(["--raw", "self-test", "machine", "0", "--ignore-requirements"]) + with pytest.raises(SystemExit) as exc_info: + args.func(args) + + assert exc_info.value.code == 0 + raw = json.loads(capsys.readouterr().out) + assert raw["success"] is False + assert "warning" in raw + assert "Requirement checks are skipped as a pass/fail gate" in raw["warning"] + assert "does not qualify this machine for verification" in raw["warning"] + + +class TestSelfTestMachinePortRange: + def test_passes_host_port_range_to_self_test_container( + self, parse_argv, monkeypatch, capsys + ): + from vastai.cli.commands import machines + from vastai.cli.self_test.port_range import PortRange + + offer = { + "id": 202, + "dlperf": 1.0, + "cuda_max_good": 13.0, + "compute_cap": 1200, + "reliability": 0.99, + "direct_port_count": 125, + "pcie_bw": 4.0, + "gpu_total_ram": 32 * 1024, + "inet_down": 200.0, + "inet_up": 200.0, + "gpu_ram": 32, + "cpu_ram": 64 * 1024, + "cpu_cores": 8, + "num_gpus": 1, + } + instance = { + "intended_status": "running", + "actual_status": "running", + "public_ipaddr": "203.0.113.10", + "ports": {"5000/tcp": [{"HostPort": "5000"}]}, + } + + monkeypatch.setattr( + machines, + "resolve_port_range", + Mock(return_value=(PortRange(40000, 40099), "host_port_range")), + ) + monkeypatch.setattr(machines.offers_api, "search_offers", Mock(return_value=[offer])) + create_instance = Mock(return_value={"new_contract": 303}) + monkeypatch.setattr(machines.instances_api, "create_instance", create_instance) + monkeypatch.setattr(machines.instances_api, "show_instance", Mock(return_value=instance)) + monkeypatch.setattr( + machines, + "scan_mapped_port_range", + Mock(return_value={ + "status": "passed", + "range": "40000-40099", + "mapped_entries": 200, + "missing_mappings": [], + "failed": [], + }), + ) + monkeypatch.setattr(machines.instances_api, "destroy_instance", Mock(return_value={"success": True})) + monkeypatch.setattr(machines.requests, "get", lambda *_, **__: SimpleNamespace(status_code=200, text="DONE")) + monkeypatch.setattr(machines.time, "sleep", lambda *_: None) + + args = parse_argv(["self-test", "machine", "123"]) + with pytest.raises(SystemExit) as exc_info: + args.func(args) + + assert exc_info.value.code == 0 + env = create_instance.call_args.kwargs["env"] + assert env["-p 40000-40099:40000-40099/tcp"] == "1" + assert env["-p 40000-40099:40000-40099/udp"] == "1" + assert env["VAST_SELF_TEST_PORT_START"] == "40000" + assert env["VAST_SELF_TEST_PORT_END"] == "40099" + assert "Port-range scan passed" in capsys.readouterr().out diff --git a/tests/cli/test_misc_commands.py b/tests/cli/test_misc_commands.py new file mode 100644 index 00000000..dd53e97b --- /dev/null +++ b/tests/cli/test_misc_commands.py @@ -0,0 +1,74 @@ +"""Integration tests for miscellaneous CLI commands with mocked HTTP.""" + +import pytest + + +class TestExecute: + def test_execute(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.put.return_value = mock_response(200, {"output": "hello"}) + args = parse_argv(["execute", "123", "ls -la"]) + args.func(args) + patch_get_client.put.assert_called_once() + call_args = patch_get_client.put.call_args + assert "/instances/command/123/" in call_args[0][0] + assert call_args[1]["json_data"]["command"] == "ls -la" + + +class TestLogs: + def test_logs(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.put.return_value = mock_response(200, {"result": "log output"}) + args = parse_argv(["logs", "123"]) + args.func(args) + patch_get_client.put.assert_called_once() + call_args = patch_get_client.put.call_args + assert "/instances/request_logs/123/" in call_args[0][0] + + +class TestReports: + def test_reports(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, [ + {"id": 1, "machine_id": 100, "report": "test report"} + ]) + args = parse_argv(["reports", "100"]) + args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/machines/100/reports" in call_args[0][0] + + +class TestSshUrl: + def test_ssh_url(self, parse_argv, patch_get_client, mock_response, capsys): + import time + patch_get_client.get.return_value = mock_response(200, { + "instances": [{ + "id": 123, "start_date": time.time(), "extra_env": [], + "ports": {"22/tcp": [{"HostPort": "12345"}]}, + "public_ipaddr": "1.2.3.4", + "ssh_host": "ssh.vast.ai", "ssh_port": 22, + "image_runtype": "ssh", + }] + }) + args = parse_argv(["ssh-url", "123"]) + args.func(args) + captured = capsys.readouterr() + assert "ssh://" in captured.out + assert "12345" in captured.out + + +class TestScpUrl: + def test_scp_url(self, parse_argv, patch_get_client, mock_response, capsys): + import time + patch_get_client.get.return_value = mock_response(200, { + "instances": [{ + "id": 123, "start_date": time.time(), "extra_env": [], + "ports": {"22/tcp": [{"HostPort": "12345"}]}, + "public_ipaddr": "1.2.3.4", + "ssh_host": "ssh.vast.ai", "ssh_port": 22, + "image_runtype": "ssh", + }] + }) + args = parse_argv(["scp-url", "123"]) + args.func(args) + captured = capsys.readouterr() + assert "scp://" in captured.out + assert "12345" in captured.out diff --git a/tests/cli/test_offers_commands.py b/tests/cli/test_offers_commands.py new file mode 100644 index 00000000..352bd499 --- /dev/null +++ b/tests/cli/test_offers_commands.py @@ -0,0 +1,104 @@ +"""Integration tests for offers/search CLI commands with mocked HTTP.""" + +import pytest + + +class TestSearchOffers: + def test_search_offers_no_default_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.post.return_value = mock_response(200, { + "offers": [{"id": 1, "gpu_name": "RTX_3090", "dph_total": 0.5}] + }) + args = parse_argv(["search", "offers", "--no-default", "--raw"]) + result = args.func(args) + patch_get_client.post.assert_called_once() + call_args = patch_get_client.post.call_args + assert "/bundles/" in call_args[0][0] + assert isinstance(result, list) + + def test_search_offers_with_query(self, parse_argv, patch_get_client, mock_response): + patch_get_client.post.return_value = mock_response(200, { + "offers": [{"id": 1, "gpu_name": "RTX_4090", "num_gpus": 1}] + }) + args = parse_argv(["search", "offers", "--no-default", "--raw", "num_gpus=1"]) + result = args.func(args) + patch_get_client.post.assert_called_once() + call_args = patch_get_client.post.call_args + json_data = call_args[1]["json_data"] + assert "num_gpus" in json_data + + def test_search_offers_display(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.post.return_value = mock_response(200, { + "offers": [{"id": 1, "gpu_name": "RTX_3090", "dph_total": 0.5, "num_gpus": 1}] + }) + args = parse_argv(["search", "offers", "--no-default"]) + args.func(args) + captured = capsys.readouterr() + assert "ID" in captured.out + + +class TestSearchTemplates: + def test_search_templates(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, [ + {"id": 1, "name": "PyTorch", "image": "pytorch/pytorch"} + ]) + args = parse_argv(["search", "templates"]) + args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/template/" in call_args[0][0] + + +class TestSearchBenchmarks: + def test_search_benchmarks(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, [ + {"id": 1, "score": 95.5, "gpu_name": "RTX_3090"} + ]) + args = parse_argv(["search", "benchmarks"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/benchmarks" in call_args[0][0] + + +class TestSearchInvoices: + def test_search_invoices(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, [ + {"id": 1, "amount_cents": 500} + ]) + args = parse_argv(["search", "invoices"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/invoices" in call_args[0][0] + + +class TestCreateTemplate: + def test_create_template(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.post.return_value = mock_response(200, { + "success": True, "template": {"id": 1, "hash_id": "abc123"} + }) + args = parse_argv(["create", "template", "--name", "Test", "--image", "pytorch/pytorch", "--no-default"]) + args.func(args) + patch_get_client.post.assert_called_once() + call_args = patch_get_client.post.call_args + assert "/template/" in call_args[0][0] + json_data = call_args[1]["json_data"] + assert json_data["name"] == "Test" + assert json_data["image"] == "pytorch/pytorch" + + +class TestDeleteTemplate: + def test_delete_template_by_hash_id(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.delete.return_value = mock_response(200, {"msg": "Deleted"}) + args = parse_argv(["delete", "template", "--hash-id", "abc123"]) + args.func(args) + patch_get_client.delete.assert_called_once() + call_args = patch_get_client.delete.call_args + assert "/template/" in call_args[0][0] + assert call_args[1]["json_data"]["hash_id"] == "abc123" + + def test_delete_template_by_id(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.delete.return_value = mock_response(200, {"msg": "Deleted"}) + args = parse_argv(["delete", "template", "--template-id", "42"]) + args.func(args) + patch_get_client.delete.assert_called_once() diff --git a/tests/cli/test_parser.py b/tests/cli/test_parser.py new file mode 100644 index 00000000..9056188a --- /dev/null +++ b/tests/cli/test_parser.py @@ -0,0 +1,150 @@ +"""Tests for vastai/cli/parser.py — apwrap, command registration, parse_args.""" + +import pytest +from vastai.cli.parser import apwrap, argument, hidden_aliases, MyWideHelpFormatter + + +class TestArgument: + def test_stores_args_and_kwargs(self): + a = argument("--foo", type=int, help="bar") + assert a.args == ("--foo",) + assert a.kwargs["type"] is int + assert a.kwargs["help"] == "bar" + + def test_mutex_group(self): + a = argument("--x", mutex_group="grp") + assert a.mutex_group == "grp" + + def test_no_mutex_group(self): + a = argument("pos") + assert a.mutex_group is None + + +class TestHiddenAliases: + def test_is_falsy(self): + h = hidden_aliases(["a", "b"]) + assert not h + assert bool(h) is False + + def test_iteration(self): + h = hidden_aliases(["x", "y"]) + assert list(h) == ["x", "y"] + + def test_append(self): + h = hidden_aliases([]) + h.append("z") + assert list(h) == ["z"] + + +class TestApwrap: + def test_creation(self): + p = apwrap(description="test") + assert p.parser is not None + assert p.subparsers_ is None + + def test_fail_with_help_raises_system_exit(self): + p = apwrap() + with pytest.raises(SystemExit): + p.fail_with_help() + + def test_command_decorator_single_word(self): + p = apwrap() + + @p.command(argument("--flag", action="store_true"), help="do stuff") + def mycommand(args): + pass + + assert callable(mycommand) + assert hasattr(mycommand, "mysignature") + + def test_command_double_underscore_becomes_two_word(self): + p = apwrap() + + @p.command(help="show items") + def show__items(args): + pass + + # The name should be "show items" internally + assert "show" in p.verbs + assert "items" in p.objs + + def test_command_with_dashes(self): + p = apwrap() + + @p.command(help="do dash things") + def do_dash__things(args): + pass + + # do-dash things + assert "do-dash" in p.verbs + assert "things" in p.objs + + def test_parse_args_joins_two_word_commands(self): + p = apwrap() + + @p.command(argument("--flag", action="store_true"), help="show stuff") + def show__stuff(args): + return 0 + + args = p.parse_args(["show", "stuff", "--flag"]) + assert args.flag is True + assert args.func is show__stuff + + def test_parse_args_empty_argv(self): + p = apwrap() + + @p.command(help="test") + def test_cmd(args): + pass + + # Empty argv should set func to fail_with_help + args = p.parse_args(["test-cmd"]) + assert args.func is test_cmd + + +class TestMutuallyExclusiveGroups: + def test_mutex_group(self): + p = apwrap() + + @p.command( + argument("--opt-a", mutex_group="grp", action="store_true"), + argument("--opt-b", mutex_group="grp", action="store_true"), + help="test mutex", + ) + def mutex__test(args): + pass + + args = p.parse_args(["mutex", "test", "--opt-a"]) + assert args.opt_a is True + assert args.opt_b is False + + +class TestCliParserReadOnlyCommands: + """Parametrized test that all read-only commands parse without error.""" + + READ_ONLY_COMMANDS = [ + ["show", "instances"], + ["show", "user"], + ["show", "invoices"], + ["show", "earnings"], + ["show", "subaccounts"], + ["show", "ipaddrs"], + ["show", "ssh-keys"], + ["show", "api-keys"], + ["show", "machines"], + ["show", "audit-logs"], + ["show", "env-vars"], + ["show", "scheduled-jobs"], + ["show", "endpoints"], + ["show", "volumes"], + ["show", "clusters"], + ["show", "overlays"], + ["show", "connections"], + ["show", "workergroups"], + ["tfa", "status"], + ] + + @pytest.mark.parametrize("argv", READ_ONLY_COMMANDS) + def test_parse_readonly_command(self, cli_parser, argv): + args = cli_parser.parse_args(argv) + assert callable(args.func) diff --git a/tests/cli/test_port_range.py b/tests/cli/test_port_range.py new file mode 100644 index 00000000..7fbc15c4 --- /dev/null +++ b/tests/cli/test_port_range.py @@ -0,0 +1,72 @@ +from vastai.cli.self_test.port_range import ( + PortRange, + parse_port_range, + port_range_docker_args, + read_host_port_range, + resolve_port_range, + scan_mapped_port_range, +) + + +def test_parse_port_range_accepts_whitespace_and_rejects_invalid_values(): + assert parse_port_range(" 40000 - 40002\n") == PortRange(40000, 40002) + assert parse_port_range("1023-40000") is None + assert parse_port_range("40002-40000") is None + assert parse_port_range("40000") is None + + +def test_read_and_resolve_port_range(tmp_path): + path = tmp_path / "host_port_range" + path.write_text("41000-41003\n", encoding="utf-8") + + assert read_host_port_range(str(path)) == PortRange(41000, 41003) + port_range, source = resolve_port_range(host_path=str(path)) + assert port_range == PortRange(41000, 41003) + assert source == "host_port_range" + + +def test_resolve_port_range_falls_back_to_instance_metadata(tmp_path): + port_range, source = resolve_port_range( + {"direct_port_start": 42000, "direct_port_end": 42002}, + host_path=str(tmp_path / "missing"), + ) + assert port_range == PortRange(42000, 42002) + assert source == "instance_metadata" + + +def test_docker_args_request_tcp_and_udp_range(): + assert port_range_docker_args(PortRange(40000, 40002)) == ( + "-p 40000-40002:40000-40002/tcp " + "-p 40000-40002:40000-40002/udp" + ) + + +def test_scan_reports_missing_and_unreachable_mappings(): + instance = { + "ports": { + "40000/tcp": [{"HostPort": "50000"}], + "40000/udp": [{"HostPort": "50001"}], + "40001/tcp": [{"HostPort": "50002"}], + } + } + + def fake_probe(public_ip, host_port, protocol, timeout): + return { + "public_ip": public_ip, + "host_port": host_port, + "protocol": protocol, + "reachable": host_port != 50002, + "error": "connection refused" if host_port == 50002 else None, + } + + result = scan_mapped_port_range( + instance, + "203.0.113.10", + PortRange(40000, 40001), + probe=fake_probe, + ) + + assert result["status"] == "failed" + assert result["mapped_entries"] == 3 + assert result["missing_mappings"] == [{"container_port": 40001, "protocol": "udp"}] + assert result["failed"][0]["host_port"] == 50002 diff --git a/tests/cli/test_price_increase_commands.py b/tests/cli/test_price_increase_commands.py new file mode 100644 index 00000000..dd70857c --- /dev/null +++ b/tests/cli/test_price_increase_commands.py @@ -0,0 +1,371 @@ +"""Integration tests for the price-increase CLI commands. + +Covers the per-row rewrite: `show pending-price-increases`, +`accept price-increase`, `reject price-increase`. Argparse rejection of +`--host` is the only regression we need against the old shape; the rest +asserts the new flow end-to-end with mocked HTTP. +""" + +import json +import re + +import pytest + + +PENDING_ROW = { + "pending_price_increase_id": 999, + "contract_id": 123, + "host_id": 7, + "new_gpu_costpersec": 0.0002, + "old_gpu_costpersec": 0.0001, + "new_disk_ram_costpersec": None, + "old_disk_ram_costpersec": None, + "new_bwu_cost": 0.02, + "old_bwu_cost": 0.01, + "new_bwd_cost": 0.02, + "old_bwd_cost": 0.01, + "new_platform_fee": 0.15, + "old_platform_fee": 0.10, + "contract_end_date": 1_700_000_000.0, + "ask_end_date": 1_700_500_000.0, + "created_at": 1_699_990_000.0, +} + +SECOND_ROW = { + **PENDING_ROW, + "pending_price_increase_id": 1000, + "contract_id": 456, +} + + +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def _envelope(rows): + return { + "success": True, + "count": len(rows), + "truncated": False, + "pending_price_increases": list(rows), + } + + +def _rendered_table_cell(output, header_text, column_name): + lines = [ + line for line in _ANSI_RE.sub("", output).splitlines() + if line.strip() + ] + header_idx = next( + idx for idx, line in enumerate(lines) if header_text in line + ) + headers = re.split(r"\s{2,}", lines[header_idx].strip()) + values = re.split(r"\s{2,}", lines[header_idx + 1].strip()) + return values[headers.index(column_name)] + + +# --------------------------------------------------------------------------- +# show pending-price-increases +# --------------------------------------------------------------------------- + + +class TestShowPendingPriceIncreases: + def test_default_renders_documented_columns(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, _envelope([PENDING_ROW])) + args = parse_argv(["show", "pending-price-increases"]) + args.func(args) + captured = capsys.readouterr() + # Columns advertised in the spec table descriptor. + for col in ("Pending ID", "Instance", "Host", "Current End", + "New End", "GPU", "Storage", "BW Up", "BW Down", + "Platform Fee"): + assert col in captured.out + # Pending id + contract id surface as data. + assert "999" in captured.out + assert "123" in captured.out + + def test_storage_with_null_new_shows_dash(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, _envelope([PENDING_ROW])) + args = parse_argv(["show", "pending-price-increases"]) + args.func(args) + captured = capsys.readouterr() + # `new_disk_ram_costpersec` is None on the fixture, so the storage cell + # collapses to "-". + assert _rendered_table_cell( + captured.out, "Storage ($/GB/mo)", "Storage ($/GB/mo)" + ) == "-" + + def test_raw_returns_envelope_unchanged(self, parse_argv, patch_get_client, mock_response): + envelope = _envelope([PENDING_ROW]) + patch_get_client.get.return_value = mock_response(200, envelope) + args = parse_argv(["show", "pending-price-increases", "--raw"]) + result = args.func(args) + assert result == envelope + + def test_quiet_prints_one_pending_id_per_line(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response( + 200, _envelope([PENDING_ROW, SECOND_ROW]), + ) + args = parse_argv(["show", "pending-price-increases", "--quiet"]) + args.func(args) + captured = capsys.readouterr() + lines = [line for line in captured.out.splitlines() if line] + assert lines == ["999", "1000"] + + def test_empty_envelope_friendly_message(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, _envelope([])) + args = parse_argv(["show", "pending-price-increases"]) + args.func(args) + captured = capsys.readouterr() + assert "No pending price increases." in captured.out + + +# --------------------------------------------------------------------------- +# accept price-increase +# --------------------------------------------------------------------------- + + +class TestAcceptPriceIncrease: + def test_single_id_resolves_then_puts_pending_id(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, _envelope([PENDING_ROW])) + patch_get_client.put.return_value = mock_response( + 200, {"success": True, "pending_price_increase_id": 999, "contract_id": 123}, + ) + args = parse_argv(["accept", "price-increase", "123", "--yes"]) + args.func(args) + patch_get_client.get.assert_called_once_with("/instances/pending-price-increases/") + patch_get_client.put.assert_called_once() + url = patch_get_client.put.call_args[0][0] + body = patch_get_client.put.call_args[1]["json_data"] + assert url == "/instances/accept-price-increase/" + assert body == {"pending_price_increase_id": 999} + captured = capsys.readouterr() + assert "Accepted pending_id=999 contract_id=123" in captured.out + # The cutover note is required on the success path. + assert "New rate applies after each contract's current end_date." in captured.out + + def test_multiple_ids_fan_out_sequential(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response( + 200, _envelope([PENDING_ROW, SECOND_ROW]), + ) + patch_get_client.put.side_effect = [ + mock_response(200, {"success": True, "pending_price_increase_id": 999, "contract_id": 123}), + mock_response(200, {"success": True, "pending_price_increase_id": 1000, "contract_id": 456}), + ] + args = parse_argv(["accept", "price-increase", "123", "456", "--yes"]) + args.func(args) + assert patch_get_client.put.call_count == 2 + bodies = [call.kwargs["json_data"] for call in patch_get_client.put.call_args_list] + assert bodies == [ + {"pending_price_increase_id": 999}, + {"pending_price_increase_id": 1000}, + ] + + def test_duplicate_ids_are_deduped(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, _envelope([PENDING_ROW])) + patch_get_client.put.return_value = mock_response( + 200, {"success": True, "pending_price_increase_id": 999, "contract_id": 123}, + ) + args = parse_argv(["accept", "price-increase", "123", "123", "--yes"]) + args.func(args) + patch_get_client.put.assert_called_once() + captured = capsys.readouterr() + assert "Accepted 1 / Stale 0 / Failed 0 of 1 requested." in captured.out + + def test_raw_returns_machine_readable_summary(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, _envelope([PENDING_ROW])) + patch_get_client.put.return_value = mock_response( + 200, {"success": True, "pending_price_increase_id": 999, "contract_id": 123}, + ) + args = parse_argv(["accept", "price-increase", "123", "--yes", "--raw"]) + with pytest.raises(SystemExit) as excinfo: + args.func(args) + assert excinfo.value.code == 0 + captured = capsys.readouterr() + result = json.loads(captured.out) + assert result["success"] is True + assert result["exit_code"] == 0 + assert result["acted"] == 1 + assert result["results"] == [ + { + "instance_id": 123, + "pending_price_increase_id": 999, + "contract_id": 123, + "status": "accepted", + "response": { + "success": True, + "pending_price_increase_id": 999, + "contract_id": 123, + }, + } + ] + + def test_raw_non_stale_failure_exits_1(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, _envelope([PENDING_ROW])) + patch_get_client.put.return_value = mock_response(500, {"msg": "boom"}) + args = parse_argv(["accept", "price-increase", "123", "--yes", "--raw"]) + with pytest.raises(SystemExit) as excinfo: + args.func(args) + assert excinfo.value.code == 1 + captured = capsys.readouterr() + result = json.loads(captured.out) + assert result["success"] is False + assert result["exit_code"] == 1 + assert result["acted"] == 0 + assert result["failed"] == 1 + assert result["stale"] == 0 + assert result["results"][0]["status"] == "failed" + assert result["results"][0]["instance_id"] == 123 + assert result["results"][0]["pending_price_increase_id"] == 999 + + def test_missing_yes_non_tty_exits_1(self, parse_argv, patch_get_client, mock_response, capsys, monkeypatch): + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + # list_pending may or may not be called before the gate; assert no PUT. + patch_get_client.get.return_value = mock_response(200, _envelope([PENDING_ROW])) + args = parse_argv(["accept", "price-increase", "123"]) + with pytest.raises(SystemExit) as excinfo: + args.func(args) + assert excinfo.value.code == 1 + patch_get_client.put.assert_not_called() + captured = capsys.readouterr() + assert "--yes is required when stdin is not a TTY" in captured.err + + def test_tty_prompt_aborts_on_no(self, parse_argv, patch_get_client, mock_response, capsys, monkeypatch): + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda _: "n") + patch_get_client.get.return_value = mock_response(200, _envelope([PENDING_ROW])) + args = parse_argv(["accept", "price-increase", "123"]) + args.func(args) + patch_get_client.put.assert_not_called() + captured = capsys.readouterr() + assert "Aborted." in captured.out + + def test_no_matching_rows_skip_prompt_and_exit_stale(self, parse_argv, patch_get_client, mock_response, monkeypatch): + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + + def fail_if_prompted(_): + raise AssertionError("prompt should not be shown without matching rows") + + monkeypatch.setattr("builtins.input", fail_if_prompted) + patch_get_client.get.return_value = mock_response(200, _envelope([PENDING_ROW])) + args = parse_argv(["accept", "price-increase", "555"]) + with pytest.raises(SystemExit) as excinfo: + args.func(args) + assert excinfo.value.code == 2 + patch_get_client.put.assert_not_called() + + def test_tty_prompt_accepts_on_y(self, parse_argv, patch_get_client, mock_response, monkeypatch): + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda _: "y") + patch_get_client.get.return_value = mock_response(200, _envelope([PENDING_ROW])) + patch_get_client.put.return_value = mock_response( + 200, {"success": True, "pending_price_increase_id": 999, "contract_id": 123}, + ) + args = parse_argv(["accept", "price-increase", "123"]) + args.func(args) + patch_get_client.put.assert_called_once() + + def test_instance_id_without_pending_row_is_stale(self, parse_argv, patch_get_client, mock_response, capsys): + # Pending list has row for contract 123; user asks for 555 → stale. + patch_get_client.get.return_value = mock_response(200, _envelope([PENDING_ROW])) + args = parse_argv(["accept", "price-increase", "555", "--yes"]) + with pytest.raises(SystemExit) as excinfo: + args.func(args) + assert excinfo.value.code == 2 + patch_get_client.put.assert_not_called() + captured = capsys.readouterr() + assert "pending price increase no longer available for instance 555" in captured.out + + def test_404_no_pending_exits_2(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, _envelope([PENDING_ROW])) + patch_get_client.put.return_value = mock_response( + 404, {"success": False, "error": "no_pending_price_increase"}, + ) + args = parse_argv(["accept", "price-increase", "123", "--yes"]) + with pytest.raises(SystemExit) as excinfo: + args.func(args) + assert excinfo.value.code == 2 + captured = capsys.readouterr() + assert "pending price increase no longer available — re-run" in captured.out + + def test_legacy_409_treated_as_stale(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, _envelope([PENDING_ROW])) + patch_get_client.put.return_value = mock_response(409, {"msg": "conflict"}) + args = parse_argv(["accept", "price-increase", "123", "--yes"]) + with pytest.raises(SystemExit) as excinfo: + args.func(args) + assert excinfo.value.code == 2 + + def test_non_stale_failure_takes_precedence_over_stale(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response( + 200, _envelope([PENDING_ROW, SECOND_ROW]), + ) + # First succeeds, second 500 (non-stale failure). + patch_get_client.put.side_effect = [ + mock_response(404, {"success": False, "error": "no_pending_price_increase"}), + mock_response(500, {"msg": "boom"}), + ] + args = parse_argv(["accept", "price-increase", "123", "456", "--yes"]) + with pytest.raises(SystemExit) as excinfo: + args.func(args) + # Failure beats stale per the spec so retries are not blindly attempted. + assert excinfo.value.code == 1 + + def test_summary_line_format(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, _envelope([PENDING_ROW])) + patch_get_client.put.return_value = mock_response( + 200, {"success": True, "pending_price_increase_id": 999, "contract_id": 123}, + ) + args = parse_argv(["accept", "price-increase", "123", "--yes"]) + args.func(args) + captured = capsys.readouterr() + assert "Accepted 1 / Stale 0 / Failed 0 of 1 requested." in captured.out + assert "Accepted price increase for 1 instance(s): 123" in captured.out + + def test_host_argument_is_rejected_by_argparse(self, cli_parser): + # The new command shape has no --host argument. argparse exits non-zero + # before any HTTP call would be made. + with pytest.raises(SystemExit): + cli_parser.parse_args(["accept", "price-increase", "--host", "1"]) + + +# --------------------------------------------------------------------------- +# reject price-increase +# --------------------------------------------------------------------------- + + +class TestRejectPriceIncrease: + def test_single_id_puts_pending_id_to_reject_route(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.get.return_value = mock_response(200, _envelope([PENDING_ROW])) + patch_get_client.put.return_value = mock_response( + 200, {"success": True, "pending_price_increase_id": 999, "contract_id": 123}, + ) + args = parse_argv(["reject", "price-increase", "123", "--yes"]) + args.func(args) + url = patch_get_client.put.call_args[0][0] + body = patch_get_client.put.call_args[1]["json_data"] + assert url == "/instances/reject-price-increase/" + assert body == {"pending_price_increase_id": 999} + captured = capsys.readouterr() + assert "Rejected pending_id=999 contract_id=123" in captured.out + assert "Rejected price increase for 1 instance(s): 123" in captured.out + # No cutover note on reject — nothing applies later. + assert "New rate applies" not in captured.out + + def test_tty_prompt_uses_reject_copy(self, parse_argv, patch_get_client, mock_response, monkeypatch, capsys): + prompts = [] + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda prompt: prompts.append(prompt) or "n") + patch_get_client.get.return_value = mock_response(200, _envelope([PENDING_ROW])) + args = parse_argv(["reject", "price-increase", "123"]) + args.func(args) + assert any("Reject these price increases?" in p for p in prompts) + + def test_404_no_pending_exits_2_on_reject(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, _envelope([PENDING_ROW])) + patch_get_client.put.return_value = mock_response( + 404, {"success": False, "error": "no_pending_price_increase"}, + ) + args = parse_argv(["reject", "price-increase", "123", "--yes"]) + with pytest.raises(SystemExit) as excinfo: + args.func(args) + assert excinfo.value.code == 2 diff --git a/tests/cli/test_storage_commands.py b/tests/cli/test_storage_commands.py new file mode 100644 index 00000000..f10a1321 --- /dev/null +++ b/tests/cli/test_storage_commands.py @@ -0,0 +1,31 @@ +"""Integration tests for storage/volume CLI commands with mocked HTTP.""" + +import pytest + + +class TestShowVolumes: + def test_show_volumes_raw(self, parse_argv, patch_get_client, mock_response): + import time + patch_get_client.get.return_value = mock_response(200, { + "volumes": [ + {"id": 1, "label": "my-vol", "disk_space": 100, "status": "active", + "start_date": time.time() - 3600} + ] + }) + args = parse_argv(["show", "volumes", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/volumes" in call_args[0][0] + + +class TestShowConnections: + def test_show_connections_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, [ + {"id": 1, "name": "my-s3", "cloud_type": "s3"} + ]) + args = parse_argv(["show", "connections", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/users/cloud_integrations/" in call_args[0][0] diff --git a/tests/cli/test_teams_commands.py b/tests/cli/test_teams_commands.py new file mode 100644 index 00000000..fccb4977 --- /dev/null +++ b/tests/cli/test_teams_commands.py @@ -0,0 +1,48 @@ +"""Integration tests for team CLI commands with mocked HTTP.""" + +import pytest + + +class TestCreateTeam: + def test_create_team(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.post.return_value = mock_response(200, {"success": True, "team_id": 1}) + args = parse_argv(["create", "team", "--team-name", "my-team"]) + args.func(args) + patch_get_client.post.assert_called_once() + call_args = patch_get_client.post.call_args + assert "/team/" in call_args[0][0] + assert call_args[1]["json_data"]["team_name"] == "my-team" + + +class TestDestroyTeam: + def test_destroy_team(self, parse_argv, patch_get_client, mock_response, capsys): + patch_get_client.delete.return_value = mock_response(200, {"success": True}) + args = parse_argv(["destroy", "team"]) + args.func(args) + patch_get_client.delete.assert_called_once() + call_args = patch_get_client.delete.call_args + assert "/team/" in call_args[0][0] + + +class TestShowMembers: + def test_show_members_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, { + "members": [{"id": 1, "email": "user@test.com", "role": "admin"}] + }) + args = parse_argv(["show", "members", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/team/members/" in call_args[0][0] + + +class TestShowTeamRoles: + def test_show_team_roles_raw(self, parse_argv, patch_get_client, mock_response): + patch_get_client.get.return_value = mock_response(200, [ + {"name": "admin", "permissions": {"all": True}} + ]) + args = parse_argv(["show", "team-roles", "--raw"]) + result = args.func(args) + patch_get_client.get.assert_called_once() + call_args = patch_get_client.get.call_args + assert "/team/roles-full/" in call_args[0][0] diff --git a/tests/cli/test_util.py b/tests/cli/test_util.py new file mode 100644 index 00000000..7863a273 --- /dev/null +++ b/tests/cli/test_util.py @@ -0,0 +1,305 @@ +"""Tests for vastai/cli/util.py — parse_env, parse_vast_url, validate_seconds, etc.""" + +import argparse +import pytest +from datetime import datetime, timedelta + + +class TestParseEnv: + def test_key_value(self): + from vastai.cli.util import parse_env + result = parse_env("-e KEY=val") + assert result["KEY"] == "val" + + def test_multiple_vars(self): + from vastai.cli.util import parse_env + result = parse_env("-e A=1 -e B=2") + assert result["A"] == "1" + assert result["B"] == "2" + + def test_port_mapping(self): + from vastai.cli.util import parse_env + result = parse_env("-p 8080:8080/tcp") + assert "-p 8080:8080/tcp" in result + + def test_port_range_mapping(self): + from vastai.cli.util import parse_env + result = parse_env("-p 40000-40002:40000-40002/tcp -p 40000-40002:40000-40002/udp") + assert "-p 40000-40002:40000-40002/tcp" in result + assert "-p 40000-40002:40000-40002/udp" in result + + def test_volume_mapping(self): + from vastai.cli.util import parse_env + result = parse_env("-v /host:/container") + assert "-v /host:/container" in result + + def test_none_input(self): + from vastai.cli.util import parse_env + result = parse_env(None) + assert result == {} + + def test_equals_in_value(self): + from vastai.cli.util import parse_env + result = parse_env("-e KEY=val=with=equals") + assert result["KEY"] == "val=with=equals" + + +class TestParseVastUrl: + def test_id_with_path(self): + from vastai.cli.util import parse_vast_url + instance_id, path = parse_vast_url("123:/data/model") + assert instance_id == "123" + assert path == "/data/model" + + def test_id_only(self): + from vastai.cli.util import parse_vast_url + instance_id, path = parse_vast_url("123") + assert instance_id == 123 + assert path == "/" + + def test_path_only(self): + from vastai.cli.util import parse_vast_url + instance_id, path = parse_vast_url("/data/model") + assert instance_id is None + assert path == "/data/model" + + def test_invalid_vrl_raises(self): + from vastai.cli.util import parse_vast_url, VRLException + with pytest.raises(VRLException): + parse_vast_url("a:b:c") + + def test_invalid_path_raises(self): + from vastai.cli.util import parse_vast_url, VRLException + with pytest.raises(VRLException, match="not a valid Unix"): + parse_vast_url("123:\x00bad") + + +class TestValidateSeconds: + def test_valid_timestamp(self): + from vastai.cli.util import validate_seconds + now = int(datetime.now().timestamp()) + assert validate_seconds(str(now)) == now + + def test_too_old_raises(self): + from vastai.cli.util import validate_seconds + with pytest.raises(argparse.ArgumentTypeError): + validate_seconds("1000") + + def test_too_far_future_raises(self): + from vastai.cli.util import validate_seconds + with pytest.raises(argparse.ArgumentTypeError): + validate_seconds("99999999999") + + def test_non_numeric_raises(self): + from vastai.cli.util import validate_seconds + with pytest.raises(argparse.ArgumentTypeError): + validate_seconds("not_a_number") + + +class TestSmartSplit: + def test_simple(self): + from vastai.cli.util import smart_split + result = smart_split("a b c", " ") + assert result == ["a", "b", "c"] + + def test_double_quoted(self): + from vastai.cli.util import smart_split + result = smart_split('a "b c" d', " ") + assert result == ["a", '"b c"', "d"] + + def test_single_quoted(self): + from vastai.cli.util import smart_split + result = smart_split("a 'b c' d", " ") + assert result == ["a", "'b c'", "d"] + + +class TestSplitList: + def test_even(self): + from vastai.cli.util import split_list + result = split_list([1, 2, 3, 4], 2) + assert result == [[1, 2], [3, 4]] + + def test_uneven(self): + from vastai.cli.util import split_list + result = split_list([1, 2, 3, 4, 5], 2) + assert result == [[1, 2], [3, 4], [5]] + + def test_empty(self): + from vastai.cli.util import split_list + result = split_list([], 3) + assert result == [] + + +class TestParseVersion: + def test_standard(self): + from vastai.cli.util import parse_version + assert parse_version("1.2.3") == (1, 2, 3) + + def test_large_numbers(self): + from vastai.cli.util import parse_version + assert parse_version("10.20.30") == (10, 20, 30) + + +class TestParseDayCronStyle: + def test_valid_day(self): + from vastai.cli.util import parse_day_cron_style + assert parse_day_cron_style("3") == 3 + + def test_wildcard(self): + from vastai.cli.util import parse_day_cron_style + assert parse_day_cron_style("*") is None + + def test_invalid_raises(self): + from vastai.cli.util import parse_day_cron_style + with pytest.raises(argparse.ArgumentTypeError): + parse_day_cron_style("7") + + def test_boundary_zero(self): + from vastai.cli.util import parse_day_cron_style + assert parse_day_cron_style("0") == 0 + + def test_boundary_six(self): + from vastai.cli.util import parse_day_cron_style + assert parse_day_cron_style("6") == 6 + + +class TestParseHourCronStyle: + def test_valid_hour(self): + from vastai.cli.util import parse_hour_cron_style + assert parse_hour_cron_style("14") == 14 + + def test_wildcard(self): + from vastai.cli.util import parse_hour_cron_style + assert parse_hour_cron_style("*") is None + + def test_invalid_raises(self): + from vastai.cli.util import parse_hour_cron_style + with pytest.raises(argparse.ArgumentTypeError): + parse_hour_cron_style("24") + + def test_boundary_zero(self): + from vastai.cli.util import parse_hour_cron_style + assert parse_hour_cron_style("0") == 0 + + def test_boundary_23(self): + from vastai.cli.util import parse_hour_cron_style + assert parse_hour_cron_style("23") == 23 + + +class TestConvertDatesToTimestamps: + def _args(self, start=None, end=None): + return argparse.Namespace(start_date=start, end_date=end) + + def test_date_only_input_is_utc_midnight(self): + from vastai.cli.util import convert_dates_to_timestamps + start, end = convert_dates_to_timestamps(self._args(start="2024-01-15", end="2024-01-16")) + # 2024-01-15 00:00 UTC and 2024-01-16 00:00 UTC + assert start == 1705276800.0 + assert end == 1705363200.0 + + def test_date_only_input_unaffected_by_local_tz(self, monkeypatch): + import time as _time + if not hasattr(_time, "tzset"): + pytest.skip("tzset unavailable on this platform") + monkeypatch.setenv("TZ", "America/Los_Angeles") + _time.tzset() + try: + from vastai.cli.util import convert_dates_to_timestamps + start, end = convert_dates_to_timestamps(self._args(start="2024-01-15", end="2024-01-16")) + assert start == 1705276800.0 + assert end == 1705363200.0 + finally: + _time.tzset() + + def test_aware_input_keeps_its_offset(self): + from vastai.cli.util import convert_dates_to_timestamps + # 2024-01-15 00:00 -05:00 = 2024-01-15 05:00 UTC + start, _ = convert_dates_to_timestamps(self._args(start="2024-01-15T00:00:00-05:00")) + assert start == 1705276800.0 + 5 * 3600 + + +class TestScheduledJobsDisplayUtc: + def test_start_time_formats_in_utc(self, monkeypatch): + import time as _time + if not hasattr(_time, "tzset"): + pytest.skip("tzset unavailable on this platform") + monkeypatch.setenv("TZ", "America/Los_Angeles") + _time.tzset() + try: + from vastai.cli.display import scheduled_jobs_fields + formatter = dict((f[0], f[3]) for f in scheduled_jobs_fields)["start_time"] + # 1705276800 = 2024-01-15 00:00 UTC + assert formatter(1705276800) == "2024-01-15/00:00" + finally: + _time.tzset() + + +class TestRequiredInetMbps: + # Inputs are gpu_total_ram in MiB (matches ask_contract_offers.gpu_total_ram). + # Formula: min(500, max(100, 500 * (mib/1024) / 192)) + + def test_missing_falls_to_floor(self): + from vastai.cli.util import required_inet_mbps + assert required_inet_mbps(None) == 100.0 + assert required_inet_mbps(0) == 100.0 + + def test_tiny_vram_floors_at_100(self): + from vastai.cli.util import required_inet_mbps + # 8 GiB + assert required_inet_mbps(8 * 1024) == 100.0 + + def test_huge_vram_caps_at_500(self): + from vastai.cli.util import required_inet_mbps + # 1 TiB total VRAM + assert required_inet_mbps(1024 * 1024) == 500.0 + + # Single-GPU reference table from the ticket. VRAM expressed as marketing-GiB + # converted to MiB by multiplying by 1024 (i.e. binary GiB inputs). + def test_reference_48gib_single_gpu(self): + from vastai.cli.util import required_inet_mbps + # A6000 marketing 48 GB + assert required_inet_mbps(48 * 1024) == pytest.approx(125.0, rel=1e-3) + + def test_reference_80gib_single_gpu(self): + from vastai.cli.util import required_inet_mbps + # H100 80 GB + assert required_inet_mbps(80 * 1024) == pytest.approx(208.33, rel=1e-3) + + def test_reference_96gib_single_gpu(self): + from vastai.cli.util import required_inet_mbps + assert required_inet_mbps(96 * 1024) == pytest.approx(250.0, rel=1e-3) + + def test_reference_141gib_single_gpu(self): + from vastai.cli.util import required_inet_mbps + # H200 141 GB + assert required_inet_mbps(141 * 1024) == pytest.approx(367.19, rel=1e-3) + + def test_reference_192gib_single_gpu_hits_cap(self): + from vastai.cli.util import required_inet_mbps + # B200 marketing 192 GB, expressed as binary GiB + assert required_inet_mbps(192 * 1024) == 500.0 + + # Multi-GPU machines scale with total VRAM and hit the cap quickly. + def test_2x_h100_total_160gib(self): + from vastai.cli.util import required_inet_mbps + assert required_inet_mbps(2 * 80 * 1024) == pytest.approx(416.67, rel=1e-3) + + def test_4x_h100_total_320gib_caps(self): + from vastai.cli.util import required_inet_mbps + assert required_inet_mbps(4 * 80 * 1024) == 500.0 + + def test_8x_a6000_total_384gib_caps(self): + from vastai.cli.util import required_inet_mbps + assert required_inet_mbps(8 * 48 * 1024) == 500.0 + + def test_2x_a6000_total_96gib(self): + from vastai.cli.util import required_inet_mbps + assert required_inet_mbps(2 * 48 * 1024) == pytest.approx(250.0, rel=1e-3) + + # Real B200 reports 183359 MiB (~179 GiB) — verifies that actual hardware + # values land just below the cap rather than hitting it exactly. Documented + # behavior; cap is reached at 192 GiB total. + def test_real_b200_mib_lands_below_cap(self): + from vastai.cli.util import required_inet_mbps + result = required_inet_mbps(183359) + assert 460.0 < result < 470.0 diff --git a/tests/compat_test.sh b/tests/compat_test.sh new file mode 100644 index 00000000..84466f48 --- /dev/null +++ b/tests/compat_test.sh @@ -0,0 +1,347 @@ +#!/bin/bash +# +# Backwards-compatibility test: compare current production vastai CLI +# against the dev build. Tests both --help interface and read-only commands. +# +# Usage: +# bash tests/compat_test.sh /path/to/dev.whl [--api-key KEY] +# + +set -uo pipefail + +usage() { + echo "Usage: bash tests/compat_test.sh /path/to/vastai-dev.whl [--api-key KEY]" + echo " (API key may also be provided via VAST_API_KEY env var)" +} + +DEV_WHL="" +API_KEY="${VAST_API_KEY:-}" + +while [ $# -gt 0 ]; do + case "$1" in + --api-key) + if [ $# -lt 2 ]; then + echo "error: --api-key requires a value" >&2 + usage + exit 1 + fi + API_KEY="$2" + shift 2 + ;; + --api-key=*) + API_KEY="${1#--api-key=}" + shift + ;; + -h|--help) + usage + exit 0 + ;; + -*) + echo "error: unknown flag: $1" >&2 + usage + exit 1 + ;; + *) + if [ -z "$DEV_WHL" ]; then + DEV_WHL="$1" + else + echo "error: unexpected positional arg: $1" >&2 + usage + exit 1 + fi + shift + ;; + esac +done + +if [ -z "$DEV_WHL" ] || [ ! -f "$DEV_WHL" ]; then + usage + exit 1 +fi + +# Halt on failures during environment setup; individual CLI comparisons later +# capture exit codes explicitly, so we deliberately do not use a global set -e. +fail_setup() { + echo "error: $1" >&2 + exit 1 +} + +PROD_VENV="/tmp/vastai-compat-prod" +DEV_VENV="/tmp/vastai-compat-dev" +RESULTS_DIR="/tmp/vastai-compat-results" + +rm -rf "$PROD_VENV" "$DEV_VENV" "$RESULTS_DIR" +mkdir -p "$RESULTS_DIR/prod/help" "$RESULTS_DIR/dev/help" +mkdir -p "$RESULTS_DIR/prod/output" "$RESULTS_DIR/dev/output" +mkdir -p "$RESULTS_DIR/diffs" + +echo "========================================" +echo "Setting up environments" +echo "========================================" + +echo " Creating prod venv..." +python3 -m venv "$PROD_VENV" || fail_setup "failed to create prod venv at $PROD_VENV" +source "$PROD_VENV/bin/activate" +pip install --quiet vastai 2>&1 | tail -1 +[ "${PIPESTATUS[0]}" -eq 0 ] || fail_setup "pip install vastai (prod) failed" +PROD_VERSION=$(vastai --version 2>&1 || echo "unknown") +deactivate +echo " Prod version: $PROD_VERSION" + +echo " Creating dev venv..." +python3 -m venv "$DEV_VENV" || fail_setup "failed to create dev venv at $DEV_VENV" +source "$DEV_VENV/bin/activate" +pip install --quiet "$DEV_WHL" 2>&1 | tail -1 +[ "${PIPESTATUS[0]}" -eq 0 ] || fail_setup "pip install $DEV_WHL (dev) failed" +DEV_VERSION=$(vastai --version 2>&1 || echo "unknown") +deactivate +echo " Dev version: $DEV_VERSION" + +# All two-word commands extracted from the CLI +COMMANDS=( + "show instances" + "show instance" + "create instance" + "create instances" + "destroy instance" + "destroy instances" + "start instance" + "start instances" + "stop instance" + "stop instances" + "reboot instance" + "recycle instance" + "update instance" + "label instance" + "prepay instance" + "change bid" + "launch instance" + "search offers" + "search benchmarks" + "search templates" + "search invoices" + "create template" + "update template" + "delete template" + "show machine" + "show machines" + "show maints" + "show network-disks" + "list machine" + "list machines" + "unlist machine" + "delete machine" + "cleanup machine" + "defrag machines" + "set min-bid" + "set defjob" + "remove defjob" + "schedule maint" + "cancel maint" + "add network-disk" + "self-test machine" + "create team" + "destroy team" + "create team-role" + "show team-role" + "show team-roles" + "update team-role" + "remove team-role" + "invite member" + "show members" + "remove member" + "create api-key" + "show api-key" + "show api-keys" + "delete api-key" + "reset api-key" + "create ssh-key" + "show ssh-keys" + "delete ssh-key" + "update ssh-key" + "attach ssh" + "detach ssh" + "create endpoint" + "show endpoints" + "update endpoint" + "delete endpoint" + "get endpt-logs" + "create workergroup" + "show workergroups" + "update workergroup" + "delete workergroup" + "get wrkgrp-logs" + "show invoices" + "show earnings" + "show deposit" + "show user" + "set user" + "show subaccounts" + "create subaccount" + "show ipaddrs" + "transfer credit" + "show scheduled-jobs" + "delete scheduled-job" + "cancel copy" + "cancel sync" + "cloud copy" + "show connections" + "search volumes" + "search network-volumes" + "create volume" + "create network-volume" + "delete volume" + "clone volume" + "show volumes" + "list volume" + "list volumes" + "list network-volume" + "unlist volume" + "unlist network-volume" + "show audit-logs" + "show env-vars" + "create env-var" + "update env-var" + "delete env-var" + "tfa activate" + "tfa delete" + "tfa login" + "tfa resend-sms" + "tfa regen-codes" + "tfa send-sms" + "tfa send-email" + "tfa auth-new" + "tfa status" + "tfa totp-setup" + "tfa update" + "show deployments" + "show deployment" + "delete deployment" + "take snapshot" +) + +# Read-only commands safe to run with a real API key +READONLY_COMMANDS=( + "show instances" + "show machines" + "show user" + "show api-keys" + "show ssh-keys" + "show endpoints" + "show workergroups" + "show volumes" + "show connections" + "show env-vars" + "show subaccounts" + "show deployments" + "show team-roles" + "show members" + "show audit-logs" + "show scheduled-jobs" + "search offers" +) + +HELP_PASS=0 +HELP_FAIL=0 +HELP_NEW=0 +HELP_REMOVED=0 +OUTPUT_PASS=0 +OUTPUT_FAIL=0 +OUTPUT_SKIP=0 + +echo "" +echo "========================================" +echo "PHASE 1: --help interface compatibility" +echo "========================================" + +for cmd in "${COMMANDS[@]}"; do + safe_name=$(echo "$cmd" | tr ' ' '_') + + # Get prod help + source "$PROD_VENV/bin/activate" + vastai $cmd --help > "$RESULTS_DIR/prod/help/$safe_name.txt" 2>&1 + prod_exit=$? + deactivate + + # Get dev help + source "$DEV_VENV/bin/activate" + vastai $cmd --help > "$RESULTS_DIR/dev/help/$safe_name.txt" 2>&1 + dev_exit=$? + deactivate + + if [ $prod_exit -ne 0 ] && [ $dev_exit -eq 0 ]; then + echo " NEW: $cmd (not in prod, added in dev)" + HELP_NEW=$((HELP_NEW + 1)) + elif [ $prod_exit -eq 0 ] && [ $dev_exit -ne 0 ]; then + echo " REMOVED: $cmd (in prod, missing in dev)" + HELP_REMOVED=$((HELP_REMOVED + 1)) + elif diff -q "$RESULTS_DIR/prod/help/$safe_name.txt" "$RESULTS_DIR/dev/help/$safe_name.txt" > /dev/null 2>&1; then + echo " PASS: $cmd" + HELP_PASS=$((HELP_PASS + 1)) + else + echo " DIFF: $cmd" + diff -u "$RESULTS_DIR/prod/help/$safe_name.txt" "$RESULTS_DIR/dev/help/$safe_name.txt" > "$RESULTS_DIR/diffs/${safe_name}_help.diff" 2>&1 + HELP_FAIL=$((HELP_FAIL + 1)) + fi +done + +echo "" +echo "Help results: $HELP_PASS identical, $HELP_FAIL changed, $HELP_NEW new, $HELP_REMOVED removed" + +if [ -n "$API_KEY" ]; then + echo "" + echo "========================================" + echo "PHASE 2: Read-only output comparison" + echo "========================================" + + for cmd in "${READONLY_COMMANDS[@]}"; do + safe_name=$(echo "$cmd" | tr ' ' '_') + + # Get prod output (flags must come after subcommand in prod) + source "$PROD_VENV/bin/activate" + timeout 30 vastai $cmd --api-key "$API_KEY" --raw > "$RESULTS_DIR/prod/output/$safe_name.txt" 2>&1 + prod_exit=$? + deactivate + + # Get dev output (flags after subcommand for consistency) + source "$DEV_VENV/bin/activate" + timeout 30 vastai $cmd --api-key "$API_KEY" --raw > "$RESULTS_DIR/dev/output/$safe_name.txt" 2>&1 + dev_exit=$? + deactivate + + if [ $prod_exit -ne 0 ] && [ $dev_exit -ne 0 ]; then + echo " SKIP: $cmd (both errored — may need args or permissions)" + OUTPUT_SKIP=$((OUTPUT_SKIP + 1)) + elif diff -q "$RESULTS_DIR/prod/output/$safe_name.txt" "$RESULTS_DIR/dev/output/$safe_name.txt" > /dev/null 2>&1; then + echo " PASS: $cmd" + OUTPUT_PASS=$((OUTPUT_PASS + 1)) + else + echo " DIFF: $cmd" + diff -u "$RESULTS_DIR/prod/output/$safe_name.txt" "$RESULTS_DIR/dev/output/$safe_name.txt" > "$RESULTS_DIR/diffs/${safe_name}_output.diff" 2>&1 + OUTPUT_FAIL=$((OUTPUT_FAIL + 1)) + fi + done + + echo "" + echo "Output results: $OUTPUT_PASS identical, $OUTPUT_FAIL changed, $OUTPUT_SKIP skipped" +else + echo "" + echo "PHASE 2 SKIPPED: No API key provided." + echo " Re-run with: bash tests/compat_test.sh $DEV_WHL --api-key YOUR_KEY" + echo " Or set VAST_API_KEY env var." +fi + +echo "" +echo "========================================" +echo "SUMMARY" +echo "========================================" +echo " Help: $HELP_PASS pass, $HELP_FAIL changed, $HELP_NEW new, $HELP_REMOVED removed" +if [ -n "$API_KEY" ]; then + echo " Output: $OUTPUT_PASS pass, $OUTPUT_FAIL changed, $OUTPUT_SKIP skipped" +fi +echo "" +echo "Diffs saved to: $RESULTS_DIR/diffs/" +echo "Full outputs: $RESULTS_DIR/prod/ and $RESULTS_DIR/dev/" + +TOTAL_FAIL=$((HELP_FAIL + HELP_REMOVED + OUTPUT_FAIL)) +[ "$TOTAL_FAIL" -eq 0 ] && exit 0 || exit 1 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..2c698590 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,1969 @@ +"""Shared pytest fixtures for vast-sdk tests. + +Fixtures follow unit-test-requirements: one fixture per concept, defined in +conftest.py for reuse across test files. Pyworker ``Metrics`` helpers live in the +``# Pyworker server`` section below. + +One fixture name per *kind* of resource; use factory callables for variants +(``make_request_http_mocks``, ``make_route_response_mock``, ``server_worker_config``, +``client_worker_dict``, ``make_session_mock``, ``make_client_session``, ``session_on_mock_endpoint``, +``make_mock_endpoint_for_session``, ``make_delegate_endpoint`` (only factory for ``Endpoint`` on +``mock_serverless_client``), ``make_serverless_bound_session``, +``make_test_endpoint``, +``make_backend_http_request``, ``make_mock_root_logger``, …) instead of parallel fixtures. + +Pyworker: ``pyworker_backend`` (Backend with Metrics mocked), ``patch_pyworker_backend_class``, +``make_pyworker_session`` (only factory for server ``Session``), ``valid_auth_data_dict`` (AuthData-shaped JSON). + +Serverless pyworker: ``serverless_backend_and_handler_default`` (default ``Backend`` + handler), +``serverless_tracked_runner_and_tcp_site`` (AppRunner/TCPSite capture bundle for ``server.lib.server``), +``run_serverless_start_server_async_patched`` (async helper applying AppRunner/TCPSite/_start_tracking patches for ``start_server_async``), +``make_patch_skip_backend_run_session_on_close`` / ``make_patch_mock_backend_close_session``, +``attach_serverless_backend_mock_aiohttp_session`` (single mock ``ClientSession`` on ``backend.session``), +``make_serverless_fetch_pubkey_client_session_return_value``, ``make_serverless_backend_session_get_steps``, +``serverless_backend_ok_json_response_chain``, ``make_serverless_mock_request_with_transport``, +``make_serverless_test_rsa_key`` (RSA key factory for signature tests). + +Serverless SSL: ``serverless_ssl_self_signed_cert_pem``, ``serverless_ssl_ca_chain_without_key_cert_sign``, +``patch_serverless_ssl_cert_download`` (callable returning a ``ClientSession`` download patch). + +An autouse fixture restores the ``Serverless`` logger after each test so global +logging state follows RAII and cannot leak between cases. +""" + +from __future__ import annotations + +import asyncio +import base64 +import dataclasses +import datetime +import inspect +import json +import logging +import os +from contextlib import ExitStack, contextmanager +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from aiohttp import ClientResponseError, web +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID +from Crypto.Hash import SHA256 +from Crypto.PublicKey import RSA +from Crypto.Signature import pkcs1_15 + +from vastai.serverless.client.client import Serverless, ServerlessRequest +from vastai.serverless.client.endpoint import Endpoint +from vastai.serverless.client.session import Session +from vastai.serverless.server.lib.backend import Backend +from vastai.serverless.server.lib import server as vast_serverless_server_mod +from vastai.serverless.server.lib.metrics import get_url +from vastai.serverless.server.lib.data_types import ( + RequestMetrics, + Session as PyworkerSession, +) +from vastai.serverless.server.worker import ( + WorkerConfig, + HandlerConfig, + BenchmarkConfig, + EndpointHandlerFactory, +) + + +def _attach_mock_aiohttp_session(sl: Serverless) -> None: + """Put an open mocked aiohttp-backed session on ``sl._session`` (in-place).""" + mock_sess = MagicMock() + mock_sess.closed = False + mock_sess.close = AsyncMock() + sl._session = mock_sess + + +# --------------------------------------------------------------------------- +# Global RAII (resource cleanup after every test) +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _restore_serverless_logger_state(): + """Snapshot and restore the ``Serverless`` client logger after each test. + + ``Serverless.__init__`` configures ``logging.getLogger("Serverless")``. Leaked + handlers or ``propagate=False`` can make later tests call ``time.time()`` via + logging and break strict clock mocks. + """ + log = logging.getLogger("Serverless") + old_handlers = list(log.handlers) + old_level = log.level + old_propagate = log.propagate + old_disabled = log.disabled + yield + log.handlers.clear() + for h in old_handlers: + log.addHandler(h) + log.setLevel(old_level) + log.propagate = old_propagate + log.disabled = old_disabled + + +# --------------------------------------------------------------------------- +# Server Worker test helpers (mocks for generate_client_response etc.) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def make_mock_web_request(): + """Factory: create a mock object for aiohttp web.Request in handler tests.""" + + def _make(spec_request: bool = False): + if spec_request: + from aiohttp import web + + return MagicMock(spec=web.Request) + return MagicMock() + + return _make + + +@pytest.fixture +def make_mock_model_response(): + """Factory: create a mock model response for generate_client_response tests. + + Returns a callable that accepts content_type, body, status, and optional + stream_chunks. If stream_chunks is provided, content.iter_any is an async + generator yielding those chunks; otherwise read() returns body. + """ + + def _make( + content_type: str = "application/json", + body: bytes | None = None, + status: int = 200, + stream_chunks: list[bytes] | None = None, + ): + mock = MagicMock() + mock.content_type = content_type + mock.status = status + mock.headers = MagicMock() + mock.headers.get = MagicMock(return_value=None) + mock.headers.copy = MagicMock(return_value={}) + if stream_chunks is not None: + + async def _iter(): + for c in stream_chunks: + yield c + + mock.content.iter_any = _iter + else: + mock.read = AsyncMock(return_value=body or b"") + return mock + + return _make + + +# --------------------------------------------------------------------------- +# Client Worker (vastai.serverless.client.worker) — single dict factory +# --------------------------------------------------------------------------- + +_FULL_CLIENT_WORKER_DICT = { + "id": 42, + "status": "RUNNING", + "cur_load": 0.5, + "new_load": 0.6, + "cur_load_rolling_avg": 0.55, + "cur_perf": 1.2, + "perf": 1.1, + "measured_perf": 1.0, + "dlperf": 0.9, + "reliability": 0.95, + "reqs_working": 3, + "disk_usage": 0.4, + "loaded_at": 1700000000.0, + "started_at": 1699999000.0, +} + + +@pytest.fixture +def client_worker_dict(): + """Single factory for API worker payload dicts (``minimal`` / ``full`` + overrides).""" + + def _make(kind: str = "minimal", **overrides: object) -> dict: + if kind == "minimal": + d = {"id": 1} + elif kind == "full": + d = dict(_FULL_CLIENT_WORKER_DICT) + else: + raise ValueError(f"unknown kind {kind!r}, use 'minimal' or 'full'") + d.update(overrides) + return d + + return _make + + +# --------------------------------------------------------------------------- +# Server Worker (vastai.serverless.server.worker) — single WorkerConfig factory +# --------------------------------------------------------------------------- + + +@pytest.fixture +def server_worker_config(): + """Single factory for :class:`WorkerConfig` (minimal / handler / from_handlers).""" + + def _make( + kind: str, + *, + route: str = "/predict", + dataset: list | None = None, + extra_handlers: list[HandlerConfig] | None = None, + handlers: list[HandlerConfig] | None = None, + ) -> WorkerConfig: + if kind == "minimal": + return WorkerConfig( + model_server_url="http://localhost", + model_server_port=8000, + ) + if kind == "handler": + if dataset is None: + dataset = [{"input": "test"}] + hs = [ + HandlerConfig( + route=route, + benchmark_config=BenchmarkConfig(dataset=dataset), + ), + ] + if extra_handlers: + hs.extend(extra_handlers) + return WorkerConfig( + model_server_url="http://localhost", + model_server_port=8000, + handlers=hs, + ) + if kind == "from_handlers": + if handlers is None: + raise ValueError("from_handlers requires handlers=") + return WorkerConfig( + model_server_url="http://localhost", + model_server_port=8000, + handlers=handlers, + ) + raise ValueError(f"unknown kind {kind!r}") + + return _make + + +# --------------------------------------------------------------------------- +# Pyworker Backend / Worker (server.lib.backend, server.worker) — single mocks +# --------------------------------------------------------------------------- + + +@pytest.fixture +def patch_pyworker_backend_class(): + """Patch :class:`Backend` in ``server.lib.backend`` with ``MagicMock`` (constructor).""" + from vastai.serverless.server.lib import backend as backend_mod + + with patch.object(backend_mod, "Backend", MagicMock()) as mock_cls: + yield mock_cls + + +@pytest.fixture +def make_mock_root_logger(): + """Factory: mock root logger for :class:`Worker` ``__init__`` logging branches. + + Returns ``(root_mock, handler_mock_or_none)`` — ``handler_mock_or_none`` is the + first handler when ``with_handlers=True``, else ``None``. + """ + + def _make(*, with_handlers: bool): + mock_root = MagicMock() + mock_root.setLevel = MagicMock() + if with_handlers: + h = MagicMock() + mock_root.handlers = [h] + mock_root.addHandler = MagicMock() + return mock_root, h + mock_root.handlers = [] + mock_root.addHandler = MagicMock() + return mock_root, None + + return _make + + +@pytest.fixture +def pyworker_backend(): + """Single :class:`Backend` test instance; ``Metrics`` is mocked (no ``CONTAINER_ID`` env).""" + from vastai.serverless.server.lib.backend import Backend + from vastai.serverless.server.lib.data_types import LogAction + + with patch( + "vastai.serverless.server.lib.backend.Metrics", + return_value=MagicMock(), + ): + return Backend( + model_server_url="http://localhost:8000", + model_log_file="/tmp/model.log", + benchmark_handler=MagicMock(), + log_actions=[(LogAction.Info, "ready")], + ) + + +@pytest.fixture +def make_backend_http_request(): + """Factory: mock ``aiohttp.web.Request`` with ``.json`` async for Backend handler tests.""" + + def _make( + *, + json_data=None, + json_side_effect=None, + ): + req = MagicMock() + if json_side_effect is not None: + req.json = AsyncMock(side_effect=json_side_effect) + else: + req.json = AsyncMock( + return_value=json_data if json_data is not None else {} + ) + return req + + return _make + + +@pytest.fixture +def web_json_body(): + """Callable: parse JSON dict from ``web.json_response`` result ``.body`` bytes.""" + + def _parse(resp): + import json + + return json.loads(resp.body.decode()) + + return _parse + + +@pytest.fixture +def valid_auth_data_dict(): + """Valid ``auth_data`` payload for :class:`AuthData` / ``get_data_from_request`` tests.""" + return { + "cost": "1", + "endpoint": "/predict", + "reqnum": 1, + "request_idx": 0, + "signature": "sig", + "url": "http://example.com", + } + + +# --------------------------------------------------------------------------- +# Serverless pyworker (Backend, server.lib.server) fixtures +# --------------------------------------------------------------------------- + +# Metrics() reads container/network env at Backend init; use with patch.dict in factories. +SERVERLESS_METRICS_TEST_ENV = { + "CONTAINER_ID": "1", + "REPORT_ADDR": "https://run.vast.ai", + "WORKER_PORT": "8080", + "PUBLIC_IPADDR": "127.0.0.1", + "VAST_TCP_PORT_8080": "8080", +} + + +@pytest.fixture +def serverless_metrics_test_env() -> dict: + """Copy of env vars required to construct Metrics inside Backend for unit tests.""" + return dict(SERVERLESS_METRICS_TEST_ENV) + + +def _serverless_worker_config_one_handler( + route: str = "/predict", + *, + allow_parallel: bool = True, + max_queue_time: float | None = None, +) -> WorkerConfig: + return WorkerConfig( + model_server_url="http://localhost", + model_server_port=8000, + model_log_file="/tmp/nonexistent-model.log", + handlers=[ + HandlerConfig( + route=route, + benchmark_config=BenchmarkConfig(dataset=[{"input": {}}]), + allow_parallel_requests=allow_parallel, + max_queue_time=max_queue_time, + ), + ], + max_sessions=None, + ) + + +@pytest.fixture +def make_serverless_backend_and_handler(): + """Factory: build Backend + generic EndpointHandler for /predict (see EndpointHandlerFactory). + + ``WorkerConfig.max_sessions`` of ``None`` is mapped to ``Backend(max_sessions=0)`` because + ``session_create_handler`` treats ``0`` and ``None`` as unlimited (no cap). + """ + + def _make( + *, + unsecured: bool = True, + max_sessions: int | None = None, + allow_parallel: bool = True, + max_queue_time: float | None = None, + remote_function=None, + ) -> tuple[Backend, object]: + config = _serverless_worker_config_one_handler( + allow_parallel=allow_parallel, + max_queue_time=max_queue_time, + ) + if remote_function is not None: + config.handlers[0] = dataclasses.replace( + config.handlers[0], remote_function=remote_function + ) + if max_sessions is not None: + config = WorkerConfig( + model_server_url=config.model_server_url, + model_server_port=config.model_server_port, + model_log_file=config.model_log_file, + handlers=config.handlers, + max_sessions=max_sessions, + ) + factory = EndpointHandlerFactory(config) + benchmark = factory.get_benchmark_handler() + handler = factory.get_handler("/predict") + assert handler is not None + effective_max = ( + max_sessions if max_sessions is not None else config.max_sessions + ) + if effective_max is None: + effective_max = 0 + with patch.dict(os.environ, SERVERLESS_METRICS_TEST_ENV, clear=False): + get_url.cache_clear() + backend = Backend( + model_server_url="http://localhost:8000", + model_log_file=config.model_log_file, + benchmark_handler=benchmark, + log_actions=[], + max_sessions=effective_max, + unsecured=unsecured, + ) + get_url.cache_clear() + return backend, handler + + return _make + + +@pytest.fixture +def parse_serverless_aiohttp_json(): + """Factory: decode JSON body from aiohttp web.Response in synchronous tests. + + When ``resp.body`` is ``None``, returns ``{}`` (same as empty JSON object) so + status-only tests need not branch; use explicit ``resp.body`` checks if you + must distinguish missing body from ``{}``. + """ + + def _parse(resp: web.StreamResponse) -> dict | list: + raw = resp.body + if raw is None: + return {} + return json.loads(raw.decode()) + + return _parse + + +@pytest.fixture +def make_serverless_json_http_request(): + """Factory: mock aiohttp Request; pass str body to simulate JSON decode errors.""" + + def _make(data: dict | str) -> MagicMock: + req = MagicMock(spec=web.Request) + if isinstance(data, str): + req.json = AsyncMock(side_effect=json.JSONDecodeError("err", data, 0)) + else: + req.json = AsyncMock(return_value=data) + return req + + return _make + + +@pytest.fixture +def make_serverless_auth_payload(): + """Factory: minimal valid auth_data + payload dict for generic pyworker handler tests.""" + + def _make( + *, + url: str = "http://example.com/predict", + signature: str = "unused-in-unsecured", + reqnum: int = 1, + ) -> dict: + return { + "auth_data": { + "cost": "1", + "endpoint": "/predict", + "reqnum": reqnum, + "request_idx": 42, + "signature": signature, + "url": url, + }, + "payload": {"input": {}}, + } + + return _make + + +@pytest.fixture +def make_serverless_signed_auth_payload(make_serverless_auth_payload): + """Factory: auth payload with PKCS1-v1.5 signature matching Backend.__check_signature.""" + + def _signed(url: str, rsa_key) -> dict: + message = json.dumps({"url": url}, indent=4, sort_keys=True) + h = SHA256.new(message.encode()) + sig = pkcs1_15.new(rsa_key).sign(h) + return make_serverless_auth_payload( + url=url, + signature=base64.b64encode(sig).decode("ascii"), + reqnum=7, + ) + + return _signed + + +@pytest.fixture +def serverless_backend_testkit( + make_serverless_backend_and_handler, + parse_serverless_aiohttp_json, + make_serverless_json_http_request, + make_serverless_auth_payload, + make_serverless_signed_auth_payload, +): + """Bundle common pyworker Backend test helpers (single parameter for test methods).""" + return SimpleNamespace( + make_backend=make_serverless_backend_and_handler, + response_json=parse_serverless_aiohttp_json, + json_request=make_serverless_json_http_request, + auth_payload=make_serverless_auth_payload, + signed_auth=make_serverless_signed_auth_payload, + ) + + +@pytest.fixture +def serverless_backend_and_handler_default(make_serverless_backend_and_handler): + """Fresh ``(Backend, handler)`` with defaults for pyworker serverless unit tests.""" + return make_serverless_backend_and_handler() + + +@pytest.fixture +def serverless_tracked_runner_and_tcp_site( + make_serverless_tracked_app_runner, + make_serverless_tracked_tcp_site, +): + """Tracked ``AppRunner`` + ``TCPSite`` side effects for ``server.lib.server`` tests.""" + apps_seen, app_runner = make_serverless_tracked_app_runner() + tcp_calls, tcp_site = make_serverless_tracked_tcp_site() + return SimpleNamespace( + apps_seen=apps_seen, + app_runner=app_runner, + tcp_calls=tcp_calls, + tcp_site=tcp_site, + ) + + +@pytest.fixture +def make_serverless_tracked_app_runner(): + """Factory: returns (captured_apps_list, AppRunner side_effect) for patching web.AppRunner.""" + + def _make(): + captured: list = [] + + def app_runner(app: web.Application, **kwargs): + captured.append(app) + m = MagicMock() + m.setup = AsyncMock() + return m + + return captured, app_runner + + return _make + + +@pytest.fixture +def make_serverless_tracked_tcp_site(): + """Factory: returns (captured_kwargs_per_call, TCPSite side_effect) for patching web.TCPSite.""" + + def _make(): + captured: list = [] + + def tcp_site(*args, **kwargs): + captured.append(kwargs) + m = MagicMock() + m.start = AsyncMock() + return m + + return captured, tcp_site + + return _make + + +@pytest.fixture +def serverless_gather_await_all(): + """Async gather replacement that awaits each awaitable (for mocked ``site.start()``). + + If an early awaitable raises :class:`Exception`, remaining awaitables are cleaned up: + :class:`asyncio.Task` / :class:`asyncio.Future` are cancelled; bare coroutines are + closed via ``.close()``; other awaitables get ``.close()`` when present (same idea as + ``serverless_gather_raise_bind_failed``). Uses ``Exception`` (not ``BaseException``) + so ``KeyboardInterrupt`` / ``SystemExit`` propagate for local debugging. + + ``**kwargs`` are accepted for call-shape parity with :func:`asyncio.gather` but ignored + (this stub does not implement ``return_exceptions`` etc.). + """ + + async def _gather(*aws, **kwargs): + pending = list(aws) + idx = -1 + try: + for idx in range(len(pending)): + await pending[idx] + return None + except Exception: + for j in range(idx + 1, len(pending)): + aw = pending[j] + if isinstance(aw, asyncio.Task): + aw.cancel() + elif isinstance(aw, asyncio.Future): + aw.cancel() + elif inspect.iscoroutine(aw): + aw.close() + elif inspect.isawaitable(aw): + closer = getattr(aw, "close", None) + if callable(closer): + closer() + raise + + return _gather + + +@pytest.fixture +def serverless_gather_raise_bind_failed(): + """Simulate gather() failing without leaking un-awaited coroutine objects. + + ``start_server_async`` does ``await gather(site.start(), http_site.start(), + backend._start_tracking())``. Those three call expressions run before ``gather``; + if the patched ``gather`` raises without consuming them, CPython warns on GC. + This replacement closes each awaitable then raises like a failed bind. + """ + + async def _gather(*aws, **kwargs): + for aw in aws: + if inspect.isawaitable(aw): + if isinstance(aw, asyncio.Task): + aw.cancel() + else: + closer = getattr(aw, "close", None) + if callable(closer): + closer() + raise RuntimeError("bind failed") + + return _gather + + +@pytest.fixture +def serverless_error_beacon_mocks(): + """Patches Metrics + sleep so server error beacon runs then exits on second sleep. + + Yields the MagicMock for Metrics._model_errored. + """ + sm = vast_serverless_server_mod + with patch.object( + sm.Metrics, "_Metrics__send_metrics_and_reset", new_callable=AsyncMock + ): + with patch.object(sm.Metrics, "_model_errored") as mock_model_errored: + with patch.object(sm.Metrics, "aclose", new_callable=AsyncMock): + sleep_mock = AsyncMock(side_effect=[None, RuntimeError("stop-beacon")]) + with patch.object(sm.asyncio, "sleep", sleep_mock): + yield mock_model_errored + + +@pytest.fixture +def serverless_aiohttp_route_path_tuples(): + """Factory: list (method, path_string) for each route on an aiohttp Application.""" + + def _paths(app: web.Application) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [] + for r in app.router.routes(): + resource = getattr(r, "resource", None) + canonical = getattr(resource, "canonical", None) if resource else None + path = str(canonical) if canonical is not None else str(r) + out.append((r.method, path)) + return out + + return _paths + + +def _serverless_aiohttp_async_enter_context( + enter_result: Any | None = None, + *, + enter_side_effect: BaseException | None = None, +) -> MagicMock: + """``async with`` context manager mock (``__aenter__`` / ``__aexit__``).""" + cm = MagicMock() + if enter_side_effect is not None: + cm.__aenter__ = AsyncMock(side_effect=enter_side_effect) + else: + cm.__aenter__ = AsyncMock(return_value=enter_result) + cm.__aexit__ = AsyncMock(return_value=None) + return cm + + +def _attach_serverless_backend_mock_aiohttp_session( + backend: Backend, + *, + spy_only: bool = False, + post_side_effect: BaseException | None = None, + response_text: str = "ok", + response_status: int = 200, + get_context_return: MagicMock | None = None, + get_side_effect=None, +) -> MagicMock: + """Attach one mock aiohttp session to ``backend.session`` (GET + POST).""" + mock_sess = MagicMock() + if get_side_effect is not None: + mock_sess.get = MagicMock(side_effect=get_side_effect) + elif get_context_return is not None: + mock_sess.get = MagicMock(return_value=get_context_return) + else: + mock_sess.get = MagicMock() + + if spy_only: + mock_sess.post = MagicMock() + elif post_side_effect is not None: + mock_sess.post = MagicMock(side_effect=post_side_effect) + else: + mock_resp = MagicMock() + mock_resp.status = response_status + mock_resp.text = AsyncMock(return_value=response_text) + mock_sess.post = MagicMock( + return_value=_serverless_aiohttp_async_enter_context(mock_resp) + ) + + object.__setattr__(backend, "session", mock_sess) + return mock_sess + + +@pytest.fixture +def attach_serverless_backend_mock_aiohttp_session(): + """Attach a single mock aiohttp ``ClientSession`` on ``backend.session`` (GET + POST). + + Call before any code path reads ``backend.session`` so the real ``cached_property`` + is never evaluated (avoids opening a real ``TCPConnector`` / ``ClientSession``). + + Post: default successful JSON/text response; ``spy_only`` / ``post_side_effect`` for variants. + Get: ``get_context_return`` (static async CM from ``make_serverless_aiohttp_get_context_manager``) + or ``get_side_effect`` (e.g. from ``make_serverless_backend_session_get_steps``). + """ + return _attach_serverless_backend_mock_aiohttp_session + + +@pytest.fixture +def make_serverless_aiohttp_get_context_manager(): + """Factory: build an async context manager for one aiohttp response object.""" + return _serverless_aiohttp_async_enter_context + + +@pytest.fixture +def make_serverless_fetch_pubkey_client_session_return_value(): + """Return value for ``patch(..., ClientSession, return_value=...)`` in ``Backend._fetch_pubkey`` tests.""" + + def _make( + *, + pem_text: str | None = None, + session_enter_error: BaseException | None = None, + ) -> MagicMock: + if session_enter_error is not None: + return _serverless_aiohttp_async_enter_context( + enter_side_effect=session_enter_error + ) + if pem_text is None: + raise ValueError("pem_text is required when session_enter_error is None") + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.text = AsyncMock(return_value=pem_text) + mock_client = MagicMock() + mock_client.get = MagicMock( + return_value=_serverless_aiohttp_async_enter_context(mock_resp) + ) + return _serverless_aiohttp_async_enter_context(mock_client) + + return _make + + +@pytest.fixture +def make_serverless_sleep_cancel_on_nth_call(): + """Async ``sleep`` replacement that raises ``CancelledError`` starting at the *n*-th invocation.""" + + def _make(n: int): + count = [0] + + async def _sleep(_delay: float) -> None: + count[0] += 1 + if count[0] >= n: + raise asyncio.CancelledError() + + return _sleep + + return _make + + +@pytest.fixture +def make_serverless_backend_healthcheck_attrs(): + """Set ``healthcheck_url`` / ``_Backend__start_healthcheck`` / ``_Backend__healthcheck_succeeded``.""" + + def _apply( + backend: Backend, + *, + url: str, + start: bool = True, + succeeded: bool = False, + ) -> None: + object.__setattr__(backend, "healthcheck_url", url) + object.__setattr__(backend, "_Backend__start_healthcheck", start) + object.__setattr__(backend, "_Backend__healthcheck_succeeded", succeeded) + + return _apply + + +@pytest.fixture +def make_serverless_backend_session_get_steps(): + """Build ``session.get`` side_effect: each call returns a CM for a response mock or raises.""" + + def _make(steps: list[Any]): + seq = iter(steps) + + def _get(*_a, **_k): + step = next(seq) + if isinstance(step, BaseException): + raise step + return _serverless_aiohttp_async_enter_context(step) + + return _get + + return _make + + +@pytest.fixture +def make_serverless_mock_request_with_transport(): + """Build ``(mock_request, mock_transport)`` for ``Session.requests`` transport tests.""" + + def _make(*, close_side_effect: BaseException | None = None): + mock_tr = MagicMock() + mock_tr.is_closing.return_value = False + if close_side_effect is not None: + mock_tr.close.side_effect = close_side_effect + mock_req = MagicMock() + mock_req.transport = mock_tr + return mock_req, mock_tr + + return _make + + +@pytest.fixture +def serverless_backend_ok_json_response_chain(): + """Shared ``MagicMock`` model + async stubs for successful ``__call_backend`` / ``generate_client_response``.""" + mock_model = MagicMock() + + async def call_backend(**kwargs): + return mock_model + + async def gen_client_response(_client_request, _model_response): + return web.json_response({"ok": True}) + + return SimpleNamespace( + mock_model=mock_model, + call_backend=call_backend, + gen_client_response=gen_client_response, + ) + + +@pytest.fixture +def make_patch_skip_backend_run_session_on_close(): + """Return ``patch.object(backend, _Backend__run_session_on_close, AsyncMock)`` context manager.""" + + def _patch(backend: Backend): + return patch.object( + backend, "_Backend__run_session_on_close", new_callable=AsyncMock + ) + + return _patch + + +@pytest.fixture +def make_patch_mock_backend_close_session(): + """Return ``patch.object(backend, _Backend__close_session, AsyncMock)`` context manager.""" + + def _patch(backend: Backend): + return patch.object(backend, "_Backend__close_session", new_callable=AsyncMock) + + return _patch + + +@pytest.fixture +def make_serverless_test_rsa_key(): + """Factory: small RSA key for Backend signature tests (not for production crypto).""" + + def _make(bits: int = 1024): + return RSA.generate(bits) + + return _make + + +@pytest.fixture +def run_serverless_start_server_async_patched(serverless_tracked_runner_and_tcp_site): + """Async callable: apply env + AppRunner + TCPSite + _start_tracking patches, then ``start_server_async``. + + Returns the ``_start_tracking`` AsyncMock. + """ + + async def _run( + backend: Backend, + routes: list, + env: dict, + *, + ssl_create_default_context_patch=None, + ) -> MagicMock: + sm = vast_serverless_server_mod + st = serverless_tracked_runner_and_tcp_site + app_runner, tcp_site = st.app_runner, st.tcp_site + with ExitStack() as stack: + stack.enter_context(patch.dict(os.environ, env, clear=False)) + if ssl_create_default_context_patch is not None: + stack.enter_context(ssl_create_default_context_patch) + stack.enter_context( + patch.object(sm.web, "AppRunner", side_effect=app_runner) + ) + stack.enter_context(patch.object(sm.web, "TCPSite", side_effect=tcp_site)) + mock_track = stack.enter_context( + patch.object(backend, "_start_tracking", new_callable=AsyncMock) + ) + await sm.start_server_async(backend, routes) + return mock_track + + return _run + + +# --------------------------------------------------------------------------- +# Connection (vastai.serverless.client.connection) fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def make_sse_response(): + """Factory: create mock aiohttp response with SSE/JSONL stream content. + + Returns a callable that accepts an iterable of bytes chunks and returns + a mock response whose content.iter_any yields those chunks. + + Use for _iter_sse_json tests. + """ + + def _make(chunks): + async def mock_iter(): + for c in chunks: + yield c + + mock_resp = MagicMock() + mock_resp.content.iter_any = mock_iter + return mock_resp + + return _make + + +@pytest.fixture +def make_mock_http_response(): + """Factory: create mock aiohttp response for async with session.get/post. + + Returns a callable that accepts status, text, json, json_side_effect + and returns a mock response configured for use in 'async with' context. + Use for _make_request tests. + """ + + def _make( + status: int = 200, + text: str = "", + json_data=None, + json_side_effect=None, + ): + mock_resp = MagicMock() + mock_resp.status = status + mock_resp.headers = {} + mock_resp.text = AsyncMock(return_value=text) + mock_resp.json = AsyncMock( + return_value=json_data, + side_effect=json_side_effect, + ) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=None) + return mock_resp + + return _make + + +@pytest.fixture +def make_request_http_mocks(): + """Single factory for ``(mock_session, mock_client)`` used by ``_make_request`` tests. + + Returns a callable ``(mock_session, mock_client)`` configured for _make_request. + + - ``make_request_http_mocks(mock_resp)`` — ``session.get`` returns ``mock_resp``. + - ``make_request_http_mocks(get_side_effect=...)`` — ``session.get`` uses + ``AsyncMock(side_effect=...)`` (exception, list of responses, etc.). + - ``make_request_http_mocks(post_return=resp)`` — ``session.post`` returns + ``resp``; ``session.get`` is a bare ``AsyncMock()`` (unused). + Pass ``mock_resp=None`` and omit the kwargs to configure ``get``/``post`` manually + on the returned ``mock_session``. + """ + + def _make(mock_resp=None, *, get_side_effect=None, post_return=None): + mock_session = MagicMock() + if get_side_effect is not None: + mock_session.get = AsyncMock(side_effect=get_side_effect) + elif post_return is not None: + mock_session.get = AsyncMock() + else: + mock_session.get = AsyncMock(return_value=mock_resp) + + if post_return is not None: + mock_session.post = AsyncMock(return_value=post_return) + else: + mock_session.post = AsyncMock() + + mock_client = MagicMock() + mock_client._get_session = AsyncMock(return_value=mock_session) + mock_client.get_ssl_context = AsyncMock(return_value=None) + + return mock_session, mock_client + + return _make + + +@pytest.fixture +def patch_build_kwargs(): + """Patch _build_kwargs for _make_request tests. + + Yields the mock; tests run with _build_kwargs patched to return + standard kwargs (headers, params, timeout). + """ + with patch("vastai.serverless.client.connection._build_kwargs") as mock_build: + mock_build.return_value = { + "headers": {}, + "params": {}, + "timeout": MagicMock(), + } + yield mock_build + + +@pytest.fixture +def make_aiohttp_client_session_mock(): + """Factory: mock aiohttp ``ClientSession`` for ``_open_once`` tests.""" + + def _make(get_returns=None, post_returns=None): + mock_session = MagicMock() + mock_session.get = AsyncMock(return_value=get_returns or MagicMock()) + mock_session.post = AsyncMock(return_value=post_returns or MagicMock()) + return mock_session + + return _make + + +@pytest.fixture +def build_kwargs_defaults(): + """Default kwargs for _build_kwargs tests. + + Returns a dict of common defaults; tests can override as needed. + """ + return { + "headers": {}, + "params": {}, + "ssl_context": None, + "timeout": 30.0, + "body": None, + "method": "GET", + "stream": False, + } + + +# --------------------------------------------------------------------------- +# Pyworker server (vastai.serverless.server.lib.metrics) fixtures +# --------------------------------------------------------------------------- + + +def _metrics_post_ok_context_and_response() -> tuple[MagicMock, MagicMock]: + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_ctx = MagicMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_resp) + mock_ctx.__aexit__ = AsyncMock(return_value=None) + return mock_ctx, mock_resp + + +@pytest.fixture +def clear_get_url_cache(): + """Clear functools.cache on get_url() before and after each test. + + Use via ``pytestmark = pytest.mark.usefixtures("clear_get_url_cache")`` on + modules that patch os.environ for URL tests. + """ + from vastai.serverless.server.lib.metrics import get_url + + get_url.cache_clear() + yield + get_url.cache_clear() + + +@pytest.fixture +def make_pyworker_metrics(): + """Factory: build Metrics with explicit id/report_addr/url (no CONTAINER_ID env).""" + + def _make(**kwargs): + from vastai.serverless.server.lib.metrics import Metrics + + defaults = dict( + id=1, + report_addr=["http://report.test"], + url="http://worker.test:9000", + ) + defaults.update(kwargs) + return Metrics(**defaults) + + return _make + + +@pytest.fixture +def make_pyworker_session(): + """Factory: server ``Session`` (pyworker data type, not aiohttp) for all serverless tests.""" + + def _make(**kwargs): + defaults = dict( + session_id="s1", + lifetime=0.0, + auth_data={}, + expiration=0.0, + on_close_route="", + on_close_payload={}, + request_idx=1, + ) + defaults.update(kwargs) + return PyworkerSession(**defaults) + + return _make + + +@pytest.fixture +def make_pyworker_request_metrics(): + """Factory: ``RequestMetrics`` with defaults; override fields per test.""" + + def _make(**kwargs): + defaults = dict( + request_idx=1, + reqnum=1, + workload=1.0, + status="", + success=False, + is_session=False, + session=None, + session_reqnum=None, + ) + defaults.update(kwargs) + return RequestMetrics(**defaults) + + return _make + + +@pytest.fixture +def make_metrics_aiohttp_post(): + """Single factory for aiohttp-style ``session.post`` / async context mocks in metrics tests. + + - ``session_ok()`` → ``(mock_session, mock_response)`` + - ``context_ok()`` → ``(context_manager, response)`` + - ``context_timeout()`` → context whose ``__aenter__`` raises ``asyncio.TimeoutError`` + - ``context_client_error(status=500)`` → context; ``raise_for_status`` raises ``ClientResponseError`` + - ``context_enter_raises(exc)`` → context whose ``__aenter__`` raises ``exc`` + """ + + def session_ok(): + mock_ctx, mock_resp = _metrics_post_ok_context_and_response() + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=mock_ctx) + return mock_session, mock_resp + + def context_ok(): + return _metrics_post_ok_context_and_response() + + def context_timeout(): + mock_ctx = MagicMock() + mock_ctx.__aenter__ = AsyncMock(side_effect=asyncio.TimeoutError()) + mock_ctx.__aexit__ = AsyncMock(return_value=None) + return mock_ctx + + def context_client_error(*, status: int = 500): + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock( + side_effect=ClientResponseError( + request_info=MagicMock(), + history=(), + status=status, + ) + ) + mock_ctx = MagicMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_resp) + mock_ctx.__aexit__ = AsyncMock(return_value=None) + return mock_ctx + + def context_enter_raises(exc: BaseException): + mock_ctx = MagicMock() + mock_ctx.__aenter__ = AsyncMock(side_effect=exc) + mock_ctx.__aexit__ = AsyncMock(return_value=None) + return mock_ctx + + return SimpleNamespace( + session_ok=session_ok, + context_ok=context_ok, + context_timeout=context_timeout, + context_client_error=context_client_error, + context_enter_raises=context_enter_raises, + ) + + +@pytest.fixture +def metrics_worker_status_context(): + """Patch ``Metrics.http``, disk usage, and optionally ``asyncio.sleep`` for ``__send_metrics_and_reset``.""" + + @contextmanager + def _cm(m, mock_session, *, disk_gb: float = 1.0, mock_asyncio_sleep: bool = False): + with patch.object(m, "http", new_callable=AsyncMock, return_value=mock_session): + with patch( + "vastai.serverless.server.lib.data_types.SystemMetrics.get_disk_usage_GB", + return_value=disk_gb, + ): + if mock_asyncio_sleep: + with patch( + "vastai.serverless.server.lib.metrics.asyncio.sleep", + new_callable=AsyncMock, + ): + yield + else: + yield + + return _cm + + +@pytest.fixture +def metrics_delete_send_context(): + """Patch ``Metrics.http`` and ``asyncio.sleep`` for ``__send_delete_requests_and_reset``.""" + + @contextmanager + def _cm(m, mock_session): + with patch.object(m, "http", new_callable=AsyncMock, return_value=mock_session): + with patch( + "vastai.serverless.server.lib.metrics.asyncio.sleep", + new_callable=AsyncMock, + ): + yield + + return _cm + + +@pytest.fixture +def patch_pyworker_metrics_loop(): + """Patch ``time``, ``_Metrics__send_metrics_and_reset``, and ``metrics.sleep`` for ``_send_metrics_loop`` tests.""" + + @contextmanager + def _cm(m, mock_send, *, time_return: float): + with patch("vastai.serverless.server.lib.metrics.time") as mock_time: + mock_time.time.return_value = time_return + with patch.object(m, "_Metrics__send_metrics_and_reset", mock_send): + with patch( + "vastai.serverless.server.lib.metrics.sleep", + new_callable=AsyncMock, + ): + yield mock_time + + return _cm + + +@pytest.fixture +def make_metrics_client_session_instance(): + """Factory: MagicMock standing in for ``aiohttp.ClientSession`` in metrics ``http()`` tests.""" + + def _make(*, close_async: bool = False): + inst = MagicMock() + if close_async: + inst.close = AsyncMock() + return inst + + return _make + + +# --------------------------------------------------------------------------- +# Serverless client (Serverless, Endpoint, Session) fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def client() -> Serverless: + """Serverless client with a fixed test API key (no aiohttp session).""" + return Serverless(api_key="k") + + +@pytest.fixture +def client_with_session(client: Serverless) -> Serverless: + """Serverless client with a mocked open aiohttp session on ``_session``.""" + _attach_mock_aiohttp_session(client) + return client + + +@pytest.fixture +def serverless_master_client() -> Serverless: + """Serverless client using api_key ``master`` (autoscaler POST payload tests).""" + return Serverless(api_key="master") + + +@pytest.fixture +def make_serverless_endpoint(): + """Build an :class:`Endpoint` bound to a :class:`Serverless` client.""" + + def _make( + sl_client: Serverless, + *, + name: str = "myep", + endpoint_id: int = 5, + api_key: str = "ekey", + ) -> Endpoint: + return Endpoint(sl_client, name, endpoint_id, api_key) + + return _make + + +@pytest.fixture +def make_serverless_bound_session(make_serverless_endpoint): + """Factory: :class:`Session` on a real :class:`Endpoint` (queue / Serverless client tests).""" + + def _make( + sl_client: Serverless, + *, + endpoint: Endpoint | None = None, + session_id: str = "sid", + lifetime: float = 60.0, + expiration: str = "e", + url: str = "https://worker/u", + auth_data: dict | None = None, + **kwargs, + ) -> Session: + ep = endpoint if endpoint is not None else make_serverless_endpoint(sl_client) + ad = {"token": "t"} if auth_data is None else auth_data + return Session(ep, session_id, lifetime, expiration, url, ad, **kwargs) + + return _make + + +@pytest.fixture +def mock_serverless_client(): + """Minimal mock Serverless-like client for Endpoint delegation tests.""" + c = MagicMock() + c.is_open = MagicMock(return_value=True) + c.autoscaler_url = "https://run.vast.ai" + c.queue_endpoint_request = MagicMock(return_value="queued") + c.end_endpoint_session = AsyncMock(return_value=None) + c.get_endpoint_session = AsyncMock(return_value=MagicMock()) + c.start_endpoint_session = AsyncMock(return_value="started") + c.get_endpoint_workers = AsyncMock(return_value=[]) + c._get_session = AsyncMock(return_value=MagicMock()) + return c + + +@pytest.fixture +def make_delegate_endpoint(mock_serverless_client): + """Factory: :class:`Endpoint` bound to ``mock_serverless_client`` (delegation / session tests). + + This is the single factory for a real :class:`Endpoint` instance backed by the shared + minimal mock client. Use :func:`make_serverless_endpoint` for endpoints on a real + :class:`Serverless` client. + """ + + def _make( + *, + name: str = "e", + endpoint_id: int | None = 1, + api_key: str = "ek", + client: Any | None = None, + ) -> Endpoint: + c = mock_serverless_client if client is None else client + return Endpoint(c, name, endpoint_id, api_key) + + return _make + + +@pytest.fixture +def make_mock_endpoint_for_session(): + """Factory: new MagicMock endpoint with session_healthcheck, close_session, request.""" + + def _make() -> MagicMock: + ep = MagicMock() + ep.session_healthcheck = AsyncMock(return_value=True) + ep.close_session = AsyncMock(return_value=None) + ep.request = AsyncMock(return_value={"status": 200, "body": "ok"}) + return ep + + return _make + + +@pytest.fixture +def make_route_response_mock(): + """Single factory for autoscaler route polling mocks (WAITING / READY).""" + + def _make( + *, + status: str = "WAITING", + url: str = "https://w/", + request_idx: int = 1, + body: dict | None = None, + ) -> MagicMock: + if status == "READY": + b = {"url": url, **(body or {})} + m = MagicMock() + m.status = "READY" + m.request_idx = request_idx + m.body = b + m.get_url = MagicMock(return_value=url) + return m + if status == "WAITING": + m = MagicMock() + m.status = "WAITING" + m.request_idx = request_idx + m.body = body if body is not None else {} + return m + raise ValueError(f"unknown status {status!r}, use WAITING or READY") + + return _make + + +@pytest.fixture +def make_completed_serverless_request(): + """Factory: build a resolved :class:`ServerlessRequest` (call from async tests only). + + Pass either ``result=`` for ``set_result`` or ``exception=`` for ``set_exception``. + """ + + def _make( + *, + result: dict | None = None, + exception: BaseException | None = None, + ) -> ServerlessRequest: + if (result is None) == (exception is None): + raise ValueError("Exactly one of result= or exception= must be given") + req = ServerlessRequest() + if exception is not None: + req.set_exception(exception) + else: + req.set_result(result) + return req + + return _make + + +@pytest.fixture +def make_session_mock(): + """Factory: ``MagicMock(spec=Session)`` stub (not a real :class:`Session` instance). + + For a real :class:`Session`, use ``make_client_session`` (mock endpoint), + ``make_session`` / ``sample_session`` (delegate :class:`Endpoint`), or + ``make_serverless_bound_session`` (real :class:`Serverless` client). + """ + + def _make( + *, + session_id: int = 1, + url: str | None = "https://worker/u", + auth_data: dict | None = None, + open_: bool = True, + ) -> MagicMock: + m = MagicMock(spec=Session) + m.session_id = session_id + m.url = url + m.auth_data = {} if auth_data is None else auth_data + m.open = open_ + return m + + return _make + + +@pytest.fixture +def default_start_endpoint_session_ep(client, make_serverless_endpoint): + """Shared :class:`Endpoint` for ``start_endpoint_session`` tests.""" + return make_serverless_endpoint(client, name="ep", endpoint_id=3, api_key="ek") + + +@pytest.fixture +def make_test_endpoint(client, make_serverless_endpoint): + """Factory for test :class:`Endpoint` instances. + + Do not depend on ``client_with_session``: that fixture mutates the shared + ``client`` in place. ``open_session=True`` uses a **new** ``Serverless`` with + its own mock aiohttp session so ``open_session=False`` keeps ``client`` without + ``_session`` (unless another fixture or test attaches one). + """ + + def _make( + *, + open_session: bool = False, + name: str = "ep", + endpoint_id: int = 1, + api_key: str = "ek", + ) -> Endpoint: + if open_session: + sl = Serverless(api_key="k") + _attach_mock_aiohttp_session(sl) + else: + sl = client + return make_serverless_endpoint( + sl, name=name, endpoint_id=endpoint_id, api_key=api_key + ) + + return _make + + +@pytest.fixture +def patch_serverless_queue_async_stubs(): + """Patch ``asyncio.sleep`` and ``random.uniform`` on the serverless client module. + + Queue/routing tests use instant sleep and fixed jitter so they stay fast and + deterministic. Does not apply when a test replaces ``sleep`` with a custom + ``side_effect`` (e.g. cancellation). + """ + with ( + patch( + "vastai.serverless.client.client.asyncio.sleep", + new_callable=AsyncMock, + ), + patch( + "vastai.serverless.client.client.random.uniform", + return_value=0.1, + ), + ): + yield + + +@pytest.fixture +def make_client_session(make_mock_endpoint_for_session): + """Factory: build Session with default mock endpoint and typical test defaults.""" + + def _make( + endpoint=None, + *, + session_id: str = "sess-1", + lifetime: float = 60.0, + expiration: str = "2099-01-01T00:00:00Z", + url: str = "https://worker.example/session", + auth_data: dict | None = None, + **kwargs, + ): + ep = endpoint if endpoint is not None else make_mock_endpoint_for_session() + ad = auth_data if auth_data is not None else {"token": "t"} + return Session( + endpoint=ep, + session_id=session_id, + lifetime=lifetime, + expiration=expiration, + url=url, + auth_data=ad, + **kwargs, + ) + + return _make + + +@pytest.fixture +def session_on_mock_endpoint(make_mock_endpoint_for_session, make_client_session): + """Single mock endpoint and :class:`Session` bound to it (configure ``ep`` attrs in tests as needed).""" + ep = make_mock_endpoint_for_session() + return ep, make_client_session(endpoint=ep) + + +# --------------------------------------------------------------------------- +# Client Session on delegate Endpoint (test_client_session.py) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def sample_endpoint(make_delegate_endpoint): + """:class:`Endpoint` on ``mock_serverless_client`` with ``test_client_session`` defaults.""" + return make_delegate_endpoint( + name="test-endpoint", endpoint_id=1, api_key="ep-api-key" + ) + + +@pytest.fixture +def make_session(make_delegate_endpoint): + """Factory: :class:`Session` bound to a delegate :class:`Endpoint` (real type, mock client).""" + + def _make( + endpoint=None, + session_id="sess-123", + lifetime=60.0, + expiration="2026-12-31T00:00:00Z", + url="https://worker1.vast.ai", + auth_data=None, + on_close_route=None, + on_close_payload=None, + ): + ep = ( + endpoint + if endpoint is not None + else make_delegate_endpoint( + name="test-endpoint", endpoint_id=1, api_key="ep-api-key" + ) + ) + if auth_data is None: + auth_data = {"url": "https://worker1.vast.ai", "signature": "abc"} + return Session( + endpoint=ep, + session_id=session_id, + lifetime=lifetime, + expiration=expiration, + url=url, + auth_data=auth_data, + on_close_route=on_close_route, + on_close_payload=on_close_payload, + ) + + return _make + + +@pytest.fixture +def sample_session(make_session): + """A ready-to-use :class:`Session` on the default delegate endpoint.""" + return make_session() + + +# --------------------------------------------------------------------------- +# Serverless client SSL test helpers (test_client_ssl.py) +# --------------------------------------------------------------------------- + + +_NOW_SSL = datetime.datetime.now(datetime.UTC) + + +def _serverless_ssl_generate_key(): + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +def _serverless_ssl_dummy_cert_pem() -> bytes: + """Self-signed PEM used only to satisfy ``load_verify_locations`` in SSL tests.""" + key = _serverless_ssl_generate_key() + subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "test")]) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(_NOW_SSL) + .not_valid_after(_NOW_SSL + datetime.timedelta(days=1)) + .sign(key, hashes.SHA256()) + ) + return cert.public_bytes(serialization.Encoding.PEM) + + +def serverless_ssl_build_ca_chain_without_key_cert_sign() -> tuple[bytes, bytes, bytes]: + """CA + leaf PEM bytes mimicking a CA without keyCertSign (see test_client_ssl docstring).""" + ca_key = _serverless_ssl_generate_key() + ca_name = x509.Name( + [x509.NameAttribute(NameOID.COMMON_NAME, "Test CA (no keyCertSign)")] + ) + ca_cert = ( + x509.CertificateBuilder() + .subject_name(ca_name) + .issuer_name(ca_name) + .public_key(ca_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(_NOW_SSL) + .not_valid_after(_NOW_SSL + datetime.timedelta(days=1)) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), + critical=False, + ) + .sign(ca_key, hashes.SHA256()) + ) + leaf_key = _serverless_ssl_generate_key() + leaf_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]) + leaf_cert = ( + x509.CertificateBuilder() + .subject_name(leaf_name) + .issuer_name(ca_name) + .public_key(leaf_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(_NOW_SSL) + .not_valid_after(_NOW_SSL + datetime.timedelta(days=1)) + .add_extension( + x509.SubjectAlternativeName([x509.DNSName("localhost")]), + critical=False, + ) + .add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), + critical=False, + ) + .sign(ca_key, hashes.SHA256()) + ) + ca_pem = ca_cert.public_bytes(serialization.Encoding.PEM) + leaf_pem = leaf_cert.public_bytes(serialization.Encoding.PEM) + leaf_key_pem = leaf_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + ) + return ca_pem, leaf_pem, leaf_key_pem + + +def patch_serverless_client_cert_download(cert_pem: bytes): + """Patch ``aiohttp.ClientSession`` so the trust-store GET returns ``cert_pem``.""" + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.read = AsyncMock(return_value=cert_pem) + + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_resp) + mock_session_ctx.__aexit__ = AsyncMock(return_value=False) + + mock_session = AsyncMock() + mock_session.get = MagicMock(return_value=mock_session_ctx) + + mock_session_outer = AsyncMock() + mock_session_outer.__aenter__ = AsyncMock(return_value=mock_session) + mock_session_outer.__aexit__ = AsyncMock(return_value=False) + + return patch( + "vastai.serverless.client.client.aiohttp.ClientSession", + return_value=mock_session_outer, + ) + + +@pytest.fixture +def serverless_ssl_self_signed_cert_pem(): + """Dummy PEM leaf for ``get_ssl_context`` tests.""" + return _serverless_ssl_dummy_cert_pem() + + +@pytest.fixture +def serverless_ssl_ca_chain_without_key_cert_sign(): + """``(ca_pem, leaf_pem, leaf_key_pem)`` for OpenSSL strict-flag regression tests.""" + return serverless_ssl_build_ca_chain_without_key_cert_sign() + + +@pytest.fixture +def patch_serverless_ssl_cert_download(): + """Callable ``(cert_pem: bytes)`` returning an active ``patch`` for cert download.""" + return patch_serverless_client_cert_download + + +# --------------------------------------------------------------------------- +# CLI fixtures (vast-cli specific) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def cli_parser(): + """Import all command modules and return the fully-populated parser.""" + from vastai.cli.main import parser + from vastai.cli.commands import ( # noqa: F401 + instances, offers, machines, teams, keys, endpoints, + billing, storage, clusters, auth, misc, deployments, + benchmarks, + price_increase, + ) + from vastai.cli.util import server_url_default, api_key_guard + parser.add_argument("--url", help="Server REST API URL", default=server_url_default) + parser.add_argument("--retry", help="Retry limit", default=3) + parser.add_argument("--explain", action="store_true", help="Verbose") + parser.add_argument("--raw", action="store_true", help="Raw json") + parser.add_argument("--full", action="store_true", help="Full output") + parser.add_argument("--curl", action="store_true", help="Curl equiv") + parser.add_argument("--api-key", help="API Key", type=str, required=False, default=api_key_guard) + parser.add_argument("--no-color", action="store_true", help="Disable color") + return parser + + +@pytest.fixture +def parse_argv(cli_parser): + """Return a callable that parses an argv list into an args namespace.""" + def _parse(argv): + args = cli_parser.parse_args(argv) + # Resolve api_key_guard to None for tests + from vastai.cli.util import api_key_guard + if args.api_key is api_key_guard: + args.api_key = "test-api-key" + if not hasattr(args, 'url'): + args.url = "https://console.vast.ai" + if not hasattr(args, 'retry'): + args.retry = 3 + if not hasattr(args, 'explain'): + args.explain = False + if not hasattr(args, 'raw'): + args.raw = False + if not hasattr(args, 'full'): + args.full = False + if not hasattr(args, 'curl'): + args.curl = False + if not hasattr(args, 'no_color'): + args.no_color = False + if not hasattr(args, 'quiet'): + args.quiet = False + if not hasattr(args, 'yes'): + args.yes = False + return args + return _parse + + +# --------------------------------------------------------------------------- +# Mock HTTP response factory (CLI tests) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_response(): + """Factory for mock requests.Response objects.""" + def _make(status_code=200, json_data=None, headers=None): + resp = MagicMock() + resp.status_code = status_code + resp.json.return_value = json_data if json_data is not None else {} + resp.headers = headers or {"Content-Type": "application/json"} + if 400 <= status_code < 600: + from requests.exceptions import HTTPError + resp.raise_for_status.side_effect = HTTPError(response=resp) + else: + resp.raise_for_status.return_value = None + return resp + return _make + + +# --------------------------------------------------------------------------- +# Server Worker test helpers (mocks for generate_client_response etc.) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def make_mock_web_request(): + """Factory: create a mock object for aiohttp web.Request in handler tests.""" + def _make(spec_request: bool = False): + if spec_request: + from aiohttp import web + return MagicMock(spec=web.Request) + return MagicMock() + return _make + + +@pytest.fixture +def make_mock_model_response(): + """Factory: create a mock model response for generate_client_response tests. + + Returns a callable that accepts content_type, body, status, and optional + stream_chunks. If stream_chunks is provided, content.iter_any is an async + generator yielding those chunks; otherwise read() returns body. + """ + def _make( + content_type: str = "application/json", + body: bytes | None = None, + status: int = 200, + stream_chunks: list[bytes] | None = None, + ): + mock = MagicMock() + mock.content_type = content_type + mock.status = status + mock.headers = MagicMock() + mock.headers.get = MagicMock(return_value=None) + if stream_chunks is not None: + async def _iter(): + for c in stream_chunks: + yield c + mock.content.iter_any = _iter + else: + mock.read = AsyncMock(return_value=body or b"") + return mock + return _make + + +# --------------------------------------------------------------------------- +# Mock VastClient (CLI tests) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_client(mock_response): + """A MagicMock VastClient whose get/post/put/delete return 200 by default.""" + client = MagicMock() + default_resp = mock_response(200, {}) + client.get.return_value = default_resp + client.post.return_value = default_resp + client.put.return_value = default_resp + client.delete.return_value = default_resp + client.api_key = "test-api-key" + client.server_url = "https://console.vast.ai" + client.retry = 3 + client.explain = False + client.curl = False + return client + + +# --------------------------------------------------------------------------- +# Patch get_client across all CLI command modules +# --------------------------------------------------------------------------- + +COMMAND_MODULES = [ + "vastai.cli.commands.billing", + "vastai.cli.commands.auth", + "vastai.cli.commands.offers", + "vastai.cli.commands.instances", + "vastai.cli.commands.machines", + "vastai.cli.commands.keys", + "vastai.cli.commands.endpoints", + "vastai.cli.commands.storage", + "vastai.cli.commands.teams", + "vastai.cli.commands.clusters", + "vastai.cli.commands.misc", + "vastai.cli.commands.deployments", + "vastai.cli.commands.benchmarks", + "vastai.cli.commands.price_increase", +] + + +@pytest.fixture +def patch_get_client(mock_client): + """Patch get_client in all command modules to return mock_client.""" + patches = [] + for mod in COMMAND_MODULES: + p = patch(f"{mod}.get_client", return_value=mock_client) + patches.append(p) + p.start() + yield mock_client + for p in patches: + p.stop() + + +# --------------------------------------------------------------------------- +# Live test fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def api_key(): + """Read VAST_API_KEY from environment; skip if missing.""" + key = os.environ.get("VAST_API_KEY") + if not key: + pytest.skip("VAST_API_KEY not set") + return key + + +@pytest.fixture(scope="session") +def live_client(api_key): + """Real VastClient for live tests.""" + from vastai.api.client import VastClient + return VastClient(api_key=api_key) diff --git a/tests/live/__init__.py b/tests/live/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/live/test_live_env_var_cycle.py b/tests/live/test_live_env_var_cycle.py new file mode 100644 index 00000000..5b7430b4 --- /dev/null +++ b/tests/live/test_live_env_var_cycle.py @@ -0,0 +1,50 @@ +"""Live env-var CRUD lifecycle test — requires VAST_API_KEY. + +Creates a unique env var, reads it, updates it, and deletes it. +Deletion happens in a finally block for guaranteed cleanup. + +Note: The API masks env var values on read (returns '********'), +so we can only verify that the key exists, not the plaintext value. +""" + +import uuid +import pytest + +pytestmark = pytest.mark.live + + +class TestEnvVarLifecycle: + def test_create_read_update_delete(self, live_client): + from vastai.api.auth import create_env_var, show_env_vars, update_env_var, delete_env_var + + var_name = f"VASTTEST_{uuid.uuid4().hex[:12].upper()}" + original_value = "test_value_1" + updated_value = "test_value_2" + + try: + # Create + result = create_env_var(live_client, name=var_name, value=original_value) + assert result.get("success") is True, f"Failed to create env var: {result}" + + # Read — API masks values, so just verify the key exists + env_vars = show_env_vars(live_client) + assert var_name in env_vars, f"Env var {var_name} not found after creation" + + # Update + result = update_env_var(live_client, name=var_name, value=updated_value) + assert result.get("success") is True, f"Failed to update env var: {result}" + + # Read — verify key still exists after update + env_vars = show_env_vars(live_client) + assert var_name in env_vars, f"Env var {var_name} not found after update" + + finally: + # Delete (guaranteed cleanup) + try: + delete_env_var(live_client, name=var_name) + except Exception: + pass + + # Verify deletion + env_vars = show_env_vars(live_client) + assert var_name not in env_vars, f"Env var {var_name} still exists after deletion" diff --git a/tests/live/test_live_readonly.py b/tests/live/test_live_readonly.py new file mode 100644 index 00000000..2a4b964b --- /dev/null +++ b/tests/live/test_live_readonly.py @@ -0,0 +1,124 @@ +"""Live read-only API tests — requires VAST_API_KEY environment variable. + +These tests call the real Vast.ai API but only perform read operations. +They verify that the API functions return data in the expected shape. +""" + +import pytest + +pytestmark = pytest.mark.live + + +class TestBillingReadonly: + def test_show_user(self, live_client): + from vastai.api.billing import show_user + result = show_user(live_client) + assert isinstance(result, dict) + assert "email" in result + assert "id" in result + assert "api_key" not in result + + def test_show_invoices(self, live_client): + from vastai.api.billing import show_invoices + result = show_invoices(live_client) + assert isinstance(result, dict) + assert "invoices" in result + assert "current" in result + + def test_show_subaccounts(self, live_client): + from vastai.api.billing import show_subaccounts + from requests.exceptions import HTTPError + try: + result = show_subaccounts(live_client) + assert isinstance(result, list) + except HTTPError as e: + if e.response.status_code == 400: + pytest.skip("Account not approved for subaccount APIs") + raise + + def test_show_ipaddrs(self, live_client): + from vastai.api.billing import show_ipaddrs + result = show_ipaddrs(live_client) + assert isinstance(result, list) + + +class TestAuthReadonly: + def test_show_audit_logs(self, live_client): + from vastai.api.auth import show_audit_logs + result = show_audit_logs(live_client) + assert isinstance(result, list) + + def test_show_env_vars(self, live_client): + from vastai.api.auth import show_env_vars + result = show_env_vars(live_client) + assert isinstance(result, dict) + + def test_show_scheduled_jobs(self, live_client): + from vastai.api.auth import show_scheduled_jobs + result = show_scheduled_jobs(live_client) + assert isinstance(result, list) + + def test_tfa_status(self, live_client): + from vastai.api.auth import tfa_status + result = tfa_status(live_client) + assert isinstance(result, dict) + assert "tfa_enabled" in result + + +class TestOffersReadonly: + def test_search_offers(self, live_client): + from vastai.api.offers import search_offers + result = search_offers(live_client, limit=3) + assert isinstance(result, list) + if result: + assert "gpu_name" in result[0] + + def test_search_templates(self, live_client): + from vastai.api.offers import search_templates + result = search_templates(live_client) + assert isinstance(result, list) + + +class TestInstancesReadonly: + def test_show_instances(self, live_client): + from vastai.api.instances import show_instances + result = show_instances(live_client) + assert isinstance(result, list) + + +class TestKeysReadonly: + def test_show_ssh_keys(self, live_client): + from vastai.api.keys import show_ssh_keys + result = show_ssh_keys(live_client) + assert result is not None + + def test_show_api_keys(self, live_client): + from vastai.api.keys import show_api_keys + result = show_api_keys(live_client) + assert result is not None + + +class TestMachinesReadonly: + def test_show_machines(self, live_client): + from vastai.api.machines import show_machines + result = show_machines(live_client) + assert isinstance(result, list) + + +class TestEndpointsReadonly: + def test_show_endpoints(self, live_client): + from vastai.api.endpoints import show_endpoints + result = show_endpoints(live_client) + assert result is not None + + +class TestStorageReadonly: + def test_show_volumes(self, live_client): + from vastai.api.storage import show_volumes + result = show_volumes(live_client) + assert isinstance(result, list) + + def test_show_connections(self, live_client): + from vastai.api.storage import show_connections + result = show_connections(live_client) + assert result is not None diff --git a/tests/live/test_live_ssh_key_cycle.py b/tests/live/test_live_ssh_key_cycle.py new file mode 100644 index 00000000..0e9e407b --- /dev/null +++ b/tests/live/test_live_ssh_key_cycle.py @@ -0,0 +1,73 @@ +"""Live SSH key CRUD lifecycle test — requires VAST_API_KEY. + +Creates a test SSH key, reads it, and deletes it. +Deletion happens in a finally block for guaranteed cleanup. +""" + +import pytest + +pytestmark = pytest.mark.live + + +def _generate_test_ssh_key() -> str: + """Generate a valid ephemeral ed25519 public key for testing.""" + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + key = Ed25519PrivateKey.generate() + pub = key.public_key().public_bytes(Encoding.OpenSSH, PublicFormat.OpenSSH).decode() + return f"{pub} vasttest@test" + + +class TestSshKeyLifecycle: + def test_create_read_delete(self, live_client): + from vastai.api.keys import create_ssh_key, show_ssh_keys, delete_ssh_key + + test_key = _generate_test_ssh_key() + created_key_id = None + try: + # Create + result = create_ssh_key(live_client, ssh_key=test_key) + assert isinstance(result, dict), f"Unexpected response: {result}" + assert result.get("success") is True, f"Failed to create SSH key: {result}" + created_key_id = result.get("key", {}).get("id") + assert created_key_id is not None, f"No key ID in response: {result}" + + # Read and verify it's in the list + keys = show_ssh_keys(live_client) + assert keys is not None, "Failed to read SSH keys" + + if isinstance(keys, dict) and "ssh_keys" in keys: + key_list = keys["ssh_keys"] + elif isinstance(keys, list): + key_list = keys + else: + key_list = [] + + found = any( + isinstance(k, dict) and k.get("id") == created_key_id + for k in key_list + ) + assert found, f"SSH key {created_key_id} not found in key list" + + finally: + # Delete (guaranteed cleanup) + if created_key_id is not None: + try: + delete_ssh_key(live_client, id=created_key_id) + except Exception: + pass + + # Verify deletion + if created_key_id is not None: + keys_after = show_ssh_keys(live_client) + if isinstance(keys_after, dict) and "ssh_keys" in keys_after: + key_list = keys_after["ssh_keys"] + elif isinstance(keys_after, list): + key_list = keys_after + else: + key_list = [] + found = any( + isinstance(k, dict) and k.get("id") == created_key_id + for k in key_list + ) + assert not found, f"SSH key {created_key_id} still exists after deletion" diff --git a/tests/pip_install/README.md b/tests/pip_install/README.md new file mode 100644 index 00000000..70835f3f --- /dev/null +++ b/tests/pip_install/README.md @@ -0,0 +1,35 @@ +# pip install integration tests + +Verifies that `pip install vastai` and `pip install vastai-sdk` produce identical +behavior across all install/uninstall scenarios. + +## Scenarios tested + +1. **`pip install vastai` only** — `import vastai` and `import vastai_sdk` both work, CLI present +2. **`pip install vastai` + `pip install vastai-sdk`** — identical to scenario 1 +3. **Both installed, uninstall `vastai-sdk`** — everything still works (no file collision) +4. **Both installed, uninstall `vastai`** — both imports correctly break +5. **Only `vastai`, then uninstall** — clean removal, no leftover files +6. **File ownership** — both `vastai` and `vastai-sdk` claim `vastai_sdk/` files (expected, since the files are identical) + +## Running + +```bash +# 1. Build both wheels from the repo root +pip install build poetry-core +python3 -m build --wheel +cd sdk-wrapper && python3 -m build --wheel && cd .. + +# 2. Run the tests +bash tests/pip_install/test_install_scenarios.sh +``` + +You can also override wheel paths: + +```bash +VASTAI_WHL=/path/to/vastai.whl SDK_WHL=/path/to/vastai_sdk.whl \ + bash tests/pip_install/test_install_scenarios.sh +``` + +Each scenario creates an isolated venv under `/tmp/vastai-pip-test-*`, which are +cleaned up automatically on exit. diff --git a/tests/pip_install/test_install_scenarios.sh b/tests/pip_install/test_install_scenarios.sh new file mode 100755 index 00000000..8bdaab96 --- /dev/null +++ b/tests/pip_install/test_install_scenarios.sh @@ -0,0 +1,170 @@ +#!/bin/bash +# +# Integration tests for verifying that `pip install vastai` and `pip install vastai-sdk` +# produce identical behavior, and that install/uninstall scenarios are clean. +# +# Prerequisites: +# - Python 3.9+ available as `python3` +# - Built wheels placed in the locations below (or override via env vars) +# +# Usage: +# # 1. Build both wheels from the repo root: +# python3 -m build --wheel # builds dist/vastai-*.whl +# cd sdk-wrapper && python3 -m build --wheel && cd .. # builds sdk-wrapper/dist/vastai_sdk-*.whl +# +# # 2. Run the tests: +# bash tests/pip_install/test_install_scenarios.sh +# +# # Or override wheel paths: +# VASTAI_WHL=/path/to/vastai.whl SDK_WHL=/path/to/vastai_sdk.whl bash tests/pip_install/test_install_scenarios.sh + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +VASTAI_WHL="${VASTAI_WHL:-$(ls "$REPO_ROOT"/dist/vastai-*.whl 2>/dev/null | head -1)}" +SDK_WHL="${SDK_WHL:-$(ls "$REPO_ROOT"/sdk-wrapper/dist/vastai_sdk-*.whl 2>/dev/null | head -1)}" + +if [ -z "$VASTAI_WHL" ] || [ ! -f "$VASTAI_WHL" ]; then + echo "ERROR: vastai wheel not found. Build it first: python3 -m build --wheel" + exit 1 +fi +if [ -z "$SDK_WHL" ] || [ ! -f "$SDK_WHL" ]; then + echo "ERROR: vastai-sdk wheel not found. Build it first: cd sdk-wrapper && python3 -m build --wheel" + exit 1 +fi + +echo "Using wheels:" +echo " vastai: $VASTAI_WHL" +echo " vastai-sdk: $SDK_WHL" +echo "" + +PASS=0 +FAIL=0 +ENVNUM=0 + +check() { + local desc="$1"; local cmd="$2"; local expect="$3" + if eval "$cmd" > /dev/null 2>&1; then result="ok"; else result="fail"; fi + if [ "$result" = "$expect" ]; then + echo " PASS: $desc"; PASS=$((PASS + 1)) + else + echo " FAIL: $desc (expected=$expect got=$result)"; FAIL=$((FAIL + 1)) + fi +} + +new_env() { + ENVNUM=$((ENVNUM + 1)) + local envdir="/tmp/vastai-pip-test-$ENVNUM" + rm -rf "$envdir" + python3 -m venv "$envdir" + source "$envdir/bin/activate" +} + +cleanup() { + for i in $(seq 1 $ENVNUM); do + rm -rf "/tmp/vastai-pip-test-$i" + done +} +trap cleanup EXIT + +echo "========================================" +echo "SCENARIO 1: pip install vastai only" +echo "========================================" +new_env +pip install "$VASTAI_WHL" > /dev/null 2>&1 +check "import vastai" "python3 -c 'import vastai'" "ok" +check "import vastai_sdk" "python3 -c 'import vastai_sdk'" "ok" +check "from vastai import VastAI" "python3 -c 'from vastai import VastAI'" "ok" +check "from vastai_sdk import VastAI" "python3 -c 'from vastai_sdk import VastAI'" "ok" +check "CLI entry point" "python3 -c 'from vastai.cli.main import main'" "ok" +check "vastai_sdk is vastai" "python3 -c 'import vastai, vastai_sdk; assert vastai.VastAI is vastai_sdk.VastAI'" "ok" +deactivate +echo "" + +echo "========================================" +echo "SCENARIO 2: pip install vastai + vastai-sdk" +echo "========================================" +new_env +pip install "$VASTAI_WHL" > /dev/null 2>&1 +pip install --no-deps "$SDK_WHL" > /dev/null 2>&1 +check "import vastai" "python3 -c 'import vastai'" "ok" +check "import vastai_sdk" "python3 -c 'import vastai_sdk'" "ok" +check "from vastai import VastAI" "python3 -c 'from vastai import VastAI'" "ok" +check "from vastai_sdk import VastAI" "python3 -c 'from vastai_sdk import VastAI'" "ok" +check "CLI entry point" "python3 -c 'from vastai.cli.main import main'" "ok" +check "vastai_sdk is vastai" "python3 -c 'import vastai, vastai_sdk; assert vastai.VastAI is vastai_sdk.VastAI'" "ok" +deactivate +echo "" + +echo "========================================" +echo "SCENARIO 3: Both installed, uninstall" +echo "vastai-sdk — everything still works" +echo "========================================" +new_env +pip install "$VASTAI_WHL" > /dev/null 2>&1 +pip install --no-deps "$SDK_WHL" > /dev/null 2>&1 +pip uninstall -y vastai-sdk > /dev/null 2>&1 +check "vastai still installed" "pip show vastai" "ok" +check "vastai-sdk removed" "pip show vastai-sdk" "fail" +check "import vastai" "python3 -c 'import vastai'" "ok" +check "import vastai_sdk still works" "python3 -c 'import vastai_sdk'" "ok" +check "from vastai_sdk import VastAI" "python3 -c 'from vastai_sdk import VastAI'" "ok" +check "CLI entry point" "python3 -c 'from vastai.cli.main import main'" "ok" +deactivate +echo "" + +echo "========================================" +echo "SCENARIO 4: Both installed, uninstall" +echo "vastai — imports break as expected" +echo "========================================" +new_env +pip install "$VASTAI_WHL" > /dev/null 2>&1 +pip install --no-deps "$SDK_WHL" > /dev/null 2>&1 +pip uninstall -y vastai > /dev/null 2>&1 +check "vastai removed" "pip show vastai" "fail" +check "vastai-sdk still listed" "pip show vastai-sdk" "ok" +check "import vastai fails" "python3 -c 'import vastai'" "fail" +check "import vastai_sdk fails" "python3 -c 'import vastai_sdk'" "fail" +deactivate +echo "" + +echo "========================================" +echo "SCENARIO 5: Only vastai, uninstall — clean" +echo "========================================" +new_env +pip install "$VASTAI_WHL" > /dev/null 2>&1 +pip uninstall -y vastai > /dev/null 2>&1 +check "vastai removed" "pip show vastai" "fail" +check "import vastai fails" "python3 -c 'import vastai'" "fail" +check "import vastai_sdk fails" "python3 -c 'import vastai_sdk'" "fail" +deactivate +echo "" + +echo "========================================" +echo "SCENARIO 6: File ownership — both packages" +echo "claim vastai_sdk/ (expected, files identical)" +echo "========================================" +new_env +pip install "$VASTAI_WHL" > /dev/null 2>&1 +pip install --no-deps "$SDK_WHL" > /dev/null 2>&1 +VASTAI_HAS=$(pip show -f vastai 2>/dev/null | grep "vastai_sdk/" || true) +SDK_HAS=$(pip show -f vastai-sdk 2>/dev/null | grep "vastai_sdk/" || true) +if [ -n "$VASTAI_HAS" ]; then + echo " PASS: vastai claims vastai_sdk/ — expected" + PASS=$((PASS + 1)) +else + echo " FAIL: vastai does NOT claim vastai_sdk/" + FAIL=$((FAIL + 1)) +fi +if [ -n "$SDK_HAS" ]; then + echo " INFO: vastai-sdk also claims vastai_sdk/ — acceptable (identical files)" +else + echo " INFO: vastai-sdk does not claim vastai_sdk/" +fi +deactivate +echo "" + +echo "========================================" +echo "RESULTS: $PASS passed, $FAIL failed" +echo "========================================" +[ "$FAIL" -eq 0 ] && exit 0 || exit 1 diff --git a/tests/poetry.lock b/tests/poetry.lock new file mode 100644 index 00000000..bfaa6caa --- /dev/null +++ b/tests/poetry.lock @@ -0,0 +1,3704 @@ +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. + +[[package]] +name = "aiodns" +version = "3.6.1" +description = "Simple DNS resolver for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiodns-3.6.1-py3-none-any.whl", hash = "sha256:46233ccad25f2037903828c5d05b64590eaa756e51d12b4a5616e2defcbc98c7"}, + {file = "aiodns-3.6.1.tar.gz", hash = "sha256:b0e9ce98718a5b8f7ca8cd16fc393163374bc2412236b91f6c851d066e3324b6"}, +] + +[package.dependencies] +pycares = ">=4.9.0,<5" + +[[package]] +name = "aiofiles" +version = "25.1.0" +description = "File support for asyncio." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695"}, + {file = "aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2"}, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +description = "Happy Eyeballs for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, + {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7"}, + {file = "aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821"}, + {file = "aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11"}, + {file = "aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd"}, + {file = "aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c"}, + {file = "aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b"}, + {file = "aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64"}, + {file = "aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29"}, + {file = "aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239"}, + {file = "aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f"}, + {file = "aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c"}, + {file = "aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168"}, + {file = "aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a"}, + {file = "aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046"}, + {file = "aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57"}, + {file = "aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c"}, + {file = "aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9"}, + {file = "aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591"}, + {file = "aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf"}, + {file = "aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e"}, + {file = "aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808"}, + {file = "aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415"}, + {file = "aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43"}, + {file = "aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1"}, + {file = "aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984"}, + {file = "aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c"}, + {file = "aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592"}, + {file = "aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa"}, + {file = "aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767"}, + {file = "aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344"}, + {file = "aiohttp-3.13.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31a83ea4aead760dfcb6962efb1d861db48c34379f2ff72db9ddddd4cda9ea2e"}, + {file = "aiohttp-3.13.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:988a8c5e317544fdf0d39871559e67b6341065b87fceac641108c2096d5506b7"}, + {file = "aiohttp-3.13.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9b174f267b5cfb9a7dba9ee6859cecd234e9a681841eb85068059bc867fb8f02"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:947c26539750deeaee933b000fb6517cc770bbd064bad6033f1cff4803881e43"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9ebf57d09e131f5323464bd347135a88622d1c0976e88ce15b670e7ad57e4bd6"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4ae5b5a0e1926e504c81c5b84353e7a5516d8778fbbff00429fe7b05bb25cbce"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ba0eea45eb5cc3172dbfc497c066f19c41bac70963ea1a67d51fc92e4cf9a80"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bae5c2ed2eae26cc382020edad80d01f36cb8e746da40b292e68fec40421dc6a"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a60e60746623925eab7d25823329941aee7242d559baa119ca2b253c88a7bd6"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e50a2e1404f063427c9d027378472316201a2290959a295169bcf25992d04558"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:9a9dc347e5a3dc7dfdbc1f82da0ef29e388ddb2ed281bfce9dd8248a313e62b7"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b46020d11d23fe16551466c77823df9cc2f2c1e63cc965daf67fa5eec6ca1877"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:69c56fbc1993fa17043e24a546959c0178fe2b5782405ad4559e6c13975c15e3"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:b99281b0704c103d4e11e72a76f1b543d4946fea7dd10767e7e1b5f00d4e5704"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:40c5e40ecc29ba010656c18052b877a1c28f84344825efa106705e835c28530f"}, + {file = "aiohttp-3.13.3-cp39-cp39-win32.whl", hash = "sha256:56339a36b9f1fc708260c76c87e593e2afb30d26de9ae1eb445b5e051b98a7a1"}, + {file = "aiohttp-3.13.3-cp39-cp39-win_amd64.whl", hash = "sha256:c6b8568a3bb5819a0ad087f16d40e5a3fb6099f39ea1d5625a3edc1e923fc538"}, + {file = "aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88"}, +] + +[package.dependencies] +aiohappyeyeballs = ">=2.5.0" +aiosignal = ">=1.4.0" +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +propcache = ">=0.2.0" +yarl = ">=1.17.0,<2.0" + +[package.extras] +speedups = ["Brotli (>=1.2) ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "backports.zstd ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "brotlicffi (>=1.2) ; platform_python_implementation != \"CPython\""] + +[[package]] +name = "aiosignal" +version = "1.4.0" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, + {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" +typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} + +[[package]] +name = "annotated-doc" +version = "0.0.4" +description = "Document parameters, class attributes, return types, and variables inline, with Annotated." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, + {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.4.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, + {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, +] + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\""] +trio = ["trio (>=0.23)"] + +[[package]] +name = "attrs" +version = "26.1.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, + {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, +] + +[[package]] +name = "borb" +version = "2.1.25" +description = "borb is a library for reading, creating and manipulating PDF files in python." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "borb-2.1.25-py3-none-any.whl", hash = "sha256:708c6b14d298890d75567cda15027d874d818e533089d88637831e495f489088"}, + {file = "borb-2.1.25.tar.gz", hash = "sha256:813a25227b96f471d29244bf3c07a7b3df36d61d62bcbecf45b14944b8011ef4"}, +] + +[package.dependencies] +cryptography = ">=37.0.4" +fonttools = ">=4.22.1" +lxml = ">=4.9.1" +Pillow = ">=7.1.0" +python-barcode = ">=0.13.1" +qrcode = {version = ">=6.1", extras = ["pil"]} +requests = ">=2.24.0" +setuptools = ">=51.1.1" + +[[package]] +name = "certifi" +version = "2026.2.25" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, + {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, +] + +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + +[[package]] +name = "charset-normalizer" +version = "3.4.6" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-win32.whl", hash = "sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-win_amd64.whl", hash = "sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-win32.whl", hash = "sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-win_amd64.whl", hash = "sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-win_arm64.whl", hash = "sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8"}, + {file = "charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69"}, + {file = "charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6"}, +] + +[[package]] +name = "click" +version = "8.3.1" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, + {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +markers = "sys_platform == \"win32\" or platform_system == \"Windows\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.13.5" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5"}, + {file = "coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0"}, + {file = "coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58"}, + {file = "coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e"}, + {file = "coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d"}, + {file = "coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8"}, + {file = "coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf"}, + {file = "coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9"}, + {file = "coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028"}, + {file = "coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01"}, + {file = "coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c"}, + {file = "coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf"}, + {file = "coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810"}, + {file = "coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de"}, + {file = "coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1"}, + {file = "coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17"}, + {file = "coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85"}, + {file = "coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b"}, + {file = "coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664"}, + {file = "coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d"}, + {file = "coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2"}, + {file = "coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a"}, + {file = "coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819"}, + {file = "coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911"}, + {file = "coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f"}, + {file = "coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0"}, + {file = "coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc"}, + {file = "coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633"}, + {file = "coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8"}, + {file = "coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b"}, + {file = "coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a"}, + {file = "coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215"}, + {file = "coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43"}, + {file = "coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45"}, + {file = "coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61"}, + {file = "coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179"}, +] + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "cryptography" +version = "46.0.6" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.8" +groups = ["main"] +files = [ + {file = "cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8"}, + {file = "cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30"}, + {file = "cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a"}, + {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175"}, + {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463"}, + {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97"}, + {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c"}, + {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507"}, + {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19"}, + {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738"}, + {file = "cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c"}, + {file = "cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f"}, + {file = "cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2"}, + {file = "cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124"}, + {file = "cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275"}, + {file = "cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4"}, + {file = "cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b"}, + {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707"}, + {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361"}, + {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b"}, + {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca"}, + {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013"}, + {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4"}, + {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a"}, + {file = "cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d"}, + {file = "cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736"}, + {file = "cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed"}, + {file = "cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4"}, + {file = "cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a"}, + {file = "cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8"}, + {file = "cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77"}, + {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290"}, + {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410"}, + {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d"}, + {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70"}, + {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d"}, + {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa"}, + {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58"}, + {file = "cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb"}, + {file = "cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72"}, + {file = "cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c"}, + {file = "cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f"}, + {file = "cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead"}, + {file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8"}, + {file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0"}, + {file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b"}, + {file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a"}, + {file = "cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e"}, + {file = "cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox[uv] (>=2024.4.15)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==46.0.6)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "fastapi" +version = "0.135.2" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5"}, + {file = "fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56"}, +] + +[package.dependencies] +annotated-doc = ">=0.0.2" +pydantic = ">=2.9.0" +starlette = ">=0.46.0" +typing-extensions = ">=4.8.0" +typing-inspection = ">=0.4.2" + +[package.extras] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "filelock" +version = "3.25.2" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70"}, + {file = "filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694"}, +] + +[[package]] +name = "fonttools" +version = "4.62.1" +description = "Tools to manipulate font files" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c"}, + {file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a"}, + {file = "fonttools-4.62.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3"}, + {file = "fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23"}, + {file = "fonttools-4.62.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d"}, + {file = "fonttools-4.62.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae"}, + {file = "fonttools-4.62.1-cp310-cp310-win32.whl", hash = "sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed"}, + {file = "fonttools-4.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9"}, + {file = "fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7"}, + {file = "fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14"}, + {file = "fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7"}, + {file = "fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b"}, + {file = "fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1"}, + {file = "fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416"}, + {file = "fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53"}, + {file = "fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2"}, + {file = "fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974"}, + {file = "fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9"}, + {file = "fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936"}, + {file = "fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392"}, + {file = "fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04"}, + {file = "fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d"}, + {file = "fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c"}, + {file = "fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42"}, + {file = "fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79"}, + {file = "fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe"}, + {file = "fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68"}, + {file = "fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1"}, + {file = "fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069"}, + {file = "fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9"}, + {file = "fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24"}, + {file = "fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056"}, + {file = "fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca"}, + {file = "fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca"}, + {file = "fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782"}, + {file = "fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae"}, + {file = "fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7"}, + {file = "fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a"}, + {file = "fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800"}, + {file = "fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e"}, + {file = "fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82"}, + {file = "fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260"}, + {file = "fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4"}, + {file = "fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b"}, + {file = "fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87"}, + {file = "fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c"}, + {file = "fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a"}, + {file = "fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e"}, + {file = "fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd"}, + {file = "fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d"}, +] + +[package.extras] +all = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\"", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.45.0)", "unicodedata2 (>=17.0.0) ; python_version <= \"3.14\"", "xattr ; sys_platform == \"darwin\"", "zopfli (>=0.1.4)"] +graphite = ["lz4 (>=1.7.4.2)"] +interpolatable = ["munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\""] +lxml = ["lxml (>=4.0)"] +pathops = ["skia-pathops (>=0.5.0)"] +plot = ["matplotlib"] +repacker = ["uharfbuzz (>=0.45.0)"] +symfont = ["sympy"] +type1 = ["xattr ; sys_platform == \"darwin\""] +unicode = ["unicodedata2 (>=17.0.0) ; python_version <= \"3.14\""] +woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"] + +[[package]] +name = "frozenlist" +version = "1.8.0" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7"}, + {file = "frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967"}, + {file = "frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa"}, + {file = "frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed"}, + {file = "frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7"}, + {file = "frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda"}, + {file = "frozenlist-1.8.0-cp39-cp39-win32.whl", hash = "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087"}, + {file = "frozenlist-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a"}, + {file = "frozenlist-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103"}, + {file = "frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d"}, + {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"}, +] + +[[package]] +name = "fsspec" +version = "2026.3.0" +description = "File-system specification" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4"}, + {file = "fsspec-2026.3.0.tar.gz", hash = "sha256:1ee6a0e28677557f8c2f994e3eea77db6392b4de9cd1f5d7a9e87a0ae9d01b41"}, +] + +[package.extras] +abfs = ["adlfs"] +adl = ["adlfs"] +arrow = ["pyarrow (>=1)"] +dask = ["dask", "distributed"] +dev = ["pre-commit", "ruff (>=0.5)"] +doc = ["numpydoc", "sphinx", "sphinx-design", "sphinx-rtd-theme", "yarl"] +dropbox = ["dropbox", "dropboxdrivefs", "requests"] +full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "dask", "distributed", "dropbox", "dropboxdrivefs", "fusepy", "gcsfs (>2024.2.0)", "libarchive-c", "ocifs", "panel", "paramiko", "pyarrow (>=1)", "pygit2", "requests", "s3fs (>2024.2.0)", "smbprotocol", "tqdm"] +fuse = ["fusepy"] +gcs = ["gcsfs (>2024.2.0)"] +git = ["pygit2"] +github = ["requests"] +gs = ["gcsfs"] +gui = ["panel"] +hdfs = ["pyarrow (>=1)"] +http = ["aiohttp (!=4.0.0a0,!=4.0.0a1)"] +libarchive = ["libarchive-c"] +oci = ["ocifs"] +s3 = ["s3fs (>2024.2.0)"] +sftp = ["paramiko"] +smb = ["smbprotocol"] +ssh = ["paramiko"] +test = ["aiohttp (!=4.0.0a0,!=4.0.0a1)", "numpy", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "requests"] +test-downstream = ["aiobotocore (>=2.5.4,<3.0.0)", "dask[dataframe,test]", "moto[server] (>4,<5)", "pytest-timeout", "xarray"] +test-full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "backports-zstd ; python_version < \"3.14\"", "cloudpickle", "dask", "distributed", "dropbox", "dropboxdrivefs", "fastparquet", "fusepy", "gcsfs", "jinja2", "kerchunk", "libarchive-c", "lz4", "notebook", "numpy", "ocifs", "pandas (<3.0.0)", "panel", "paramiko", "pyarrow", "pyarrow (>=1)", "pyftpdlib", "pygit2", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "python-snappy", "requests", "smbprotocol", "tqdm", "urllib3", "zarr", "zstandard ; python_version < \"3.14\""] +tqdm = ["tqdm"] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "hf-transfer" +version = "0.1.9" +description = "Speed up file transfers with the Hugging Face Hub." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "hf_transfer-0.1.9-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:6e94e8822da79573c9b6ae4d6b2f847c59a7a06c5327d7db20751b68538dc4f6"}, + {file = "hf_transfer-0.1.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ebc4ab9023414880c8b1d3c38174d1c9989eb5022d37e814fa91a3060123eb0"}, + {file = "hf_transfer-0.1.9-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8674026f21ed369aa2a0a4b46000aca850fc44cd2b54af33a172ce5325b4fc82"}, + {file = "hf_transfer-0.1.9-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a736dfbb2c84f5a2c975478ad200c0c8bfcb58a25a35db402678fb87ce17fa4"}, + {file = "hf_transfer-0.1.9-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:504b8427fd785dd8546d53b9fafe6e436bd7a3adf76b9dce556507650a7b4567"}, + {file = "hf_transfer-0.1.9-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c7fc1b85f4d0f76e452765d7648c9f4bfd0aedb9ced2ae1ebfece2d8cfaf8e2"}, + {file = "hf_transfer-0.1.9-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d991376f0eac70a60f0cbc95602aa708a6f7c8617f28b4945c1431d67b8e3c8"}, + {file = "hf_transfer-0.1.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e6ac4eddcd99575ed3735ed911ddf9d1697e2bd13aa3f0ad7e3904dd4863842e"}, + {file = "hf_transfer-0.1.9-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:57fd9880da1ee0f47250f735f791fab788f0aa1ee36afc49f761349869c8b4d9"}, + {file = "hf_transfer-0.1.9-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:5d561f0520f493c66b016d99ceabe69c23289aa90be38dd802d2aef279f15751"}, + {file = "hf_transfer-0.1.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a5b366d34cd449fe9b20ef25941e6eef0460a2f74e7389f02e673e1f88ebd538"}, + {file = "hf_transfer-0.1.9-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e66acf91df4a8b72f60223059df3003062a5ae111757187ed1a06750a30e911b"}, + {file = "hf_transfer-0.1.9-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:8669dbcc7a3e2e8d61d42cd24da9c50d57770bd74b445c65123291ca842a7e7a"}, + {file = "hf_transfer-0.1.9-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8fd0167c4407a3bc4cdd0307e65ada2294ec04f1813d8a69a5243e379b22e9d8"}, + {file = "hf_transfer-0.1.9-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee8b10afedcb75f71091bcc197c526a6ebf5c58bbbadb34fdeee6160f55f619f"}, + {file = "hf_transfer-0.1.9-cp38-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5828057e313de59300dd1abb489444bc452efe3f479d3c55b31a8f680936ba42"}, + {file = "hf_transfer-0.1.9-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc6bd19e1cc177c66bdef15ef8636ad3bde79d5a4f608c158021153b4573509d"}, + {file = "hf_transfer-0.1.9-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdca9bfb89e6f8f281890cc61a8aff2d3cecaff7e1a4d275574d96ca70098557"}, + {file = "hf_transfer-0.1.9-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:89a23f58b7b7effbc047b8ca286f131b17728c99a9f972723323003ffd1bb916"}, + {file = "hf_transfer-0.1.9-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:dc7fff1345980d6c0ebb92c811d24afa4b98b3e07ed070c8e38cc91fd80478c5"}, + {file = "hf_transfer-0.1.9-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1a6bd16c667ebe89a069ca163060127a794fa3a3525292c900b8c8cc47985b0d"}, + {file = "hf_transfer-0.1.9-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d2fde99d502093ade3ab1b53f80da18480e9902aa960dab7f74fb1b9e5bc5746"}, + {file = "hf_transfer-0.1.9-cp38-abi3-win32.whl", hash = "sha256:435cc3cdc8524ce57b074032b8fd76eed70a4224d2091232fa6a8cef8fd6803e"}, + {file = "hf_transfer-0.1.9-cp38-abi3-win_amd64.whl", hash = "sha256:16f208fc678911c37e11aa7b586bc66a37d02e636208f18b6bc53d29b5df40ad"}, + {file = "hf_transfer-0.1.9.tar.gz", hash = "sha256:035572865dab29d17e783fbf1e84cf1cb24f3fcf8f1b17db1cfc7fdf139f02bf"}, +] + +[[package]] +name = "hf-xet" +version = "1.4.2" +description = "Fast transfer of large files with the Hugging Face Hub." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"arm64\" or platform_machine == \"aarch64\"" +files = [ + {file = "hf_xet-1.4.2-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ac8202ae1e664b2c15cdfc7298cbb25e80301ae596d602ef7870099a126fcad4"}, + {file = "hf_xet-1.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6d2f8ee39fa9fba9af929f8c0d0482f8ee6e209179ad14a909b6ad78ffcb7c81"}, + {file = "hf_xet-1.4.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4642a6cf249c09da8c1f87fe50b24b2a3450b235bf8adb55700b52f0ea6e2eb6"}, + {file = "hf_xet-1.4.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:769431385e746c92dc05492dde6f687d304584b89c33d79def8367ace06cb555"}, + {file = "hf_xet-1.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c9dd1c1bc4cc56168f81939b0e05b4c36dd2d28c13dc1364b17af89aa0082496"}, + {file = "hf_xet-1.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:fca58a2ae4e6f6755cc971ac6fcdf777ea9284d7e540e350bb000813b9a3008d"}, + {file = "hf_xet-1.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:163aab46854ccae0ab6a786f8edecbbfbaa38fcaa0184db6feceebf7000c93c0"}, + {file = "hf_xet-1.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:09b138422ecbe50fd0c84d4da5ff537d27d487d3607183cd10e3e53f05188e82"}, + {file = "hf_xet-1.4.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:949dcf88b484bb9d9276ca83f6599e4aa03d493c08fc168c124ad10b2e6f75d7"}, + {file = "hf_xet-1.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:41659966020d59eb9559c57de2cde8128b706a26a64c60f0531fa2318f409418"}, + {file = "hf_xet-1.4.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c588e21d80010119458dd5d02a69093f0d115d84e3467efe71ffb2c67c19146"}, + {file = "hf_xet-1.4.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a296744d771a8621ad1d50c098d7ab975d599800dae6d48528ba3944e5001ba0"}, + {file = "hf_xet-1.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f563f7efe49588b7d0629d18d36f46d1658fe7e08dce3fa3d6526e1c98315e2d"}, + {file = "hf_xet-1.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5b2e0132c56d7ee1bf55bdb638c4b62e7106f6ac74f0b786fed499d5548c5570"}, + {file = "hf_xet-1.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:2f45c712c2fa1215713db10df6ac84b49d0e1c393465440e9cb1de73ecf7bbf6"}, + {file = "hf_xet-1.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6d53df40616f7168abfccff100d232e9d460583b9d86fa4912c24845f192f2b8"}, + {file = "hf_xet-1.4.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:71f02d6e4cdd07f344f6844845d78518cc7186bd2bc52d37c3b73dc26a3b0bc5"}, + {file = "hf_xet-1.4.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9b38d876e94d4bdcf650778d6ebbaa791dd28de08db9736c43faff06ede1b5a"}, + {file = "hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:77e8c180b7ef12d8a96739a4e1e558847002afe9ea63b6f6358b2271a8bdda1c"}, + {file = "hf_xet-1.4.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c3b3c6a882016b94b6c210957502ff7877802d0dbda8ad142c8595db8b944271"}, + {file = "hf_xet-1.4.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d9a634cc929cfbaf2e1a50c0e532ae8c78fa98618426769480c58501e8c8ac2"}, + {file = "hf_xet-1.4.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6b0932eb8b10317ea78b7da6bab172b17be03bbcd7809383d8d5abd6a2233e04"}, + {file = "hf_xet-1.4.2-cp37-abi3-win_amd64.whl", hash = "sha256:ad185719fb2e8ac26f88c8100562dbf9dbdcc3d9d2add00faa94b5f106aea53f"}, + {file = "hf_xet-1.4.2-cp37-abi3-win_arm64.whl", hash = "sha256:32c012286b581f783653e718c1862aea5b9eb140631685bb0c5e7012c8719a87"}, + {file = "hf_xet-1.4.2.tar.gz", hash = "sha256:b7457b6b482d9e0743bd116363239b1fa904a5e65deede350fbc0c4ea67c71ea"}, +] + +[package.extras] +tests = ["pytest"] + +[[package]] +name = "httptools" +version = "0.7.1" +description = "A collection of framework independent HTTP protocol utils." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78"}, + {file = "httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4"}, + {file = "httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05"}, + {file = "httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed"}, + {file = "httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a"}, + {file = "httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b"}, + {file = "httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568"}, + {file = "httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657"}, + {file = "httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70"}, + {file = "httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df"}, + {file = "httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e"}, + {file = "httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274"}, + {file = "httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec"}, + {file = "httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb"}, + {file = "httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5"}, + {file = "httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5"}, + {file = "httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03"}, + {file = "httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2"}, + {file = "httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362"}, + {file = "httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c"}, + {file = "httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321"}, + {file = "httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3"}, + {file = "httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca"}, + {file = "httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c"}, + {file = "httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66"}, + {file = "httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346"}, + {file = "httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650"}, + {file = "httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6"}, + {file = "httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270"}, + {file = "httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3"}, + {file = "httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1"}, + {file = "httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b"}, + {file = "httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60"}, + {file = "httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca"}, + {file = "httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96"}, + {file = "httptools-0.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ac50afa68945df63ec7a2707c506bd02239272288add34539a2ef527254626a4"}, + {file = "httptools-0.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de987bb4e7ac95b99b805b99e0aae0ad51ae61df4263459d36e07cf4052d8b3a"}, + {file = "httptools-0.7.1-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d169162803a24425eb5e4d51d79cbf429fd7a491b9e570a55f495ea55b26f0bf"}, + {file = "httptools-0.7.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49794f9250188a57fa73c706b46cb21a313edb00d337ca4ce1a011fe3c760b28"}, + {file = "httptools-0.7.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aeefa0648362bb97a7d6b5ff770bfb774930a327d7f65f8208394856862de517"}, + {file = "httptools-0.7.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0d92b10dbf0b3da4823cde6a96d18e6ae358a9daa741c71448975f6a2c339cad"}, + {file = "httptools-0.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:5ddbd045cfcb073db2449563dd479057f2c2b681ebc232380e63ef15edc9c023"}, + {file = "httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9"}, +] + +[[package]] +name = "huggingface-hub" +version = "0.36.2" +description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270"}, + {file = "huggingface_hub-0.36.2.tar.gz", hash = "sha256:1934304d2fb224f8afa3b87007d58501acfda9215b334eed53072dd5e815ff7a"}, +] + +[package.dependencies] +filelock = "*" +fsspec = ">=2023.5.0" +hf-xet = {version = ">=1.1.3,<2.0.0", markers = "platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"arm64\" or platform_machine == \"aarch64\""} +packaging = ">=20.9" +pyyaml = ">=5.1" +requests = "*" +tqdm = ">=4.42.1" +typing-extensions = ">=3.7.4.3" + +[package.extras] +all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "authlib (>=1.3.2)", "fastapi", "fastapi", "gradio (>=4.0.0)", "httpx", "itsdangerous", "jedi", "libcst (>=1.4.0)", "mypy (==1.15.0) ; python_version >= \"3.9\"", "mypy (>=1.14.1,<1.15.0) ; python_version == \"3.8\"", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures (<16.0)", "pytest-vcr", "pytest-xdist", "ruff (>=0.9.0)", "soundfile", "ty", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] +cli = ["InquirerPy (==0.3.4)"] +dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "authlib (>=1.3.2)", "fastapi", "fastapi", "gradio (>=4.0.0)", "httpx", "itsdangerous", "jedi", "libcst (>=1.4.0)", "mypy (==1.15.0) ; python_version >= \"3.9\"", "mypy (>=1.14.1,<1.15.0) ; python_version == \"3.8\"", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures (<16.0)", "pytest-vcr", "pytest-xdist", "ruff (>=0.9.0)", "soundfile", "ty", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] +fastai = ["fastai (>=2.4)", "fastcore (>=1.3.27)", "toml"] +hf-transfer = ["hf_transfer (>=0.1.4)"] +hf-xet = ["hf-xet (>=1.1.2,<2.0.0)"] +inference = ["aiohttp"] +mcp = ["aiohttp", "mcp (>=1.8.0)", "typer"] +oauth = ["authlib (>=1.3.2)", "fastapi", "httpx", "itsdangerous"] +quality = ["libcst (>=1.4.0)", "mypy (==1.15.0) ; python_version >= \"3.9\"", "mypy (>=1.14.1,<1.15.0) ; python_version == \"3.8\"", "ruff (>=0.9.0)", "ty"] +tensorflow = ["graphviz", "pydot", "tensorflow"] +tensorflow-testing = ["keras (<3.0)", "tensorflow"] +testing = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "authlib (>=1.3.2)", "fastapi", "fastapi", "gradio (>=4.0.0)", "httpx", "itsdangerous", "jedi", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures (<16.0)", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"] +torch = ["safetensors[torch]", "torch"] +typing = ["types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)"] + +[[package]] +name = "idna" +version = "3.11" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "joblib" +version = "1.5.3" +description = "Lightweight pipelining with Python functions" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713"}, + {file = "joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3"}, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"}, + {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.3.6" +referencing = ">=0.28.4" +rpds-py = ">=0.25.0" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, + {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, +] + +[package.dependencies] +referencing = ">=0.31.0" + +[[package]] +name = "lxml" +version = "6.0.2" +description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "lxml-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e77dd455b9a16bbd2a5036a63ddbd479c19572af81b624e79ef422f929eef388"}, + {file = "lxml-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d444858b9f07cefff6455b983aea9a67f7462ba1f6cbe4a21e8bf6791bf2153"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f952dacaa552f3bb8834908dddd500ba7d508e6ea6eb8c52eb2d28f48ca06a31"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71695772df6acea9f3c0e59e44ba8ac50c4f125217e84aab21074a1a55e7e5c9"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f68764f35fd78d7c4cc4ef209a184c38b65440378013d24b8aecd327c3e0c8"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:058027e261afed589eddcfe530fcc6f3402d7fd7e89bfd0532df82ebc1563dba"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8ffaeec5dfea5881d4c9d8913a32d10cfe3923495386106e4a24d45300ef79c"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:f2e3b1a6bb38de0bc713edd4d612969dd250ca8b724be8d460001a387507021c"}, + {file = "lxml-6.0.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d6690ec5ec1cce0385cb20896b16be35247ac8c2046e493d03232f1c2414d321"}, + {file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2a50c3c1d11cad0ebebbac357a97b26aa79d2bcaf46f256551152aa85d3a4d1"}, + {file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3efe1b21c7801ffa29a1112fab3b0f643628c30472d507f39544fd48e9549e34"}, + {file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:59c45e125140b2c4b33920d21d83681940ca29f0b83f8629ea1a2196dc8cfe6a"}, + {file = "lxml-6.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:452b899faa64f1805943ec1c0c9ebeaece01a1af83e130b69cdefeda180bb42c"}, + {file = "lxml-6.0.2-cp310-cp310-win32.whl", hash = "sha256:1e786a464c191ca43b133906c6903a7e4d56bef376b75d97ccbb8ec5cf1f0a4b"}, + {file = "lxml-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:dacf3c64ef3f7440e3167aa4b49aa9e0fb99e0aa4f9ff03795640bf94531bcb0"}, + {file = "lxml-6.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:45f93e6f75123f88d7f0cfd90f2d05f441b808562bf0bc01070a00f53f5028b5"}, + {file = "lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607"}, + {file = "lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178"}, + {file = "lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553"}, + {file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb"}, + {file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a"}, + {file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c"}, + {file = "lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7"}, + {file = "lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46"}, + {file = "lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078"}, + {file = "lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285"}, + {file = "lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456"}, + {file = "lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0"}, + {file = "lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092"}, + {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f"}, + {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8"}, + {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f"}, + {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6"}, + {file = "lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322"}, + {file = "lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849"}, + {file = "lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f"}, + {file = "lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6"}, + {file = "lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77"}, + {file = "lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6"}, + {file = "lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a"}, + {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679"}, + {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659"}, + {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484"}, + {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2"}, + {file = "lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314"}, + {file = "lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2"}, + {file = "lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7"}, + {file = "lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf"}, + {file = "lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe"}, + {file = "lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37"}, + {file = "lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9"}, + {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917"}, + {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f"}, + {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8"}, + {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a"}, + {file = "lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c"}, + {file = "lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b"}, + {file = "lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed"}, + {file = "lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8"}, + {file = "lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d"}, + {file = "lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d"}, + {file = "lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9"}, + {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e"}, + {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d"}, + {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec"}, + {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272"}, + {file = "lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f"}, + {file = "lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312"}, + {file = "lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca"}, + {file = "lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c"}, + {file = "lxml-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a656ca105115f6b766bba324f23a67914d9c728dafec57638e2b92a9dcd76c62"}, + {file = "lxml-6.0.2-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c54d83a2188a10ebdba573f16bd97135d06c9ef60c3dc495315c7a28c80a263f"}, + {file = "lxml-6.0.2-cp38-cp38-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:1ea99340b3c729beea786f78c38f60f4795622f36e305d9c9be402201efdc3b7"}, + {file = "lxml-6.0.2-cp38-cp38-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af85529ae8d2a453feee4c780d9406a5e3b17cee0dd75c18bd31adcd584debc3"}, + {file = "lxml-6.0.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:fe659f6b5d10fb5a17f00a50eb903eb277a71ee35df4615db573c069bcf967ac"}, + {file = "lxml-6.0.2-cp38-cp38-win32.whl", hash = "sha256:5921d924aa5468c939d95c9814fa9f9b5935a6ff4e679e26aaf2951f74043512"}, + {file = "lxml-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:0aa7070978f893954008ab73bb9e3c24a7c56c054e00566a21b553dc18105fca"}, + {file = "lxml-6.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2c8458c2cdd29589a8367c09c8f030f1d202be673f0ca224ec18590b3b9fb694"}, + {file = "lxml-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3fee0851639d06276e6b387f1c190eb9d7f06f7f53514e966b26bae46481ec90"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b2142a376b40b6736dfc214fd2902409e9e3857eff554fed2d3c60f097e62a62"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6b5b39cc7e2998f968f05309e666103b53e2edd01df8dc51b90d734c0825444"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4aec24d6b72ee457ec665344a29acb2d35937d5192faebe429ea02633151aad"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:b42f4d86b451c2f9d06ffb4f8bbc776e04df3ba070b9fe2657804b1b40277c48"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cdaefac66e8b8f30e37a9b4768a391e1f8a16a7526d5bc77a7928408ef68e93"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:b738f7e648735714bbb82bdfd030203360cfeab7f6e8a34772b3c8c8b820568c"}, + {file = "lxml-6.0.2-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daf42de090d59db025af61ce6bdb2521f0f102ea0e6ea310f13c17610a97da4c"}, + {file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:66328dabea70b5ba7e53d94aa774b733cf66686535f3bc9250a7aab53a91caaf"}, + {file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:e237b807d68a61fc3b1e845407e27e5eb8ef69bc93fe8505337c1acb4ee300b6"}, + {file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:ac02dc29fd397608f8eb15ac1610ae2f2f0154b03f631e6d724d9e2ad4ee2c84"}, + {file = "lxml-6.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:817ef43a0c0b4a77bd166dc9a09a555394105ff3374777ad41f453526e37f9cb"}, + {file = "lxml-6.0.2-cp39-cp39-win32.whl", hash = "sha256:bc532422ff26b304cfb62b328826bd995c96154ffd2bac4544f37dbb95ecaa8f"}, + {file = "lxml-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:995e783eb0374c120f528f807443ad5a83a656a8624c467ea73781fc5f8a8304"}, + {file = "lxml-6.0.2-cp39-cp39-win_arm64.whl", hash = "sha256:08b9d5e803c2e4725ae9e8559ee880e5328ed61aa0935244e0515d7d9dbec0aa"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e748d4cf8fef2526bb2a589a417eba0c8674e29ffcb570ce2ceca44f1e567bf6"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4ddb1049fa0579d0cbd00503ad8c58b9ab34d1254c77bc6a5576d96ec7853dba"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb233f9c95f83707dae461b12b720c1af9c28c2d19208e1be03387222151daf5"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc456d04db0515ce3320d714a1eac7a97774ff0849e7718b492d957da4631dd4"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2613e67de13d619fd283d58bda40bff0ee07739f624ffee8b13b631abf33083d"}, + {file = "lxml-6.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:24a8e756c982c001ca8d59e87c80c4d9dcd4d9b44a4cbeb8d9be4482c514d41d"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a"}, + {file = "lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e"}, + {file = "lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62"}, +] + +[package.extras] +cssselect = ["cssselect (>=0.7)"] +html-clean = ["lxml_html_clean"] +html5 = ["html5lib"] +htmlsoup = ["BeautifulSoup4"] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, + {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins (>=0.5.0)"] +profiling = ["gprof2dot"] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "multidict" +version = "6.7.1" +description = "multidict implementation" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5"}, + {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8"}, + {file = "multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505"}, + {file = "multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122"}, + {file = "multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df"}, + {file = "multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db"}, + {file = "multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d"}, + {file = "multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e"}, + {file = "multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa"}, + {file = "multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a"}, + {file = "multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b"}, + {file = "multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6"}, + {file = "multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172"}, + {file = "multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd"}, + {file = "multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba"}, + {file = "multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511"}, + {file = "multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19"}, + {file = "multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf"}, + {file = "multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23"}, + {file = "multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2"}, + {file = "multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33"}, + {file = "multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3"}, + {file = "multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5"}, + {file = "multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df"}, + {file = "multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1"}, + {file = "multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963"}, + {file = "multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108"}, + {file = "multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32"}, + {file = "multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8"}, + {file = "multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118"}, + {file = "multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee"}, + {file = "multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2"}, + {file = "multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b"}, + {file = "multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d"}, + {file = "multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f"}, + {file = "multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5"}, + {file = "multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581"}, + {file = "multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a"}, + {file = "multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2"}, + {file = "multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7"}, + {file = "multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5"}, + {file = "multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2"}, + {file = "multidict-6.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f"}, + {file = "multidict-6.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358"}, + {file = "multidict-6.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5"}, + {file = "multidict-6.7.1-cp39-cp39-win32.whl", hash = "sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0"}, + {file = "multidict-6.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4"}, + {file = "multidict-6.7.1-cp39-cp39-win_arm64.whl", hash = "sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9"}, + {file = "multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56"}, + {file = "multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d"}, +] + +[[package]] +name = "nltk" +version = "3.9.4" +description = "Natural Language Toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f"}, + {file = "nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0"}, +] + +[package.dependencies] +click = "*" +joblib = "*" +regex = ">=2021.8.3" +tqdm = "*" + +[package.extras] +all = ["matplotlib", "numpy", "pyparsing", "python-crfsuite", "requests", "scikit-learn", "scipy", "twython"] +corenlp = ["requests"] +machine-learning = ["numpy", "python-crfsuite", "scikit-learn", "scipy"] +plot = ["matplotlib"] +tgrep = ["pyparsing"] +twitter = ["twython"] + +[[package]] +name = "numpy" +version = "2.4.3" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "numpy-2.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33b3bf58ee84b172c067f56aeadc7ee9ab6de69c5e800ab5b10295d54c581adb"}, + {file = "numpy-2.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8ba7b51e71c05aa1f9bc3641463cd82308eab40ce0d5c7e1fd4038cbf9938147"}, + {file = "numpy-2.4.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a1988292870c7cb9d0ebb4cc96b4d447513a9644801de54606dc7aabf2b7d920"}, + {file = "numpy-2.4.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:23b46bb6d8ecb68b58c09944483c135ae5f0e9b8d8858ece5e4ead783771d2a9"}, + {file = "numpy-2.4.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a016db5c5dba78fa8fe9f5d80d6708f9c42ab087a739803c0ac83a43d686a470"}, + {file = "numpy-2.4.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:715de7f82e192e8cae5a507a347d97ad17598f8e026152ca97233e3666daaa71"}, + {file = "numpy-2.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ddb7919366ee468342b91dea2352824c25b55814a987847b6c52003a7c97f15"}, + {file = "numpy-2.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a315e5234d88067f2d97e1f2ef670a7569df445d55400f1e33d117418d008d52"}, + {file = "numpy-2.4.3-cp311-cp311-win32.whl", hash = "sha256:2b3f8d2c4589b1a2028d2a770b0fc4d1f332fb5e01521f4de3199a896d158ddd"}, + {file = "numpy-2.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:77e76d932c49a75617c6d13464e41203cd410956614d0a0e999b25e9e8d27eec"}, + {file = "numpy-2.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:eb610595dd91560905c132c709412b512135a60f1851ccbd2c959e136431ff67"}, + {file = "numpy-2.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef"}, + {file = "numpy-2.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e"}, + {file = "numpy-2.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4"}, + {file = "numpy-2.4.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18"}, + {file = "numpy-2.4.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5"}, + {file = "numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97"}, + {file = "numpy-2.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c"}, + {file = "numpy-2.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc"}, + {file = "numpy-2.4.3-cp312-cp312-win32.whl", hash = "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9"}, + {file = "numpy-2.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5"}, + {file = "numpy-2.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e"}, + {file = "numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3"}, + {file = "numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9"}, + {file = "numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee"}, + {file = "numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f"}, + {file = "numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f"}, + {file = "numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc"}, + {file = "numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476"}, + {file = "numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92"}, + {file = "numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687"}, + {file = "numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd"}, + {file = "numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d"}, + {file = "numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875"}, + {file = "numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070"}, + {file = "numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73"}, + {file = "numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368"}, + {file = "numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22"}, + {file = "numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a"}, + {file = "numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349"}, + {file = "numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c"}, + {file = "numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26"}, + {file = "numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02"}, + {file = "numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4"}, + {file = "numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168"}, + {file = "numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b"}, + {file = "numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950"}, + {file = "numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd"}, + {file = "numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24"}, + {file = "numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0"}, + {file = "numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0"}, + {file = "numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a"}, + {file = "numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc"}, + {file = "numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7"}, + {file = "numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657"}, + {file = "numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7"}, + {file = "numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093"}, + {file = "numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a"}, + {file = "numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611"}, + {file = "numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720"}, + {file = "numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5"}, + {file = "numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0"}, + {file = "numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b"}, + {file = "numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e"}, + {file = "numpy-2.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c6b124bfcafb9e8d3ed09130dbee44848c20b3e758b6bbf006e641778927c028"}, + {file = "numpy-2.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:76dbb9d4e43c16cf9aa711fcd8de1e2eeb27539dcefb60a1d5e9f12fae1d1ed8"}, + {file = "numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:29363fbfa6f8ee855d7569c96ce524845e3d726d6c19b29eceec7dd555dab152"}, + {file = "numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:bc71942c789ef415a37f0d4eab90341425a00d538cd0642445d30b41023d3395"}, + {file = "numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e58765ad74dcebd3ef0208a5078fba32dc8ec3578fe84a604432950cd043d79"}, + {file = "numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e236dbda4e1d319d681afcbb136c0c4a8e0f1a5c58ceec2adebb547357fe857"}, + {file = "numpy-2.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5"}, + {file = "numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd"}, +] + +[[package]] +name = "packaging" +version = "26.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, + {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, +] + +[[package]] +name = "pillow" +version = "12.1.1" +description = "Python Imaging Library (fork)" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0"}, + {file = "pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4"}, + {file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e"}, + {file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff"}, + {file = "pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40"}, + {file = "pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23"}, + {file = "pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9"}, + {file = "pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32"}, + {file = "pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b"}, + {file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5"}, + {file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d"}, + {file = "pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c"}, + {file = "pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563"}, + {file = "pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80"}, + {file = "pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052"}, + {file = "pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0"}, + {file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3"}, + {file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35"}, + {file = "pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a"}, + {file = "pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6"}, + {file = "pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523"}, + {file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e"}, + {file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9"}, + {file = "pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6"}, + {file = "pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60"}, + {file = "pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717"}, + {file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a"}, + {file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029"}, + {file = "pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b"}, + {file = "pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1"}, + {file = "pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a"}, + {file = "pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da"}, + {file = "pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13"}, + {file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf"}, + {file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524"}, + {file = "pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986"}, + {file = "pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c"}, + {file = "pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3"}, + {file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af"}, + {file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f"}, + {file = "pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642"}, + {file = "pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd"}, + {file = "pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e"}, + {file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0"}, + {file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb"}, + {file = "pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f"}, + {file = "pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15"}, + {file = "pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f"}, + {file = "pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8"}, + {file = "pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586"}, + {file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce"}, + {file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8"}, + {file = "pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36"}, + {file = "pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b"}, + {file = "pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e"}, + {file = "pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] +xmp = ["defusedxml"] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "propcache" +version = "0.4.1" +description = "Accelerated property cache" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db"}, + {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8"}, + {file = "propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c"}, + {file = "propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb"}, + {file = "propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37"}, + {file = "propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f"}, + {file = "propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1"}, + {file = "propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6"}, + {file = "propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75"}, + {file = "propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8"}, + {file = "propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db"}, + {file = "propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66"}, + {file = "propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81"}, + {file = "propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e"}, + {file = "propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1"}, + {file = "propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717"}, + {file = "propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37"}, + {file = "propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144"}, + {file = "propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f"}, + {file = "propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153"}, + {file = "propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455"}, + {file = "propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85"}, + {file = "propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1"}, + {file = "propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3d233076ccf9e450c8b3bc6720af226b898ef5d051a2d145f7d765e6e9f9bcff"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:357f5bb5c377a82e105e44bd3d52ba22b616f7b9773714bff93573988ef0a5fb"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cbc3b6dfc728105b2a57c06791eb07a94229202ea75c59db644d7d496b698cac"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:182b51b421f0501952d938dc0b0eb45246a5b5153c50d42b495ad5fb7517c888"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b536b39c5199b96fc6245eb5fb796c497381d3942f169e44e8e392b29c9ebcc"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db65d2af507bbfbdcedb254a11149f894169d90488dd3e7190f7cdcb2d6cd57a"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd2dbc472da1f772a4dae4fa24be938a6c544671a912e30529984dd80400cd88"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:daede9cd44e0f8bdd9e6cc9a607fc81feb80fae7a5fc6cecaff0e0bb32e42d00"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:71b749281b816793678ae7f3d0d84bd36e694953822eaad408d682efc5ca18e0"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fe49d0a85038f36ba9e3ffafa1103e61170b28e95b16622e11be0a0ea07c6781"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:99d43339c83aaf4d32bda60928231848eee470c6bda8d02599cc4cebe872d183"}, + {file = "propcache-0.4.1-cp39-cp39-win32.whl", hash = "sha256:a129e76735bc792794d5177069691c3217898b9f5cee2b2661471e52ffe13f19"}, + {file = "propcache-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:948dab269721ae9a87fd16c514a0a2c2a1bdb23a9a61b969b0f9d9ee2968546f"}, + {file = "propcache-0.4.1-cp39-cp39-win_arm64.whl", hash = "sha256:5fd37c406dd6dc85aa743e214cef35dc54bbdd1419baac4f6ae5e5b1a2976938"}, + {file = "propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237"}, + {file = "propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d"}, +] + +[[package]] +name = "psutil" +version = "6.0.0" +description = "Cross-platform lib for process and system monitoring in Python." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +groups = ["main"] +files = [ + {file = "psutil-6.0.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a021da3e881cd935e64a3d0a20983bda0bb4cf80e4f74fa9bfcb1bc5785360c6"}, + {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:1287c2b95f1c0a364d23bc6f2ea2365a8d4d9b726a3be7294296ff7ba97c17f0"}, + {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:a9a3dbfb4de4f18174528d87cc352d1f788b7496991cca33c6996f40c9e3c92c"}, + {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:6ec7588fb3ddaec7344a825afe298db83fe01bfaaab39155fa84cf1c0d6b13c3"}, + {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:1e7c870afcb7d91fdea2b37c24aeb08f98b6d67257a5cb0a8bc3ac68d0f1a68c"}, + {file = "psutil-6.0.0-cp27-none-win32.whl", hash = "sha256:02b69001f44cc73c1c5279d02b30a817e339ceb258ad75997325e0e6169d8b35"}, + {file = "psutil-6.0.0-cp27-none-win_amd64.whl", hash = "sha256:21f1fb635deccd510f69f485b87433460a603919b45e2a324ad65b0cc74f8fb1"}, + {file = "psutil-6.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c588a7e9b1173b6e866756dde596fd4cad94f9399daf99ad8c3258b3cb2b47a0"}, + {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ed2440ada7ef7d0d608f20ad89a04ec47d2d3ab7190896cd62ca5fc4fe08bf0"}, + {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd9a97c8e94059b0ef54a7d4baf13b405011176c3b6ff257c247cae0d560ecd"}, + {file = "psutil-6.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e8d0054fc88153ca0544f5c4d554d42e33df2e009c4ff42284ac9ebdef4132"}, + {file = "psutil-6.0.0-cp36-cp36m-win32.whl", hash = "sha256:fc8c9510cde0146432bbdb433322861ee8c3efbf8589865c8bf8d21cb30c4d14"}, + {file = "psutil-6.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:34859b8d8f423b86e4385ff3665d3f4d94be3cdf48221fbe476e883514fdb71c"}, + {file = "psutil-6.0.0-cp37-abi3-win32.whl", hash = "sha256:a495580d6bae27291324fe60cea0b5a7c23fa36a7cd35035a16d93bdcf076b9d"}, + {file = "psutil-6.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:33ea5e1c975250a720b3a6609c490db40dae5d83a4eb315170c4fe0d8b1f34b3"}, + {file = "psutil-6.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:ffe7fc9b6b36beadc8c322f84e1caff51e8703b88eee1da46d1e3a6ae11b4fd0"}, + {file = "psutil-6.0.0.tar.gz", hash = "sha256:8faae4f310b6d969fa26ca0545338b21f73c6b15db7c4a8d934a5482faa818f2"}, +] + +[package.extras] +test = ["enum34 ; python_version <= \"3.4\"", "ipaddress ; python_version < \"3.0\"", "mock ; python_version < \"3.0\"", "pywin32 ; sys_platform == \"win32\"", "wmi ; sys_platform == \"win32\""] + +[[package]] +name = "pycares" +version = "4.11.0" +description = "Python interface for c-ares" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pycares-4.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:87dab618fe116f1936f8461df5970fcf0befeba7531a36b0a86321332ff9c20b"}, + {file = "pycares-4.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3db6b6439e378115572fa317053f3ee6eecb39097baafe9292320ff1a9df73e3"}, + {file = "pycares-4.11.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:742fbaa44b418237dbd6bf8cdab205c98b3edb334436a972ad341b0ea296fb47"}, + {file = "pycares-4.11.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:d2a3526dbf6cb01b355e8867079c9356a8df48706b4b099ac0bf59d4656e610d"}, + {file = "pycares-4.11.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:3d5300a598ad48bbf169fba1f2b2e4cf7ab229e7c1a48d8c1166f9ccf1755cb3"}, + {file = "pycares-4.11.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:066f3caa07c85e1a094aebd9e7a7bb3f3b2d97cff2276665693dd5c0cc81cf84"}, + {file = "pycares-4.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dcd4a7761fdfb5aaac88adad0a734dd065c038f5982a8c4b0dd28efa0bd9cc7c"}, + {file = "pycares-4.11.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:83a7401d7520fa14b00d85d68bcca47a0676c69996e8515d53733972286f9739"}, + {file = "pycares-4.11.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:66c310773abe42479302abf064832f4a37c8d7f788f4d5ee0d43cbad35cf5ff4"}, + {file = "pycares-4.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:95bc81f83fadb67f7f87914f216a0e141555ee17fd7f56e25aa0cc165e99e53b"}, + {file = "pycares-4.11.0-cp310-cp310-win32.whl", hash = "sha256:1dbbf0cfb39be63598b4cdc2522960627bf2f523e49c4349fb64b0499902ec7c"}, + {file = "pycares-4.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dde02314eefb85dce3cfdd747e8b44c69a94d442c0d7221b7de151ee4c93f0f5"}, + {file = "pycares-4.11.0-cp310-cp310-win_arm64.whl", hash = "sha256:9518514e3e85646bac798d94d34bf5b8741ee0cb580512e8450ce884f526b7cf"}, + {file = "pycares-4.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c2971af3a4094280f7c24293ff4d361689c175c1ebcbea6b3c1560eaff7cb240"}, + {file = "pycares-4.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d69e2034160e1219665decb8140e439afc7a7afcfd4adff08eb0f6142405c3e"}, + {file = "pycares-4.11.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:3bd81ad69f607803f531ff5cfa1262391fa06e78488c13495cee0f70d02e0287"}, + {file = "pycares-4.11.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:0aed0974eab3131d832e7e84a73ddb0dddbc57393cd8c0788d68a759a78c4a7b"}, + {file = "pycares-4.11.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:30d197180af626bb56f17e1fa54640838d7d12ed0f74665a3014f7155435b199"}, + {file = "pycares-4.11.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:cb711a66246561f1cae51244deef700eef75481a70d99611fd3c8ab5bd69ab49"}, + {file = "pycares-4.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7aba9a312a620052133437f2363aae90ae4695ee61cb2ee07cbb9951d4c69ddd"}, + {file = "pycares-4.11.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c2af7a9d3afb63da31df1456d38b91555a6c147710a116d5cc70ab1e9f457a4f"}, + {file = "pycares-4.11.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d5fe089be67bc5927f0c0bd60c082c79f22cf299635ee3ddd370ae2a6e8b4ae0"}, + {file = "pycares-4.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:35ff1ec260372c97ed688efd5b3c6e5481f2274dea08f6c4ea864c195a9673c6"}, + {file = "pycares-4.11.0-cp311-cp311-win32.whl", hash = "sha256:ff3d25883b7865ea34c00084dd22a7be7c58fd3131db6b25c35eafae84398f9d"}, + {file = "pycares-4.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:f4695153333607e63068580f2979b377b641a03bc36e02813659ffbea2b76fe2"}, + {file = "pycares-4.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:dc54a21586c096df73f06f9bdf594e8d86d7be84e5d4266358ce81c04c3cc88c"}, + {file = "pycares-4.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b93d624560ba52287873bacff70b42c99943821ecbc810b959b0953560f53c36"}, + {file = "pycares-4.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:775d99966e28c8abd9910ddef2de0f1e173afc5a11cea9f184613c747373ab80"}, + {file = "pycares-4.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:84fde689557361764f052850a2d68916050adbfd9321f6105aca1d8f1a9bd49b"}, + {file = "pycares-4.11.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:30ceed06f3bf5eff865a34d21562c25a7f3dad0ed336b9dd415330e03a6c50c4"}, + {file = "pycares-4.11.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:97d971b3a88a803bb95ff8a40ea4d68da59319eb8b59e924e318e2560af8c16d"}, + {file = "pycares-4.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:2d5cac829da91ade70ce1af97dad448c6cd4778b48facbce1b015e16ced93642"}, + {file = "pycares-4.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee1ea367835eb441d246164c09d1f9703197af4425fc6865cefcde9e2ca81f85"}, + {file = "pycares-4.11.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3139ec1f4450a4b253386035c5ecd2722582ae3320a456df5021ffe3f174260a"}, + {file = "pycares-4.11.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5d70324ca1d82c6c4b00aa678347f7560d1ef2ce1d181978903459a97751543a"}, + {file = "pycares-4.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e2f8d9cfe0eb3a2997fde5df99b1aaea5a46dabfcfcac97b2d05f027c2cd5e28"}, + {file = "pycares-4.11.0-cp312-cp312-win32.whl", hash = "sha256:1571a7055c03a95d5270c914034eac7f8bfa1b432fc1de53d871b821752191a4"}, + {file = "pycares-4.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:7570e0b50db619b2ee370461c462617225dc3a3f63f975c6f117e2f0c94f82ca"}, + {file = "pycares-4.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:f199702740f3b766ed8c70efb885538be76cb48cd0cb596b948626f0b825e07a"}, + {file = "pycares-4.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c296ab94d1974f8d2f76c499755a9ce31ffd4986e8898ef19b90e32525f7d84"}, + {file = "pycares-4.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0fcd3a8bac57a0987d9b09953ba0f8703eb9dca7c77f7051d8c2ed001185be8"}, + {file = "pycares-4.11.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:bac55842047567ddae177fb8189b89a60633ac956d5d37260f7f71b517fd8b87"}, + {file = "pycares-4.11.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:4da2e805ed8c789b9444ef4053f6ef8040cd13b0c1ca6d3c4fe6f9369c458cb4"}, + {file = "pycares-4.11.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:ea785d1f232b42b325578f0c8a2fa348192e182cc84a1e862896076a4a2ba2a7"}, + {file = "pycares-4.11.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:aa160dc9e785212c49c12bb891e242c949758b99542946cc8e2098ef391f93b0"}, + {file = "pycares-4.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7830709c23bbc43fbaefbb3dde57bdd295dc86732504b9d2e65044df8fd5e9fb"}, + {file = "pycares-4.11.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ef1ab7abbd238bb2dbbe871c3ea39f5a7fc63547c015820c1e24d0d494a1689"}, + {file = "pycares-4.11.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a4060d8556c908660512d42df1f4a874e4e91b81f79e3a9090afedc7690ea5ba"}, + {file = "pycares-4.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a98fac4a3d4f780817016b6f00a8a2c2f41df5d25dfa8e5b1aa0d783645a6566"}, + {file = "pycares-4.11.0-cp313-cp313-win32.whl", hash = "sha256:faa8321bc2a366189dcf87b3823e030edf5ac97a6b9a7fc99f1926c4bf8ef28e"}, + {file = "pycares-4.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:6f74b1d944a50fa12c5006fd10b45e1a45da0c5d15570919ce48be88e428264c"}, + {file = "pycares-4.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f7581793d8bb3014028b8397f6f80b99db8842da58f4409839c29b16397ad"}, + {file = "pycares-4.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:df0a17f4e677d57bca3624752bbb515316522ad1ce0de07ed9d920e6c4ee5d35"}, + {file = "pycares-4.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3b44e54cad31d3c3be5e8149ac36bc1c163ec86e0664293402f6f846fb22ad00"}, + {file = "pycares-4.11.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:80752133442dc7e6dd9410cec227c49f69283c038c316a8585cca05ec32c2766"}, + {file = "pycares-4.11.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:84b0b402dd333403fdce0e204aef1ef834d839c439c0c1aa143dc7d1237bb197"}, + {file = "pycares-4.11.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:c0eec184df42fc82e43197e073f9cc8f93b25ad2f11f230c64c2dc1c80dbc078"}, + {file = "pycares-4.11.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ee751409322ff10709ee867d5aea1dc8431eec7f34835f0f67afd016178da134"}, + {file = "pycares-4.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1732db81e348bfce19c9bf9448ba660aea03042eeeea282824da1604a5bd4dcf"}, + {file = "pycares-4.11.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:702d21823996f139874aba5aa9bb786d69e93bde6e3915b99832eb4e335d31ae"}, + {file = "pycares-4.11.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:218619b912cef7c64a339ab0e231daea10c994a05699740714dff8c428b9694a"}, + {file = "pycares-4.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:719f7ddff024fdacde97b926b4b26d0cc25901d5ef68bb994a581c420069936d"}, + {file = "pycares-4.11.0-cp314-cp314-win32.whl", hash = "sha256:d552fb2cb513ce910d1dc22dbba6420758a991a356f3cd1b7ec73a9e31f94d01"}, + {file = "pycares-4.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:23d50a0842e8dbdddf870a7218a7ab5053b68892706b3a391ecb3d657424d266"}, + {file = "pycares-4.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:836725754c32363d2c5d15b931b3ebd46b20185c02e850672cb6c5f0452c1e80"}, + {file = "pycares-4.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c9d839b5700542b27c1a0d359cbfad6496341e7c819c7fea63db9588857065ed"}, + {file = "pycares-4.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:31b85ad00422b38f426e5733a71dfb7ee7eb65a99ea328c508d4f552b1760dc8"}, + {file = "pycares-4.11.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cdac992206756b024b371760c55719eb5cd9d6b2cb25a8d5a04ae1b0ff426232"}, + {file = "pycares-4.11.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:ffb22cee640bc12ee0e654eba74ecfb59e2e0aebc5bccc3cc7ef92f487008af7"}, + {file = "pycares-4.11.0-cp314-cp314t-manylinux_2_28_s390x.whl", hash = "sha256:00538826d2eaf4a0e4becb0753b0ac8d652334603c445c9566c9eb273657eb4c"}, + {file = "pycares-4.11.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:29daa36548c04cdcd1a78ae187a4b7b003f0b357a2f4f1f98f9863373eedc759"}, + {file = "pycares-4.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cf306f3951740d7bed36149a6d8d656a7d5432dd4bbc6af3bb6554361fc87401"}, + {file = "pycares-4.11.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:386da2581db4ea2832629e275c061103b0be32f9391c5dfaea7f6040951950ad"}, + {file = "pycares-4.11.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:45d3254a694459fdb0640ef08724ca9d4b4f6ff6d7161c9b526d7d2e2111379e"}, + {file = "pycares-4.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eddf5e520bb88b23b04ac1f28f5e9a7c77c718b8b4af3a4a7a2cc4a600f34502"}, + {file = "pycares-4.11.0-cp314-cp314t-win32.whl", hash = "sha256:8a75a406432ce39ce0ca41edff7486df6c970eb0fe5cfbe292f195a6b8654461"}, + {file = "pycares-4.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3784b80d797bcc2ff2bf3d4b27f46d8516fe1707ff3b82c2580dc977537387f9"}, + {file = "pycares-4.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:afc6503adf8b35c21183b9387be64ca6810644ef54c9ef6c99d1d5635c01601b"}, + {file = "pycares-4.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5e1ab899bb0763dea5d6569300aab3a205572e6e2d0ef1a33b8cf2b86d1312a4"}, + {file = "pycares-4.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9d0c543bdeefa4794582ef48f3c59e5e7a43d672a4bfad9cbbd531e897911690"}, + {file = "pycares-4.11.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:5344d52efa37df74728505a81dd52c15df639adffd166f7ddca7a6318ecdb605"}, + {file = "pycares-4.11.0-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:b50ca218a3e2e23cbda395fd002d030385202fbb8182aa87e11bea0a568bd0b8"}, + {file = "pycares-4.11.0-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:30feeab492ac609f38a0d30fab3dc1789bd19c48f725b2955bcaaef516e32a21"}, + {file = "pycares-4.11.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:6195208b16cce1a7b121727710a6f78e8403878c1017ab5a3f92158b048cec34"}, + {file = "pycares-4.11.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:77bf82dc0beb81262bf1c7f546e1c1fde4992e5c8a2343b867ca201b85f9e1aa"}, + {file = "pycares-4.11.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:aca981fc00c8af8d5b9254ea5c2f276df8ece089b081af1ef4856fbcfc7c698a"}, + {file = "pycares-4.11.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:96e07d5a8b733d753e37d1f7138e7321d2316bb3f0f663ab4e3d500fabc82807"}, + {file = "pycares-4.11.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9a00408105901ede92e318eecb46d0e661d7d093d0a9b1224c71b5dd94f79e83"}, + {file = "pycares-4.11.0-cp39-cp39-win32.whl", hash = "sha256:910ce19a549f493fb55cfd1d7d70960706a03de6bfc896c1429fc5d6216df77e"}, + {file = "pycares-4.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:6f751f5a0e4913b2787f237c2c69c11a53f599269012feaa9fb86d7cef3aec26"}, + {file = "pycares-4.11.0-cp39-cp39-win_arm64.whl", hash = "sha256:f6c602c5e3615abbf43dbdf3c6c64c65e76e5aa23cb74e18466b55d4a2095468"}, + {file = "pycares-4.11.0.tar.gz", hash = "sha256:c863d9003ca0ce7df26429007859afd2a621d3276ed9fef154a9123db9252557"}, +] + +[package.dependencies] +cffi = {version = ">=1.5.0", markers = "python_version < \"3.14\""} + +[package.extras] +idna = ["idna (>=2.1)"] + +[[package]] +name = "pycparser" +version = "3.0" +description = "C parser in Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] + +[[package]] +name = "pycryptodome" +version = "3.20.0" +description = "Cryptographic library for Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["main"] +files = [ + {file = "pycryptodome-3.20.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:f0e6d631bae3f231d3634f91ae4da7a960f7ff87f2865b2d2b831af1dfb04e9a"}, + {file = "pycryptodome-3.20.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:baee115a9ba6c5d2709a1e88ffe62b73ecc044852a925dcb67713a288c4ec70f"}, + {file = "pycryptodome-3.20.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:417a276aaa9cb3be91f9014e9d18d10e840a7a9b9a9be64a42f553c5b50b4d1d"}, + {file = "pycryptodome-3.20.0-cp27-cp27m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a1250b7ea809f752b68e3e6f3fd946b5939a52eaeea18c73bdab53e9ba3c2dd"}, + {file = "pycryptodome-3.20.0-cp27-cp27m-musllinux_1_1_aarch64.whl", hash = "sha256:d5954acfe9e00bc83ed9f5cb082ed22c592fbbef86dc48b907238be64ead5c33"}, + {file = "pycryptodome-3.20.0-cp27-cp27m-win32.whl", hash = "sha256:06d6de87c19f967f03b4cf9b34e538ef46e99a337e9a61a77dbe44b2cbcf0690"}, + {file = "pycryptodome-3.20.0-cp27-cp27m-win_amd64.whl", hash = "sha256:ec0bb1188c1d13426039af8ffcb4dbe3aad1d7680c35a62d8eaf2a529b5d3d4f"}, + {file = "pycryptodome-3.20.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:5601c934c498cd267640b57569e73793cb9a83506f7c73a8ec57a516f5b0b091"}, + {file = "pycryptodome-3.20.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d29daa681517f4bc318cd8a23af87e1f2a7bad2fe361e8aa29c77d652a065de4"}, + {file = "pycryptodome-3.20.0-cp27-cp27mu-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3427d9e5310af6680678f4cce149f54e0bb4af60101c7f2c16fdf878b39ccccc"}, + {file = "pycryptodome-3.20.0-cp27-cp27mu-musllinux_1_1_aarch64.whl", hash = "sha256:3cd3ef3aee1079ae44afaeee13393cf68b1058f70576b11439483e34f93cf818"}, + {file = "pycryptodome-3.20.0-cp35-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac1c7c0624a862f2e53438a15c9259d1655325fc2ec4392e66dc46cdae24d044"}, + {file = "pycryptodome-3.20.0-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:76658f0d942051d12a9bd08ca1b6b34fd762a8ee4240984f7c06ddfb55eaf15a"}, + {file = "pycryptodome-3.20.0-cp35-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f35d6cee81fa145333137009d9c8ba90951d7d77b67c79cbe5f03c7eb74d8fe2"}, + {file = "pycryptodome-3.20.0-cp35-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76cb39afede7055127e35a444c1c041d2e8d2f1f9c121ecef573757ba4cd2c3c"}, + {file = "pycryptodome-3.20.0-cp35-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49a4c4dc60b78ec41d2afa392491d788c2e06edf48580fbfb0dd0f828af49d25"}, + {file = "pycryptodome-3.20.0-cp35-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fb3b87461fa35afa19c971b0a2b7456a7b1db7b4eba9a8424666104925b78128"}, + {file = "pycryptodome-3.20.0-cp35-abi3-musllinux_1_1_i686.whl", hash = "sha256:acc2614e2e5346a4a4eab6e199203034924313626f9620b7b4b38e9ad74b7e0c"}, + {file = "pycryptodome-3.20.0-cp35-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:210ba1b647837bfc42dd5a813cdecb5b86193ae11a3f5d972b9a0ae2c7e9e4b4"}, + {file = "pycryptodome-3.20.0-cp35-abi3-win32.whl", hash = "sha256:8d6b98d0d83d21fb757a182d52940d028564efe8147baa9ce0f38d057104ae72"}, + {file = "pycryptodome-3.20.0-cp35-abi3-win_amd64.whl", hash = "sha256:9b3ae153c89a480a0ec402e23db8d8d84a3833b65fa4b15b81b83be9d637aab9"}, + {file = "pycryptodome-3.20.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:4401564ebf37dfde45d096974c7a159b52eeabd9969135f0426907db367a652a"}, + {file = "pycryptodome-3.20.0-pp27-pypy_73-win32.whl", hash = "sha256:ec1f93feb3bb93380ab0ebf8b859e8e5678c0f010d2d78367cf6bc30bfeb148e"}, + {file = "pycryptodome-3.20.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:acae12b9ede49f38eb0ef76fdec2df2e94aad85ae46ec85be3648a57f0a7db04"}, + {file = "pycryptodome-3.20.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f47888542a0633baff535a04726948e876bf1ed880fddb7c10a736fa99146ab3"}, + {file = "pycryptodome-3.20.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e0e4a987d38cfc2e71b4a1b591bae4891eeabe5fa0f56154f576e26287bfdea"}, + {file = "pycryptodome-3.20.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c18b381553638414b38705f07d1ef0a7cf301bc78a5f9bc17a957eb19446834b"}, + {file = "pycryptodome-3.20.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a60fedd2b37b4cb11ccb5d0399efe26db9e0dd149016c1cc6c8161974ceac2d6"}, + {file = "pycryptodome-3.20.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:405002eafad114a2f9a930f5db65feef7b53c4784495dd8758069b89baf68eab"}, + {file = "pycryptodome-3.20.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ab6ab0cb755154ad14e507d1df72de9897e99fd2d4922851a276ccc14f4f1a5"}, + {file = "pycryptodome-3.20.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:acf6e43fa75aca2d33e93409f2dafe386fe051818ee79ee8a3e21de9caa2ac9e"}, + {file = "pycryptodome-3.20.0.tar.gz", hash = "sha256:09609209ed7de61c2b560cc5c8c4fbf892f8b15b1faf7e4cbffac97db1fffda7"}, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, + {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.41.5" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, + {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.1" + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pyopenssl" +version = "26.0.0" +description = "Python wrapper module around the OpenSSL library" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81"}, + {file = "pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc"}, +] + +[package.dependencies] +cryptography = ">=46.0.0,<47" +typing-extensions = {version = ">=4.9", markers = "python_version < \"3.13\" and python_version >= \"3.8\""} + +[package.extras] +docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx_rtd_theme"] +test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"] + +[[package]] +name = "pyparsing" +version = "3.3.2" +description = "pyparsing - Classes and methods to define and execute parsing grammars" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d"}, + {file = "pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc"}, +] + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.24.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b"}, + {file = "pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276"}, +] + +[package.dependencies] +pytest = ">=8.2,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "pytest-cov" +version = "6.3.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749"}, + {file = "pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2"}, +] + +[package.dependencies] +coverage = {version = ">=7.5", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=6.2.5" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "python-barcode" +version = "0.16.1" +description = "Create standard barcodes with Python. No external modules needed. (optional Pillow support included)." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "python_barcode-0.16.1-py3-none-any.whl", hash = "sha256:5776567478c9a0dae473374bb86631ba0b5ea99aaf302763b364e367ac51f367"}, + {file = "python_barcode-0.16.1.tar.gz", hash = "sha256:665ed09516b0088b5593061c5ac8662caa0b08d56bdad328388b1cab39939ac5"}, +] + +[package.extras] +images = ["pillow"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-dotenv" +version = "1.2.2" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"}, + {file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "pytz" +version = "2026.1.post1" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a"}, + {file = "pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + +[[package]] +name = "qrcode" +version = "8.2" +description = "QR Code image generator" +optional = false +python-versions = "<4.0,>=3.9" +groups = ["main"] +files = [ + {file = "qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f"}, + {file = "qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +pillow = {version = ">=9.1.0", optional = true, markers = "extra == \"pil\" or extra == \"all\""} + +[package.extras] +all = ["pillow (>=9.1.0)", "pypng"] +pil = ["pillow (>=9.1.0)"] +png = ["pypng"] + +[[package]] +name = "referencing" +version = "0.37.0" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, + {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" +typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} + +[[package]] +name = "regex" +version = "2026.2.28" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "regex-2026.2.28-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fc48c500838be6882b32748f60a15229d2dea96e59ef341eaa96ec83538f498d"}, + {file = "regex-2026.2.28-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2afa673660928d0b63d84353c6c08a8a476ddfc4a47e11742949d182e6863ce8"}, + {file = "regex-2026.2.28-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7ab218076eb0944549e7fe74cf0e2b83a82edb27e81cc87411f76240865e04d5"}, + {file = "regex-2026.2.28-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d63db12e45a9b9f064bfe4800cefefc7e5f182052e4c1b774d46a40ab1d9bb"}, + {file = "regex-2026.2.28-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:195237dc327858a7721bf8b0bbbef797554bc13563c3591e91cd0767bacbe359"}, + {file = "regex-2026.2.28-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b387a0d092dac157fb026d737dde35ff3e49ef27f285343e7c6401851239df27"}, + {file = "regex-2026.2.28-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3935174fa4d9f70525a4367aaff3cb8bc0548129d114260c29d9dfa4a5b41692"}, + {file = "regex-2026.2.28-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b2b23587b26496ff5fd40df4278becdf386813ec00dc3533fa43a4cf0e2ad3c"}, + {file = "regex-2026.2.28-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3b24bd7e9d85dc7c6a8bd2aa14ecd234274a0248335a02adeb25448aecdd420d"}, + {file = "regex-2026.2.28-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bd477d5f79920338107f04aa645f094032d9e3030cc55be581df3d1ef61aa318"}, + {file = "regex-2026.2.28-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b49eb78048c6354f49e91e4b77da21257fecb92256b6d599ae44403cab30b05b"}, + {file = "regex-2026.2.28-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a25c7701e4f7a70021db9aaf4a4a0a67033c6318752146e03d1b94d32006217e"}, + {file = "regex-2026.2.28-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9dd450db6458387167e033cfa80887a34c99c81d26da1bf8b0b41bf8c9cac88e"}, + {file = "regex-2026.2.28-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2954379dd20752e82d22accf3ff465311cbb2bac6c1f92c4afd400e1757f7451"}, + {file = "regex-2026.2.28-cp310-cp310-win32.whl", hash = "sha256:1f8b17be5c27a684ea6759983c13506bd77bfc7c0347dff41b18ce5ddd2ee09a"}, + {file = "regex-2026.2.28-cp310-cp310-win_amd64.whl", hash = "sha256:dd8847c4978bc3c7e6c826fb745f5570e518b8459ac2892151ce6627c7bc00d5"}, + {file = "regex-2026.2.28-cp310-cp310-win_arm64.whl", hash = "sha256:73cdcdbba8028167ea81490c7f45280113e41db2c7afb65a276f4711fa3bcbff"}, + {file = "regex-2026.2.28-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e621fb7c8dc147419b28e1702f58a0177ff8308a76fa295c71f3e7827849f5d9"}, + {file = "regex-2026.2.28-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0d5bef2031cbf38757a0b0bc4298bb4824b6332d28edc16b39247228fbdbad97"}, + {file = "regex-2026.2.28-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bcb399ed84eabf4282587ba151f2732ad8168e66f1d3f85b1d038868fe547703"}, + {file = "regex-2026.2.28-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1b34dfa72f826f535b20712afa9bb3ba580020e834f3c69866c5bddbf10098"}, + {file = "regex-2026.2.28-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:851fa70df44325e1e4cdb79c5e676e91a78147b1b543db2aec8734d2add30ec2"}, + {file = "regex-2026.2.28-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:516604edd17b1c2c3e579cf4e9b25a53bf8fa6e7cedddf1127804d3e0140ca64"}, + {file = "regex-2026.2.28-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7ce83654d1ab701cb619285a18a8e5a889c1216d746ddc710c914ca5fd71022"}, + {file = "regex-2026.2.28-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2791948f7c70bb9335a9102df45e93d428f4b8128020d85920223925d73b9e1"}, + {file = "regex-2026.2.28-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:03a83cc26aa2acda6b8b9dfe748cf9e84cbd390c424a1de34fdcef58961a297a"}, + {file = "regex-2026.2.28-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ec6f5674c5dc836994f50f1186dd1fafde4be0666aae201ae2fcc3d29d8adf27"}, + {file = "regex-2026.2.28-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:50c2fc924749543e0eacc93ada6aeeb3ea5f6715825624baa0dccaec771668ae"}, + {file = "regex-2026.2.28-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ba55c50f408fb5c346a3a02d2ce0ebc839784e24f7c9684fde328ff063c3cdea"}, + {file = "regex-2026.2.28-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:edb1b1b3a5576c56f08ac46f108c40333f222ebfd5cf63afdfa3aab0791ebe5b"}, + {file = "regex-2026.2.28-cp311-cp311-win32.whl", hash = "sha256:948c12ef30ecedb128903c2c2678b339746eb7c689c5c21957c4a23950c96d15"}, + {file = "regex-2026.2.28-cp311-cp311-win_amd64.whl", hash = "sha256:fd63453f10d29097cc3dc62d070746523973fb5aa1c66d25f8558bebd47fed61"}, + {file = "regex-2026.2.28-cp311-cp311-win_arm64.whl", hash = "sha256:00f2b8d9615aa165fdff0a13f1a92049bfad555ee91e20d246a51aa0b556c60a"}, + {file = "regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7"}, + {file = "regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d"}, + {file = "regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d"}, + {file = "regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc"}, + {file = "regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8"}, + {file = "regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d"}, + {file = "regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4"}, + {file = "regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05"}, + {file = "regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5"}, + {file = "regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59"}, + {file = "regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf"}, + {file = "regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae"}, + {file = "regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b"}, + {file = "regex-2026.2.28-cp312-cp312-win32.whl", hash = "sha256:f8ed9a5d4612df9d4de15878f0bc6aa7a268afbe5af21a3fdd97fa19516e978c"}, + {file = "regex-2026.2.28-cp312-cp312-win_amd64.whl", hash = "sha256:01d65fd24206c8e1e97e2e31b286c59009636c022eb5d003f52760b0f42155d4"}, + {file = "regex-2026.2.28-cp312-cp312-win_arm64.whl", hash = "sha256:c0b5ccbb8ffb433939d248707d4a8b31993cb76ab1a0187ca886bf50e96df952"}, + {file = "regex-2026.2.28-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6d63a07e5ec8ce7184452cb00c41c37b49e67dc4f73b2955b5b8e782ea970784"}, + {file = "regex-2026.2.28-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e59bc8f30414d283ae8ee1617b13d8112e7135cb92830f0ec3688cb29152585a"}, + {file = "regex-2026.2.28-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:de0cf053139f96219ccfabb4a8dd2d217c8c82cb206c91d9f109f3f552d6b43d"}, + {file = "regex-2026.2.28-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb4db2f17e6484904f986c5a657cec85574c76b5c5e61c7aae9ffa1bc6224f95"}, + {file = "regex-2026.2.28-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52b017b35ac2214d0db5f4f90e303634dc44e4aba4bd6235a27f97ecbe5b0472"}, + {file = "regex-2026.2.28-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69fc560ccbf08a09dc9b52ab69cacfae51e0ed80dc5693078bdc97db2f91ae96"}, + {file = "regex-2026.2.28-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e61eea47230eba62a31f3e8a0e3164d0f37ef9f40529fb2c79361bc6b53d2a92"}, + {file = "regex-2026.2.28-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4f5c0b182ad4269e7381b7c27fdb0408399881f7a92a4624fd5487f2971dfc11"}, + {file = "regex-2026.2.28-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:96f6269a2882fbb0ee76967116b83679dc628e68eaea44e90884b8d53d833881"}, + {file = "regex-2026.2.28-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b5acd4b6a95f37c3c3828e5d053a7d4edaedb85de551db0153754924cb7c83e3"}, + {file = "regex-2026.2.28-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2234059cfe33d9813a3677ef7667999caea9eeaa83fef98eb6ce15c6cf9e0215"}, + {file = "regex-2026.2.28-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c15af43c72a7fb0c97cbc66fa36a43546eddc5c06a662b64a0cbf30d6ac40944"}, + {file = "regex-2026.2.28-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9185cc63359862a6e80fe97f696e04b0ad9a11c4ac0a4a927f979f611bfe3768"}, + {file = "regex-2026.2.28-cp313-cp313-win32.whl", hash = "sha256:fb66e5245db9652abd7196ace599b04d9c0e4aa7c8f0e2803938377835780081"}, + {file = "regex-2026.2.28-cp313-cp313-win_amd64.whl", hash = "sha256:71a911098be38c859ceb3f9a9ce43f4ed9f4c6720ad8684a066ea246b76ad9ff"}, + {file = "regex-2026.2.28-cp313-cp313-win_arm64.whl", hash = "sha256:39bb5727650b9a0275c6a6690f9bb3fe693a7e6cc5c3155b1240aedf8926423e"}, + {file = "regex-2026.2.28-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:97054c55db06ab020342cc0d35d6f62a465fa7662871190175f1ad6c655c028f"}, + {file = "regex-2026.2.28-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0d25a10811de831c2baa6aef3c0be91622f44dd8d31dd12e69f6398efb15e48b"}, + {file = "regex-2026.2.28-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d6cfe798d8da41bb1862ed6e0cba14003d387c3c0c4a5d45591076ae9f0ce2f8"}, + {file = "regex-2026.2.28-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd0ce43e71d825b7c0661f9c54d4d74bd97c56c3fd102a8985bcfea48236bacb"}, + {file = "regex-2026.2.28-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00945d007fd74a9084d2ab79b695b595c6b7ba3698972fadd43e23230c6979c1"}, + {file = "regex-2026.2.28-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bec23c11cbbf09a4df32fe50d57cbdd777bc442269b6e39a1775654f1c95dee2"}, + {file = "regex-2026.2.28-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5cdcc17d935c8f9d3f4db5c2ebe2640c332e3822ad5d23c2f8e0228e6947943a"}, + {file = "regex-2026.2.28-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a448af01e3d8031c89c5d902040b124a5e921a25c4e5e07a861ca591ce429341"}, + {file = "regex-2026.2.28-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:10d28e19bd4888e4abf43bd3925f3c134c52fdf7259219003588a42e24c2aa25"}, + {file = "regex-2026.2.28-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:99985a2c277dcb9ccb63f937451af5d65177af1efdeb8173ac55b61095a0a05c"}, + {file = "regex-2026.2.28-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e1e7b24cb3ae9953a560c563045d1ba56ee4749fbd05cf21ba571069bd7be81b"}, + {file = "regex-2026.2.28-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d8511a01d0e4ee1992eb3ba19e09bc1866fe03f05129c3aec3fdc4cbc77aad3f"}, + {file = "regex-2026.2.28-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:aaffaecffcd2479ce87aa1e74076c221700b7c804e48e98e62500ee748f0f550"}, + {file = "regex-2026.2.28-cp313-cp313t-win32.whl", hash = "sha256:ef77bdde9c9eba3f7fa5b58084b29bbcc74bcf55fdbeaa67c102a35b5bd7e7cc"}, + {file = "regex-2026.2.28-cp313-cp313t-win_amd64.whl", hash = "sha256:98adf340100cbe6fbaf8e6dc75e28f2c191b1be50ffefe292fb0e6f6eefdb0d8"}, + {file = "regex-2026.2.28-cp313-cp313t-win_arm64.whl", hash = "sha256:2fb950ac1d88e6b6a9414381f403797b236f9fa17e1eee07683af72b1634207b"}, + {file = "regex-2026.2.28-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:78454178c7df31372ea737996fb7f36b3c2c92cccc641d251e072478afb4babc"}, + {file = "regex-2026.2.28-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:5d10303dd18cedfd4d095543998404df656088240bcfd3cd20a8f95b861f74bd"}, + {file = "regex-2026.2.28-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:19a9c9e0a8f24f39d575a6a854d516b48ffe4cbdcb9de55cb0570a032556ecff"}, + {file = "regex-2026.2.28-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09500be324f49b470d907b3ef8af9afe857f5cca486f853853f7945ddbf75911"}, + {file = "regex-2026.2.28-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb1c4ff62277d87a7335f2c1ea4e0387b8f2b3ad88a64efd9943906aafad4f33"}, + {file = "regex-2026.2.28-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b8b3f1be1738feadc69f62daa250c933e85c6f34fa378f54a7ff43807c1b9117"}, + {file = "regex-2026.2.28-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc8ed8c3f41c27acb83f7b6a9eb727a73fc6663441890c5cb3426a5f6a91ce7d"}, + {file = "regex-2026.2.28-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa539be029844c0ce1114762d2952ab6cfdd7c7c9bd72e0db26b94c3c36dcc5a"}, + {file = "regex-2026.2.28-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7900157786428a79615a8264dac1f12c9b02957c473c8110c6b1f972dcecaddf"}, + {file = "regex-2026.2.28-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0b1d2b07614d95fa2bf8a63fd1e98bd8fa2b4848dc91b1efbc8ba219fdd73952"}, + {file = "regex-2026.2.28-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b389c61aa28a79c2e0527ac36da579869c2e235a5b208a12c5b5318cda2501d8"}, + {file = "regex-2026.2.28-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f467cb602f03fbd1ab1908f68b53c649ce393fde056628dc8c7e634dab6bfc07"}, + {file = "regex-2026.2.28-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c8cb2deba42f5ec1ede46374e990f8adc5e6456a57ac1a261b19be6f28e4e6"}, + {file = "regex-2026.2.28-cp314-cp314-win32.whl", hash = "sha256:9036b400b20e4858d56d117108d7813ed07bb7803e3eed766675862131135ca6"}, + {file = "regex-2026.2.28-cp314-cp314-win_amd64.whl", hash = "sha256:1d367257cd86c1cbb97ea94e77b373a0bbc2224976e247f173d19e8f18b4afa7"}, + {file = "regex-2026.2.28-cp314-cp314-win_arm64.whl", hash = "sha256:5e68192bb3a1d6fb2836da24aa494e413ea65853a21505e142e5b1064a595f3d"}, + {file = "regex-2026.2.28-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a5dac14d0872eeb35260a8e30bac07ddf22adc1e3a0635b52b02e180d17c9c7e"}, + {file = "regex-2026.2.28-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ec0c608b7a7465ffadb344ed7c987ff2f11ee03f6a130b569aa74d8a70e8333c"}, + {file = "regex-2026.2.28-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7815afb0ca45456613fdaf60ea9c993715511c8d53a83bc468305cbc0ee23c7"}, + {file = "regex-2026.2.28-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b059e71ec363968671693a78c5053bd9cb2fe410f9b8e4657e88377ebd603a2e"}, + {file = "regex-2026.2.28-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8cf76f1a29f0e99dcfd7aef1551a9827588aae5a737fe31442021165f1920dc"}, + {file = "regex-2026.2.28-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:180e08a435a0319e6a4821c3468da18dc7001987e1c17ae1335488dfe7518dd8"}, + {file = "regex-2026.2.28-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e496956106fd59ba6322a8ea17141a27c5040e5ee8f9433ae92d4e5204462a0"}, + {file = "regex-2026.2.28-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bba2b18d70eeb7b79950f12f633beeecd923f7c9ad6f6bae28e59b4cb3ab046b"}, + {file = "regex-2026.2.28-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6db7bfae0f8a2793ff1f7021468ea55e2699d0790eb58ee6ab36ae43aa00bc5b"}, + {file = "regex-2026.2.28-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d0b02e8b7e5874b48ae0f077ecca61c1a6a9f9895e9c6dfb191b55b242862033"}, + {file = "regex-2026.2.28-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:25b6eb660c5cf4b8c3407a1ed462abba26a926cc9965e164268a3267bcc06a43"}, + {file = "regex-2026.2.28-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5a932ea8ad5d0430351ff9c76c8db34db0d9f53c1d78f06022a21f4e290c5c18"}, + {file = "regex-2026.2.28-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2c95e1a2b0f89d01e821ff4de1be4b5d73d1f4b0bf679fa27c1ad8d2327f1a"}, + {file = "regex-2026.2.28-cp314-cp314t-win32.whl", hash = "sha256:bbb882061f742eb5d46f2f1bd5304055be0a66b783576de3d7eef1bed4778a6e"}, + {file = "regex-2026.2.28-cp314-cp314t-win_amd64.whl", hash = "sha256:6591f281cb44dc13de9585b552cec6fc6cf47fb2fe7a48892295ee9bc4a612f9"}, + {file = "regex-2026.2.28-cp314-cp314t-win_arm64.whl", hash = "sha256:dee50f1be42222f89767b64b283283ef963189da0dda4a515aa54a5563c62dec"}, + {file = "regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2"}, +] + +[[package]] +name = "requests" +version = "2.33.0" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b"}, + {file = "requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652"}, +] + +[package.dependencies] +certifi = ">=2023.5.7" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.26,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +test = ["PySocks (>=1.5.6,!=1.5.7)", "pytest (>=3)", "pytest-cov", "pytest-httpbin (==2.1.0)", "pytest-mock", "pytest-xdist"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] + +[[package]] +name = "rich" +version = "15.0.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.9.0" +groups = ["main"] +files = [ + {file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"}, + {file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "rpds-py" +version = "0.30.0" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"}, + {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139"}, + {file = "rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464"}, + {file = "rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85"}, + {file = "rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394"}, + {file = "rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95"}, + {file = "rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53"}, + {file = "rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e"}, + {file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"}, +] + +[[package]] +name = "safetensors" +version = "0.7.0" +description = "" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517"}, + {file = "safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57"}, + {file = "safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542"}, + {file = "safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104"}, + {file = "safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d"}, + {file = "safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a"}, + {file = "safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48"}, + {file = "safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981"}, + {file = "safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b"}, + {file = "safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85"}, + {file = "safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0"}, + {file = "safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4"}, + {file = "safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba"}, + {file = "safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755"}, + {file = "safetensors-0.7.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4729811a6640d019a4b7ba8638ee2fd21fa5ca8c7e7bdf0fed62068fcaac737"}, + {file = "safetensors-0.7.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12f49080303fa6bb424b362149a12949dfbbf1e06811a88f2307276b0c131afd"}, + {file = "safetensors-0.7.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0071bffba4150c2f46cae1432d31995d77acfd9f8db598b5d1a2ce67e8440ad2"}, + {file = "safetensors-0.7.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:473b32699f4200e69801bf5abf93f1a4ecd432a70984df164fc22ccf39c4a6f3"}, + {file = "safetensors-0.7.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b95a3fa7b3abb9b5b0e07668e808364d0d40f6bbbf9ae0faa8b5b210c97b140"}, + {file = "safetensors-0.7.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cfdead2f57330d76aa7234051dadfa7d4eedc0e5a27fd08e6f96714a92b00f09"}, + {file = "safetensors-0.7.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc92bc2db7b45bda4510e4f51c59b00fe80b2d6be88928346e4294ce1c2abe7c"}, + {file = "safetensors-0.7.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6999421eb8ba9df4450a16d9184fcb7bef26240b9f98e95401f17af6c2210b71"}, + {file = "safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0"}, +] + +[package.extras] +all = ["safetensors[jax]", "safetensors[numpy]", "safetensors[paddlepaddle]", "safetensors[pinned-tf]", "safetensors[quality]", "safetensors[testing]", "safetensors[torch]"] +dev = ["safetensors[all]"] +jax = ["flax (>=0.6.3)", "jax (>=0.3.25)", "jaxlib (>=0.3.25)", "safetensors[numpy]"] +mlx = ["mlx (>=0.0.9)"] +numpy = ["numpy (>=1.21.6)"] +paddlepaddle = ["paddlepaddle (>=2.4.1)", "safetensors[numpy]"] +pinned-tf = ["safetensors[numpy]", "tensorflow (==2.18.0)"] +quality = ["ruff"] +tensorflow = ["safetensors[numpy]", "tensorflow (>=2.11.0)"] +testing = ["h5py (>=3.7.0)", "huggingface-hub (>=0.12.1)", "hypothesis (>=6.70.2)", "pytest (>=7.2.0)", "pytest-benchmark (>=4.0.0)", "safetensors[numpy]", "setuptools-rust (>=1.5.2)"] +testingfree = ["huggingface-hub (>=0.12.1)", "hypothesis (>=6.70.2)", "pytest (>=7.2.0)", "pytest-benchmark (>=4.0.0)", "safetensors[numpy]", "setuptools-rust (>=1.5.2)"] +torch = ["packaging", "safetensors[numpy]", "torch (>=1.10)"] + +[[package]] +name = "setuptools" +version = "82.0.1" +description = "Most extensible Python build backend with support for C/C++ extension modules" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb"}, + {file = "setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy"] + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "starlette" +version = "1.0.0" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b"}, + {file = "starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149"}, +] + +[package.dependencies] +anyio = ">=3.6.2,<5" +typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] + +[[package]] +name = "tokenizers" +version = "0.21.4" +description = "" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tokenizers-0.21.4-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2ccc10a7c3bcefe0f242867dc914fc1226ee44321eb618cfe3019b5df3400133"}, + {file = "tokenizers-0.21.4-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:5e2f601a8e0cd5be5cc7506b20a79112370b9b3e9cb5f13f68ab11acd6ca7d60"}, + {file = "tokenizers-0.21.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:39b376f5a1aee67b4d29032ee85511bbd1b99007ec735f7f35c8a2eb104eade5"}, + {file = "tokenizers-0.21.4-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2107ad649e2cda4488d41dfd031469e9da3fcbfd6183e74e4958fa729ffbf9c6"}, + {file = "tokenizers-0.21.4-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c73012da95afafdf235ba80047699df4384fdc481527448a078ffd00e45a7d9"}, + {file = "tokenizers-0.21.4-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f23186c40395fc390d27f519679a58023f368a0aad234af145e0f39ad1212732"}, + {file = "tokenizers-0.21.4-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc88bb34e23a54cc42713d6d98af5f1bf79c07653d24fe984d2d695ba2c922a2"}, + {file = "tokenizers-0.21.4-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51b7eabb104f46c1c50b486520555715457ae833d5aee9ff6ae853d1130506ff"}, + {file = "tokenizers-0.21.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:714b05b2e1af1288bd1bc56ce496c4cebb64a20d158ee802887757791191e6e2"}, + {file = "tokenizers-0.21.4-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1340ff877ceedfa937544b7d79f5b7becf33a4cfb58f89b3b49927004ef66f78"}, + {file = "tokenizers-0.21.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3c1f4317576e465ac9ef0d165b247825a2a4078bcd01cba6b54b867bdf9fdd8b"}, + {file = "tokenizers-0.21.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c212aa4e45ec0bb5274b16b6f31dd3f1c41944025c2358faaa5782c754e84c24"}, + {file = "tokenizers-0.21.4-cp39-abi3-win32.whl", hash = "sha256:6c42a930bc5f4c47f4ea775c91de47d27910881902b0f20e4990ebe045a415d0"}, + {file = "tokenizers-0.21.4-cp39-abi3-win_amd64.whl", hash = "sha256:475d807a5c3eb72c59ad9b5fcdb254f6e17f53dfcbb9903233b0dfa9c943b597"}, + {file = "tokenizers-0.21.4.tar.gz", hash = "sha256:fa23f85fbc9a02ec5c6978da172cdcbac23498c3ca9f3645c5c68740ac007880"}, +] + +[package.dependencies] +huggingface-hub = ">=0.16.4,<1.0" + +[package.extras] +dev = ["tokenizers[testing]"] +docs = ["setuptools-rust", "sphinx", "sphinx-rtd-theme"] +testing = ["black (==22.3)", "datasets", "numpy", "pytest", "requests", "ruff"] + +[[package]] +name = "tqdm" +version = "4.67.3" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf"}, + {file = "tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"] +discord = ["requests"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + +[[package]] +name = "transformers" +version = "4.52.4" +description = "State-of-the-art Machine Learning for JAX, PyTorch and TensorFlow" +optional = false +python-versions = ">=3.9.0" +groups = ["main"] +files = [ + {file = "transformers-4.52.4-py3-none-any.whl", hash = "sha256:203f5c19416d5877e36e88633943761719538a25d9775977a24fe77a1e5adfc7"}, + {file = "transformers-4.52.4.tar.gz", hash = "sha256:aff3764441c1adc192a08dba49740d3cbbcb72d850586075aed6bd89b98203e6"}, +] + +[package.dependencies] +filelock = "*" +huggingface-hub = ">=0.30.0,<1.0" +numpy = ">=1.17" +packaging = ">=20.0" +pyyaml = ">=5.1" +regex = "!=2019.12.17" +requests = "*" +safetensors = ">=0.4.3" +tokenizers = ">=0.21,<0.22" +tqdm = ">=4.27" + +[package.extras] +accelerate = ["accelerate (>=0.26.0)"] +all = ["Pillow (>=10.0.1,<=15.0)", "accelerate (>=0.26.0)", "av", "codecarbon (>=2.8.1)", "flax (>=0.4.1,<=0.7.0)", "jax (>=0.4.1,<=0.4.13)", "jaxlib (>=0.4.1,<=0.4.13)", "kenlm", "keras-nlp (>=0.3.1,<0.14.0)", "kernels (>=0.4.4,<0.5)", "librosa", "num2words", "onnxconverter-common", "optax (>=0.0.8,<=0.1.4)", "optuna", "phonemizer", "protobuf", "pyctcdecode (>=0.4.0)", "ray[tune] (>=2.7.0)", "scipy (<1.13.0)", "sentencepiece (>=0.1.91,!=0.1.92)", "sigopt", "tensorflow (>2.9,<2.16)", "tensorflow-text (<2.16)", "tf2onnx", "timm (<=1.0.11)", "tokenizers (>=0.21,<0.22)", "torch (>=2.1,<2.7)", "torchaudio", "torchvision"] +audio = ["kenlm", "librosa", "phonemizer", "pyctcdecode (>=0.4.0)"] +benchmark = ["optimum-benchmark (>=0.3.0)"] +codecarbon = ["codecarbon (>=2.8.1)"] +deepspeed = ["accelerate (>=0.26.0)", "deepspeed (>=0.9.3)"] +deepspeed-testing = ["GitPython (<3.1.19)", "accelerate (>=0.26.0)", "beautifulsoup4", "cookiecutter (==1.7.3)", "datasets (!=2.5.0)", "deepspeed (>=0.9.3)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "nltk (<=3.8.1)", "optuna", "parameterized", "protobuf", "psutil", "pydantic", "pytest (>=7.2.0)", "pytest-asyncio", "pytest-order", "pytest-rerunfailures", "pytest-rich", "pytest-timeout", "pytest-xdist", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.11.2)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "sentencepiece (>=0.1.91,!=0.1.92)", "tensorboard", "timeout-decorator"] +dev = ["GitPython (<3.1.19)", "Pillow (>=10.0.1,<=15.0)", "accelerate (>=0.26.0)", "av", "beautifulsoup4", "codecarbon (>=2.8.1)", "cookiecutter (==1.7.3)", "datasets (!=2.5.0)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "flax (>=0.4.1,<=0.7.0)", "fugashi (>=1.0)", "ipadic (>=1.0.0,<2.0)", "isort (>=5.5.4)", "jax (>=0.4.1,<=0.4.13)", "jaxlib (>=0.4.1,<=0.4.13)", "kenlm", "keras-nlp (>=0.3.1,<0.14.0)", "kernels (>=0.4.4,<0.5)", "libcst", "librosa", "nltk (<=3.8.1)", "num2words", "onnxconverter-common", "optax (>=0.0.8,<=0.1.4)", "optuna", "parameterized", "phonemizer", "protobuf", "psutil", "pyctcdecode (>=0.4.0)", "pydantic", "pytest (>=7.2.0)", "pytest-asyncio", "pytest-order", "pytest-rerunfailures", "pytest-rich", "pytest-timeout", "pytest-xdist", "ray[tune] (>=2.7.0)", "rhoknp (>=1.1.0,<1.3.1)", "rich", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.11.2)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "scikit-learn", "scipy (<1.13.0)", "sentencepiece (>=0.1.91,!=0.1.92)", "sigopt", "sudachidict-core (>=20220729)", "sudachipy (>=0.6.6)", "tensorboard", "tensorflow (>2.9,<2.16)", "tensorflow-text (<2.16)", "tf2onnx", "timeout-decorator", "timm (<=1.0.11)", "tokenizers (>=0.21,<0.22)", "torch (>=2.1,<2.7)", "torchaudio", "torchvision", "unidic (>=1.0.2)", "unidic-lite (>=1.0.7)", "urllib3 (<2.0.0)"] +dev-tensorflow = ["GitPython (<3.1.19)", "Pillow (>=10.0.1,<=15.0)", "beautifulsoup4", "cookiecutter (==1.7.3)", "datasets (!=2.5.0)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "isort (>=5.5.4)", "kenlm", "keras-nlp (>=0.3.1,<0.14.0)", "libcst", "librosa", "nltk (<=3.8.1)", "onnxconverter-common", "onnxruntime (>=1.4.0)", "onnxruntime-tools (>=1.4.2)", "parameterized", "phonemizer", "protobuf", "psutil", "pyctcdecode (>=0.4.0)", "pydantic", "pytest (>=7.2.0)", "pytest-asyncio", "pytest-order", "pytest-rerunfailures", "pytest-rich", "pytest-timeout", "pytest-xdist", "rich", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.11.2)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "scikit-learn", "sentencepiece (>=0.1.91,!=0.1.92)", "tensorboard", "tensorflow (>2.9,<2.16)", "tensorflow-text (<2.16)", "tf2onnx", "timeout-decorator", "tokenizers (>=0.21,<0.22)", "urllib3 (<2.0.0)"] +dev-torch = ["GitPython (<3.1.19)", "Pillow (>=10.0.1,<=15.0)", "accelerate (>=0.26.0)", "beautifulsoup4", "codecarbon (>=2.8.1)", "cookiecutter (==1.7.3)", "datasets (!=2.5.0)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "fugashi (>=1.0)", "ipadic (>=1.0.0,<2.0)", "isort (>=5.5.4)", "kenlm", "kernels (>=0.4.4,<0.5)", "libcst", "librosa", "nltk (<=3.8.1)", "num2words", "onnxruntime (>=1.4.0)", "onnxruntime-tools (>=1.4.2)", "optuna", "parameterized", "phonemizer", "protobuf", "psutil", "pyctcdecode (>=0.4.0)", "pydantic", "pytest (>=7.2.0)", "pytest-asyncio", "pytest-order", "pytest-rerunfailures", "pytest-rich", "pytest-timeout", "pytest-xdist", "ray[tune] (>=2.7.0)", "rhoknp (>=1.1.0,<1.3.1)", "rich", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.11.2)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "scikit-learn", "sentencepiece (>=0.1.91,!=0.1.92)", "sigopt", "sudachidict-core (>=20220729)", "sudachipy (>=0.6.6)", "tensorboard", "timeout-decorator", "timm (<=1.0.11)", "tokenizers (>=0.21,<0.22)", "torch (>=2.1,<2.7)", "torchaudio", "torchvision", "unidic (>=1.0.2)", "unidic-lite (>=1.0.7)", "urllib3 (<2.0.0)"] +flax = ["flax (>=0.4.1,<=0.7.0)", "jax (>=0.4.1,<=0.4.13)", "jaxlib (>=0.4.1,<=0.4.13)", "optax (>=0.0.8,<=0.1.4)", "scipy (<1.13.0)"] +flax-speech = ["kenlm", "librosa", "phonemizer", "pyctcdecode (>=0.4.0)"] +ftfy = ["ftfy"] +hf-xet = ["hf-xet"] +hub-kernels = ["kernels (>=0.4.4,<0.5)"] +integrations = ["kernels (>=0.4.4,<0.5)", "optuna", "ray[tune] (>=2.7.0)", "sigopt"] +ja = ["fugashi (>=1.0)", "ipadic (>=1.0.0,<2.0)", "rhoknp (>=1.1.0,<1.3.1)", "sudachidict-core (>=20220729)", "sudachipy (>=0.6.6)", "unidic (>=1.0.2)", "unidic-lite (>=1.0.7)"] +modelcreation = ["cookiecutter (==1.7.3)"] +natten = ["natten (>=0.14.6,<0.15.0)"] +num2words = ["num2words"] +onnx = ["onnxconverter-common", "onnxruntime (>=1.4.0)", "onnxruntime-tools (>=1.4.2)", "tf2onnx"] +onnxruntime = ["onnxruntime (>=1.4.0)", "onnxruntime-tools (>=1.4.2)"] +optuna = ["optuna"] +quality = ["GitPython (<3.1.19)", "datasets (!=2.5.0)", "isort (>=5.5.4)", "libcst", "rich", "ruff (==0.11.2)", "urllib3 (<2.0.0)"] +ray = ["ray[tune] (>=2.7.0)"] +retrieval = ["datasets (!=2.5.0)", "faiss-cpu"] +ruff = ["ruff (==0.11.2)"] +sagemaker = ["sagemaker (>=2.31.0)"] +sentencepiece = ["protobuf", "sentencepiece (>=0.1.91,!=0.1.92)"] +serving = ["fastapi", "pydantic", "starlette", "uvicorn"] +sigopt = ["sigopt"] +sklearn = ["scikit-learn"] +speech = ["kenlm", "librosa", "phonemizer", "pyctcdecode (>=0.4.0)", "torchaudio"] +testing = ["GitPython (<3.1.19)", "beautifulsoup4", "cookiecutter (==1.7.3)", "datasets (!=2.5.0)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "nltk (<=3.8.1)", "parameterized", "psutil", "pydantic", "pytest (>=7.2.0)", "pytest-asyncio", "pytest-order", "pytest-rerunfailures", "pytest-rich", "pytest-timeout", "pytest-xdist", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.11.2)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "sentencepiece (>=0.1.91,!=0.1.92)", "tensorboard", "timeout-decorator"] +tf = ["keras-nlp (>=0.3.1,<0.14.0)", "onnxconverter-common", "tensorflow (>2.9,<2.16)", "tensorflow-text (<2.16)", "tf2onnx"] +tf-cpu = ["keras (>2.9,<2.16)", "keras-nlp (>=0.3.1,<0.14.0)", "onnxconverter-common", "tensorflow-cpu (>2.9,<2.16)", "tensorflow-probability (<0.24)", "tensorflow-text (<2.16)", "tf2onnx"] +tf-speech = ["kenlm", "librosa", "phonemizer", "pyctcdecode (>=0.4.0)"] +tiktoken = ["blobfile", "tiktoken"] +timm = ["timm (<=1.0.11)"] +tokenizers = ["tokenizers (>=0.21,<0.22)"] +torch = ["accelerate (>=0.26.0)", "torch (>=2.1,<2.7)"] +torch-speech = ["kenlm", "librosa", "phonemizer", "pyctcdecode (>=0.4.0)", "torchaudio"] +torch-vision = ["Pillow (>=10.0.1,<=15.0)", "torchvision"] +torchhub = ["filelock", "huggingface-hub (>=0.30.0,<1.0)", "importlib-metadata", "numpy (>=1.17)", "packaging (>=20.0)", "protobuf", "regex (!=2019.12.17)", "requests", "sentencepiece (>=0.1.91,!=0.1.92)", "tokenizers (>=0.21,<0.22)", "torch (>=2.1,<2.7)", "tqdm (>=4.27)"] +video = ["av"] +vision = ["Pillow (>=10.0.1,<=15.0)"] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[[package]] +name = "urllib3" +version = "2.6.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, +] + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[[package]] +name = "uvicorn" +version = "0.31.1" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "uvicorn-0.31.1-py3-none-any.whl", hash = "sha256:adc42d9cac80cf3e51af97c1851648066841e7cfb6993a4ca8de29ac1548ed41"}, + {file = "uvicorn-0.31.1.tar.gz", hash = "sha256:f5167919867b161b7bcaf32646c6a94cdbd4c3aa2eb5c17d36bb9aa5cfd8c493"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} +h11 = ">=0.8" +httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standard\""} +python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} +uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} + +[package.extras] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "uvloop" +version = "0.22.1" +description = "Fast implementation of asyncio event loop on top of libuv" +optional = false +python-versions = ">=3.8.1" +groups = ["main"] +markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c"}, + {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792"}, + {file = "uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86"}, + {file = "uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd"}, + {file = "uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2"}, + {file = "uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec"}, + {file = "uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9"}, + {file = "uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77"}, + {file = "uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21"}, + {file = "uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702"}, + {file = "uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733"}, + {file = "uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473"}, + {file = "uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42"}, + {file = "uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6"}, + {file = "uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370"}, + {file = "uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4"}, + {file = "uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2"}, + {file = "uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0"}, + {file = "uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705"}, + {file = "uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8"}, + {file = "uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d"}, + {file = "uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e"}, + {file = "uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e"}, + {file = "uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad"}, + {file = "uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142"}, + {file = "uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74"}, + {file = "uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35"}, + {file = "uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25"}, + {file = "uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6"}, + {file = "uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079"}, + {file = "uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289"}, + {file = "uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3"}, + {file = "uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c"}, + {file = "uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21"}, + {file = "uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88"}, + {file = "uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e"}, + {file = "uvloop-0.22.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:80eee091fe128e425177fbd82f8635769e2f32ec9daf6468286ec57ec0313efa"}, + {file = "uvloop-0.22.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:017bd46f9e7b78e81606329d07141d3da446f8798c6baeec124260e22c262772"}, + {file = "uvloop-0.22.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3e5c6727a57cb6558592a95019e504f605d1c54eb86463ee9f7a2dbd411c820"}, + {file = "uvloop-0.22.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:57df59d8b48feb0e613d9b1f5e57b7532e97cbaf0d61f7aa9aa32221e84bc4b6"}, + {file = "uvloop-0.22.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:55502bc2c653ed2e9692e8c55cb95b397d33f9f2911e929dc97c4d6b26d04242"}, + {file = "uvloop-0.22.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4a968a72422a097b09042d5fa2c5c590251ad484acf910a651b4b620acd7f193"}, + {file = "uvloop-0.22.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b45649628d816c030dba3c80f8e2689bab1c89518ed10d426036cdc47874dfc4"}, + {file = "uvloop-0.22.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ea721dd3203b809039fcc2983f14608dae82b212288b346e0bfe46ec2fab0b7c"}, + {file = "uvloop-0.22.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ae676de143db2b2f60a9696d7eca5bb9d0dd6cc3ac3dad59a8ae7e95f9e1b54"}, + {file = "uvloop-0.22.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17d4e97258b0172dfa107b89aa1eeba3016f4b1974ce85ca3ef6a66b35cbf659"}, + {file = "uvloop-0.22.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:05e4b5f86e621cf3927631789999e697e58f0d2d32675b67d9ca9eb0bca55743"}, + {file = "uvloop-0.22.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:286322a90bea1f9422a470d5d2ad82d38080be0a29c4dd9b3e6384320a4d11e7"}, + {file = "uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f"}, +] + +[package.extras] +dev = ["Cython (>=3.0,<4.0)", "setuptools (>=60)"] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx_rtd_theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["aiohttp (>=3.10.5)", "flake8 (>=6.1,<7.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=25.3.0,<25.4.0)", "pycodestyle (>=2.11.0,<2.12.0)"] + +[[package]] +name = "vastai-sdk" +version = "0.0.0" +description = "SDK for Vast.ai GPU Cloud Service" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [] +develop = true + +[package.dependencies] +aiodns = ">=3.6.0" +aiofiles = ">=23.0" +aiohttp = ">=3.9.1" +anyio = "~4.4" +borb = "~2.1.25" +fastapi = ">=0.110,<1.0" +hf_transfer = ">=0.1.9" +jsonschema = ">=3.2" +nltk = "~3.9" +psutil = "~6.0" +pycares = "=4.11.0" +pycryptodome = "~3.20" +pyparsing = ">=3.1,<4.0" +python-dateutil = ">=2.8.2" +pytz = ">=2023.3" +requests = ">=2.32.3" +transformers = "~4.52" +urllib3 = ">=2.0,<3.0" +uvicorn = {version = ">=0.24,<0.32", extras = ["standard"]} +xdg = ">=1.0.0" + +[package.source] +type = "directory" +url = ".." + +[[package]] +name = "watchfiles" +version = "1.1.1" +description = "Simple, modern and high performance file watching and code reload in python." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c"}, + {file = "watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab"}, + {file = "watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82"}, + {file = "watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4"}, + {file = "watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844"}, + {file = "watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e"}, + {file = "watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5"}, + {file = "watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606"}, + {file = "watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701"}, + {file = "watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10"}, + {file = "watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849"}, + {file = "watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4"}, + {file = "watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e"}, + {file = "watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d"}, + {file = "watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803"}, + {file = "watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94"}, + {file = "watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43"}, + {file = "watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9"}, + {file = "watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9"}, + {file = "watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404"}, + {file = "watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18"}, + {file = "watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d"}, + {file = "watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b"}, + {file = "watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374"}, + {file = "watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0"}, + {file = "watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42"}, + {file = "watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18"}, + {file = "watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da"}, + {file = "watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77"}, + {file = "watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef"}, + {file = "watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"}, + {file = "watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5"}, + {file = "watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05"}, + {file = "watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6"}, + {file = "watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81"}, + {file = "watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b"}, + {file = "watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a"}, + {file = "watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02"}, + {file = "watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21"}, + {file = "watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c"}, + {file = "watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099"}, + {file = "watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01"}, + {file = "watchfiles-1.1.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c882d69f6903ef6092bedfb7be973d9319940d56b8427ab9187d1ecd73438a70"}, + {file = "watchfiles-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d6ff426a7cb54f310d51bfe83fe9f2bbe40d540c741dc974ebc30e6aa238f52e"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79ff6c6eadf2e3fc0d7786331362e6ef1e51125892c75f1004bd6b52155fb956"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c1f5210f1b8fc91ead1283c6fd89f70e76fb07283ec738056cf34d51e9c1d62c"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9c4702f29ca48e023ffd9b7ff6b822acdf47cb1ff44cb490a3f1d5ec8987e9c"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acb08650863767cbc58bca4813b92df4d6c648459dcaa3d4155681962b2aa2d3"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08af70fd77eee58549cd69c25055dc344f918d992ff626068242259f98d598a2"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c3631058c37e4a0ec440bf583bc53cdbd13e5661bb6f465bc1d88ee9a0a4d02"}, + {file = "watchfiles-1.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cf57a27fb986c6243d2ee78392c503826056ffe0287e8794503b10fb51b881be"}, + {file = "watchfiles-1.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d7e7067c98040d646982daa1f37a33d3544138ea155536c2e0e63e07ff8a7e0f"}, + {file = "watchfiles-1.1.1-cp39-cp39-win32.whl", hash = "sha256:6c9c9262f454d1c4d8aaa7050121eb4f3aea197360553699520767daebf2180b"}, + {file = "watchfiles-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:74472234c8370669850e1c312490f6026d132ca2d396abfad8830b4f1c096957"}, + {file = "watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3"}, + {file = "watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2"}, + {file = "watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d"}, + {file = "watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b"}, + {file = "watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88"}, + {file = "watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336"}, + {file = "watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24"}, + {file = "watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49"}, + {file = "watchfiles-1.1.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdab464fee731e0884c35ae3588514a9bcf718d0e2c82169c1c4a85cc19c3c7f"}, + {file = "watchfiles-1.1.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3dbd8cbadd46984f802f6d479b7e3afa86c42d13e8f0f322d669d79722c8ec34"}, + {file = "watchfiles-1.1.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5524298e3827105b61951a29c3512deb9578586abf3a7c5da4a8069df247cccc"}, + {file = "watchfiles-1.1.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b943d3668d61cfa528eb949577479d3b077fd25fb83c641235437bc0b5bc60e"}, + {file = "watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2"}, +] + +[package.dependencies] +anyio = ">=3.0.0" + +[[package]] +name = "websockets" +version = "16.0" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"}, + {file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"}, + {file = "websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957"}, + {file = "websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72"}, + {file = "websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3"}, + {file = "websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9"}, + {file = "websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad"}, + {file = "websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d"}, + {file = "websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe"}, + {file = "websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64"}, + {file = "websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6"}, + {file = "websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79"}, + {file = "websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39"}, + {file = "websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c"}, + {file = "websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2"}, + {file = "websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89"}, + {file = "websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230"}, + {file = "websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c"}, + {file = "websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5"}, + {file = "websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f"}, + {file = "websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a"}, + {file = "websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904"}, + {file = "websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"}, + {file = "websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e"}, + {file = "websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3"}, + {file = "websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8"}, + {file = "websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e"}, + {file = "websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641"}, + {file = "websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8"}, + {file = "websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206"}, + {file = "websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6"}, + {file = "websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c"}, + {file = "websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767"}, + {file = "websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec"}, + {file = "websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5"}, +] + +[[package]] +name = "xdg" +version = "6.0.0" +description = "Variables defined by the XDG Base Directory Specification" +optional = false +python-versions = ">=3.7,<4.0" +groups = ["main"] +files = [ + {file = "xdg-6.0.0-py3-none-any.whl", hash = "sha256:df3510755b4395157fc04fc3b02467c777f3b3ca383257397f09ab0d4c16f936"}, + {file = "xdg-6.0.0.tar.gz", hash = "sha256:24278094f2d45e846d1eb28a2ebb92d7b67fc0cab5249ee3ce88c95f649a1c92"}, +] + +[[package]] +name = "yarl" +version = "1.23.0" +description = "Yet another URL library" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "yarl-1.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cff6d44cb13d39db2663a22b22305d10855efa0fa8015ddeacc40bc59b9d8107"}, + {file = "yarl-1.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4c53f8347cd4200f0d70a48ad059cabaf24f5adc6ba08622a23423bc7efa10d"}, + {file = "yarl-1.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a6940a074fb3c48356ed0158a3ca5699c955ee4185b4d7d619be3c327143e05"}, + {file = "yarl-1.23.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed5f69ce7be7902e5c70ea19eb72d20abf7d725ab5d49777d696e32d4fc1811d"}, + {file = "yarl-1.23.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:389871e65468400d6283c0308e791a640b5ab5c83bcee02a2f51295f95e09748"}, + {file = "yarl-1.23.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dda608c88cf709b1d406bdfcd84d8d63cff7c9e577a403c6108ce8ce9dcc8764"}, + {file = "yarl-1.23.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c4fe09e0780c6c3bf2b7d4af02ee2394439d11a523bbcf095cf4747c2932007"}, + {file = "yarl-1.23.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31c9921eb8bd12633b41ad27686bbb0b1a2a9b8452bfdf221e34f311e9942ed4"}, + {file = "yarl-1.23.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5f10fd85e4b75967468af655228fbfd212bdf66db1c0d135065ce288982eda26"}, + {file = "yarl-1.23.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dbf507e9ef5688bada447a24d68b4b58dd389ba93b7afc065a2ba892bea54769"}, + {file = "yarl-1.23.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:85e9beda1f591bc73e77ea1c51965c68e98dafd0fec72cdd745f77d727466716"}, + {file = "yarl-1.23.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0e1fdaa14ef51366d7757b45bde294e95f6c8c049194e793eedb8387c86d5993"}, + {file = "yarl-1.23.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:75e3026ab649bf48f9a10c0134512638725b521340293f202a69b567518d94e0"}, + {file = "yarl-1.23.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:80e6d33a3d42a7549b409f199857b4fb54e2103fc44fb87605b6663b7a7ff750"}, + {file = "yarl-1.23.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5ec2f42d41ccbd5df0270d7df31618a8ee267bfa50997f5d720ddba86c4a83a6"}, + {file = "yarl-1.23.0-cp310-cp310-win32.whl", hash = "sha256:debe9c4f41c32990771be5c22b56f810659f9ddf3d63f67abfdcaa2c6c9c5c1d"}, + {file = "yarl-1.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:ab5f043cb8a2d71c981c09c510da013bc79fd661f5c60139f00dd3c3cc4f2ffb"}, + {file = "yarl-1.23.0-cp310-cp310-win_arm64.whl", hash = "sha256:263cd4f47159c09b8b685890af949195b51d1aa82ba451c5847ca9bc6413c220"}, + {file = "yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99"}, + {file = "yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c"}, + {file = "yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432"}, + {file = "yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a"}, + {file = "yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05"}, + {file = "yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83"}, + {file = "yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c"}, + {file = "yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598"}, + {file = "yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b"}, + {file = "yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c"}, + {file = "yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788"}, + {file = "yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222"}, + {file = "yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb"}, + {file = "yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc"}, + {file = "yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2"}, + {file = "yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5"}, + {file = "yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46"}, + {file = "yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928"}, + {file = "yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860"}, + {file = "yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069"}, + {file = "yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25"}, + {file = "yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8"}, + {file = "yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072"}, + {file = "yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8"}, + {file = "yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7"}, + {file = "yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51"}, + {file = "yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67"}, + {file = "yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7"}, + {file = "yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d"}, + {file = "yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760"}, + {file = "yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2"}, + {file = "yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86"}, + {file = "yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34"}, + {file = "yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d"}, + {file = "yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e"}, + {file = "yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9"}, + {file = "yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e"}, + {file = "yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5"}, + {file = "yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b"}, + {file = "yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035"}, + {file = "yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5"}, + {file = "yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735"}, + {file = "yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401"}, + {file = "yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4"}, + {file = "yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f"}, + {file = "yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a"}, + {file = "yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2"}, + {file = "yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f"}, + {file = "yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b"}, + {file = "yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a"}, + {file = "yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543"}, + {file = "yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957"}, + {file = "yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3"}, + {file = "yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3"}, + {file = "yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa"}, + {file = "yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120"}, + {file = "yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59"}, + {file = "yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512"}, + {file = "yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4"}, + {file = "yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1"}, + {file = "yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea"}, + {file = "yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9"}, + {file = "yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123"}, + {file = "yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24"}, + {file = "yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de"}, + {file = "yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b"}, + {file = "yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6"}, + {file = "yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6"}, + {file = "yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5"}, + {file = "yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595"}, + {file = "yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090"}, + {file = "yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144"}, + {file = "yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912"}, + {file = "yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474"}, + {file = "yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719"}, + {file = "yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319"}, + {file = "yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434"}, + {file = "yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723"}, + {file = "yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039"}, + {file = "yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52"}, + {file = "yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c"}, + {file = "yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae"}, + {file = "yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e"}, + {file = "yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85"}, + {file = "yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd"}, + {file = "yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6"}, + {file = "yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe"}, + {file = "yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169"}, + {file = "yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70"}, + {file = "yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e"}, + {file = "yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679"}, + {file = "yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412"}, + {file = "yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4"}, + {file = "yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c"}, + {file = "yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4"}, + {file = "yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94"}, + {file = "yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28"}, + {file = "yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6"}, + {file = "yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277"}, + {file = "yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4"}, + {file = "yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a"}, + {file = "yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb"}, + {file = "yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41"}, + {file = "yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2"}, + {file = "yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4"}, + {file = "yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4"}, + {file = "yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2"}, + {file = "yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25"}, + {file = "yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f"}, + {file = "yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" +propcache = ">=0.2.1" + +[metadata] +lock-version = "2.1" +python-versions = ">=3.12,<3.13" +content-hash = "cb5068da39fdd6a0ec047622ed87e701760fad79966bdd561f41dfa504c77d8f" diff --git a/tests/pyproject.toml b/tests/pyproject.toml new file mode 100644 index 00000000..5d57edce --- /dev/null +++ b/tests/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] +name = "vast-sdk-tests" +version = "0.0.0" +description = "Test suite and dependencies for Vast SDK" +package-mode = false + +[tool.poetry.dependencies] +# Matches CI Python 3.12; root ``vastai-sdk`` allows a wider range—use 3.12 locally for this env. +python = ">=3.12,<3.13" +pytest = "^8.0.0" +pytest-asyncio = "^0.24.0" +pytest-cov = "^6.0.0" +cryptography = ">=41.0" +pyOpenSSL = ">=23.0" +vastai-sdk = { path = "..", develop = true } +rich = ">=13.0" + +[tool.pytest.ini_options] +testpaths = ["."] +python_files = ["test_*.py"] +python_functions = ["test_*"] +asyncio_mode = "auto" diff --git a/tests/sdk/__init__.py b/tests/sdk/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/sdk/test_imports.py b/tests/sdk/test_imports.py new file mode 100644 index 00000000..fef4d036 --- /dev/null +++ b/tests/sdk/test_imports.py @@ -0,0 +1,31 @@ +"""Smoke tests for package imports and exports.""" +import pytest + + +class TestPackageImports: + """Verify the vastai package imports and exports correctly.""" + + def test_import_vastai(self) -> None: + """VastAI can be imported from vastai.""" + from vastai import VastAI + assert VastAI is not None + + def test_import_serverless(self) -> None: + """Serverless can be imported from vastai.""" + from vastai import Serverless, ServerlessRequest + assert Serverless is not None + assert ServerlessRequest is not None + + def test_import_endpoint(self) -> None: + """Endpoint can be imported from vastai.""" + from vastai import Endpoint + assert Endpoint is not None + + def test_import_worker_config(self) -> None: + """Worker config dataclasses can be imported from vastai.""" + from vastai import Worker, WorkerConfig, HandlerConfig, LogActionConfig, BenchmarkConfig + assert Worker is not None + assert WorkerConfig is not None + assert HandlerConfig is not None + assert LogActionConfig is not None + assert BenchmarkConfig is not None diff --git a/tests/sdk/test_sdk.py b/tests/sdk/test_sdk.py new file mode 100644 index 00000000..9f78316b --- /dev/null +++ b/tests/sdk/test_sdk.py @@ -0,0 +1,452 @@ +"""Tests for VastAI SDK class — covers methods with real logic beyond simple delegation.""" + +import os + +import pytest +from unittest.mock import patch, MagicMock, mock_open + +from vastai.sdk import VastAI + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def sdk(): + """VastAI instance with a mock client.""" + with patch("vastai.sdk.VastClient"): + v = VastAI(api_key="test-key") + v.client = MagicMock() + yield v + + +# --------------------------------------------------------------------------- +# __init__ — API key resolution +# --------------------------------------------------------------------------- + + +class TestInit: + def test_explicit_key(self): + with patch("vastai.sdk.VastClient") as MockClient: + VastAI(api_key="explicit-key") + assert MockClient.call_args[0][0] == "explicit-key" + + def test_reads_key_from_legacy_file(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) # Path.home() on Windows + monkeypatch.delenv("VAST_API_KEY", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + (tmp_path / ".vast_api_key").write_text(" legacy-key \n") + with patch("vastai.sdk.VastClient") as MockClient: + VastAI() + assert MockClient.call_args[0][0] == "legacy-key" + + def test_reads_key_from_xdg_path(self, tmp_path, monkeypatch): + """Regression: VastAI() must pick up the key stored by `vastai set api-key`.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) # Path.home() on Windows + monkeypatch.delenv("VAST_API_KEY", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + xdg_dir = tmp_path / ".config" / "vastai" + xdg_dir.mkdir(parents=True) + (xdg_dir / "vast_api_key").write_text("xdg-key") + with patch("vastai.sdk.VastClient") as MockClient: + VastAI() + assert MockClient.call_args[0][0] == "xdg-key" + + def test_reads_key_from_xdg_path_when_xdg_import_fails(self, tmp_path, monkeypatch): + # `import xdg` raising in _resolve_api_key must fall back to ~/.config. + import sys + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) # Path.home() on Windows + monkeypatch.delenv("VAST_API_KEY", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setitem(sys.modules, "xdg", None) + xdg_dir = tmp_path / ".config" / "vastai" + xdg_dir.mkdir(parents=True) + (xdg_dir / "vast_api_key").write_text("xdg-fallback-key") + with patch("vastai.sdk.VastClient") as MockClient: + VastAI() + assert MockClient.call_args[0][0] == "xdg-fallback-key" + + def test_reads_key_from_env(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) # Path.home() on Windows + monkeypatch.setenv("VAST_API_KEY", "env-key") + with patch("vastai.sdk.VastClient") as MockClient: + VastAI() + assert MockClient.call_args[0][0] == "env-key" + + def test_env_var_takes_precedence_over_files(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) # Path.home() on Windows + monkeypatch.setenv("VAST_API_KEY", "env-key") + (tmp_path / ".vast_api_key").write_text("legacy-key") + xdg_dir = tmp_path / ".config" / "vastai" + xdg_dir.mkdir(parents=True) + (xdg_dir / "vast_api_key").write_text("xdg-key") + with patch("vastai.sdk.VastClient") as MockClient: + VastAI() + assert MockClient.call_args[0][0] == "env-key" + + def test_no_key_raises(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) # Path.home() on Windows + monkeypatch.delenv("VAST_API_KEY", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + with pytest.raises(RuntimeError, match="No API key found"): + VastAI() + + def test_options_passed_through(self): + with patch("vastai.sdk.VastClient") as MockClient: + v = VastAI(api_key="k", server_url="http://test", retry=5, explain=True, curl=True, raw=True, quiet=True) + MockClient.assert_called_once_with("k", "http://test", 5, True, True) + assert v.raw is True + assert v.quiet is True + + +# --------------------------------------------------------------------------- +# ssh_url / scp_url — URL building with fallback logic +# --------------------------------------------------------------------------- + + +class TestSshUrl: + def test_direct_fields(self, sdk): + with patch("vastai.api.instances.show_instance", return_value={"ssh_host": "1.2.3.4", "ssh_port": 2222}): + assert sdk.ssh_url(1) == "ssh://root@1.2.3.4:2222" + + def test_falls_back_to_ports_dict(self, sdk): + inst = {"public_ipaddr": "5.6.7.8", "ports": {"22/tcp": [{"HostPort": "9999"}]}} + with patch("vastai.api.instances.show_instance", return_value=inst): + assert sdk.ssh_url(1) == "ssh://root@5.6.7.8:9999" + + def test_empty_when_missing(self, sdk): + with patch("vastai.api.instances.show_instance", return_value={}): + assert sdk.ssh_url(1) == "" + + def test_empty_when_deleted(self, sdk): + with patch("vastai.api.instances.show_instance", return_value=None): + assert sdk.ssh_url(1) == "" + + def test_unwraps_list_response(self, sdk): + with patch("vastai.api.instances.show_instance", return_value=[{"ssh_host": "1.2.3.4", "ssh_port": 22}]): + assert sdk.ssh_url(1) == "ssh://root@1.2.3.4:22" + + def test_empty_list_response(self, sdk): + with patch("vastai.api.instances.show_instance", return_value=[]): + assert sdk.ssh_url(1) == "" + + +class TestScpUrl: + def test_builds_scp_url(self, sdk): + with patch("vastai.api.instances.show_instance", return_value={"ssh_host": "1.2.3.4", "ssh_port": 22}): + assert sdk.scp_url(1) == "scp://root@1.2.3.4:22" + + def test_empty_when_missing(self, sdk): + with patch("vastai.api.instances.show_instance", return_value={}): + assert sdk.scp_url(1) == "" + + def test_empty_when_deleted(self, sdk): + with patch("vastai.api.instances.show_instance", return_value=None): + assert sdk.scp_url(1) == "" + + +# --------------------------------------------------------------------------- +# search_offers — query/order string parsing +# --------------------------------------------------------------------------- + + +class TestSearchOffers: + def test_string_query_is_parsed(self, sdk): + with patch("vastai.api.offers.search_offers", return_value=[]) as mock: + sdk.search_offers("num_gpus=1") + query = mock.call_args.kwargs["query"] + assert isinstance(query, dict) + assert "num_gpus" in query + + def test_dict_query_passed_through(self, sdk): + q = {"num_gpus": {"eq": 1}} + with patch("vastai.api.offers.search_offers", return_value=[]) as mock: + sdk.search_offers(q) + assert mock.call_args.kwargs["query"] is q + + def test_none_query(self, sdk): + with patch("vastai.api.offers.search_offers", return_value=[]) as mock: + sdk.search_offers() + assert mock.call_args.kwargs["query"] is None + + def test_order_desc(self, sdk): + with patch("vastai.api.offers.search_offers", return_value=[]) as mock: + sdk.search_offers(order="score-") + assert mock.call_args.kwargs["order"] == [["score", "desc"]] + + def test_order_asc(self, sdk): + with patch("vastai.api.offers.search_offers", return_value=[]) as mock: + sdk.search_offers(order="dph_total+") + assert mock.call_args.kwargs["order"] == [["dph_total", "asc"]] + + def test_order_multi_field(self, sdk): + with patch("vastai.api.offers.search_offers", return_value=[]) as mock: + sdk.search_offers(order="score-,dph_total") + order = mock.call_args.kwargs["order"] + assert len(order) == 2 + assert order[0] == ["score", "desc"] + assert order[1][1] == "asc" + + def test_order_list_passed_through(self, sdk): + order = [["score", "desc"]] + with patch("vastai.api.offers.search_offers", return_value=[]) as mock: + sdk.search_offers(order=order) + assert mock.call_args.kwargs["order"] is order + + def test_empty_order_segments_skipped(self, sdk): + """Empty segments in order string (e.g. trailing comma) should be skipped.""" + with patch("vastai.api.offers.search_offers", return_value=[]) as mock: + sdk.search_offers(order="score-,") + order = mock.call_args.kwargs["order"] + assert len(order) == 1 + + def test_string_query_seeds_defaults(self, sdk): + """String queries should arrive at the helper with defaults pre-merged + and no_default=True so defaults are not applied a second time.""" + with patch("vastai.api.offers.search_offers", return_value=[]) as mock: + sdk.search_offers("num_gpus>=1") + q = mock.call_args.kwargs["query"] + assert q["verified"] == {"eq": True} + assert q["rentable"] == {"eq": True} + assert q["external"] == {"eq": False} + assert mock.call_args.kwargs["no_default"] is True + + def test_field_any_removes_default(self, sdk): + """Regression: explicit `field=any` must clear the default filter + (matches CLI behavior). Reported in vast-cli#383.""" + with patch("vastai.api.offers.search_offers", return_value=[]) as mock: + sdk.search_offers("num_gpus>=1 verified=any rentable=any") + q = mock.call_args.kwargs["query"] + assert "verified" not in q + assert "rentable" not in q + assert q["external"] == {"eq": False} + assert mock.call_args.kwargs["no_default"] is True + + def test_no_default_skips_seeding(self, sdk): + """no_default=True should skip default seeding entirely.""" + with patch("vastai.api.offers.search_offers", return_value=[]) as mock: + sdk.search_offers("num_gpus>=1", no_default=True) + q = mock.call_args.kwargs["query"] + assert "verified" not in q + assert "rentable" not in q + assert mock.call_args.kwargs["no_default"] is True + + def test_dict_query_lets_helper_apply_defaults(self, sdk): + """Dict queries are not pre-seeded; the helper still applies defaults + per no_default. Preserves existing behavior for dict callers.""" + with patch("vastai.api.offers.search_offers", return_value=[]) as mock: + sdk.search_offers({"num_gpus": {"gte": 1}}) + assert mock.call_args.kwargs["no_default"] is False + + def test_field_any_removes_default_search_offers_new(self, sdk): + """Same regression coverage for the search_offers_new path.""" + with patch("vastai.api.offers.search_offers_new", return_value=[]) as mock: + sdk.search_offers_new("num_gpus>=1 verified=any") + q = mock.call_args.kwargs["query"] + assert "verified" not in q + assert mock.call_args.kwargs["no_default"] is True + + +# --------------------------------------------------------------------------- +# show_env_vars — value masking +# --------------------------------------------------------------------------- + + +class TestShowEnvVars: + def test_masks_values_by_default(self, sdk): + with patch("vastai.api.auth.show_env_vars", return_value={"SECRET": "real_value", "OTHER": "also_secret"}): + result = sdk.show_env_vars() + assert result["SECRET"] == "****" + assert result["OTHER"] == "****" + + def test_shows_values_when_requested(self, sdk): + with patch("vastai.api.auth.show_env_vars", return_value={"SECRET": "real_value"}): + result = sdk.show_env_vars(show_values=True) + assert result["SECRET"] == "real_value" + + def test_handles_non_dict_response(self, sdk): + """If API returns non-dict, pass through without masking.""" + with patch("vastai.api.auth.show_env_vars", return_value=[]): + result = sdk.show_env_vars() + assert result == [] + + +# --------------------------------------------------------------------------- +# create_template — kwarg translation +# --------------------------------------------------------------------------- + + +class TestCreateTemplate: + def test_jupyter_direct(self, sdk): + with patch("vastai.api.offers.create_template", return_value={"success": True}) as mock: + sdk.create_template(image="test", jupyter=True, direct=True) + kw = mock.call_args.kwargs + assert kw["jup_direct"] is True + assert kw["runtype"] == "jupyter" + assert kw["use_ssh"] is True + + def test_ssh_mode(self, sdk): + with patch("vastai.api.offers.create_template", return_value={"success": True}) as mock: + sdk.create_template(image="test", ssh=True) + kw = mock.call_args.kwargs + assert kw["ssh_direct"] is False + assert kw["use_ssh"] is True + assert kw["runtype"] == "ssh" + + def test_args_mode_default(self, sdk): + with patch("vastai.api.offers.create_template", return_value={"success": True}) as mock: + sdk.create_template(image="test") + kw = mock.call_args.kwargs + assert kw["runtype"] == "args" + assert kw["use_ssh"] is False + assert kw["jup_direct"] is False + + def test_login_extracts_repo(self, sdk): + with patch("vastai.api.offers.create_template", return_value={"success": True}) as mock: + sdk.create_template(image="test", login="docker.io/myrepo user pass") + assert mock.call_args.kwargs["docker_login_repo"] == "docker.io/myrepo" + + def test_public_and_hide_readme(self, sdk): + with patch("vastai.api.offers.create_template", return_value={"success": True}) as mock: + sdk.create_template(image="test", public=True, hide_readme=True) + kw = mock.call_args.kwargs + assert kw["private"] is False + assert kw["readme_visible"] is False + + def test_strips_non_api_kwargs(self, sdk): + """search_params and no_default should be removed before calling API.""" + with patch("vastai.api.offers.create_template", return_value={"success": True}) as mock: + sdk.create_template(image="test", search_params="x", no_default=True) + kw = mock.call_args.kwargs + assert "search_params" not in kw + assert "no_default" not in kw + + +# --------------------------------------------------------------------------- +# copy — vast URL parsing +# --------------------------------------------------------------------------- + + +class TestCopy: + def test_parses_urls(self, sdk): + with patch("vastai.api.storage.copy", return_value={"success": True}) as mock: + sdk.copy("12345:/data/input", "67890:/data/output") + # parse_vast_url returns string IDs + mock.assert_called_once_with(sdk.client, "12345", "67890", "/data/input", "/data/output") + + +# --------------------------------------------------------------------------- +# create_subaccount — type translation +# --------------------------------------------------------------------------- + + +class TestCreateSubaccount: + def test_host_type(self, sdk): + with patch("vastai.api.billing.create_subaccount", return_value={"success": True}) as mock: + sdk.create_subaccount("a@b.com", "user", "pass", type="host") + assert mock.call_args.kwargs["host_only"] is True + + def test_no_type(self, sdk): + with patch("vastai.api.billing.create_subaccount", return_value={"success": True}) as mock: + sdk.create_subaccount("a@b.com", "user", "pass") + assert mock.call_args.kwargs["host_only"] is False + + def test_non_host_type(self, sdk): + with patch("vastai.api.billing.create_subaccount", return_value={"success": True}) as mock: + sdk.create_subaccount("a@b.com", "user", "pass", type="client") + assert mock.call_args.kwargs["host_only"] is False + + +# --------------------------------------------------------------------------- +# list_machines — loops over IDs +# --------------------------------------------------------------------------- + + +class TestShowApiKeysUnwraps: + def test_unwraps_envelope(self, sdk): + with patch("vastai.api.keys.show_api_keys", return_value={"apikeys": [{"id": 1}, {"id": 2}]}): + result = sdk.show_api_keys() + assert result == [{"id": 1}, {"id": 2}] + + def test_empty_envelope(self, sdk): + with patch("vastai.api.keys.show_api_keys", return_value={"apikeys": []}): + assert sdk.show_api_keys() == [] + + def test_passes_through_non_envelope(self, sdk): + """If the backend ever switches to a bare list, don't choke.""" + with patch("vastai.api.keys.show_api_keys", return_value=[{"id": 1}]): + assert sdk.show_api_keys() == [{"id": 1}] + + +class TestShowMachineUnwraps: + def test_unwraps_single_element_list(self, sdk): + with patch("vastai.api.machines.show_machine", return_value=[{"id": 42, "gpu_name": "RTX_4090"}]): + result = sdk.show_machine(id=42) + assert result == {"id": 42, "gpu_name": "RTX_4090"} + + def test_empty_list_raises(self, sdk): + with patch("vastai.api.machines.show_machine", return_value=[]): + with pytest.raises(ValueError, match="not found"): + sdk.show_machine(id=42) + + def test_multiple_rows_raises(self, sdk): + with patch("vastai.api.machines.show_machine", return_value=[{"id": 42}, {"id": 43}]): + with pytest.raises(ValueError, match="got 2"): + sdk.show_machine(id=42) + + def test_passes_through_non_list_response(self, sdk): + """Defensive: if the backend ever starts returning a dict directly, don't choke.""" + with patch("vastai.api.machines.show_machine", return_value={"id": 42, "gpu_name": "RTX_4090"}): + result = sdk.show_machine(id=42) + assert result == {"id": 42, "gpu_name": "RTX_4090"} + + +class TestListMachines: + def test_calls_per_id(self, sdk): + with patch("vastai.api.machines.list_machine", return_value={"success": True}) as mock: + result = sdk.list_machines([1, 2, 3], gpu_name="RTX_4090") + assert mock.call_count == 3 + assert len(result) == 3 + # Verify each ID was called + called_ids = [call.args[1] for call in mock.call_args_list] + assert called_ids == [1, 2, 3] + + def test_empty_list(self, sdk): + with patch("vastai.api.machines.list_machine") as mock: + result = sdk.list_machines([]) + assert result == [] + mock.assert_not_called() + + +# --------------------------------------------------------------------------- +# set_api_key — direct mutation +# --------------------------------------------------------------------------- + + +class TestSetApiKey: + def test_updates_client(self, sdk): + sdk.set_api_key("new-key") + assert sdk.client.api_key == "new-key" + + +# --------------------------------------------------------------------------- +# NotImplementedError methods +# --------------------------------------------------------------------------- + + +class TestNotImplemented: + def test_generate_pdf_invoices(self, sdk): + with pytest.raises(NotImplementedError): + sdk.generate_pdf_invoices() + + def test_self_test_machine(self, sdk): + with pytest.raises(NotImplementedError): + sdk.self_test_machine(1) diff --git a/tests/serverless/__init__.py b/tests/serverless/__init__.py new file mode 100644 index 00000000..a9fb6948 --- /dev/null +++ b/tests/serverless/__init__.py @@ -0,0 +1 @@ +# Serverless client tests diff --git a/tests/serverless/test_backend.py b/tests/serverless/test_backend.py new file mode 100644 index 00000000..4a03e237 --- /dev/null +++ b/tests/serverless/test_backend.py @@ -0,0 +1,1623 @@ +"""Unit tests for vastai.serverless.server.lib.backend.Backend. + +Covers session HTTP handlers, request forwarding entry points, and small helpers. +All I/O and crypto verification are mocked per unit-test-requirements (no real network). +""" + +from __future__ import annotations + +import asyncio +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from aiohttp import ClientTimeout, web + +from vastai.serverless.server.lib.data_types import ( + JsonDataException, + RequestMetrics, +) + +pytestmark = pytest.mark.usefixtures("clear_get_url_cache") + +# --------------------------------------------------------------------------- +# Session: health +# --------------------------------------------------------------------------- + + +class TestBackendSessionHealth: + """Tests for Backend.session_health_handler.""" + + @pytest.mark.asyncio + async def test_session_health_invalid_json_returns_422( + self, serverless_backend_and_handler_default, serverless_backend_testkit + ) -> None: + """ + Verifies session_health_handler returns 422 when request body is not valid JSON. + + This test verifies by: + 1. Using ``serverless_backend_and_handler_default`` for the Backend instance + 2. Calling session_health_handler with a mock request whose json() raises JSONDecodeError + 3. Asserting status 422 and error payload + + Assumptions: + - No session state is required for this path + """ + backend, _ = serverless_backend_and_handler_default + req = serverless_backend_testkit.json_request("{") + resp = await backend.session_health_handler(req) + assert resp.status == 422 + body = serverless_backend_testkit.response_json(resp) + assert body.get("error") == "invalid JSON" + + @pytest.mark.asyncio + async def test_session_health_missing_session_id_returns_422( + self, serverless_backend_and_handler_default, serverless_backend_testkit + ) -> None: + """ + Verifies session_health_handler returns 422 when session_id is absent or empty. + + This test verifies by: + 1. POSTing JSON without session_id and with ``session_id`` set to ``""`` + 2. Asserting 422 and missing session_id error in both cases + + Assumptions: + - Empty string session_id is treated as missing (falsy) + """ + backend, _ = serverless_backend_and_handler_default + req = serverless_backend_testkit.json_request({"session_auth": "x"}) + resp = await backend.session_health_handler(req) + assert resp.status == 422 + assert ( + serverless_backend_testkit.response_json(resp).get("error") + == "missing session_id" + ) + + req_empty = serverless_backend_testkit.json_request( + {"session_id": "", "session_auth": "x"} + ) + resp_empty = await backend.session_health_handler(req_empty) + assert resp_empty.status == 422 + assert ( + serverless_backend_testkit.response_json(resp_empty).get("error") + == "missing session_id" + ) + + @pytest.mark.asyncio + async def test_session_health_unknown_session_returns_ok_false( + self, serverless_backend_and_handler_default, serverless_backend_testkit + ) -> None: + """ + Verifies unknown session_id returns 200 with ok False (not an error status). + + This test verifies by: + 1. Sending a session_id that is not in backend.sessions + 2. Asserting 200 and {"ok": false} + + Assumptions: + - Handler distinguishes missing session from invalid auth for known sessions + """ + backend, _ = serverless_backend_and_handler_default + req = serverless_backend_testkit.json_request( + {"session_id": "nope", "session_auth": None} + ) + resp = await backend.session_health_handler(req) + assert resp.status == 200 + assert serverless_backend_testkit.response_json(resp) == {"ok": False} + + @pytest.mark.asyncio + async def test_session_health_invalid_auth_returns_401( + self, + serverless_backend_and_handler_default, + serverless_backend_testkit, + make_pyworker_session, + ) -> None: + """ + Verifies session_health_handler returns 401 when session_auth does not match. + + This test verifies by: + 1. Inserting a session with auth_data {"secret": 1} + 2. Sending wrong session_auth + 3. Asserting 401 + + Assumptions: + - auth_data equality is compared to session_auth as stored + """ + backend, _ = serverless_backend_and_handler_default + sid = "sess1" + backend.sessions[sid] = make_pyworker_session( + session_id=sid, + lifetime=60.0, + auth_data={"k": "good"}, + expiration=time.time() + 120, + on_close_route=None, + on_close_payload=None, + ) + req = serverless_backend_testkit.json_request( + {"session_id": sid, "session_auth": {"k": "bad"}} + ) + resp = await backend.session_health_handler(req) + assert resp.status == 401 + + @pytest.mark.asyncio + async def test_session_health_valid_returns_ok_true( + self, + serverless_backend_and_handler_default, + serverless_backend_testkit, + make_pyworker_session, + ) -> None: + """ + Verifies session_health_handler returns 200 ok True when id and auth match. + + This test verifies by: + 1. Storing a session whose auth_data matches session_auth in the request + 2. Asserting 200 and ok True + + Assumptions: + - session_auth may be a dict matching session.auth_data + """ + backend, _ = serverless_backend_and_handler_default + auth = {"token": "abc"} + sid = "sess2" + backend.sessions[sid] = make_pyworker_session( + session_id=sid, + lifetime=60.0, + auth_data=auth, + expiration=time.time() + 120, + on_close_route=None, + on_close_payload=None, + ) + req = serverless_backend_testkit.json_request( + {"session_id": sid, "session_auth": auth} + ) + resp = await backend.session_health_handler(req) + assert resp.status == 200 + assert serverless_backend_testkit.response_json(resp) == {"ok": True} + + +# --------------------------------------------------------------------------- +# Session: get +# --------------------------------------------------------------------------- + + +class TestBackendSessionGet: + """Tests for Backend.session_get_handler.""" + + @pytest.mark.asyncio + async def test_session_get_unknown_session_returns_400( + self, serverless_backend_and_handler_default, serverless_backend_testkit + ) -> None: + """ + Verifies session_get_handler returns 400 when session does not exist. + + This test verifies by: + 1. Requesting a non-existent session_id + 2. Asserting 400 and error message + + Assumptions: + - Unlike health, get uses an error status for missing session + """ + backend, _ = serverless_backend_and_handler_default + req = serverless_backend_testkit.json_request( + {"session_id": "missing", "session_auth": None}, + ) + resp = await backend.session_get_handler(req) + assert resp.status == 400 + assert "does not exist" in serverless_backend_testkit.response_json(resp).get( + "error", "" + ) + + @pytest.mark.asyncio + async def test_session_get_success_returns_session_fields( + self, + serverless_backend_and_handler_default, + serverless_backend_testkit, + make_pyworker_session, + ) -> None: + """ + Verifies session_get_handler returns session metadata when auth matches. + + This test verifies by: + 1. Storing a Session with known fields + 2. Calling get with matching session_auth + 3. Asserting JSON includes session_id, lifetime, expiration, on_close fields + + Assumptions: + - auth_data in response is labeled auth_data in JSON (handler uses auth_data key) + """ + backend, _ = serverless_backend_and_handler_default + auth = {"role": "user"} + sid = "sess3" + backend.sessions[sid] = make_pyworker_session( + session_id=sid, + lifetime=30.0, + auth_data=auth, + expiration=12345.0, + on_close_route="http://cb/end", + on_close_payload={"a": 1}, + created_at=100.0, + request_idx=7, + ) + req = serverless_backend_testkit.json_request( + {"session_id": sid, "session_auth": auth} + ) + resp = await backend.session_get_handler(req) + assert resp.status == 200 + data = serverless_backend_testkit.response_json(resp) + assert data["session_id"] == sid + assert data["auth_data"] == auth + assert data["lifetime"] == 30.0 + assert data["expiration"] == 12345.0 + assert data["on_close_route"] == "http://cb/end" + assert data["on_close_payload"] == {"a": 1} + assert data["created_at"] == 100.0 + + @pytest.mark.asyncio + async def test_session_get_missing_session_id_returns_422( + self, serverless_backend_and_handler_default, serverless_backend_testkit + ) -> None: + """ + Verifies session_get_handler returns 422 when session_id is missing or empty. + + This test verifies by: + 1. Sending JSON with only ``session_auth`` (missing ``session_id`` key) + 2. Sending JSON with ``session_id`` ``""`` and ``session_auth`` + 3. Asserting 422 and missing session_id error for both + + Assumptions: + - Same validation as other session handlers for session_id + """ + backend, _ = serverless_backend_and_handler_default + req = serverless_backend_testkit.json_request({"session_auth": {}}) + resp = await backend.session_get_handler(req) + assert resp.status == 422 + assert ( + serverless_backend_testkit.response_json(resp).get("error") + == "missing session_id" + ) + + req_empty = serverless_backend_testkit.json_request( + {"session_id": "", "session_auth": {}} + ) + resp_empty = await backend.session_get_handler(req_empty) + assert resp_empty.status == 422 + assert ( + serverless_backend_testkit.response_json(resp_empty).get("error") + == "missing session_id" + ) + + @pytest.mark.asyncio + async def test_session_get_invalid_json_returns_422( + self, serverless_backend_and_handler_default, serverless_backend_testkit + ) -> None: + """ + Verifies session_get_handler returns 422 when the body is not valid JSON. + + This test verifies by: + 1. Using a mock request whose json() raises JSONDecodeError + 2. Asserting 422 and invalid JSON error + + Assumptions: + - Same error shape as session_health_handler for decode failures + """ + backend, _ = serverless_backend_and_handler_default + req = serverless_backend_testkit.json_request("{") + resp = await backend.session_get_handler(req) + assert resp.status == 422 + assert ( + serverless_backend_testkit.response_json(resp).get("error") + == "invalid JSON" + ) + + @pytest.mark.asyncio + async def test_session_get_wrong_auth_returns_401( + self, + serverless_backend_and_handler_default, + serverless_backend_testkit, + make_pyworker_session, + ) -> None: + """ + Verifies session_get_handler returns 401 when session_auth does not match. + + This test verifies by: + 1. Storing a session with known auth_data + 2. Calling get with a different session_auth dict + 3. Asserting 401 + + Assumptions: + - Validation matches session_health_handler semantics + """ + backend, _ = serverless_backend_and_handler_default + sid = "sg-auth" + backend.sessions[sid] = make_pyworker_session( + session_id=sid, + lifetime=10.0, + auth_data={"role": "a"}, + expiration=time.time() + 100, + on_close_route=None, + on_close_payload=None, + ) + req = serverless_backend_testkit.json_request( + {"session_id": sid, "session_auth": {"role": "b"}}, + ) + resp = await backend.session_get_handler(req) + assert resp.status == 401 + + +# --------------------------------------------------------------------------- +# Session: create / end +# --------------------------------------------------------------------------- + + +class TestBackendSessionCreate: + """Tests for Backend.session_create_handler.""" + + @pytest.mark.asyncio + async def test_session_create_invalid_json_returns_422( + self, serverless_backend_and_handler_default, serverless_backend_testkit + ) -> None: + """ + Verifies session_create_handler returns 422 on JSON decode errors. + + This test verifies by: + 1. Using a mock request with json() raising JSONDecodeError + 2. Asserting 422 and invalid JSON error + + Assumptions: + - Handler catches json.JSONDecodeError specifically + """ + backend, _ = serverless_backend_and_handler_default + req = serverless_backend_testkit.json_request("not-json") + resp = await backend.session_create_handler(req) + assert resp.status == 422 + assert ( + serverless_backend_testkit.response_json(resp).get("error") + == "invalid JSON" + ) + + @pytest.mark.asyncio + async def test_session_create_at_max_sessions_returns_429( + self, serverless_backend_testkit, make_pyworker_session + ) -> None: + """ + Verifies session_create_handler returns 429 when session cap is reached. + + This test verifies by: + 1. Setting max_sessions=1 and pre-populating one session + 2. POSTing a valid create body + 3. Asserting 429 + + Assumptions: + - max_sessions=None and 0 mean unlimited (no reject); use 1 for cap + """ + backend, _ = serverless_backend_testkit.make_backend(max_sessions=1) + backend.sessions["existing"] = make_pyworker_session( + session_id="existing", + lifetime=1.0, + auth_data={}, + expiration=time.time() + 10, + on_close_route=None, + on_close_payload=None, + ) + req = serverless_backend_testkit.json_request( + { + "auth_data": {"request_idx": 0, "reqnum": 0, "cost": 1.0}, + "payload": {"lifetime": 10.0}, + }, + ) + resp = await backend.session_create_handler(req) + assert resp.status == 429 + + @pytest.mark.asyncio + async def test_session_create_max_sessions_negative_one_empty_sessions_returns_429( + self, serverless_backend_testkit + ) -> None: + """ + Documents dataclass default ``max_sessions=-1``: cap logic treats only ``None``/``0`` + as unlimited, so ``len(sessions) >= -1`` is true immediately and the first create + gets 429 (pre-existing product semantics; see peer-review consensus). + """ + backend, _ = serverless_backend_testkit.make_backend(max_sessions=-1) + assert len(backend.sessions) == 0 + req = serverless_backend_testkit.json_request( + { + "auth_data": {"request_idx": 0, "reqnum": 1, "cost": 1.0}, + "payload": {"lifetime": 10.0}, + }, + ) + resp = await backend.session_create_handler(req) + assert resp.status == 429 + + @pytest.mark.parametrize( + "body", + [ + pytest.param( + {"auth_data": None, "payload": {"lifetime": 10.0}}, id="auth_null" + ), + pytest.param({"payload": {"lifetime": 10.0}}, id="auth_omitted"), + ], + ) + @pytest.mark.asyncio + async def test_session_create_null_or_missing_auth_data_raises_attribute_error( + self, serverless_backend_and_handler_default, serverless_backend_testkit, body + ) -> None: + """ + Documents current behavior: ``auth_data`` must be a mapping; ``null`` or a missing + key yields ``None`` and ``auth_data.get`` raises (not a structured 4xx). + """ + backend, _ = serverless_backend_and_handler_default + req = serverless_backend_testkit.json_request(body) + with pytest.raises(AttributeError): + await backend.session_create_handler(req) + + @pytest.mark.asyncio + async def test_session_create_null_payload_raises_attribute_error( + self, serverless_backend_and_handler_default, serverless_backend_testkit + ) -> None: + """Documents current behavior when ``payload`` JSON is ``null``.""" + backend, _ = serverless_backend_and_handler_default + req = serverless_backend_testkit.json_request( + { + "auth_data": {"request_idx": 0, "reqnum": 1, "cost": 1.0}, + "payload": None, + }, + ) + with pytest.raises(AttributeError): + await backend.session_create_handler(req) + + @pytest.mark.asyncio + async def test_session_create_on_close_route_without_on_close_payload( + self, serverless_backend_and_handler_default, serverless_backend_testkit + ) -> None: + """``on_close_route`` set without ``on_close_payload`` leaves session callback payload None.""" + backend, _ = serverless_backend_and_handler_default + fixed_id = "sess-route-only" + body = { + "auth_data": {"request_idx": 3, "reqnum": 4, "cost": 1.0}, + "payload": { + "lifetime": 12.0, + "on_close_route": "http://notify/only-route", + }, + } + req = serverless_backend_testkit.json_request(body) + with patch.object(backend, "generate_session_id", return_value=fixed_id): + resp = await backend.session_create_handler(req) + assert resp.status == 201 + stored = backend.sessions[fixed_id] + assert stored.on_close_route == "http://notify/only-route" + assert stored.on_close_payload is None + + @pytest.mark.asyncio + async def test_session_create_omitted_lifetime_defaults_to_sixty( + self, serverless_backend_and_handler_default, serverless_backend_testkit + ) -> None: + """Omitting ``lifetime`` uses default ``60.0`` for ``Session.lifetime`` and expiration.""" + backend, _ = serverless_backend_and_handler_default + fixed_id = "sess-default-life" + body = { + "auth_data": {"request_idx": 4, "reqnum": 5, "cost": 1.0}, + "payload": {}, + } + req = serverless_backend_testkit.json_request(body) + before = time.time() + with patch.object(backend, "generate_session_id", return_value=fixed_id): + resp = await backend.session_create_handler(req) + assert resp.status == 201 + stored = backend.sessions[fixed_id] + assert stored.lifetime == 60.0 + assert stored.expiration == pytest.approx(before + 60.0, abs=2.0) + + @pytest.mark.asyncio + async def test_session_create_returns_201_with_session_id( + self, serverless_backend_and_handler_default, serverless_backend_testkit + ) -> None: + """ + Verifies session_create_handler creates a session and returns 201. + + This test verifies by: + 1. Patching generate_session_id to a fixed id + 2. Sending auth_data and payload with lifetime + 3. Asserting 201, body session_id and expiration, and internal sessions map + + Assumptions: + - Metrics hooks run without error on real Metrics instance + """ + backend, _ = serverless_backend_and_handler_default + fixed_id = "fixedsessionid" + body = { + "auth_data": {"request_idx": 1, "reqnum": 2, "cost": 0.5}, + "payload": {"lifetime": 45.0, "on_close_route": None}, + } + req = serverless_backend_testkit.json_request(body) + with patch.object(backend, "generate_session_id", return_value=fixed_id): + resp = await backend.session_create_handler(req) + assert resp.status == 201 + out = serverless_backend_testkit.response_json(resp) + assert out["session_id"] == fixed_id + assert "expiration" in out + assert fixed_id in backend.sessions + assert backend.sessions[fixed_id].lifetime == 45.0 + + @pytest.mark.asyncio + async def test_session_create_persists_on_close_route_and_payload( + self, serverless_backend_and_handler_default, serverless_backend_testkit + ) -> None: + """ + Verifies session_create_handler copies on_close_route and on_close_payload into Session. + + This test verifies by: + 1. POSTing payload with both callback fields set + 2. Asserting the stored Session matches + + Assumptions: + - Branch runs when on_close_route is not None (payload may still omit on_close_payload) + """ + backend, _ = serverless_backend_and_handler_default + fixed_id = "sess-close-fields" + body = { + "auth_data": {"request_idx": 2, "reqnum": 3, "cost": 1.0}, + "payload": { + "lifetime": 20.0, + "on_close_route": "http://internal/session-ended", + "on_close_payload": {"tag": "x"}, + }, + } + req = serverless_backend_testkit.json_request(body) + with patch.object(backend, "generate_session_id", return_value=fixed_id): + resp = await backend.session_create_handler(req) + assert resp.status == 201 + stored = backend.sessions[fixed_id] + assert stored.on_close_route == "http://internal/session-ended" + assert stored.on_close_payload == {"tag": "x"} + + +class TestBackendSessionEnd: + """Tests for Backend.session_end_handler.""" + + @pytest.mark.asyncio + async def test_session_end_not_found_returns_400( + self, serverless_backend_and_handler_default, serverless_backend_testkit + ) -> None: + """ + Verifies session_end_handler returns 400 when session_id is unknown. + + This test verifies by: + 1. POSTing end for a missing session with arbitrary auth + 2. Asserting 400 + + Assumptions: + - Error is returned while holding the sessions lock (before close) + """ + backend, _ = serverless_backend_and_handler_default + req = serverless_backend_testkit.json_request( + {"session_id": "ghost", "session_auth": {"x": 1}}, + ) + resp = await backend.session_end_handler(req) + assert resp.status == 400 + + @pytest.mark.asyncio + async def test_session_end_invalid_json_returns_422( + self, serverless_backend_and_handler_default, serverless_backend_testkit + ) -> None: + """ + Verifies session_end_handler returns 422 on invalid JSON. + + This test verifies by: + 1. Using a mock request whose json() raises JSONDecodeError + 2. Asserting status 422 + + Assumptions: + - Handler uses the same JSON error pattern as session health + """ + backend, _ = serverless_backend_and_handler_default + req = serverless_backend_testkit.json_request("not-json") + resp = await backend.session_end_handler(req) + assert resp.status == 422 + + @pytest.mark.asyncio + async def test_session_end_missing_session_id_returns_422( + self, serverless_backend_and_handler_default, serverless_backend_testkit + ) -> None: + """ + Verifies session_end_handler returns 422 when session_id is missing. + + This test verifies by: + 1. Sending a JSON object without session_id and with ``session_id`` ``""`` + 2. Asserting 422 in both cases + + Assumptions: + - Empty string session_id counts as missing (falsy) + """ + backend, _ = serverless_backend_and_handler_default + req = serverless_backend_testkit.json_request({"session_auth": {}}) + resp = await backend.session_end_handler(req) + assert resp.status == 422 + + req_empty = serverless_backend_testkit.json_request( + {"session_id": "", "session_auth": {}} + ) + resp_empty = await backend.session_end_handler(req_empty) + assert resp_empty.status == 422 + + @pytest.mark.asyncio + async def test_session_end_wrong_auth_returns_401( + self, + serverless_backend_and_handler_default, + serverless_backend_testkit, + make_pyworker_session, + make_patch_mock_backend_close_session, + ) -> None: + """ + Verifies session_end_handler returns 401 when session_auth does not match. + + This test verifies by: + 1. Storing a session with known auth_data + 2. POSTing end with a different session_auth + 3. Asserting 401 before any close runs + + Assumptions: + - Validation occurs under the sessions lock before __close_session + """ + backend, _ = serverless_backend_and_handler_default + sid = "s1" + backend.sessions[sid] = make_pyworker_session( + session_id=sid, + lifetime=60.0, + auth_data={"ok": True}, + expiration=time.time() + 100, + on_close_route=None, + on_close_payload=None, + ) + req = serverless_backend_testkit.json_request( + {"session_id": sid, "session_auth": {"ok": False}} + ) + with make_patch_mock_backend_close_session(backend) as mock_close: + resp = await backend.session_end_handler(req) + mock_close.assert_not_awaited() + assert resp.status == 401 + + @pytest.mark.asyncio + async def test_session_end_success_removes_session( + self, + serverless_backend_and_handler_default, + serverless_backend_testkit, + make_pyworker_session, + make_patch_skip_backend_run_session_on_close, + ) -> None: + """ + Verifies session_end_handler closes session and returns 200 ended true. + + This test verifies by: + 1. Inserting a session with matching auth + 2. Patching __run_session_on_close to avoid HTTP + 3. Calling session_end_handler and asserting session removed from backend.sessions + + Assumptions: + - __close_session runs metrics updates; real Metrics is acceptable + """ + backend, _ = serverless_backend_and_handler_default + auth = {"t": 1} + sid = "to-close" + backend.sessions[sid] = make_pyworker_session( + session_id=sid, + lifetime=60.0, + auth_data=auth, + expiration=time.time() + 100, + on_close_route=None, + on_close_payload=None, + ) + backend.session_metrics[sid] = MagicMock() + req = serverless_backend_testkit.json_request( + {"session_id": sid, "session_auth": auth} + ) + with make_patch_skip_backend_run_session_on_close(backend): + resp = await backend.session_end_handler(req) + assert resp.status == 200 + data = serverless_backend_testkit.response_json(resp) + assert data.get("ended") is True + assert data.get("removed_session") == sid + assert sid not in backend.sessions + + @pytest.mark.asyncio + async def test_session_end_returns_410_when_close_returns_false( + self, + serverless_backend_and_handler_default, + serverless_backend_testkit, + make_pyworker_session, + make_patch_mock_backend_close_session, + ) -> None: + """ + Verifies session_end_handler returns 410 if the session vanishes before __close_session. + + This test verifies by: + 1. Passing auth checks with a present session + 2. Patching __close_session to return False (simulating a concurrent close) + 3. Asserting 410 and 'already closed' error + + Assumptions: + - __close_session returns False when the session id is no longer in self.sessions + """ + backend, _ = serverless_backend_and_handler_default + auth = {"same": True} + sid = "double-end" + backend.sessions[sid] = make_pyworker_session( + session_id=sid, + lifetime=60.0, + auth_data=auth, + expiration=time.time() + 100, + on_close_route=None, + on_close_payload=None, + ) + req = serverless_backend_testkit.json_request( + {"session_id": sid, "session_auth": auth} + ) + with make_patch_mock_backend_close_session(backend) as mock_close: + mock_close.return_value = False + resp = await backend.session_end_handler(req) + assert resp.status == 410 + err = serverless_backend_testkit.response_json(resp).get("error", "") + assert "already closed" in err + + +class TestBackendCloseSession: + """Direct tests for Backend.__close_session (transport teardown, removal).""" + + @pytest.mark.asyncio + async def test_close_session_closes_open_request_transports( + self, + serverless_backend_and_handler_default, + make_pyworker_session, + make_patch_skip_backend_run_session_on_close, + make_serverless_mock_request_with_transport, + ) -> None: + """ + Verifies __close_session closes each in-flight request transport when open. + + This test verifies by: + 1. Building a session whose requests list holds a mock with transport.is_closing False + 2. Awaiting __close_session and asserting transport.close() was called + 3. Asserting the session is removed from backend.sessions + + Assumptions: + - Transport close failures are swallowed inside __close_session + """ + backend, _ = serverless_backend_and_handler_default + mock_req, mock_tr = make_serverless_mock_request_with_transport() + sid = "sess-transport" + backend.sessions[sid] = make_pyworker_session( + session_id=sid, + lifetime=1.0, + auth_data={}, + expiration=time.time() + 60, + on_close_route=None, + on_close_payload=None, + requests=[mock_req], + ) + backend.session_metrics[sid] = MagicMock() + with make_patch_skip_backend_run_session_on_close(backend): + removed = await backend._Backend__close_session(sid) + assert removed is True + assert sid not in backend.sessions + mock_tr.close.assert_called_once() + + +# --------------------------------------------------------------------------- +# __handle_request (via create_handler) +# --------------------------------------------------------------------------- + + +class TestBackendHandleRequest: + """Tests for Backend.create_handler / __handle_request.""" + + @pytest.mark.asyncio + async def test_handle_request_json_decode_error_returns_422( + self, serverless_backend_and_handler_default, serverless_backend_testkit + ) -> None: + """ + Verifies the endpoint handler returns 422 when body is not valid JSON. + + This test verifies by: + 1. Creating handler_fn via create_handler + 2. Passing a request whose json() raises JSONDecodeError + 3. Asserting 422 + + Assumptions: + - unsecured=True so signature check does not block earlier paths + """ + backend, handler = serverless_backend_and_handler_default + fn = backend.create_handler(handler) + req = serverless_backend_testkit.json_request("[[[") + resp = await fn(req) + assert resp.status == 422 + + @pytest.mark.asyncio + async def test_handle_request_json_data_exception_returns_422( + self, serverless_backend_and_handler_default, serverless_backend_testkit + ) -> None: + """ + Verifies JsonDataException from handler.get_data_from_request yields 422. + + This test verifies by: + 1. Patching handler.get_data_from_request to raise JsonDataException + 2. Calling the wrapped handler with valid JSON object + 3. Asserting 422 and message payload + + Assumptions: + - Exception message is passed as json_response data= for this exception type + """ + backend, handler = serverless_backend_and_handler_default + fn = backend.create_handler(handler) + req = serverless_backend_testkit.json_request({"any": "body"}) + + def _raise_json_data_exc(cls, req_data): + raise JsonDataException({"field": "bad"}) + + with patch.object( + type(handler), + "get_data_from_request", + classmethod(_raise_json_data_exc), + ): + resp = await fn(req) + assert resp.status == 422 + assert serverless_backend_testkit.response_json(resp) == {"field": "bad"} + + @pytest.mark.asyncio + async def test_handle_request_invalid_session_returns_410( + self, serverless_backend_and_handler_default, serverless_backend_testkit + ) -> None: + """ + Verifies requests with session_id for unknown session return 410. + + This test verifies by: + 1. Sending valid auth_data and payload plus session_id not in backend.sessions + 2. Patching __call_backend so it would not be reached incorrectly + 3. Asserting 410 + + Assumptions: + - unsecured=True; signature check passes without pubkey + """ + backend, handler = serverless_backend_and_handler_default + fn = backend.create_handler(handler) + data = serverless_backend_testkit.auth_payload() + data["session_id"] = "no-such-session" + req = serverless_backend_testkit.json_request(data) + with patch.object(backend, "_Backend__call_backend", new_callable=AsyncMock): + resp = await fn(req) + assert resp.status == 410 + + @pytest.mark.asyncio + async def test_handle_request_secured_without_pubkey_returns_401( + self, serverless_backend_testkit + ) -> None: + """ + Verifies secured mode rejects when public key was never loaded. + + This test verifies by: + 1. Building backend with unsecured=False and _pubkey None + 2. Sending a syntactically valid request + 3. Asserting 401 + + Assumptions: + - __check_signature returns False when _pubkey is None and not unsecured + """ + backend, handler = serverless_backend_testkit.make_backend(unsecured=False) + backend._pubkey = None + fn = backend.create_handler(handler) + req = serverless_backend_testkit.json_request( + serverless_backend_testkit.auth_payload() + ) + resp = await fn(req) + assert resp.status == 401 + + @pytest.mark.asyncio + async def test_handle_request_success_calls_backend_and_returns_response( + self, serverless_backend_and_handler_default, serverless_backend_testkit + ) -> None: + """ + Verifies happy path calls __call_backend and generate_client_response. + + This test verifies by: + 1. Patching __call_backend to return a mock ClientResponse + 2. Patching handler.generate_client_response to return a fixed web.Response + 3. Asserting returned status and body match the patched response + + Assumptions: + - allow_parallel_requests=True so queue wait is skipped + """ + backend, handler = serverless_backend_and_handler_default + fn = backend.create_handler(handler) + req = serverless_backend_testkit.json_request( + serverless_backend_testkit.auth_payload() + ) + mock_model_resp = MagicMock() + mock_model_resp.status = 200 + expected = web.json_response({"result": "ok"}, status=200) + with patch.object( + backend, "_Backend__call_backend", new_callable=AsyncMock + ) as mock_back: + mock_back.return_value = mock_model_resp + with patch.object( + handler, + "generate_client_response", + new_callable=AsyncMock, + return_value=expected, + ): + resp = await fn(req) + assert resp.status == 200 + assert serverless_backend_testkit.response_json(resp) == {"result": "ok"} + mock_back.assert_awaited_once() + + @pytest.mark.asyncio + async def test_handle_request_max_queue_time_exceeded_returns_429( + self, serverless_backend_testkit + ) -> None: + """ + Verifies __handle_request returns 429 when model wait_time exceeds handler cap. + + This test verifies by: + 1. Using a handler with max_queue_time set and allow_parallel True + 2. Seeding metrics.model_metrics.requests_working and low max_throughput so wait_time is huge + 3. Asserting 429 without calling the model + + Assumptions: + - wait_time is derived from pending workloads / max_throughput (see ModelMetrics) + """ + backend, handler = serverless_backend_testkit.make_backend(max_queue_time=10.0) + fn = backend.create_handler(handler) + rm = RequestMetrics(request_idx=0, reqnum=99, workload=100.0, status="Started") + backend.metrics.model_metrics.requests_working[99] = rm + backend.metrics.model_metrics.max_throughput = 0.00001 + req = serverless_backend_testkit.json_request( + serverless_backend_testkit.auth_payload() + ) + with patch.object(backend.metrics, "_request_reject") as mock_reject: + with patch.object( + backend, "_Backend__call_backend", new_callable=AsyncMock + ) as mock_cb: + resp = await fn(req) + assert resp.status == 429 + mock_cb.assert_not_awaited() + mock_reject.assert_called_once() + + @pytest.mark.asyncio + async def test_handle_request_model_error_returns_500( + self, serverless_backend_and_handler_default, serverless_backend_testkit + ) -> None: + """ + Verifies exceptions from generate_client_response become HTTP 500. + + This test verifies by: + 1. Patching __call_backend to return a dummy model response + 2. Patching generate_client_response to raise RuntimeError + 3. Asserting response status 500 + + Assumptions: + - make_request catches non-cancel exceptions and returns web.Response(500) + """ + backend, handler = serverless_backend_and_handler_default + fn = backend.create_handler(handler) + req = serverless_backend_testkit.json_request( + serverless_backend_testkit.auth_payload() + ) + mock_model = MagicMock() + with patch.object(backend.metrics, "_request_errored") as mock_errored: + with patch.object( + backend, "_Backend__call_backend", new_callable=AsyncMock + ) as mock_cb: + mock_cb.return_value = mock_model + with patch.object( + handler, + "generate_client_response", + new_callable=AsyncMock, + side_effect=RuntimeError("model exploded"), + ): + resp = await fn(req) + assert resp.status == 500 + assert resp.body is None or resp.body == b"" + mock_errored.assert_called_once() + assert "model exploded" in mock_errored.call_args[0][1] + + @pytest.mark.asyncio + async def test_handle_request_call_backend_raises_returns_500( + self, serverless_backend_and_handler_default, serverless_backend_testkit + ) -> None: + """ + Verifies exceptions from __call_backend (before generate_client_response) become HTTP 500. + + This test verifies by: + 1. Patching __call_backend to raise RuntimeError + 2. Asserting response status 500 and empty body + + Assumptions: + - make_request except-branch returns web.Response(500) without body + """ + backend, handler = serverless_backend_and_handler_default + fn = backend.create_handler(handler) + req = serverless_backend_testkit.json_request( + serverless_backend_testkit.auth_payload() + ) + with patch.object(backend.metrics, "_request_errored") as mock_errored: + with patch.object( + backend, + "_Backend__call_backend", + new_callable=AsyncMock, + side_effect=RuntimeError("backend call failed"), + ): + resp = await fn(req) + assert resp.status == 500 + assert resp.body is None or resp.body == b"" + mock_errored.assert_called_once() + assert "backend call failed" in mock_errored.call_args[0][1] + + @pytest.mark.asyncio + async def test_handle_request_call_api_uses_session_post( + self, + serverless_backend_and_handler_default, + serverless_backend_testkit, + make_mock_model_response, + ) -> None: + """ + Verifies the non-remote path posts JSON to handler.endpoint via ClientSession. + + This test verifies by: + 1. Replacing backend.session with a mock whose post() returns an async context manager + 2. Letting __call_backend run (no patch) and using the default generate_client_response + 3. Asserting session.post awaited with url and json from the payload + + Assumptions: + - Generic handler reads non-streaming bodies via model_response.read() + """ + backend, handler = serverless_backend_and_handler_default + fn = backend.create_handler(handler) + req = serverless_backend_testkit.json_request( + serverless_backend_testkit.auth_payload() + ) + mock_resp = make_mock_model_response(body=b'{"model": true}') + mock_sess = MagicMock() + # Backend.__call_api does `return await self.session.post(...)` (awaitable, not async-with). + mock_sess.post = AsyncMock(return_value=mock_resp) + object.__setattr__(backend, "session", mock_sess) + resp = await fn(req) + assert resp.status == 200 + mock_sess.post.assert_awaited_once() + assert mock_sess.post.await_args.kwargs["url"] == handler.endpoint + assert mock_sess.post.await_args.kwargs["json"] == {"input": {}} + + @pytest.mark.asyncio + async def test_handle_request_verified_signature_allows_request( + self, serverless_backend_testkit, make_serverless_test_rsa_key + ) -> None: + """ + Verifies secured mode accepts a correctly signed auth_data payload. + + This test verifies by: + 1. Generating an RSA key pair and signing the canonical url message + 2. Setting backend._pubkey to the public key and unsecured False + 3. Patching __call_backend and asserting it is awaited (signature path passed) + + Assumptions: + - Message format matches __check_signature (json.dumps with indent=4, sort_keys=True) + """ + key = make_serverless_test_rsa_key() + url = "https://tenant.example/v1/predict" + backend, handler = serverless_backend_testkit.make_backend(unsecured=False) + backend._pubkey = key.publickey() + fn = backend.create_handler(handler) + req = serverless_backend_testkit.json_request( + serverless_backend_testkit.signed_auth(url, key) + ) + mock_model = MagicMock() + with patch.object( + backend, "_Backend__call_backend", new_callable=AsyncMock + ) as mock_cb: + mock_cb.return_value = mock_model + with patch.object( + handler, + "generate_client_response", + new_callable=AsyncMock, + return_value=web.json_response({"ok": 1}), + ): + resp = await fn(req) + assert resp.status == 200 + mock_cb.assert_awaited_once() + assert backend.reqnum >= 7 + + @pytest.mark.asyncio + async def test_handle_request_fifo_mode_single_request_succeeds( + self, serverless_backend_testkit + ) -> None: + """ + Verifies queued (non-parallel) handler still completes when the request is alone. + + This test verifies by: + 1. Using allow_parallel_requests=False so the FIFO branch runs + 2. Patching __call_backend and generate_client_response as in the parallel happy path + 3. Asserting 200 response + + Assumptions: + - A sole queued request is head-of-line and its Event is set before wait + """ + backend, handler = serverless_backend_testkit.make_backend(allow_parallel=False) + fn = backend.create_handler(handler) + req = serverless_backend_testkit.json_request( + serverless_backend_testkit.auth_payload() + ) + mock_model = MagicMock() + with patch.object( + backend, "_Backend__call_backend", new_callable=AsyncMock + ) as mock_cb: + mock_cb.return_value = mock_model + with patch.object( + handler, + "generate_client_response", + new_callable=AsyncMock, + return_value=web.json_response({"queued": False}), + ): + resp = await fn(req) + assert resp.status == 200 + assert serverless_backend_testkit.response_json(resp) == {"queued": False} + + @pytest.mark.asyncio + async def test_handle_request_fifo_two_concurrent_requests_both_succeed( + self, serverless_backend_testkit + ) -> None: + """ + Verifies FIFO mode processes a second request after the first completes. + + This test verifies by: + 1. Starting the first handler call so it begins model work + 2. Starting the second (waits on queue) before the first finishes + 3. Asserting both return 200 in some order + + Assumptions: + - advance_queue_after_completion wakes the next waiter when the head finishes + """ + backend, handler = serverless_backend_testkit.make_backend(allow_parallel=False) + fn = backend.create_handler(handler) + mock_model = MagicMock() + first_in_backend = asyncio.Event() + release_first = asyncio.Event() + call_count = {"n": 0} + + async def _gated_backend(*args, **kwargs): + call_count["n"] += 1 + if call_count["n"] == 1: + first_in_backend.set() + await release_first.wait() + return mock_model + + req1 = serverless_backend_testkit.json_request( + serverless_backend_testkit.auth_payload(reqnum=1) + ) + req2 = serverless_backend_testkit.json_request( + serverless_backend_testkit.auth_payload(reqnum=2) + ) + with patch.object( + backend, "_Backend__call_backend", side_effect=_gated_backend + ): + with patch.object( + handler, + "generate_client_response", + new_callable=AsyncMock, + return_value=web.json_response({"ok": True}), + ): + t1 = asyncio.create_task(fn(req1)) + await first_in_backend.wait() + t2 = asyncio.create_task(fn(req2)) + await asyncio.sleep(0) + release_first.set() + r1, r2 = await asyncio.gather(t1, t2) + assert r1.status == 200 + assert r2.status == 200 + + @pytest.mark.asyncio + async def test_handle_request_invalid_signature_returns_401( + self, serverless_backend_testkit, make_serverless_test_rsa_key + ) -> None: + """ + Verifies secured mode rejects when the signature does not match the claimed URL. + + This test verifies by: + 1. Setting a real RSA public key on the backend + 2. Sending a PKCS1 signature over a different URL than ``auth_data.url`` + 3. Asserting 401 without calling the model + + Assumptions: + - ``__check_signature`` returns False when verify fails (cryptographically wrong sig) + """ + key = make_serverless_test_rsa_key() + backend, handler = serverless_backend_testkit.make_backend(unsecured=False) + backend._pubkey = key.publickey() + fn = backend.create_handler(handler) + signed_url = "https://tenant.example/v1/predict" + body = serverless_backend_testkit.signed_auth(signed_url, key) + body["auth_data"]["url"] = "https://other.example/different" + req = serverless_backend_testkit.json_request(body) + with patch.object( + backend, "_Backend__call_backend", new_callable=AsyncMock + ) as mock_cb: + resp = await fn(req) + assert resp.status == 401 + mock_cb.assert_not_awaited() + + @pytest.mark.asyncio + async def test_handle_request_session_request_extends_expiration( + self, + serverless_backend_testkit, + make_pyworker_session, + ) -> None: + """ + Verifies an authenticated session request extends expiration and counts reqnums. + + This test verifies by: + 1. Seeding backend.sessions with a known session_id and lifetime + 2. POSTing handler payload that includes that session_id + 3. Asserting expiration increased by lifetime and session_reqnum incremented + + Assumptions: + - Generic handler get_data_from_request passes session_id through from top-level JSON + """ + backend, handler = serverless_backend_testkit.make_backend() + fn = backend.create_handler(handler) + sid = "live-sess" + exp_before = 1_700_000_000.0 + sess = make_pyworker_session( + session_id=sid, + lifetime=30.0, + auth_data={}, + expiration=exp_before, + on_close_route=None, + on_close_payload=None, + session_reqnum=0, + ) + backend.sessions[sid] = sess + data = serverless_backend_testkit.auth_payload() + data["session_id"] = sid + req = serverless_backend_testkit.json_request(data) + mock_model = MagicMock() + with patch.object( + backend, "_Backend__call_backend", new_callable=AsyncMock + ) as mock_cb: + mock_cb.return_value = mock_model + with patch.object( + handler, + "generate_client_response", + new_callable=AsyncMock, + return_value=web.json_response({"s": "ok"}), + ): + resp = await fn(req) + assert resp.status == 200 + assert sess.expiration == exp_before + sess.lifetime + assert sess.session_reqnum == 1 + mock_cb.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# Session garbage collection +# --------------------------------------------------------------------------- + + +class TestBackendSessionGc: + """Tests for Backend.__session_gc_loop periodic cleanup.""" + + @pytest.mark.asyncio + async def test_session_gc_loop_closes_expired_sessions( + self, + serverless_backend_and_handler_default, + make_pyworker_session, + make_patch_skip_backend_run_session_on_close, + ) -> None: + """ + Verifies the GC loop removes sessions whose expiration is in the past. + + This test verifies by: + 1. Inserting a session with expiration already elapsed + 2. Patching backend.sleep to return immediately so the loop ticks + 3. Running the loop briefly and asserting the session was closed/removed + + Assumptions: + - __session_gc_loop uses module-level asyncio.sleep imported as sleep + """ + backend, _ = serverless_backend_and_handler_default + sid = "expired-gc" + backend.sessions[sid] = make_pyworker_session( + session_id=sid, + lifetime=10.0, + auth_data={}, + expiration=time.time() - 1.0, + on_close_route=None, + on_close_payload=None, + ) + backend.session_metrics[sid] = MagicMock() + session_removed = asyncio.Event() + orig_close = backend._Backend__close_session + + async def _close_then_signal(session_id: str): + try: + return await orig_close(session_id) + finally: + if session_id == sid: + session_removed.set() + + async def _yield_only(_delay: float = 0) -> None: + await asyncio.sleep(0) + + with patch( + "vastai.serverless.server.lib.backend.sleep", side_effect=_yield_only + ): + with make_patch_skip_backend_run_session_on_close(backend): + with patch.object( + backend, "_Backend__close_session", _close_then_signal + ): + task = asyncio.create_task(backend._Backend__session_gc_loop()) + t0 = asyncio.get_running_loop().time() + await asyncio.wait_for(session_removed.wait(), timeout=5.0) + assert asyncio.get_running_loop().time() - t0 < 1.0 + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + assert sid not in backend.sessions + + +# --------------------------------------------------------------------------- +# Helpers and metrics +# --------------------------------------------------------------------------- + + +class TestBackendHelpers: + """Tests for generate_session_id, backend_errored, and __run_session_on_close shape.""" + + def test_generate_session_id_length_and_charset( + self, serverless_backend_and_handler_default + ) -> None: + """ + Verifies generate_session_id returns 13 alphanumeric characters. + + This test verifies by: + 1. Calling generate_session_id many times with patched random.choices + 2. Asserting length 13 and allowed character set + + Assumptions: + - Implementation uses string.ascii_letters + digits and k=13 + """ + backend, _ = serverless_backend_and_handler_default + with patch( + "vastai.serverless.server.lib.backend.random.choices" + ) as mock_choices: + mock_choices.return_value = list("abcdefghijklm") + sid = backend.generate_session_id() + assert len(sid) == 13 + assert sid == "abcdefghijklm" + mock_choices.assert_called_once() + args, kwargs = mock_choices.call_args + assert kwargs.get("k") == 13 + + def test_backend_errored_forwards_to_metrics( + self, serverless_backend_and_handler_default + ) -> None: + """ + Verifies backend_errored delegates to metrics._model_errored. + + This test verifies by: + 1. Patching metrics._model_errored on the backend instance + 2. Calling backend_errored("msg") + 3. Asserting the mock was called with the message + + Assumptions: + - Metrics is constructed in __post_init__ + """ + backend, _ = serverless_backend_and_handler_default + with patch.object(backend.metrics, "_model_errored") as mock_err: + backend.backend_errored("failure-reason") + mock_err.assert_called_once_with("failure-reason") + + @pytest.mark.asyncio + async def test_run_session_on_close_posts_json_with_session_id( + self, + serverless_backend_and_handler_default, + make_pyworker_session, + attach_serverless_backend_mock_aiohttp_session, + ) -> None: + """ + Verifies __run_session_on_close POSTs merged body including session_id. + + This test verifies by: + 1. Building a session with on_close_route and dict on_close_payload + 2. Replacing backend.session with a mock ClientSession whose post returns async context + 3. Awaiting __run_session_on_close and asserting post called with json containing session_id + + Assumptions: + - ClientSession.post is used with json= and timeout; response text is read + """ + backend, _ = serverless_backend_and_handler_default + session = make_pyworker_session( + session_id="cb1", + lifetime=1.0, + auth_data={}, + expiration=time.time() + 10, + on_close_route="http://internal/hook", + on_close_payload={"foo": "bar"}, + ) + mock_sess = attach_serverless_backend_mock_aiohttp_session( + backend, response_text="ok" + ) + await backend._Backend__run_session_on_close(session) + mock_sess.post.assert_called_once() + call_kw = mock_sess.post.call_args.kwargs + assert call_kw["url"] == "http://internal/hook" + assert call_kw["timeout"] == ClientTimeout(total=10) + body = call_kw["json"] + assert body["foo"] == "bar" + assert body["session_id"] == "cb1" + + @pytest.mark.asyncio + async def test_run_session_on_close_wraps_scalar_payload( + self, + serverless_backend_and_handler_default, + make_pyworker_session, + attach_serverless_backend_mock_aiohttp_session, + ) -> None: + """ + Verifies __run_session_on_close wraps non-dict on_close_payload under 'payload'. + + This test verifies by: + 1. Using on_close_payload that is a string scalar + 2. Inspecting the JSON body sent to POST + + Assumptions: + - Non-dict branches use body = {"payload": on_close_payload} plus session_id + """ + backend, _ = serverless_backend_and_handler_default + session = make_pyworker_session( + session_id="cb2", + lifetime=1.0, + auth_data={}, + expiration=time.time() + 10, + on_close_route="http://hook/cb", + on_close_payload="done", + ) + mock_sess = attach_serverless_backend_mock_aiohttp_session( + backend, response_text="" + ) + await backend._Backend__run_session_on_close(session) + body = mock_sess.post.call_args.kwargs["json"] + assert body == {"payload": "done", "session_id": "cb2"} + + @pytest.mark.asyncio + async def test_run_session_on_close_no_op_without_route( + self, + serverless_backend_and_handler_default, + make_pyworker_session, + attach_serverless_backend_mock_aiohttp_session, + ) -> None: + """ + Verifies __run_session_on_close returns immediately when on_close_route is falsy. + + This test verifies by: + 1. Attaching a mock ClientSession with post + 2. Awaiting __run_session_on_close for a session with no callback URL + 3. Asserting post was never called + + Assumptions: + - Early return happens before any HTTP + """ + backend, _ = serverless_backend_and_handler_default + mock_sess = attach_serverless_backend_mock_aiohttp_session( + backend, spy_only=True + ) + session = make_pyworker_session( + session_id="no-cb", + lifetime=1.0, + auth_data={}, + expiration=time.time() + 10, + on_close_route=None, + on_close_payload={"ignored": True}, + ) + await backend._Backend__run_session_on_close(session) + mock_sess.post.assert_not_called() + + @pytest.mark.asyncio + async def test_run_session_on_close_none_payload_sends_session_id_only( + self, + serverless_backend_and_handler_default, + make_pyworker_session, + attach_serverless_backend_mock_aiohttp_session, + ) -> None: + """ + Verifies __run_session_on_close uses an empty dict when on_close_payload is None. + + This test verifies by: + 1. Session with route set and on_close_payload None + 2. Asserting POST json is only session_id (setdefault) + + Assumptions: + - None branch uses body = {} then setdefault session_id + """ + backend, _ = serverless_backend_and_handler_default + session = make_pyworker_session( + session_id="cb-null-payload", + lifetime=1.0, + auth_data={}, + expiration=time.time() + 10, + on_close_route="http://notify/", + on_close_payload=None, + ) + mock_sess = attach_serverless_backend_mock_aiohttp_session(backend) + await backend._Backend__run_session_on_close(session) + assert mock_sess.post.call_args.kwargs["json"] == { + "session_id": "cb-null-payload" + } + + @pytest.mark.asyncio + async def test_run_session_on_close_completes_on_http_error_status( + self, + serverless_backend_and_handler_default, + make_pyworker_session, + attach_serverless_backend_mock_aiohttp_session, + ) -> None: + """ + Verifies __run_session_on_close does not raise when the callback returns HTTP >= 400. + + This test verifies by: + 1. Mocking POST context with response status 503 + 2. Awaiting __run_session_on_close successfully + + Assumptions: + - Failures are recorded at DEBUG via ``log.debug`` (invisible at INFO); caller + sees no exception + """ + backend, _ = serverless_backend_and_handler_default + mock_sess = attach_serverless_backend_mock_aiohttp_session( + backend, response_status=503, response_text="unavailable" + ) + session = make_pyworker_session( + session_id="cb-http-err", + lifetime=1.0, + auth_data={}, + expiration=time.time() + 10, + on_close_route="http://hook/err", + on_close_payload={}, + ) + with patch("vastai.serverless.server.lib.backend.log") as mock_log: + await backend._Backend__run_session_on_close(session) + mock_log.debug.assert_called_once() + assert "on_close POST failed" in mock_log.debug.call_args[0][0] + mock_sess.post.assert_called_once() + + @pytest.mark.asyncio + async def test_run_session_on_close_swallows_post_exception( + self, + serverless_backend_and_handler_default, + make_pyworker_session, + attach_serverless_backend_mock_aiohttp_session, + ) -> None: + """ + Verifies __run_session_on_close catches exceptions from session.post. + + This test verifies by: + 1. Making post() raise ConnectionError + 2. Asserting the await completes without propagating + + Assumptions: + - Broad ``except`` logs at DEBUG and returns (not visible at INFO by default) + """ + backend, _ = serverless_backend_and_handler_default + mock_sess = attach_serverless_backend_mock_aiohttp_session( + backend, post_side_effect=ConnectionError("refused") + ) + session = make_pyworker_session( + session_id="cb-exc", + lifetime=1.0, + auth_data={}, + expiration=time.time() + 10, + on_close_route="http://hook/x", + on_close_payload={}, + ) + with patch("vastai.serverless.server.lib.backend.log") as mock_log: + await backend._Backend__run_session_on_close(session) + mock_log.debug.assert_called_once() + assert "on_close POST exception" in mock_log.debug.call_args[0][0] + mock_sess.post.assert_called_once() diff --git a/tests/serverless/test_backend_session_handlers.py b/tests/serverless/test_backend_session_handlers.py new file mode 100644 index 00000000..fdd7d568 --- /dev/null +++ b/tests/serverless/test_backend_session_handlers.py @@ -0,0 +1,140 @@ +"""Unit tests for Backend session-related HTTP handlers (health, get). + +Exercises request validation and session lookup without starting the worker or +binding real ports. +""" +from __future__ import annotations + +import json + +import pytest + +from vastai.serverless.server.lib.backend import Backend + + +@pytest.mark.asyncio +async def test_session_health_handler_invalid_json_returns_422( + pyworker_backend: Backend, + make_backend_http_request, + web_json_body, +) -> None: + req = make_backend_http_request( + json_side_effect=json.JSONDecodeError("msg", "doc", 0), + ) + resp = await pyworker_backend.session_health_handler(req) + assert resp.status == 422 + assert "invalid JSON" in web_json_body(resp).get("error", "") + + +@pytest.mark.asyncio +async def test_session_health_handler_missing_session_id_returns_422( + pyworker_backend: Backend, + make_backend_http_request, + web_json_body, +) -> None: + req = make_backend_http_request(json_data={}) + resp = await pyworker_backend.session_health_handler(req) + assert resp.status == 422 + assert "session_id" in web_json_body(resp).get("error", "") + + +@pytest.mark.asyncio +async def test_session_health_handler_unknown_session_returns_ok_false( + pyworker_backend: Backend, + make_backend_http_request, + web_json_body, +) -> None: + req = make_backend_http_request(json_data={"session_id": "missing"}) + resp = await pyworker_backend.session_health_handler(req) + assert resp.status == 200 + assert web_json_body(resp) == {"ok": False} + + +@pytest.mark.asyncio +async def test_session_health_handler_valid_auth_returns_ok_true( + pyworker_backend: Backend, + make_backend_http_request, + make_pyworker_session, + web_json_body, +) -> None: + pyworker_backend.sessions["s1"] = make_pyworker_session( + session_id="s1", + lifetime=1.0, + auth_data={"token": "abc"}, + ) + req = make_backend_http_request( + json_data={ + "session_id": "s1", + "session_auth": {"token": "abc"}, + } + ) + resp = await pyworker_backend.session_health_handler(req) + assert resp.status == 200 + assert web_json_body(resp) == {"ok": True} + + +@pytest.mark.asyncio +async def test_session_health_handler_wrong_auth_returns_401( + pyworker_backend: Backend, + make_backend_http_request, + make_pyworker_session, + web_json_body, +) -> None: + pyworker_backend.sessions["s1"] = make_pyworker_session( + session_id="s1", + lifetime=1.0, + auth_data={"token": "good"}, + ) + req = make_backend_http_request( + json_data={ + "session_id": "s1", + "session_auth": {"token": "bad"}, + } + ) + resp = await pyworker_backend.session_health_handler(req) + assert resp.status == 401 + assert "session_auth" in web_json_body(resp).get("error", "") + + +@pytest.mark.asyncio +async def test_session_get_handler_unknown_session_returns_400( + pyworker_backend: Backend, + make_backend_http_request, +) -> None: + req = make_backend_http_request( + json_data={"session_id": "nope", "session_auth": {}}, + ) + resp = await pyworker_backend.session_get_handler(req) + assert resp.status == 400 + + +@pytest.mark.asyncio +async def test_session_get_handler_returns_session_fields_when_valid( + pyworker_backend: Backend, + make_backend_http_request, + make_pyworker_session, + web_json_body, +) -> None: + pyworker_backend.sessions["s2"] = make_pyworker_session( + session_id="s2", + lifetime=30.0, + auth_data={"k": "v"}, + expiration=99.0, + on_close_route="/cb", + on_close_payload={"x": 1}, + ) + req = make_backend_http_request( + json_data={ + "session_id": "s2", + "session_auth": {"k": "v"}, + } + ) + resp = await pyworker_backend.session_get_handler(req) + assert resp.status == 200 + data = web_json_body(resp) + assert data["session_id"] == "s2" + assert data["auth_data"] == {"k": "v"} + assert data["lifetime"] == 30.0 + assert data["expiration"] == 99.0 + assert data["on_close_route"] == "/cb" + assert data["on_close_payload"] == {"x": 1} diff --git a/tests/serverless/test_client.py b/tests/serverless/test_client.py new file mode 100644 index 00000000..886e6c92 --- /dev/null +++ b/tests/serverless/test_client.py @@ -0,0 +1,2278 @@ +"""Unit tests for vastai.serverless.client.client (Serverless, ServerlessRequest). + +All HTTP and SSL fetch paths are mocked; no real network calls. + +Coverage notes (functionality-oriented): +- ``ServerlessRequest.then``: stdout on exception path +- ``__init__``: instance match arms (prod/alpha/local/candidate/default), ``debug`` logging, + ``VAST_API_KEY`` (subprocess import), connection/tuning kwargs, preconfigured logger +- ``_get_session``: ``TCPConnector(limit=connection_limit)`` +- ``get_ssl_context``: non-200 cert response +- Session helpers: ``/session/get`` and ``/session/end`` success and error paths, ``TimeoutError`` passthrough +- ``start_endpoint_session``: validation of queue result shape +- ``queue_endpoint_request``: timeouts, retry branches, session shortcut, transport errors, + non-OK worker responses, stream mode, task cancellation, ``latencies``, session without URL, + ``_route`` failure → ``Errored`` +""" + +from __future__ import annotations + +import asyncio +import logging +import subprocess +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import aiohttp +import pytest + +from vastai.serverless.client import client as serverless_client_mod +from vastai.serverless.client.client import Serverless, ServerlessRequest +from vastai.serverless.client.endpoint import Endpoint +from vastai.serverless.client.session import Session + +# Repo root (tests/serverless -> parents[2]) for subprocess imports of ``vastai``. +_REPO_ROOT = str(Path(__file__).resolve().parents[2]) + + +# --------------------------------------------------------------------------- +# ServerlessRequest +# --------------------------------------------------------------------------- + + +class TestServerlessRequest: + """Verify ServerlessRequest future wrapper and then() chaining.""" + + def test_then_invokes_callback_with_result_on_success(self) -> None: + """ + Verifies that then() registers a done callback that receives the future result. + + This test verifies by: + 1. Creating a ServerlessRequest and chaining then() with a callback that appends results + 2. Resolving the future with a known value + 3. Running the event loop until callbacks run + 4. Asserting the callback received the resolved value + + Assumptions: + - asyncio event loop processes done callbacks when the future is marked done + """ + results: list = [] + + async def _run() -> None: + req = ServerlessRequest() + req.then(lambda r: results.append(r)) + req.set_result("ok") + await asyncio.sleep(0) + + asyncio.run(_run()) + assert results == ["ok"] + + def test_then_skips_callback_when_future_has_exception(self) -> None: + """ + Verifies that then()'s callback is not invoked when the future completes with an exception. + + This test verifies by: + 1. Registering then() with a callback that appends to a list + 2. Setting an exception on the future + 3. Yielding to the loop and asserting the callback did not run + + Assumptions: + - Implementation checks fut.exception() is None before calling the user callback + """ + results: list = [] + + async def _run() -> None: + req = ServerlessRequest() + req.then(lambda r: results.append(r)) + req.set_exception(RuntimeError("boom")) + await asyncio.sleep(0) + + asyncio.run(_run()) + assert results == [] + + def test_then_logs_exception_to_stdout_when_future_fails(self, capsys) -> None: + """then() prints the future's exception before skipping the user callback.""" + + async def _run() -> None: + req = ServerlessRequest() + req.then(lambda r: None) + req.set_exception(ValueError("then-exc-marker")) + await asyncio.sleep(0) + + asyncio.run(_run()) + captured = capsys.readouterr() + assert "then-exc-marker" in captured.out or "ValueError" in captured.out + + def test_new_request_has_expected_initial_tracking_fields(self) -> None: + """ServerlessRequest starts in New state with timestamps and index defaults.""" + + async def _run() -> None: + req = ServerlessRequest() + assert req.status == "New" + assert req.req_idx == 0 + assert req.start_time is None + assert req.complete_time is None + assert isinstance(req.create_time, float) + + asyncio.run(_run()) + + +# --------------------------------------------------------------------------- +# Serverless construction and URLs +# --------------------------------------------------------------------------- + + +class TestServerlessInit: + """Verify API key validation and instance-based base URLs.""" + + def test_raises_attribute_error_when_api_key_is_none(self) -> None: + """ + Verifies that Serverless raises AttributeError when api_key is None. + + This test verifies by: + 1. Instantiating Serverless(api_key=None) explicitly + 2. Asserting AttributeError with a message about the API key + + Assumptions: + - Explicit None bypasses environment default for this call + """ + with pytest.raises(AttributeError, match="API key missing"): + Serverless(api_key=None) + + def test_raises_attribute_error_when_api_key_is_empty_string(self) -> None: + """ + Verifies that Serverless raises AttributeError when api_key is the empty string. + + This test verifies by: + 1. Instantiating Serverless(api_key="") + 2. Asserting AttributeError + + Assumptions: + - Empty string is treated as missing per client implementation + """ + with pytest.raises(AttributeError, match="API key missing"): + Serverless(api_key="") + + def test_accepts_explicit_api_key(self) -> None: + """ + Verifies that Serverless stores a non-empty explicit api_key. + + This test verifies by: + 1. Constructing Serverless(api_key="sk-test") + 2. Asserting client.api_key equals the provided value + + Assumptions: + - No environment patching required when api_key is passed explicitly + """ + client = Serverless(api_key="sk-test") + assert client.api_key == "sk-test" + + def test_instance_prod_sets_console_and_run_urls(self) -> None: + """ + Verifies that instance='prod' sets autoscaler and web URLs to production hosts. + + This test verifies by: + 1. Creating Serverless with instance='prod' + 2. Asserting autoscaler_url and vast_web_url match expected prod values + + Assumptions: + - Production URLs are stable contract surface for the SDK + """ + client = Serverless(api_key="k", instance="prod") + assert client.autoscaler_url == "https://run.vast.ai" + assert client.vast_web_url == "https://console.vast.ai" + + def test_instance_alpha_sets_alpha_hosts(self) -> None: + """ + Verifies that instance='alpha' selects alpha run and web hosts. + + This test verifies by: + 1. Creating Serverless with instance='alpha' + 2. Asserting URLs contain alpha hostnames + + Assumptions: + - Alpha instance string is supported by match/case in __init__ + """ + client = Serverless(api_key="k", instance="alpha") + assert client.autoscaler_url == "https://run-alpha.vast.ai" + + def test_instance_local_sets_local_autoscaler(self) -> None: + """ + Verifies that instance='local' points autoscaler at localhost. + + This test verifies by: + 1. Creating Serverless with instance='local' + 2. Asserting autoscaler_url is http://localhost:8080 + + Assumptions: + - Local dev instance uses fixed port 8080 per implementation + """ + client = Serverless(api_key="k", instance="local") + assert client.autoscaler_url == "http://localhost:8080" + + def test_instance_candidate_sets_candidate_hosts(self) -> None: + """ + Verifies that instance='candidate' selects candidate run and web hosts. + + This test verifies by: + 1. Creating Serverless with instance='candidate' + 2. Asserting autoscaler_url and vast_web_url match candidate endpoints + + Assumptions: + - Candidate instance string is supported by match/case in __init__ + """ + client = Serverless(api_key="k", instance="candidate") + assert client.autoscaler_url == "https://run-candidate.vast.ai" + + def test_instance_unknown_string_falls_back_to_default_urls(self) -> None: + """ + Verifies that an unrecognized instance value uses the default (prod-like) URL pair. + + This test verifies by: + 1. Creating Serverless with a non-matching instance string + 2. Asserting URLs match the default branch (same as prod) + + Assumptions: + - Default branch is the final case in the instance match + """ + client = Serverless(api_key="k", instance="unknown-env") + assert client.autoscaler_url == "https://run.vast.ai" + assert client.vast_web_url == "https://console.vast.ai" + + def test_debug_true_attaches_stream_handler_and_disables_propagation(self) -> None: + """ + Verifies that debug=True configures the class logger for debug output. + + This test verifies by: + 1. Constructing Serverless(api_key=..., debug=True) + 2. Asserting debug flag, DEBUG level, and propagate is False (per implementation) + + Assumptions: + - Debug mode adds a StreamHandler and avoids duplicate root logging + + Logger teardown is handled by the autouse ``_restore_serverless_logger_state`` + fixture in ``tests/conftest.py`` (RAII). + """ + client = Serverless(api_key="k", debug=True) + assert client.debug is True + assert client.logger.level == logging.DEBUG + assert client.logger.propagate is False + assert any( + isinstance(h, logging.StreamHandler) + and not isinstance(h, logging.NullHandler) + for h in client.logger.handlers + ) + + def test_debug_false_leaves_logger_propagate_true(self) -> None: + """Non-debug mode keeps propagate True so app logging config applies.""" + client = Serverless(api_key="k", debug=False) + assert client.debug is False + assert client.logger.propagate is True + + def test_uses_vast_api_key_from_environment_when_not_passed(self) -> None: + """Omitting api_key uses VAST_API_KEY (read when ``client`` module is imported). + + The default ``api_key=os.environ.get(...)`` is bound at class definition time in + CPython, so a fresh interpreter is needed to observe env changes. + """ + code = ( + "import os, sys\n" + f"sys.path.insert(0, {_REPO_ROOT!r})\n" + "os.environ['VAST_API_KEY'] = 'key-from-env-xyz'\n" + "from vastai.serverless.client.client import Serverless\n" + "c = Serverless()\n" + "assert c.api_key == 'key-from-env-xyz', c.api_key\n" + ) + subprocess.run([sys.executable, "-c", code], check=True) + + def test_constructor_stores_connection_limit_and_timeouts(self) -> None: + """connection_limit, default_request_timeout, and max_poll_interval are kept on the client.""" + client = Serverless( + api_key="k", + connection_limit=321, + default_request_timeout=999.5, + max_poll_interval=3.25, + ) + assert client.connection_limit == 321 + assert client.default_request_timeout == 999.5 + assert client.max_poll_interval == 3.25 + + def test_skips_null_handler_when_serverless_logger_already_configured(self) -> None: + """If the class logger already has handlers, __init__ does not add NullHandler.""" + log = logging.getLogger("Serverless") + existing = logging.StreamHandler() + log.addHandler(existing) + try: + client = Serverless(api_key="k", debug=False) + assert existing in client.logger.handlers + assert not any( + isinstance(h, logging.NullHandler) for h in client.logger.handlers + ) + finally: + log.removeHandler(existing) + + +# --------------------------------------------------------------------------- +# Session lifecycle helpers +# --------------------------------------------------------------------------- + + +class TestServerlessSessionOpen: + """Verify is_open, context manager, and close behavior with mocked aiohttp session.""" + + @pytest.mark.asyncio + async def test_is_open_true_when_session_exists_and_not_closed( + self, client + ) -> None: + """is_open() is True when _session is set and aiohttp session is open.""" + mock_sess = MagicMock() + mock_sess.closed = False + client._session = mock_sess + assert client.is_open() is True + + @pytest.mark.asyncio + async def test_is_open_false_before_session_created(self, client) -> None: + """ + Verifies is_open() is False until _get_session has created a session. + + This test verifies by: + 1. Constructing Serverless without opening a session + 2. Calling is_open() and asserting False + + Assumptions: + - _session starts as None + """ + assert client.is_open() is False + + @pytest.mark.asyncio + async def test_context_manager_closes_session(self, client) -> None: + """ + Verifies __aexit__ closes the aiohttp session opened in __aenter__. + + This test verifies by: + 1. Patching ClientSession and get_ssl_context so _get_session succeeds + 2. Using async with Serverless() + 3. Asserting session.close was awaited after the block + + Assumptions: + - ClientSession is constructed via vastai.serverless.client.client.aiohttp.ClientSession + """ + mock_sess = MagicMock() + mock_sess.closed = False + mock_sess.close = AsyncMock() + + with ( + patch( + "vastai.serverless.client.client.aiohttp.ClientSession", + return_value=mock_sess, + ), + patch.object( + Serverless, + "get_ssl_context", + new=AsyncMock(return_value=None), + ), + ): + async with client: + assert client._session is mock_sess + mock_sess.close.assert_awaited() + + @pytest.mark.asyncio + async def test_close_is_idempotent_when_no_session(self, client) -> None: + """ + Verifies close() does not raise when there is no active session. + + This test verifies by: + 1. Creating Serverless and calling await close() without _get_session + + Assumptions: + - close() guards on self._session truthiness + """ + await client.close() + + @pytest.mark.asyncio + async def test_get_session_recreates_when_previous_session_marked_closed( + self, client + ) -> None: + """ + Verifies _get_session builds a new ClientSession when the existing one is closed. + + This test verifies by: + 1. Patching ClientSession to return distinct mock sessions per construction + 2. Opening a session, marking it closed, calling _get_session again + 3. Asserting a second ClientSession was constructed + + Assumptions: + - Branch ``self._session is None or self._session.closed`` triggers recreation + """ + instances: list[MagicMock] = [] + + def _new_session(*_a, **_kw) -> MagicMock: + m = MagicMock() + m.closed = False + m.close = AsyncMock() + instances.append(m) + return m + + with ( + patch( + "vastai.serverless.client.client.aiohttp.ClientSession", + side_effect=_new_session, + ), + patch.object( + Serverless, + "get_ssl_context", + new=AsyncMock(return_value=None), + ), + ): + first = await client._get_session() + first.closed = True + second = await client._get_session() + + assert len(instances) == 2 + assert second is instances[1] + assert client._session is second + + @pytest.mark.asyncio + async def test_close_awaits_session_close_when_session_open(self, client) -> None: + """ + Verifies close() awaits session.close when the session exists and is open. + + This test verifies by: + 1. Assigning a mock session with closed=False and AsyncMock close() + 2. Awaiting client.close() + 3. Asserting close was awaited on the session + + Assumptions: + - close() only runs when self._session is truthy and not closed + """ + mock_sess = MagicMock() + mock_sess.closed = False + mock_sess.close = AsyncMock() + client._session = mock_sess + await client.close() + mock_sess.close.assert_awaited() + + @pytest.mark.asyncio + async def test_get_session_passes_connection_limit_to_tcp_connector(self) -> None: + """_get_session builds TCPConnector with limit=connection_limit.""" + limits: list[int] = [] + mock_connector = MagicMock() + + def _tcp_side_effect(*_a, **kw) -> MagicMock: + limits.append(kw["limit"]) + return mock_connector + + mock_sess = MagicMock() + mock_sess.closed = False + mock_sess.close = AsyncMock() + + tuned = Serverless(api_key="k", connection_limit=88) + with ( + patch( + "vastai.serverless.client.client.aiohttp.TCPConnector", + side_effect=_tcp_side_effect, + ), + patch( + "vastai.serverless.client.client.aiohttp.ClientSession", + return_value=mock_sess, + ), + patch.object( + Serverless, + "get_ssl_context", + new=AsyncMock(return_value=None), + ), + ): + await tuned._get_session() + + assert limits == [88] + + @pytest.mark.asyncio + async def test_get_session_returns_same_open_session_without_recreating( + self, client + ) -> None: + """Second _get_session call reuses ClientSession when the first is still open.""" + mock_sess = MagicMock() + mock_sess.closed = False + mock_sess.close = AsyncMock() + + with ( + patch( + "vastai.serverless.client.client.aiohttp.ClientSession", + return_value=mock_sess, + ) as client_session_ctor, + patch.object( + Serverless, + "get_ssl_context", + new=AsyncMock(return_value=None), + ), + ): + first = await client._get_session() + second = await client._get_session() + + assert first is second is mock_sess + assert client_session_ctor.call_count == 1 + + @pytest.mark.asyncio + async def test_is_open_false_when_session_marked_closed(self, client) -> None: + """is_open() is False when _session exists but aiohttp reports closed.""" + mock_sess = MagicMock() + mock_sess.closed = True + client._session = mock_sess + assert client.is_open() is False + + +# --------------------------------------------------------------------------- +# get_ssl_context +# --------------------------------------------------------------------------- + + +class TestServerlessGetSslContext: + """Verify SSL context loading uses mocked cert download and ssl APIs.""" + + @pytest.mark.asyncio + async def test_get_ssl_context_fetches_cert_and_caches_context( + self, client + ) -> None: + """ + Verifies get_ssl_context downloads PEM bytes, loads them, and caches SSLContext. + + This test verifies by: + 1. Patching aiohttp.ClientSession and nested get() response with status 200 and read() + 2. Patching ssl.create_default_context to return a mock context + 3. Calling get_ssl_context twice and asserting create_default_context once + 4. Asserting load_verify_locations was called with a .cer temp path + + Assumptions: + - Second call returns cached _ssl_context without another HTTP fetch + """ + mock_resp = MagicMock() + mock_resp.status = 200 + mock_resp.read = AsyncMock( + return_value=b"-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----" + ) + + mock_get_cm = MagicMock() + mock_get_cm.__aenter__ = AsyncMock(return_value=mock_resp) + mock_get_cm.__aexit__ = AsyncMock(return_value=None) + + mock_sess_inst = MagicMock() + mock_sess_inst.get = MagicMock(return_value=mock_get_cm) + mock_sess_inst.__aenter__ = AsyncMock(return_value=mock_sess_inst) + mock_sess_inst.__aexit__ = AsyncMock(return_value=None) + + mock_ctx = MagicMock() + + with ( + patch( + "vastai.serverless.client.client.aiohttp.ClientSession", + return_value=mock_sess_inst, + ), + patch( + "vastai.serverless.client.client.ssl.create_default_context", + return_value=mock_ctx, + ), + patch("vastai.serverless.client.client.os.unlink") as mock_unlink, + ): + ctx1 = await client.get_ssl_context() + ctx2 = await client.get_ssl_context() + + assert ctx1 is mock_ctx is ctx2 + mock_ctx.load_verify_locations.assert_called_once() + cafile = mock_ctx.load_verify_locations.call_args.kwargs.get("cafile") + assert cafile.endswith(".cer") + # Only one unlink is from our client (the .cer temp). Some Python/ssl builds + # also unlink other temps during load_verify_locations — do not require exactly + # one os.unlink call on the mock. + unlink_targets = [c.args[0] for c in mock_unlink.call_args_list if c.args] + assert cafile in unlink_targets + + @pytest.mark.asyncio + async def test_get_ssl_context_raises_when_cert_fetch_status_not_200( + self, client + ) -> None: + """ + Verifies get_ssl_context raises when the certificate HTTP response is not 200. + + This test verifies by: + 1. Mocking the cert GET response with a non-200 status + 2. Awaiting get_ssl_context and asserting an exception mentions the status + + Assumptions: + - Non-200 responses do not write temp files or cache SSL context + """ + mock_resp = MagicMock() + mock_resp.status = 503 + + mock_get_cm = MagicMock() + mock_get_cm.__aenter__ = AsyncMock(return_value=mock_resp) + mock_get_cm.__aexit__ = AsyncMock(return_value=None) + + mock_sess_inst = MagicMock() + mock_sess_inst.get = MagicMock(return_value=mock_get_cm) + mock_sess_inst.__aenter__ = AsyncMock(return_value=mock_sess_inst) + mock_sess_inst.__aexit__ = AsyncMock(return_value=None) + + with patch( + "vastai.serverless.client.client.aiohttp.ClientSession", + return_value=mock_sess_inst, + ): + with pytest.raises(Exception, match="Failed to fetch SSL cert: 503"): + await client.get_ssl_context() + + +# --------------------------------------------------------------------------- +# get_endpoints / get_endpoint +# --------------------------------------------------------------------------- + + +class TestServerlessGetEndpoints: + """Verify endpoint listing and lookup delegate to _make_request with correct args.""" + + @pytest.mark.asyncio + async def test_get_endpoints_parses_results_into_endpoint_objects( + self, serverless_master_client + ) -> None: + """ + Verifies get_endpoints maps API results to Endpoint instances with correct fields. + + This test verifies by: + 1. Patching vastai.serverless.client.client._make_request (AsyncMock) to return ok+json + 2. Awaiting get_endpoints and asserting length, names, ids, api_keys + + Assumptions: + - _make_request is imported into client module (patch target is client._make_request) + """ + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + ) as mock_req: + mock_req.return_value = { + "ok": True, + "json": { + "results": [ + { + "endpoint_name": "a", + "id": 1, + "api_key": "ek1", + "cold_workers": 1, + "max_workers": 20, + "min_load": 100, + "target_util": 0.9, + "cold_mult": 1.5, + "max_queue_time": 30, + "target_queue_time": 5, + "endpoint_state": "running", + "inactivity_timeout": 600, + "user_id": 5, + "created_at": 129401, + }, + { + "endpoint_name": "b", + "id": 2, + "api_key": "ek2", + "cold_workers": 1, + "max_workers": 20, + "min_load": 100, + "target_util": 0.9, + "cold_mult": 1.5, + "max_queue_time": 30, + "target_queue_time": 5, + "endpoint_state": "running", + "inactivity_timeout": 600, + "user_id": 5, + "created_at": 129401, + }, + ] + }, + } + endpoints = await serverless_master_client.get_endpoints() + + assert len(endpoints) == 2 + assert endpoints[0].name == "a" and endpoints[0].id == 1 + assert endpoints[0].api_key == "ek1" + mock_req.assert_awaited() + call_kw = mock_req.call_args.kwargs + assert call_kw["url"] == serverless_master_client.vast_web_url + assert call_kw["route"] == "/api/v0/endptjobs/" + assert call_kw["api_key"] == "master" + assert call_kw["params"] == {"client_id": "me"} + + @pytest.mark.asyncio + async def test_get_endpoints_wraps_make_request_exception(self, client) -> None: + """ + Verifies get_endpoints raises Exception with context when _make_request fails. + + This test verifies by: + 1. Making _make_request raise ValueError + 2. Awaiting get_endpoints and asserting raised Exception mentions Failed to get endpoints + + Assumptions: + - Client wraps underlying errors in a single message prefix + """ + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + side_effect=ValueError("network"), + ): + with pytest.raises(Exception, match="Failed to get endpoints"): + await client.get_endpoints() + + @pytest.mark.asyncio + async def test_get_endpoints_returns_empty_list_when_no_results( + self, client + ) -> None: + """ok=True with empty results yields an empty Endpoint list.""" + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": True, "json": {"results": []}}, + ): + endpoints = await client.get_endpoints() + assert endpoints == [] + + @pytest.mark.asyncio + async def test_get_endpoints_raises_when_http_result_not_ok(self, client) -> None: + """ + Verifies get_endpoints raises when the request dict has ok=False. + + This test verifies by: + 1. Returning ok=False with status and text from the mock + 2. Asserting Exception mentions HTTP status + + Assumptions: + - Non-ok results are surfaced without parsing results + """ + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": False, "status": 503, "text": "unavailable"}, + ): + with pytest.raises(Exception, match="HTTP 503"): + await client.get_endpoints() + + @pytest.mark.asyncio + async def test_get_endpoint_returns_matching_endpoint_by_name(self, client) -> None: + """ + Verifies get_endpoint returns the Endpoint whose name matches the argument. + + This test verifies by: + 1. Patching get_endpoints on the instance to return predefined endpoints + 2. Awaiting get_endpoint('two') and asserting the correct object is returned + + Assumptions: + - Lookup is linear scan over get_endpoints() results + """ + e1 = Endpoint(client, "one", 1, "k1") + e2 = Endpoint(client, "two", 2, "k2") + with patch.object( + client, "get_endpoints", new_callable=AsyncMock, return_value=[e1, e2] + ): + found = await client.get_endpoint("two") + assert found is e2 + + @pytest.mark.asyncio + async def test_get_endpoint_raises_when_name_not_found(self, client) -> None: + """ + Verifies get_endpoint raises when no endpoint matches the given name. + + This test verifies by: + 1. Patching get_endpoints to return an empty list + 2. Asserting Exception mentions the endpoint name + + Assumptions: + - Missing name produces a clear error string + """ + with patch.object( + client, "get_endpoints", new_callable=AsyncMock, return_value=[] + ): + with pytest.raises(Exception, match="could not be found"): + await client.get_endpoint("missing") + + +# --------------------------------------------------------------------------- +# get_endpoint_workers +# --------------------------------------------------------------------------- + + +class TestServerlessGetEndpointWorkers: + """Verify worker listing via autoscaler POST and response edge cases.""" + + @pytest.mark.asyncio + async def test_get_endpoint_workers_requires_endpoint_type( + self, client_with_session + ) -> None: + """ + Verifies get_endpoint_workers raises ValueError for non-Endpoint argument. + + This test verifies by: + 1. Passing a MagicMock instead of Endpoint + 2. Asserting ValueError with message about Endpoint + + Assumptions: + - isinstance(endpoint, Endpoint) guard is enforced + """ + client = client_with_session + with pytest.raises(ValueError, match="endpoint must be an Endpoint"): + await client.get_endpoint_workers(MagicMock()) + + @pytest.mark.asyncio + async def test_get_endpoint_workers_returns_worker_list( + self, serverless_master_client, make_mock_http_response + ) -> None: + """ + Verifies JSON list responses are converted to Worker dataclass instances. + + This test verifies by: + 1. Mocking _session.post async context with status 200 and json list + 2. Awaiting get_endpoint_workers with a real Endpoint + 3. Asserting Worker id and status + + Assumptions: + - POST URL is autoscaler_url + get_endpoint_workers/ + """ + payload_item = {"id": 7, "status": "READY"} + mock_resp = make_mock_http_response( + status=200, + json_data=[payload_item], + text="", + ) + + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=mock_resp) + + client = serverless_master_client + client._session = mock_session + ep = Endpoint(client, "ep", 99, "ek") + + workers = await client.get_endpoint_workers(ep) + + assert len(workers) == 1 + assert workers[0].id == 7 + assert workers[0].status == "READY" + mock_session.post.assert_called_once() + url = mock_session.post.call_args[0][0] + assert url.endswith("/get_endpoint_workers/") + assert mock_session.post.call_args.kwargs["json"] == { + "id": 99, + "api_key": "master", + } + + @pytest.mark.asyncio + async def test_get_endpoint_workers_returns_empty_list_on_error_msg( + self, client_with_session, make_serverless_endpoint, make_mock_http_response + ) -> None: + """ + Verifies dict responses containing error_msg yield an empty worker list. + + This test verifies by: + 1. Returning JSON dict with error_msg key from the mock response + 2. Asserting the result is [] + + Assumptions: + - Server may return error_msg when workers are not ready; client soft-fails + """ + mock_resp = make_mock_http_response( + status=200, + json_data={"error_msg": "not ready"}, + ) + + client = client_with_session + client._session.post = MagicMock(return_value=mock_resp) + ep = make_serverless_endpoint(client, name="ep", endpoint_id=1, api_key="ek") + + workers = await client.get_endpoint_workers(ep) + assert workers == [] + + @pytest.mark.asyncio + async def test_get_endpoint_workers_raises_on_non_200( + self, client_with_session, make_serverless_endpoint, make_mock_http_response + ) -> None: + """ + Verifies non-200 HTTP status raises RuntimeError with body text. + + This test verifies by: + 1. Setting resp.status to 502 and text() to a message + 2. Asserting RuntimeError mentions HTTP 502 + + Assumptions: + - resp.text is awaited for error diagnostics + """ + mock_resp = make_mock_http_response(status=502, text="bad gateway") + + client = client_with_session + client._session.post = MagicMock(return_value=mock_resp) + ep = make_serverless_endpoint(client, name="ep", endpoint_id=1, api_key="ek") + + with pytest.raises(RuntimeError, match="HTTP 502"): + await client.get_endpoint_workers(ep) + + @pytest.mark.asyncio + async def test_get_endpoint_workers_raises_on_unexpected_json_type( + self, client_with_session, make_serverless_endpoint, make_mock_http_response + ) -> None: + """ + Verifies non-list JSON (without error_msg) raises RuntimeError. + + This test verifies by: + 1. Returning JSON string or other non-list type + 2. Asserting RuntimeError mentions Unexpected response type + + Assumptions: + - Successful worker list must be a JSON array + """ + mock_resp = make_mock_http_response(status=200, json_data="not-a-list") + + client = client_with_session + client._session.post = MagicMock(return_value=mock_resp) + ep = make_serverless_endpoint(client, name="ep", endpoint_id=1, api_key="ek") + + with pytest.raises(RuntimeError, match="Unexpected response type"): + await client.get_endpoint_workers(ep) + + @pytest.mark.asyncio + async def test_get_endpoint_workers_raises_on_dict_without_error_msg( + self, client_with_session, make_serverless_endpoint, make_mock_http_response + ) -> None: + """JSON object without error_msg is not a worker list; client raises RuntimeError.""" + mock_resp = make_mock_http_response(status=200, json_data={"status": "ok"}) + + client = client_with_session + client._session.post = MagicMock(return_value=mock_resp) + ep = make_serverless_endpoint(client, name="ep", endpoint_id=1, api_key="ek") + + with pytest.raises(RuntimeError, match="wanted list"): + await client.get_endpoint_workers(ep) + + +# --------------------------------------------------------------------------- +# Session get / end +# --------------------------------------------------------------------------- + + +class TestServerlessEndpointSessionHttp: + """Verify get_endpoint_session and end_endpoint_session use _make_request.""" + + @pytest.mark.asyncio + async def test_get_endpoint_session_builds_session_from_json( + self, client, make_serverless_endpoint + ) -> None: + """ + Verifies get_endpoint_session constructs Session from auth_data and metadata. + + This test verifies by: + 1. Mocking _make_request to return ok with json containing auth_data and url + 2. Awaiting get_endpoint_session and asserting Session fields + + Assumptions: + - session_auth dict includes url key used as request base + """ + ep = make_serverless_endpoint(client, name="n", endpoint_id=1, api_key="ek") + session_auth = {"url": "https://worker.example/session"} + worker_json = { + "auth_data": {"token": "t", "url": "https://worker.example/w"}, + "lifetime": 60.0, + "expiration": "later", + } + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": True, "json": worker_json}, + ) as mock_req: + sess = await client.get_endpoint_session(ep, 42, session_auth, timeout=5.0) + + assert isinstance(sess, Session) + assert sess.session_id == 42 + assert sess.auth_data == worker_json["auth_data"] + assert sess.url == "https://worker.example/w" + mock_req.assert_awaited() + assert mock_req.call_args.kwargs["url"] == session_auth["url"] + assert mock_req.call_args.kwargs["route"] == "/session/get" + assert mock_req.call_args.kwargs["body"]["session_id"] == 42 + + @pytest.mark.asyncio + async def test_get_endpoint_session_raises_without_auth_data( + self, client, make_serverless_endpoint + ) -> None: + """ + Verifies missing auth_data in JSON raises Exception. + + This test verifies by: + 1. Returning ok json without auth_data key + 2. Asserting Exception is raised + + Assumptions: + - auth_data is required to build Session + """ + ep = make_serverless_endpoint(client, name="n", endpoint_id=1, api_key="ek") + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": True, "json": {"lifetime": 1}}, + ): + with pytest.raises(Exception, match="Missing auth_data"): + await client.get_endpoint_session(ep, 1, {"url": "https://x"}) + + @pytest.mark.asyncio + async def test_end_endpoint_session_raises_when_not_ok( + self, client, make_session_mock + ) -> None: + """ + Verifies end_endpoint_session raises when _make_request returns ok=False. + + This test verifies by: + 1. Mocking _make_request with ok False and json error + 2. Passing a minimal Session mock with required attributes + 3. Asserting Exception mentions /session/end + + Assumptions: + - Session.url and session.auth_data are read for the request body + """ + mock_session = make_session_mock( + session_id=9, url="https://worker/u", auth_data={"a": 1} + ) + + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": False, "json": {"error": "gone"}}, + ): + with pytest.raises(Exception, match="/session/end"): + await client.end_endpoint_session(mock_session) + + @pytest.mark.asyncio + async def test_get_endpoint_session_raises_when_http_result_not_ok( + self, client, make_serverless_endpoint + ) -> None: + """ + Verifies get_endpoint_session raises when _make_request returns ok=False. + + This test verifies by: + 1. Returning a failed result with json error detail + 2. Asserting the raised Exception mentions /session/get + + Assumptions: + - Error message prefers json['error'] when present + """ + ep = make_serverless_endpoint(client, name="n", endpoint_id=1, api_key="ek") + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": False, "json": {"error": "nope"}}, + ): + with pytest.raises(Exception, match="/session/get"): + await client.get_endpoint_session(ep, 1, {"url": "https://x"}) + + @pytest.mark.asyncio + async def test_get_endpoint_session_propagates_timeout_error( + self, client, make_serverless_endpoint + ) -> None: + """ + Verifies asyncio.TimeoutError from _make_request is not wrapped by the outer handler. + + This test verifies by: + 1. Making _make_request raise asyncio.TimeoutError + 2. Awaiting get_endpoint_session and expecting the same exception type + + Assumptions: + - TimeoutError is listed before the broad Exception handler in the implementation + """ + ep = make_serverless_endpoint(client, name="n", endpoint_id=1, api_key="ek") + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + side_effect=asyncio.TimeoutError, + ): + with pytest.raises(asyncio.TimeoutError): + await client.get_endpoint_session(ep, 1, {"url": "https://x"}) + + @pytest.mark.asyncio + async def test_get_endpoint_session_wraps_unexpected_errors( + self, client, make_serverless_endpoint + ) -> None: + """ + Verifies non-timeout errors from _make_request are wrapped with session context. + + This test verifies by: + 1. Making _make_request raise OSError + 2. Asserting raised Exception message includes session id and Failed to get session + + Assumptions: + - Outer except Exception path logs and re-raises a new Exception + """ + ep = make_serverless_endpoint(client, name="n", endpoint_id=1, api_key="ek") + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + side_effect=OSError("disk"), + ): + with pytest.raises(Exception, match="Failed to get session 5"): + await client.get_endpoint_session(ep, 5, {"url": "https://x"}) + + @pytest.mark.asyncio + async def test_end_endpoint_session_succeeds_when_ok_true( + self, client, make_session_mock + ) -> None: + """ + Verifies end_endpoint_session returns None when _make_request reports success. + + This test verifies by: + 1. Mocking _make_request to return ok=True + 2. Awaiting end_endpoint_session and asserting no exception + + Assumptions: + - Successful end is a fire-and-forget style API (implicit None return) + """ + mock_session = make_session_mock(auth_data={"a": 1}) + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": True, "json": {}}, + ): + await client.end_endpoint_session(mock_session) + + @pytest.mark.asyncio + async def test_end_endpoint_session_wraps_generic_errors( + self, client, make_session_mock + ) -> None: + """Non-timeout failures from _make_request are wrapped with session context.""" + mock_session = make_session_mock(session_id=3, auth_data={}) + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + side_effect=OSError("down"), + ): + with pytest.raises(Exception, match="Failed to end session 3"): + await client.end_endpoint_session(mock_session) + + @pytest.mark.asyncio + async def test_end_endpoint_session_propagates_timeout_error( + self, client, make_session_mock + ) -> None: + """ + Verifies asyncio.TimeoutError from end_endpoint_session is not wrapped. + + This test verifies by: + 1. Making _make_request raise asyncio.TimeoutError + 2. Asserting asyncio.TimeoutError propagates + + Assumptions: + - Same TimeoutError ordering as get_endpoint_session + """ + mock_session = make_session_mock(auth_data={}) + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + side_effect=asyncio.TimeoutError, + ): + with pytest.raises(asyncio.TimeoutError): + await client.end_endpoint_session(mock_session) + + +# --------------------------------------------------------------------------- +# start_endpoint_session +# --------------------------------------------------------------------------- + + +class TestServerlessStartEndpointSession: + """Verify start_endpoint_session awaits queue_endpoint_request result shape.""" + + @pytest.mark.asyncio + async def test_start_endpoint_session_returns_session_on_success( + self, + client, + default_start_endpoint_session_ep, + make_completed_serverless_request, + ) -> None: + """ + Verifies start_endpoint_session returns Session when queue result is well-formed. + + This test verifies by: + 1. Patching queue_endpoint_request to return an already-resolved ServerlessRequest + 2. Awaiting start_endpoint_session and asserting Session session_id and url + + Assumptions: + - queue_endpoint_request is awaited inside start_endpoint_session + """ + ep = default_start_endpoint_session_ep + done = make_completed_serverless_request( + result={ + "ok": True, + "response": {"session_id": 100, "expiration": "e"}, + "url": "https://w/u", + "auth_data": {"x": 1}, + } + ) + + with patch.object(client, "queue_endpoint_request", return_value=done): + sess = await client.start_endpoint_session(ep, cost=50, lifetime=30.0) + + assert isinstance(sess, Session) + assert sess.session_id == 100 + assert sess.url == "https://w/u" + assert sess.lifetime == 30.0 + + @pytest.mark.asyncio + async def test_start_endpoint_session_raises_when_queue_reports_not_ok( + self, + client, + default_start_endpoint_session_ep, + make_completed_serverless_request, + ) -> None: + """ + Verifies start_endpoint_session raises when the queued worker result has ok=False. + + This test verifies by: + 1. Resolving the queue future with ok=False and json error + 2. Asserting Exception mentions /session/create + + Assumptions: + - queue_endpoint_request result dict uses the same ok/json shape as HTTP helpers + """ + ep = default_start_endpoint_session_ep + done = make_completed_serverless_request( + result={"ok": False, "json": {"error": "busy"}, "text": ""} + ) + with patch.object(client, "queue_endpoint_request", return_value=done): + with pytest.raises(Exception, match="/session/create"): + await client.start_endpoint_session(ep) + + @pytest.mark.asyncio + async def test_start_endpoint_session_raises_when_session_id_missing( + self, + client, + default_start_endpoint_session_ep, + make_completed_serverless_request, + ) -> None: + """ + Verifies start_endpoint_session raises when response JSON omits session_id. + + This test verifies by: + 1. Returning ok=True with a response dict without session_id + 2. Asserting Exception mentions Missing session id + + Assumptions: + - session_id is required to construct Session + """ + ep = default_start_endpoint_session_ep + done = make_completed_serverless_request( + result={ + "ok": True, + "response": {"expiration": "e"}, + "url": "https://w/", + "auth_data": {"x": 1}, + } + ) + with patch.object(client, "queue_endpoint_request", return_value=done): + with pytest.raises(Exception, match="Missing session id"): + await client.start_endpoint_session(ep) + + @pytest.mark.asyncio + async def test_start_endpoint_session_raises_when_url_missing( + self, + client, + default_start_endpoint_session_ep, + make_completed_serverless_request, + ) -> None: + """ + Verifies start_endpoint_session raises when the queue result omits url. + + This test verifies by: + 1. Returning ok=True with session_id but url None / missing + 2. Asserting Exception mentions Missing URL + + Assumptions: + - url is required for subsequent session calls + """ + ep = default_start_endpoint_session_ep + done = make_completed_serverless_request( + result={ + "ok": True, + "response": {"session_id": 1, "expiration": "e"}, + "url": None, + "auth_data": {"x": 1}, + } + ) + with patch.object(client, "queue_endpoint_request", return_value=done): + with pytest.raises(Exception, match="Missing URL"): + await client.start_endpoint_session(ep) + + @pytest.mark.asyncio + async def test_start_endpoint_session_raises_when_auth_data_missing( + self, + client, + default_start_endpoint_session_ep, + make_completed_serverless_request, + ) -> None: + """ + Verifies start_endpoint_session raises when auth_data is absent from the queue result. + + This test verifies by: + 1. Returning ok=True with valid response and url but auth_data None + 2. Asserting Exception mentions Missing auth data + + Assumptions: + - auth_data is required to build Session + """ + ep = default_start_endpoint_session_ep + done = make_completed_serverless_request( + result={ + "ok": True, + "response": {"session_id": 1, "expiration": "e"}, + "url": "https://w/", + "auth_data": None, + } + ) + with patch.object(client, "queue_endpoint_request", return_value=done): + with pytest.raises(Exception, match="Missing auth data"): + await client.start_endpoint_session(ep) + + @pytest.mark.asyncio + async def test_start_endpoint_session_raises_when_response_none( + self, + client, + default_start_endpoint_session_ep, + make_completed_serverless_request, + ) -> None: + """ + Verifies ok=True but ``response`` is None raises + """ + ep = default_start_endpoint_session_ep + done = make_completed_serverless_request( + result={ + "ok": True, + "response": None, + "url": "https://w/", + "auth_data": {"x": 1}, + } + ) + with patch.object(client, "queue_endpoint_request", return_value=done): + with pytest.raises(Exception, match="No response from /session/create"): + await client.start_endpoint_session(ep) + + @pytest.mark.asyncio + async def test_start_endpoint_session_wraps_generic_queue_errors( + self, + client, + default_start_endpoint_session_ep, + make_completed_serverless_request, + ) -> None: + """Errors other than TimeoutError from the queue future are wrapped.""" + ep = default_start_endpoint_session_ep + done = make_completed_serverless_request(exception=ValueError("queue broke")) + with patch.object(client, "queue_endpoint_request", return_value=done): + with pytest.raises(Exception, match="Failed to create session"): + await client.start_endpoint_session(ep) + + @pytest.mark.asyncio + async def test_start_endpoint_session_propagates_timeout_error( + self, + client, + default_start_endpoint_session_ep, + make_completed_serverless_request, + ) -> None: + """ + Verifies asyncio.TimeoutError from queue_endpoint_request is re-raised. + + This test verifies by: + 1. Patching queue_endpoint_request to return a future completed with TimeoutError via set_exception + + Assumptions: + - Awaiting the ServerlessRequest propagates set_exception payloads + """ + ep = default_start_endpoint_session_ep + done = make_completed_serverless_request(exception=asyncio.TimeoutError()) + with patch.object(client, "queue_endpoint_request", return_value=done): + with pytest.raises(asyncio.TimeoutError): + await client.start_endpoint_session(ep) + + +# --------------------------------------------------------------------------- +# queue_endpoint_request +# --------------------------------------------------------------------------- + + +class TestServerlessQueueEndpointRequest: + """Verify routing poll loop and worker _make_request success path (mocked, no real sleep).""" + + @pytest.mark.asyncio + async def test_queue_endpoint_request_completes_after_route_ready_and_worker_ok( + self, + client_with_session, + make_serverless_endpoint, + make_route_response_mock, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Verifies queue_endpoint_request resolves with worker JSON when route then worker succeed. + + This test verifies by: + 1. Patching endpoint._route to return WAITING then READY with url and body + 2. Patching client._make_request to return ok with json + 3. Patching asyncio.sleep and random.uniform to avoid delays + 4. Awaiting the returned ServerlessRequest and asserting response payload and ok + + Assumptions: + - Endpoint._route is replaced with a fake that simulates WAITING then READY + - Worker call uses vastai.serverless.client.client._make_request + """ + ep = make_serverless_endpoint(client_with_session) + client = client_with_session + + waiting = make_route_response_mock(request_idx=7) + ready = make_route_response_mock( + status="READY", + url="https://worker/", + request_idx=7, + body={"token": "t"}, + ) + + route_seq = iter([waiting, ready]) + + async def fake_route(*_a, **_kw): + return next(route_seq) + + worker_json = {"result": 42} + + with ( + patch.object(Endpoint, "_route", side_effect=fake_route), + patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": True, "json": worker_json}, + ), + ): + fut = client.queue_endpoint_request( + endpoint=ep, + worker_route="/do", + worker_payload={"x": 1}, + cost=10, + ) + result = await fut + + assert result["ok"] is True + assert result["response"] == worker_json + assert result["url"] == "https://worker/" + + +class TestServerlessQueueEndpointRequestBranches: + """Additional queue_endpoint_request paths (timeouts, retries, session, cancel, stream).""" + + @pytest.mark.asyncio + async def test_queue_times_out_before_route_when_timeout_zero( + self, monkeypatch, client_with_session, make_serverless_endpoint + ) -> None: + """ + Verifies the outer loop raises TimeoutError when elapsed time exceeds timeout. + + This test verifies by: + 1. Patching client module time.time so the timeout check sees elapsed >= 0 immediately + 2. Using timeout=0 and awaiting the ServerlessRequest + 3. Asserting asyncio.TimeoutError is delivered to the awaiter + + Assumptions: + - TimeoutError is stored on the future via the task's outer Exception handler + + Note: + Use pytest ``monkeypatch`` (not ``unittest.mock.patch``) for ``time.time`` so the + mock is always undone after the test. A stuck ``return_value=100.0`` patch would + leave ``start_time`` and later ``time()`` identical in the next test's polling + loop, causing an infinite spin and a hung suite (often seen as Cursor/IDE freeze). + """ + client = client_with_session + ep = make_serverless_endpoint(client, name="ep", endpoint_id=1, api_key="ek") + + # Patch the same ``time`` module object ``client.py`` imports (not a string path: + # ``pytest`` may resolve ``...client.time`` incorrectly). + monkeypatch.setattr(serverless_client_mod.time, "time", lambda: 100.0) + fut = client.queue_endpoint_request( + endpoint=ep, + worker_route="/r", + worker_payload={}, + timeout=0.0, + ) + with pytest.raises(asyncio.TimeoutError): + await fut + + @pytest.mark.asyncio + async def test_queue_times_out_while_polling_route_status( + self, + monkeypatch, + client_with_session, + make_serverless_endpoint, + make_route_response_mock, + ) -> None: + """ + Verifies TimeoutError when the route stays non-READY until the deadline. + + This test verifies by: + 1. Returning a perpetual WAITING route from _route + 2. Patching time.time so the first *inner* poll iteration sees elapsed time past timeout + (accounting for create_time + start_time + outer deadline calls) + 3. Asserting asyncio.TimeoutError with the poll-loop message + + Assumptions: + - Polling loop checks the same elapsed timeout as the top of the outer loop + - time.time() may be invoked more than once per iteration; the fake must stay stable + + ``logging`` calls ``time.time()`` for every ``LogRecord``. If any handler is left on + the ``Serverless`` logger (e.g. from a prior test), extra calls desynchronize this + fake and the client spins in the poll loop forever (100% CPU — feels like a freeze). + """ + client = client_with_session + monkeypatch.setattr(client.logger, "disabled", True) + ep = make_serverless_endpoint(client) + + waiting = make_route_response_mock() + + calls = {"n": 0} + + def _fake_time() -> float: + calls["n"] += 1 + # ServerlessRequest.__init__ calls time.time() for create_time before the task body. + # Then: start_time, outer deadline (line ~357), then inner poll deadline (~381). + if calls["n"] <= 3: + return 0.0 + return 100.0 + + async def always_waiting(*_a, **_kw): + return waiting + + monkeypatch.setattr(serverless_client_mod.time, "time", _fake_time) + + with ( + patch.object(Endpoint, "_route", side_effect=always_waiting), + patch( + "vastai.serverless.client.client.asyncio.sleep", new_callable=AsyncMock + ), + patch("vastai.serverless.client.client.random.uniform", return_value=0.1), + ): + fut = client.queue_endpoint_request( + endpoint=ep, + worker_route="/do", + worker_payload={}, + timeout=10.0, + ) + with pytest.raises( + asyncio.TimeoutError, match="waiting for worker to become ready" + ): + await fut + + @pytest.mark.asyncio + async def test_queue_logs_retry_route_after_connector_error_continues_loop( + self, + client_with_session, + make_serverless_endpoint, + make_route_response_mock, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Verifies ClientConnectorError without a session sets Retrying and re-enters routing. + + This test verifies by: + 1. First worker _make_request raises ClientConnectorError + 2. Second iteration uses a new route READY and second worker call succeeds + 3. Asserting final ok and that two _route calls occurred (retry path) + + Assumptions: + - request_idx stays non-zero on retry so the 'retry route call' log branch can run + """ + ep = make_serverless_endpoint(client_with_session) + client = client_with_session + + ready = make_route_response_mock( + status="READY", + url="https://worker/", + request_idx=3, + body={"token": "t"}, + ) + + route_calls = 0 + + async def route_then_route(*_a, **_kw): + nonlocal route_calls + route_calls += 1 + return ready + + make_req = AsyncMock( + side_effect=[ + aiohttp.ClientConnectorError(MagicMock(), OSError("gone")), + {"ok": True, "json": {"done": True}}, + ] + ) + + with ( + patch.object(Endpoint, "_route", side_effect=route_then_route), + patch("vastai.serverless.client.client._make_request", make_req), + ): + fut = client.queue_endpoint_request( + endpoint=ep, + worker_route="/do", + worker_payload={}, + cost=10, + ) + result = await fut + + assert result["ok"] is True + assert route_calls == 2 + + @pytest.mark.asyncio + async def test_queue_connector_error_with_session_raises_connection_error( + self, + client_with_session, + make_serverless_endpoint, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Verifies ClientConnectorError with an active session raises ConnectionError. + + This test verifies by: + 1. Passing a Session with url set and open=True + 2. Making _make_request raise ClientConnectorError + 3. Asserting ConnectionError and session.open is False + + Assumptions: + - Session-bound workers cannot re-route; client marks session closed + """ + ep = make_serverless_endpoint(client_with_session) + client = client_with_session + sess = Session(ep, 1, 60.0, "e", "https://sess/", {"a": 1}) + + with ( + patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + side_effect=aiohttp.ClientConnectorError(MagicMock(), OSError("gone")), + ), + ): + fut = client.queue_endpoint_request( + endpoint=ep, + worker_route="/do", + worker_payload={}, + session=sess, + ) + with pytest.raises(ConnectionError, match="Session worker unavailable"): + await fut + + assert sess.open is False + + @pytest.mark.asyncio + async def test_queue_server_disconnected_with_session_raises_connection_error( + self, + client_with_session, + make_serverless_endpoint, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Verifies ServerDisconnectedError with session is treated like a dead worker. + + This test verifies by: + 1. Raising aiohttp.ServerDisconnectedError from _make_request + 2. Asserting ConnectionError + + Assumptions: + - Same handling as ClientConnectorError for session-bound calls + """ + ep = make_serverless_endpoint(client_with_session) + client = client_with_session + sess = Session(ep, 1, 60.0, "e", "https://sess/", {"a": 1}) + + with ( + patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + side_effect=aiohttp.ServerDisconnectedError(), + ), + ): + fut = client.queue_endpoint_request( + endpoint=ep, + worker_route="/do", + worker_payload={}, + session=sess, + ) + with pytest.raises(ConnectionError): + await fut + + @pytest.mark.asyncio + async def test_queue_generic_exception_on_worker_retries_then_succeeds( + self, + client_with_session, + make_serverless_endpoint, + make_route_response_mock, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Verifies non-aiohttp exceptions from _make_request trigger Retrying and continue. + + This test verifies by: + 1. First _make_request raises ValueError + 2. Second returns ok True + 3. Asserting successful result + + Assumptions: + - Generic Exception path does not re-raise immediately; loop continues + """ + ep = make_serverless_endpoint(client_with_session) + client = client_with_session + + ready = make_route_response_mock(status="READY", request_idx=2, body={"t": 1}) + + make_req = AsyncMock( + side_effect=[ + ValueError("transient"), + {"ok": True, "json": {"v": 1}}, + ] + ) + + with ( + patch.object(Endpoint, "_route", AsyncMock(return_value=ready)), + patch("vastai.serverless.client.client._make_request", make_req), + ): + fut = client.queue_endpoint_request( + endpoint=ep, + worker_route="/do", + worker_payload={}, + ) + result = await fut + + assert result["ok"] is True + assert make_req.await_count == 2 + + @pytest.mark.asyncio + async def test_queue_uses_session_url_without_routing( + self, + client_with_session, + make_serverless_endpoint, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Verifies queue_endpoint_request skips routing when session is provided with a URL. + + This test verifies by: + 1. Passing Session with url and auth_data + 2. Asserting Endpoint._route is never called and worker URL matches session.url + + Assumptions: + - session branch sets worker_url and auth_data from the session + """ + ep = make_serverless_endpoint(client_with_session) + client = client_with_session + sess = Session(ep, 9, 60.0, "e", "https://sess-worker/", {"tok": 1}) + + route_mock = AsyncMock() + with ( + patch.object(Endpoint, "_route", route_mock), + patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": True, "json": {"x": 1}}, + ) as make_req, + ): + fut = client.queue_endpoint_request( + endpoint=ep, + worker_route="/in", + worker_payload={"p": 1}, + session=sess, + ) + result = await fut + + route_mock.assert_not_called() + assert result["url"] == "https://sess-worker/" + called_url = make_req.call_args.kwargs["url"] + assert called_url == "https://sess-worker/" + + @pytest.mark.asyncio + async def test_queue_non_ok_retryable_retries_until_success( + self, + client_with_session, + make_serverless_endpoint, + make_route_response_mock, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Verifies retryable ok=False responses sleep and retry when retry=True. + + This test verifies by: + 1. Returning ok=False with retryable=True then ok=True + 2. Patching sleep and random + 3. Asserting final success and multiple _make_request calls + + Assumptions: + - max_retries None allows retries while retryable remains true + """ + ep = make_serverless_endpoint(client_with_session) + client = client_with_session + + ready = make_route_response_mock(status="READY") + + make_req = AsyncMock( + side_effect=[ + {"ok": False, "retryable": True, "status": 503}, + {"ok": True, "json": {"ok": True}}, + ] + ) + + with ( + patch.object(Endpoint, "_route", AsyncMock(return_value=ready)), + patch("vastai.serverless.client.client._make_request", make_req), + ): + fut = client.queue_endpoint_request( + endpoint=ep, + worker_route="/do", + worker_payload={}, + retry=True, + ) + result = await fut + + assert result["ok"] is True + assert make_req.await_count == 2 + + @pytest.mark.asyncio + async def test_queue_retry_false_finishes_on_retryable_without_retry( + self, + client_with_session, + make_serverless_endpoint, + make_route_response_mock, + patch_serverless_queue_async_stubs, + ) -> None: + """When retry=False, a retryable worker error completes immediately (no loop).""" + ep = make_serverless_endpoint(client_with_session) + client = client_with_session + + ready = make_route_response_mock(status="READY") + + with ( + patch.object(Endpoint, "_route", AsyncMock(return_value=ready)), + patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={ + "ok": False, + "retryable": True, + "status": 503, + "text": "busy", + }, + ) as make_req, + ): + fut = client.queue_endpoint_request( + endpoint=ep, + worker_route="/do", + worker_payload={}, + retry=False, + ) + result = await fut + + assert result["ok"] is False + assert make_req.await_count == 1 + + @pytest.mark.asyncio + async def test_queue_max_retries_stops_retryable_loop( + self, + client_with_session, + make_serverless_endpoint, + make_route_response_mock, + patch_serverless_queue_async_stubs, + ) -> None: + """When max_retries is set and exhausted, return the last non-ok result.""" + ep = make_serverless_endpoint(client_with_session) + client = client_with_session + + ready = make_route_response_mock(status="READY") + + fail = {"ok": False, "retryable": True, "status": 503, "json": {"detail": "x"}} + + with ( + patch.object(Endpoint, "_route", AsyncMock(return_value=ready)), + patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value=fail, + ) as make_req, + ): + fut = client.queue_endpoint_request( + endpoint=ep, + worker_route="/do", + worker_payload={}, + max_retries=1, + ) + result = await fut + + assert result["ok"] is False + assert result["response"] == {"detail": "x"} + assert make_req.await_count == 1 + + @pytest.mark.asyncio + async def test_queue_non_ok_retryable_times_out_before_backoff_sleep( + self, + monkeypatch, + client_with_session, + make_serverless_endpoint, + make_route_response_mock, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Verifies TimeoutError when retry is needed but global timeout is already exceeded. + + This test verifies by: + 1. Returning ok=False retryable with timeout set + 2. Patching time.time so elapsed time exceeds timeout before sleep + 3. Asserting asyncio.TimeoutError + + Assumptions: + - Lines that guard retry with remaining timeout are exercised + + Disables the client logger so ``LogRecord`` timestamps do not consume ``time.time()`` + calls from the side_effect iterator (same freeze risk as the polling-timeout test). + """ + client = client_with_session + monkeypatch.setattr(client.logger, "disabled", True) + ep = make_serverless_endpoint(client) + + ready = make_route_response_mock(status="READY") + + # time.time() order: create_time (ServerlessRequest.__init__), start_time, first + # outer-loop deadline (~357), then retry guard (~447), then f-string in TimeoutError. + _clock = iter([0.0, 0.0, 0.0, 100.0, 100.0]) + + def _t() -> float: + return next(_clock, 1e9) + + with ( + patch.object(Endpoint, "_route", AsyncMock(return_value=ready)), + patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": False, "retryable": True, "status": 503}, + ), + patch("vastai.serverless.client.client.time.time", side_effect=_t), + ): + fut = client.queue_endpoint_request( + endpoint=ep, + worker_route="/do", + worker_payload={}, + timeout=50.0, + ) + # LogRecord timestamps may call time.time() while the patch is active, so the + # exact call sequence to reach the retry-path guard (line ~447) can shift. + with pytest.raises(asyncio.TimeoutError, match="(?i)timed out after"): + await fut + + @pytest.mark.asyncio + async def test_queue_non_ok_not_retryable_returns_error_dict_without_json( + self, + client_with_session, + make_serverless_endpoint, + make_route_response_mock, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Verifies completed future carries text fallback when HTTP json is missing. + + This test verifies by: + 1. Returning ok=False without retryable and without json but with text + 2. Asserting response['response'] embeds error text + + Assumptions: + - Non-retryable failures finalize the ServerlessRequest with ok False + """ + ep = make_serverless_endpoint(client_with_session) + client = client_with_session + + ready = make_route_response_mock(status="READY") + + with ( + patch.object(Endpoint, "_route", AsyncMock(return_value=ready)), + patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={ + "ok": False, + "retryable": False, + "status": 400, + "json": None, + "text": "bad request body", + }, + ), + ): + fut = client.queue_endpoint_request( + endpoint=ep, + worker_route="/do", + worker_payload={}, + retry=True, + ) + result = await fut + + assert result["ok"] is False + assert result["response"] == {"error": "bad request body"} + + @pytest.mark.asyncio + async def test_queue_stream_true_uses_stream_field_in_success_result( + self, + client_with_session, + make_serverless_endpoint, + make_route_response_mock, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Verifies stream=True maps worker_response from result['stream']. + + This test verifies by: + 1. Returning ok=True with stream=iterable-like sentinel (mock) + 2. Asserting response['response'] is that stream object + + Assumptions: + - Streaming mode does not read json for the worker payload slot + """ + ep = make_serverless_endpoint(client_with_session) + client = client_with_session + + ready = make_route_response_mock(status="READY") + stream_obj = object() + + with ( + patch.object(Endpoint, "_route", AsyncMock(return_value=ready)), + patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": True, "stream": stream_obj}, + ), + ): + fut = client.queue_endpoint_request( + endpoint=ep, + worker_route="/do", + worker_payload={}, + stream=True, + ) + result = await fut + + assert result["response"] is stream_obj + + @pytest.mark.asyncio + async def test_queue_cancel_marks_background_task_cancelled( + self, + client_with_session, + make_serverless_endpoint, + make_route_response_mock, + ) -> None: + """ + Verifies cancelling the ServerlessRequest cancels the background task and ends clean. + + This test verifies by: + 1. Patching sleep to block until cancelled (CancelledError) + 2. Cancelling the future from the test task + 3. Asserting the future is cancelled or completes without result + + Assumptions: + - add_done_callback propagates cancellation to the asyncio.Task running task() + """ + ep = make_serverless_endpoint(client_with_session) + client = client_with_session + + waiting = make_route_response_mock() + + cancel_event = asyncio.Event() + # Patching client.asyncio.sleep replaces the real asyncio.sleep globally; keep a + # reference so the side_effect can await the true sleep and avoid recursion. + real_sleep = asyncio.sleep + + async def slow_sleep(_delay: float) -> None: + cancel_event.set() + await real_sleep(0.01) + + async def wait_route(*_a, **_kw): + return waiting + + with ( + patch.object(Endpoint, "_route", side_effect=wait_route), + patch( + "vastai.serverless.client.client.asyncio.sleep", side_effect=slow_sleep + ), + patch("vastai.serverless.client.client.random.uniform", return_value=0.1), + ): + fut = client.queue_endpoint_request( + endpoint=ep, + worker_route="/do", + worker_payload={}, + ) + await asyncio.wait_for(cancel_event.wait(), timeout=2.0) + fut.cancel() + with pytest.raises(asyncio.CancelledError): + await fut + # Let the background task process cancellation and run the in-task + # ``except asyncio.CancelledError`` handler (coverage + deterministic teardown). + await asyncio.sleep(0.05) + + @pytest.mark.asyncio + async def test_queue_reuses_provided_serverless_request_instance( + self, + client_with_session, + make_serverless_endpoint, + make_route_response_mock, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Verifies queue_endpoint_request returns the same ServerlessRequest when passed in. + + This test verifies by: + 1. Passing serverless_request=existing future + 2. Asserting identity is preserved (branch that skips default construction) + + Assumptions: + - Callers can correlate logs/status on a pre-created request object + """ + client = client_with_session + ep = make_serverless_endpoint(client) + + ready = make_route_response_mock(status="READY") + existing = ServerlessRequest() + + with ( + patch.object(Endpoint, "_route", AsyncMock(return_value=ready)), + patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": True, "json": {}}, + ), + ): + fut = client.queue_endpoint_request( + endpoint=ep, + worker_route="/do", + worker_payload={}, + serverless_request=existing, + ) + assert fut is existing + await fut + + @pytest.mark.asyncio + async def test_queue_route_ready_with_zero_request_idx_still_completes( + self, + client_with_session, + make_serverless_endpoint, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Verifies routing with falsy request_idx still reaches worker _make_request. + + This test verifies by: + 1. Using RouteResponse-like body without request_idx so internal idx is 0 + 2. Asserting worker call succeeds (covers 'no request_idx' log branch) + + Assumptions: + - request_idx 0 is falsy and triggers the error log but processing continues + """ + ep = make_serverless_endpoint(client_with_session) + client = client_with_session + + from vastai.serverless.client.endpoint import RouteResponse + + route = RouteResponse({"url": "https://w/", "token": "t"}) + + with ( + patch.object(Endpoint, "_route", AsyncMock(return_value=route)), + patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": True, "json": {"z": 2}}, + ), + ): + fut = client.queue_endpoint_request( + endpoint=ep, + worker_route="/do", + worker_payload={}, + ) + result = await fut + + assert result["ok"] is True + + @pytest.mark.asyncio + async def test_queue_success_records_latency_in_client_deque( + self, + client_with_session, + make_serverless_endpoint, + make_route_response_mock, + patch_serverless_queue_async_stubs, + ) -> None: + """Completed worker requests append one sample to ``Serverless.latencies``.""" + client = client_with_session + client.latencies.clear() + ep = make_serverless_endpoint(client) + ready = make_route_response_mock(status="READY") + + with ( + patch.object(Endpoint, "_route", AsyncMock(return_value=ready)), + patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": True, "json": {"x": 1}}, + ), + ): + fut = client.queue_endpoint_request( + endpoint=ep, + worker_route="/do", + worker_payload={}, + ) + await fut + + assert len(client.latencies) == 1 + assert isinstance(client.latencies[0], float) + assert client.latencies[0] >= 0.0 + + @pytest.mark.asyncio + async def test_queue_session_with_url_none_calls_worker_with_empty_url( + self, + client_with_session, + make_serverless_endpoint, + make_session_mock, + ) -> None: + """If ``session.url`` is falsy, routing body is skipped and worker URL stays empty.""" + client = client_with_session + ep = make_serverless_endpoint(client) + + mock_session = make_session_mock( + session_id=7, + url=None, + auth_data={"tok": 1}, + ) + + route_mock = AsyncMock() + make_req = AsyncMock(return_value={"ok": True, "json": {"done": True}}) + + with ( + patch.object(Endpoint, "_route", route_mock), + patch("vastai.serverless.client.client._make_request", make_req), + ): + fut = client.queue_endpoint_request( + endpoint=ep, + worker_route="/in", + worker_payload={"p": 1}, + session=mock_session, + ) + await fut + + route_mock.assert_not_called() + assert make_req.call_args.kwargs["url"] == "" + + @pytest.mark.asyncio + async def test_queue_route_raises_sets_errored_exception_on_future( + self, + client_with_session, + make_serverless_endpoint, + ) -> None: + """Failures from ``endpoint._route`` outside the worker try block surface on the future.""" + client = client_with_session + ep = make_serverless_endpoint(client) + + with patch.object( + Endpoint, + "_route", + AsyncMock(side_effect=RuntimeError("scheduler unavailable")), + ): + fut = client.queue_endpoint_request( + endpoint=ep, + worker_route="/do", + worker_payload={}, + ) + with pytest.raises(RuntimeError, match="scheduler unavailable"): + await fut + + assert fut.status == "Errored" diff --git a/tests/serverless/test_client_serverless.py b/tests/serverless/test_client_serverless.py new file mode 100644 index 00000000..68594c5c --- /dev/null +++ b/tests/serverless/test_client_serverless.py @@ -0,0 +1,1830 @@ +"""Unit tests for vastai.serverless.client.client Serverless and ServerlessRequest classes. + +Tests Serverless initialization (API key validation, instance URL mapping, debug logging), +connection lifecycle (is_open, close), endpoint retrieval, worker retrieval, +session management, and ServerlessRequest future behavior. +""" + +from __future__ import annotations + +import asyncio +import logging +from unittest.mock import AsyncMock, MagicMock, patch + +import aiohttp +import pytest + +from vastai.serverless.client.client import Serverless, ServerlessRequest +from vastai.serverless.client.endpoint import Endpoint +from vastai.serverless.client.worker import Worker +from vastai.serverless.client.session import Session + + +# --------------------------------------------------------------------------- +# ServerlessRequest +# --------------------------------------------------------------------------- + + +class TestServerlessRequest: + """Verify ServerlessRequest is an asyncio.Future with status tracking.""" + + def test_initial_status_is_new(self) -> None: + """ + Verifies that a new ServerlessRequest has status "New". + + This test verifies by: + 1. Creating a ServerlessRequest + 2. Asserting status == "New" + + Assumptions: + - __init__ sets status to "New" + """ + req = ServerlessRequest() + assert req.status == "New" + assert req.start_time is None + assert req.complete_time is None + assert req.req_idx == 0 + + async def test_then_registers_callback(self) -> None: + """ + Verifies that .then() registers a done callback. + + This test verifies by: + 1. Creating a ServerlessRequest inside running event loop + 2. Calling .then(callback) + 3. Setting result and yielding to let callbacks fire + 4. Asserting callback was called with the result + + Assumptions: + - then uses add_done_callback internally + - Callbacks fire on the event loop after set_result + """ + req = ServerlessRequest() + results = [] + req.then(lambda r: results.append(r)) + req.set_result({"data": "hello"}) + # Yield control so done callbacks can fire + await asyncio.sleep(0) + assert results == [{"data": "hello"}] + + def test_then_returns_self_for_chaining(self) -> None: + """ + Verifies that .then() returns self for chaining. + + This test verifies by: + 1. Calling .then() + 2. Asserting return value is the same request + + Assumptions: + - then returns self + """ + req = ServerlessRequest() + result = req.then(lambda r: None) + assert result is req + + async def test_then_callback_not_called_on_exception(self) -> None: + """ + Verifies that .then() callback is not called when future has an exception. + + This test verifies by: + 1. Registering a then callback + 2. Setting exception on the future + 3. Yielding to let callbacks fire + 4. Asserting callback was NOT called with a result + + Assumptions: + - _done checks fut.exception() is not None and returns early + """ + req = ServerlessRequest() + results = [] + req.then(lambda r: results.append(r)) + req.set_exception(RuntimeError("fail")) + await asyncio.sleep(0) + assert results == [] + + +# --------------------------------------------------------------------------- +# Serverless.__init__ +# --------------------------------------------------------------------------- + + +class TestServerlessInit: + """Verify Serverless client initialization and configuration.""" + + def test_raises_when_api_key_missing(self) -> None: + """ + Verifies that Serverless raises AttributeError when api_key is None. + + This test verifies by: + 1. Creating Serverless with api_key=None + 2. Asserting AttributeError raised + + Assumptions: + - __init__ checks api_key is not None or empty + """ + with pytest.raises(AttributeError, match="API key missing"): + Serverless(api_key=None) + + def test_raises_when_api_key_empty(self) -> None: + """ + Verifies that Serverless raises AttributeError when api_key is empty string. + + This test verifies by: + 1. Creating Serverless with api_key="" + 2. Asserting AttributeError raised + + Assumptions: + - __init__ checks api_key == "" + """ + with pytest.raises(AttributeError, match="API key missing"): + Serverless(api_key="") + + def test_prod_instance_urls(self) -> None: + """ + Verifies that instance="prod" sets correct autoscaler and web URLs. + + This test verifies by: + 1. Creating Serverless with instance="prod" + 2. Asserting URLs match production + + Assumptions: + - prod maps to run.vast.ai and console.vast.ai + """ + client = Serverless(api_key="test-key", instance="prod") + assert client.autoscaler_url == "https://run.vast.ai" + + def test_alpha_instance_urls(self) -> None: + """ + Verifies that instance="alpha" sets correct URLs. + + This test verifies by: + 1. Creating Serverless with instance="alpha" + 2. Asserting URLs match alpha environment + + Assumptions: + - alpha maps to run-alpha.vast.ai and alpha.vast.ai + """ + client = Serverless(api_key="test-key", instance="alpha") + assert client.autoscaler_url == "https://run-alpha.vast.ai" + + def test_candidate_instance_urls(self) -> None: + """ + Verifies that instance="candidate" sets correct URLs. + + This test verifies by: + 1. Creating Serverless with instance="candidate" + 2. Asserting URLs match candidate environment + + Assumptions: + - candidate maps to run-candidate.vast.ai and candidate.vast.ai + """ + client = Serverless(api_key="test-key", instance="candidate") + assert client.autoscaler_url == "https://run-candidate.vast.ai" + + def test_local_instance_urls(self) -> None: + """ + Verifies that instance="local" sets correct URLs. + + This test verifies by: + 1. Creating Serverless with instance="local" + 2. Asserting autoscaler is localhost + + Assumptions: + - local maps to localhost:8080 for autoscaler + """ + client = Serverless(api_key="test-key", instance="local") + assert client.autoscaler_url == "http://localhost:8080" + + def test_unknown_instance_defaults_to_prod(self) -> None: + """ + Verifies that unknown instance defaults to production URLs. + + This test verifies by: + 1. Creating Serverless with instance="foobar" + 2. Asserting URLs match production + + Assumptions: + - match/case _ branch defaults to prod URLs + """ + client = Serverless(api_key="test-key", instance="foobar") + assert client.autoscaler_url == "https://run.vast.ai" + assert client.vast_web_url == "https://console.vast.ai" + + def test_default_config_values(self) -> None: + """ + Verifies that Serverless has expected default configuration. + + This test verifies by: + 1. Creating Serverless with minimal args + 2. Asserting defaults for timeout, poll_interval, connection_limit + + Assumptions: + - Defaults: timeout=600, max_poll_interval=5, connection_limit=500 + """ + client = Serverless(api_key="test-key") + assert client.default_request_timeout == 600.0 + assert client.max_poll_interval == 5.0 + assert client.connection_limit == 500 + assert client.debug is False + + def test_custom_config_values(self) -> None: + """ + Verifies that Serverless accepts custom configuration. + + This test verifies by: + 1. Creating Serverless with custom values + 2. Asserting all custom values are stored + + Assumptions: + - All config params are stored as instance attributes + """ + client = Serverless( + api_key="test-key", + debug=True, + connection_limit=100, + default_request_timeout=300.0, + max_poll_interval=10.0, + ) + assert client.debug is True + assert client.connection_limit == 100 + assert client.default_request_timeout == 300.0 + assert client.max_poll_interval == 10.0 + + def test_debug_mode_configures_logging(self) -> None: + """ + Verifies that debug=True adds a StreamHandler and sets DEBUG level. + + This test verifies by: + 1. Creating Serverless with debug=True + 2. Asserting logger has StreamHandler and DEBUG level + + Assumptions: + - debug adds StreamHandler and sets level to DEBUG + """ + client = Serverless(api_key="test-key", debug=True) + assert client.logger.level == logging.DEBUG + stream_handlers = [ + h + for h in client.logger.handlers + if isinstance(h, logging.StreamHandler) + and not isinstance(h, logging.FileHandler) + ] + assert len(stream_handlers) >= 1 + assert client.logger.propagate is False + + def test_non_debug_mode_uses_null_handler(self) -> None: + """ + Verifies that debug=False adds NullHandler and allows propagation. + + This test verifies by: + 1. Creating Serverless with debug=False + 2. Asserting logger has NullHandler and propagate=True + + Assumptions: + - Non-debug adds NullHandler and sets propagate=True + """ + client = Serverless(api_key="test-key", debug=False) + null_handlers = [ + h for h in client.logger.handlers if isinstance(h, logging.NullHandler) + ] + assert len(null_handlers) >= 1 + assert client.logger.propagate is True + + def test_session_starts_as_none(self) -> None: + """ + Verifies that _session is None before any connection. + + This test verifies by: + 1. Creating Serverless + 2. Asserting _session is None + + Assumptions: + - _session is lazily initialized + """ + client = Serverless(api_key="test-key") + assert client._session is None + assert client._ssl_context is None + + +# --------------------------------------------------------------------------- +# Serverless.is_open / close +# --------------------------------------------------------------------------- + + +class TestServerlessConnection: + """Verify Serverless connection lifecycle.""" + + def test_is_open_returns_false_when_no_session(self) -> None: + """ + Verifies that is_open returns False when _session is None. + + This test verifies by: + 1. Creating Serverless (session starts as None) + 2. Asserting is_open() is False + + Assumptions: + - is_open checks _session is not None and not closed + """ + client = Serverless(api_key="test-key") + assert client.is_open() is False + + def test_is_open_returns_true_when_session_active(self) -> None: + """ + Verifies that is_open returns True when _session exists and not closed. + + This test verifies by: + 1. Setting _session to a mock with closed=False + 2. Asserting is_open() is True + + Assumptions: + - is_open checks session.closed + """ + client = Serverless(api_key="test-key") + mock_session = MagicMock() + mock_session.closed = False + client._session = mock_session + assert client.is_open() is True + + def test_is_open_returns_false_when_session_closed(self) -> None: + """ + Verifies that is_open returns False when _session.closed is True. + + This test verifies by: + 1. Setting _session with closed=True + 2. Asserting is_open() is False + + Assumptions: + - is_open checks not _session.closed + """ + client = Serverless(api_key="test-key") + mock_session = MagicMock() + mock_session.closed = True + client._session = mock_session + assert client.is_open() is False + + async def test_close_closes_session(self) -> None: + """ + Verifies that close() closes the aiohttp session. + + This test verifies by: + 1. Setting _session to a mock + 2. Calling close + 3. Asserting session.close was called + + Assumptions: + - close calls _session.close() + """ + client = Serverless(api_key="test-key") + mock_session = MagicMock() + mock_session.closed = False + mock_session.close = AsyncMock() + client._session = mock_session + await client.close() + mock_session.close.assert_called_once() + + async def test_close_noop_when_no_session(self) -> None: + """ + Verifies that close() is safe when _session is None. + + This test verifies by: + 1. Calling close with _session=None + 2. Asserting no error raised + + Assumptions: + - close checks _session exists before closing + """ + client = Serverless(api_key="test-key") + await client.close() # Should not raise + + +# --------------------------------------------------------------------------- +# Serverless async context manager +# --------------------------------------------------------------------------- + + +class TestServerlessContextManager: + """Verify Serverless works as an async context manager.""" + + async def test_aenter_returns_self(self) -> None: + """ + Verifies that __aenter__ initializes session and returns self. + + This test verifies by: + 1. Patching _get_session + 2. Using async with + 3. Asserting yielded value is the client + + Assumptions: + - __aenter__ calls _get_session and returns self + """ + client = Serverless(api_key="test-key") + client._get_session = AsyncMock() + client.close = AsyncMock() + async with client as c: + assert c is client + + async def test_aexit_calls_close(self) -> None: + """ + Verifies that __aexit__ calls close. + + This test verifies by: + 1. Patching _get_session and close + 2. Exiting context + 3. Asserting close was called + + Assumptions: + - __aexit__ calls self.close() + """ + client = Serverless(api_key="test-key") + client._get_session = AsyncMock() + client.close = AsyncMock() + async with client: + pass + client.close.assert_called_once() + + +# --------------------------------------------------------------------------- +# Serverless.get_endpoints / get_endpoint +# --------------------------------------------------------------------------- + + +class TestServerlessEndpoints: + """Verify Serverless endpoint retrieval methods.""" + + async def test_get_endpoints_returns_endpoint_list(self) -> None: + """ + Verifies that get_endpoints parses API response into Endpoint objects. + + This test verifies by: + 1. Patching _make_request to return endpoint results + 2. Calling get_endpoints + 3. Asserting Endpoint objects created with correct attributes + + Assumptions: + - get_endpoints calls /api/v0/endptjobs/ and parses results + """ + client = Serverless(api_key="test-key") + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_req: + mock_req.return_value = { + "ok": True, + "json": { + "results": [ + { + "endpoint_name": "a", + "id": 1, + "api_key": "ek1", + "cold_workers": 1, + "max_workers": 20, + "min_load": 100, + "target_util": 0.9, + "cold_mult": 1.5, + "max_queue_time": 30, + "target_queue_time": 5, + "endpoint_state": "running", + "inactivity_timeout": 600, + "user_id": 5, + "created_at": 129401, + }, + { + "endpoint_name": "b", + "id": 2, + "api_key": "ek2", + "cold_workers": 1, + "max_workers": 20, + "min_load": 100, + "target_util": 0.9, + "cold_mult": 1.5, + "max_queue_time": 30, + "target_queue_time": 5, + "endpoint_state": "running", + "inactivity_timeout": 600, + "user_id": 5, + "created_at": 129401, + }, + ] + }, + } + endpoints = await client.get_endpoints() + assert len(endpoints) == 2 + assert isinstance(endpoints[0], Endpoint) + assert endpoints[0].name == "a" + assert endpoints[0].id == 1 + assert endpoints[0].api_key == "ek1" + assert endpoints[1].name == "b" + + async def test_get_endpoints_raises_on_http_failure(self) -> None: + """ + Verifies that get_endpoints raises when API returns not ok. + + This test verifies by: + 1. Patching _make_request to return ok=False + 2. Calling get_endpoints + 3. Asserting Exception raised + + Assumptions: + - get_endpoints checks result["ok"] + """ + client = Serverless(api_key="test-key") + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_req: + mock_req.return_value = {"ok": False, "status": 401, "text": "Unauthorized"} + with pytest.raises(Exception, match="Failed to get endpoints"): + await client.get_endpoints() + + async def test_get_endpoints_raises_on_transport_error(self) -> None: + """ + Verifies that get_endpoints wraps transport errors. + + This test verifies by: + 1. Patching _make_request to raise ConnectionError + 2. Asserting Exception raised + + Assumptions: + - get_endpoints catches and re-raises with context + """ + client = Serverless(api_key="test-key") + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_req: + mock_req.side_effect = ConnectionError("connection refused") + with pytest.raises(Exception, match="Failed to get endpoints"): + await client.get_endpoints() + + async def test_get_endpoints_returns_empty_when_no_results(self) -> None: + """ + Verifies that get_endpoints returns empty list when no endpoints exist. + + This test verifies by: + 1. Patching _make_request to return empty results + 2. Asserting empty list returned + + Assumptions: + - Empty results array results in empty endpoint list + """ + client = Serverless(api_key="test-key") + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_req: + mock_req.return_value = {"ok": True, "json": {"results": []}} + endpoints = await client.get_endpoints() + assert endpoints == [] + + async def test_get_endpoint_returns_matching_endpoint(self) -> None: + """ + Verifies that get_endpoint returns the endpoint matching by name. + + This test verifies by: + 1. Patching get_endpoints to return multiple endpoints + 2. Calling get_endpoint("ep2") + 3. Asserting returned endpoint has name "ep2" + + Assumptions: + - get_endpoint iterates endpoints and matches by name + """ + client = Serverless(api_key="test-key") + ep1 = Endpoint(client=client, name="ep1", id=1, api_key="k1") + ep2 = Endpoint(client=client, name="ep2", id=2, api_key="k2") + client.get_endpoints = AsyncMock(return_value=[ep1, ep2]) + result = await client.get_endpoint("ep2") + assert result is ep2 + + async def test_get_endpoint_raises_when_not_found(self) -> None: + """ + Verifies that get_endpoint raises when no endpoint matches. + + This test verifies by: + 1. Patching get_endpoints to return endpoints that don't match + 2. Asserting Exception raised + + Assumptions: + - get_endpoint raises if no match found + """ + client = Serverless(api_key="test-key") + client.get_endpoints = AsyncMock(return_value=[]) + with pytest.raises(Exception, match="could not be found"): + await client.get_endpoint("missing") + + +# --------------------------------------------------------------------------- +# Serverless.get_endpoint_workers +# --------------------------------------------------------------------------- + + +class TestServerlessWorkers: + """Verify Serverless.get_endpoint_workers.""" + + async def test_get_endpoint_workers_returns_worker_list(self) -> None: + """ + Verifies that get_endpoint_workers parses response into Worker objects. + + This test verifies by: + 1. Creating client with mock session + 2. Mocking POST to return worker data list + 3. Asserting Worker objects returned + + Assumptions: + - Response is list of dicts parsed by Worker.from_dict + """ + client = Serverless(api_key="test-key") + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock( + return_value=[ + {"id": 1, "status": "RUNNING", "cur_load": 0.5}, + {"id": 2, "status": "IDLE", "cur_load": 0.0}, + ] + ) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=None) + + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=mock_resp) + client._session = mock_session + + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + workers = await client.get_endpoint_workers(ep) + assert len(workers) == 2 + assert isinstance(workers[0], Worker) + assert workers[0].id == 1 + assert workers[1].status == "IDLE" + + async def test_get_endpoint_workers_raises_on_non_endpoint(self) -> None: + """ + Verifies that get_endpoint_workers raises ValueError for non-Endpoint arg. + + This test verifies by: + 1. Calling with a non-Endpoint object + 2. Asserting ValueError raised + + Assumptions: + - isinstance check at top of method + """ + client = Serverless(api_key="test-key") + with pytest.raises(ValueError, match="must be an Endpoint"): + await client.get_endpoint_workers("not-an-endpoint") + + async def test_get_endpoint_workers_returns_empty_on_error_msg(self) -> None: + """ + Verifies that get_endpoint_workers returns empty list on error_msg response. + + This test verifies by: + 1. Mocking response as dict with error_msg + 2. Asserting empty list returned + + Assumptions: + - Dict response with error_msg triggers warning and empty return + """ + client = Serverless(api_key="test-key") + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"error_msg": "not ready"}) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=None) + + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=mock_resp) + client._session = mock_session + + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + workers = await client.get_endpoint_workers(ep) + assert workers == [] + + async def test_get_endpoint_workers_raises_on_http_error(self) -> None: + """ + Verifies that get_endpoint_workers raises RuntimeError on non-200 status. + + This test verifies by: + 1. Mocking response with status 500 + 2. Asserting RuntimeError raised + + Assumptions: + - Non-200 status triggers RuntimeError + """ + client = Serverless(api_key="test-key") + mock_resp = AsyncMock() + mock_resp.status = 500 + mock_resp.text = AsyncMock(return_value="Internal Server Error") + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=None) + + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=mock_resp) + client._session = mock_session + + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + with pytest.raises(RuntimeError, match="get_endpoint_workers failed"): + await client.get_endpoint_workers(ep) + + async def test_get_endpoint_workers_raises_on_unexpected_type(self) -> None: + """ + Verifies that get_endpoint_workers raises on non-list response. + + This test verifies by: + 1. Mocking response as a string + 2. Asserting RuntimeError raised + + Assumptions: + - Non-list, non-error-dict response triggers RuntimeError + """ + client = Serverless(api_key="test-key") + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value="unexpected string") + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=None) + + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=mock_resp) + client._session = mock_session + + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + with pytest.raises(RuntimeError, match="Unexpected response type"): + await client.get_endpoint_workers(ep) + + +# --------------------------------------------------------------------------- +# Serverless session management +# --------------------------------------------------------------------------- + + +class TestServerlessSessionManagement: + """Verify Serverless session CRUD methods.""" + + async def test_get_endpoint_session_returns_session(self) -> None: + """ + Verifies that get_endpoint_session creates Session from worker response. + + This test verifies by: + 1. Patching _make_request to return session data with auth_data + 2. Calling get_endpoint_session + 3. Asserting Session created with correct fields + + Assumptions: + - Response contains auth_data, lifetime, expiration + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_req: + mock_req.return_value = { + "ok": True, + "json": { + "auth_data": {"url": "https://w.vast.ai", "signature": "sig"}, + "lifetime": 120.0, + "expiration": "2026-12-31T00:00:00Z", + }, + } + session = await client.get_endpoint_session( + endpoint=ep, + session_id=42, + session_auth={"url": "https://w.vast.ai"}, + ) + assert isinstance(session, Session) + assert session.session_id == 42 + assert session.lifetime == 120.0 + assert session.url == "https://w.vast.ai" + + async def test_get_endpoint_session_raises_on_not_ok(self) -> None: + """ + Verifies that get_endpoint_session raises when response is not ok. + + This test verifies by: + 1. Patching _make_request to return ok=False + 2. Asserting Exception raised + + Assumptions: + - Not-ok response triggers exception + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_req: + mock_req.return_value = { + "ok": False, + "json": {"error": "not found"}, + "text": "", + } + with pytest.raises(Exception, match="Failed to get session"): + await client.get_endpoint_session( + endpoint=ep, + session_id=42, + session_auth={"url": "https://w.vast.ai"}, + ) + + async def test_get_endpoint_session_raises_on_missing_auth_data(self) -> None: + """ + Verifies that get_endpoint_session raises when auth_data is missing. + + This test verifies by: + 1. Patching _make_request to return ok=True but no auth_data + 2. Asserting Exception raised + + Assumptions: + - Missing auth_data triggers "Missing auth_data" exception + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_req: + mock_req.return_value = { + "ok": True, + "json": {"lifetime": 60.0}, + } + with pytest.raises(Exception, match="Missing auth_data"): + await client.get_endpoint_session( + endpoint=ep, + session_id=42, + session_auth={"url": "https://w.vast.ai"}, + ) + + async def test_get_endpoint_session_reraises_timeout(self) -> None: + """ + Verifies that get_endpoint_session re-raises asyncio.TimeoutError. + + This test verifies by: + 1. Patching _make_request to raise TimeoutError + 2. Asserting asyncio.TimeoutError propagates + + Assumptions: + - TimeoutError is caught and re-raised directly + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_req: + mock_req.side_effect = asyncio.TimeoutError() + with pytest.raises(asyncio.TimeoutError): + await client.get_endpoint_session( + endpoint=ep, + session_id=42, + session_auth={"url": "https://w.vast.ai"}, + ) + + async def test_end_endpoint_session_success(self) -> None: + """ + Verifies that end_endpoint_session calls /session/end and returns. + + This test verifies by: + 1. Patching _make_request to return ok=True + 2. Calling end_endpoint_session + 3. Asserting no exception raised + + Assumptions: + - end_endpoint_session POSTs to /session/end + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + session = Session( + endpoint=ep, + session_id="s1", + lifetime=60, + expiration="x", + url="https://w.vast.ai", + auth_data={"url": "https://w.vast.ai"}, + ) + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_req: + mock_req.return_value = {"ok": True, "json": {}} + await client.end_endpoint_session(session=session) + mock_req.assert_called_once() + call_kwargs = mock_req.call_args[1] + assert call_kwargs["route"] == "/session/end" + assert call_kwargs["body"]["session_id"] == "s1" + + async def test_end_endpoint_session_raises_on_failure(self) -> None: + """ + Verifies that end_endpoint_session raises on not-ok response. + + This test verifies by: + 1. Patching _make_request to return ok=False + 2. Asserting Exception raised + + Assumptions: + - Not-ok triggers error path + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + session = Session( + endpoint=ep, + session_id="s1", + lifetime=60, + expiration="x", + url="https://w.vast.ai", + auth_data={"url": "https://w.vast.ai"}, + ) + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_req: + mock_req.return_value = {"ok": False, "json": {"error": "fail"}, "text": ""} + with pytest.raises(Exception, match="Failed to end session"): + await client.end_endpoint_session(session=session) + + +# --------------------------------------------------------------------------- +# Serverless.start_endpoint_session +# --------------------------------------------------------------------------- + + +class TestServerlessStartEndpointSession: + """Verify Serverless.start_endpoint_session creates Session from queue response.""" + + async def test_start_session_success_returns_session(self) -> None: + """ + Verifies that start_endpoint_session returns a Session on success. + + This test verifies by: + 1. Mocking queue_endpoint_request to return a full success response + 2. Calling start_endpoint_session + 3. Asserting Session is created with correct fields + + Assumptions: + - Response contains ok, response.session_id, response.expiration, url, auth_data + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + client.queue_endpoint_request = AsyncMock( + return_value={ + "ok": True, + "response": {"session_id": "sess-42", "expiration": "2026-12-31"}, + "url": "https://w.vast.ai", + "auth_data": {"url": "https://w.vast.ai", "sig": "abc"}, + "status": 200, + } + ) + session = await client.start_endpoint_session( + endpoint=ep, cost=100, lifetime=120 + ) + assert isinstance(session, Session) + assert session.session_id == "sess-42" + assert session.lifetime == 120 + assert session.url == "https://w.vast.ai" + + async def test_start_session_raises_on_not_ok(self) -> None: + """ + Verifies that start_endpoint_session raises when response is not ok. + + This test verifies by: + 1. Mocking queue_endpoint_request to return ok=False + 2. Asserting Exception raised with error message + + Assumptions: + - Not-ok triggers "Error on /session/create" then wraps in "Failed to create session" + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + client.queue_endpoint_request = AsyncMock( + return_value={ + "ok": False, + "json": {"error": "quota exceeded"}, + "text": "quota exceeded", + "status": 429, + } + ) + with pytest.raises(Exception, match="Failed to create session"): + await client.start_endpoint_session(endpoint=ep) + + async def test_start_session_raises_on_none_response(self) -> None: + """ + Verifies that start_endpoint_session raises when response is None. + + This test verifies by: + 1. Mocking queue_endpoint_request with response=None + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + client.queue_endpoint_request = AsyncMock( + return_value={ + "ok": True, + "response": None, + "status": 200, + } + ) + with pytest.raises(Exception, match="No response from /session/create"): + await client.start_endpoint_session(endpoint=ep) + + async def test_start_session_raises_on_missing_session_id(self) -> None: + """ + Verifies that start_endpoint_session raises when session_id is missing. + + This test verifies by: + 1. Mocking response without session_id + 2. Asserting Exception raised + + Assumptions: + - Missing session_id triggers "Missing session id" + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + client.queue_endpoint_request = AsyncMock( + return_value={ + "ok": True, + "response": {"expiration": "2026-12-31"}, + "url": "https://w.vast.ai", + "auth_data": {"url": "https://w.vast.ai"}, + "status": 200, + } + ) + with pytest.raises(Exception, match="Missing session id"): + await client.start_endpoint_session(endpoint=ep) + + async def test_start_session_raises_on_missing_url(self) -> None: + """ + Verifies that start_endpoint_session raises when url is missing. + + This test verifies by: + 1. Mocking response without url + 2. Asserting Exception raised + + Assumptions: + - Missing url triggers "Missing URL" + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + client.queue_endpoint_request = AsyncMock( + return_value={ + "ok": True, + "response": {"session_id": "s1", "expiration": "x"}, + "url": None, + "auth_data": {"url": "https://w.vast.ai"}, + "status": 200, + } + ) + with pytest.raises(Exception, match="Missing URL"): + await client.start_endpoint_session(endpoint=ep) + + async def test_start_session_raises_on_missing_auth_data(self) -> None: + """ + Verifies that start_endpoint_session raises when auth_data is missing. + + This test verifies by: + 1. Mocking response without auth_data + 2. Asserting Exception raised + + Assumptions: + - Missing auth_data triggers "Missing auth data" + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + client.queue_endpoint_request = AsyncMock( + return_value={ + "ok": True, + "response": {"session_id": "s1", "expiration": "x"}, + "url": "https://w.vast.ai", + "auth_data": None, + "status": 200, + } + ) + with pytest.raises(Exception, match="Missing auth data"): + await client.start_endpoint_session(endpoint=ep) + + async def test_start_session_reraises_timeout(self) -> None: + """ + Verifies that start_endpoint_session re-raises asyncio.TimeoutError. + + This test verifies by: + 1. Mocking queue_endpoint_request to raise TimeoutError + 2. Asserting asyncio.TimeoutError propagates directly + + Assumptions: + - TimeoutError is caught and re-raised without wrapping + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + client.queue_endpoint_request = AsyncMock(side_effect=asyncio.TimeoutError()) + with pytest.raises(asyncio.TimeoutError): + await client.start_endpoint_session(endpoint=ep) + + async def test_start_session_passes_correct_payload(self) -> None: + """ + Verifies that start_endpoint_session sends correct worker_payload. + + This test verifies by: + 1. Calling with lifetime, on_close_route, on_close_payload + 2. Asserting queue_endpoint_request called with correct payload + + Assumptions: + - Payload includes lifetime, on_close_route, on_close_payload + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + client.queue_endpoint_request = AsyncMock( + return_value={ + "ok": True, + "response": {"session_id": "s1", "expiration": "x"}, + "url": "https://w.vast.ai", + "auth_data": {"url": "https://w.vast.ai"}, + "status": 200, + } + ) + await client.start_endpoint_session( + endpoint=ep, + cost=50, + lifetime=300, + on_close_route="/cleanup", + on_close_payload={"key": "val"}, + timeout=30.0, + ) + call_kwargs = client.queue_endpoint_request.call_args.kwargs + assert call_kwargs["worker_route"] == "/session/create" + assert call_kwargs["worker_payload"] == { + "lifetime": 300, + "on_close_route": "/cleanup", + "on_close_payload": {"key": "val"}, + } + assert call_kwargs["cost"] == 50 + assert call_kwargs["timeout"] == 30.0 + assert call_kwargs["worker_timeout"] == 10.0 + + +# --------------------------------------------------------------------------- +# Serverless.queue_endpoint_request +# --------------------------------------------------------------------------- + + +class TestServerlessQueueEndpointRequest: + """Verify Serverless.queue_endpoint_request returns a managed Future.""" + + async def test_returns_serverless_request(self) -> None: + """ + Verifies that queue_endpoint_request returns a ServerlessRequest. + + This test verifies by: + 1. Creating client and endpoint with mocked internals + 2. Calling queue_endpoint_request inside a running event loop + 3. Asserting result is ServerlessRequest + + Assumptions: + - queue_endpoint_request wraps async work in ServerlessRequest future + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + client._get_session = AsyncMock() + client.get_ssl_context = AsyncMock(return_value=None) + + with patch.object(ep, "_route", new_callable=AsyncMock) as mock_route: + mock_route.return_value = MagicMock( + status="READY", + request_idx=1, + get_url=MagicMock(return_value="https://w.vast.ai"), + body={"url": "https://w.vast.ai"}, + ) + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_req: + mock_req.return_value = { + "ok": True, + "json": {"result": "done"}, + "status": 200, + "text": "", + } + req = client.queue_endpoint_request( + endpoint=ep, + worker_route="/predict", + worker_payload={"input": "test"}, + ) + assert isinstance(req, ServerlessRequest) + # Await the background task to avoid orphaned task warnings + result = await req + assert result["ok"] is True + + async def test_accepts_custom_serverless_request(self) -> None: + """ + Verifies that queue_endpoint_request uses provided ServerlessRequest. + + This test verifies by: + 1. Creating a custom ServerlessRequest + 2. Passing it to queue_endpoint_request + 3. Asserting the same object is returned + + Assumptions: + - If serverless_request is provided, it's used instead of creating new one + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + custom_req = ServerlessRequest() + client._get_session = AsyncMock() + client.get_ssl_context = AsyncMock(return_value=None) + + with patch.object(ep, "_route", new_callable=AsyncMock) as mock_route: + mock_route.return_value = MagicMock( + status="READY", + request_idx=1, + get_url=MagicMock(return_value="https://w.vast.ai"), + body={"url": "https://w.vast.ai"}, + ) + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_req: + mock_req.return_value = { + "ok": True, + "json": {"result": "done"}, + "status": 200, + "text": "", + } + req = client.queue_endpoint_request( + endpoint=ep, + worker_route="/predict", + worker_payload={}, + serverless_request=custom_req, + ) + assert req is custom_req + await req + + +# --------------------------------------------------------------------------- +# Serverless.queue_endpoint_request — async task behavior +# --------------------------------------------------------------------------- + + +class TestQueueEndpointRequestTaskBehavior: + """Verify the async task logic inside queue_endpoint_request.""" + + async def test_session_based_routing_skips_route_call(self) -> None: + """ + Verifies that providing a session bypasses _route and uses session.url directly. + + This test verifies by: + 1. Creating a session with url and auth_data + 2. Calling queue_endpoint_request with session param + 3. Asserting _route was NOT called + 4. Asserting _make_request was called with session.url + + Assumptions: + - When session is provided, worker_url = session.url, auth_data = session.auth_data + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + session = Session( + endpoint=ep, + session_id="s1", + lifetime=60, + expiration="x", + url="https://session-worker.vast.ai", + auth_data={"sig": "abc"}, + ) + client._get_session = AsyncMock() + client.get_ssl_context = AsyncMock(return_value=None) + + with patch.object(ep, "_route", new_callable=AsyncMock) as mock_route: + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_req: + mock_req.return_value = { + "ok": True, + "json": {"result": "done"}, + "status": 200, + "text": "", + } + req = client.queue_endpoint_request( + endpoint=ep, + worker_route="/predict", + worker_payload={"input": "test"}, + session=session, + ) + result = await req + mock_route.assert_not_called() + assert result["ok"] is True + # Verify _make_request was called with the session's URL + call_kwargs = mock_req.call_args.kwargs + assert call_kwargs["url"] == "https://session-worker.vast.ai" + assert call_kwargs["body"]["session_id"] == "s1" + assert call_kwargs["body"]["auth_data"] == {"sig": "abc"} + + async def test_polling_loop_when_route_returns_waiting(self) -> None: + """ + Verifies that queue_endpoint_request polls when route status is WAITING. + + This test verifies by: + 1. Mocking _route to return WAITING first, then READY + 2. Asserting _route was called multiple times + 3. Asserting final result is successful + + Assumptions: + - While route.status != READY, the task polls with asyncio.sleep + - max_poll_interval=0.001 keeps real sleeps negligible + """ + client = Serverless(api_key="test-key", max_poll_interval=0.001) + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + client._get_session = AsyncMock() + client.get_ssl_context = AsyncMock(return_value=None) + + waiting_response = MagicMock( + status="WAITING", + request_idx=5, + get_url=MagicMock(return_value=None), + body={}, + ) + ready_response = MagicMock( + status="READY", + request_idx=5, + get_url=MagicMock(return_value="https://w.vast.ai"), + body={"url": "https://w.vast.ai"}, + ) + + with patch.object(ep, "_route", new_callable=AsyncMock) as mock_route: + mock_route.side_effect = [ + waiting_response, + waiting_response, + ready_response, + ] + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_req: + mock_req.return_value = { + "ok": True, + "json": {"result": "ok"}, + "status": 200, + "text": "", + } + req = client.queue_endpoint_request( + endpoint=ep, + worker_route="/predict", + worker_payload={}, + ) + result = await req + assert result["ok"] is True + # Initial route + 2 polls (WAITING, WAITING) + READY not polled again + assert mock_route.call_count == 3 + + async def test_retry_on_retryable_http_error(self) -> None: + """ + Verifies that queue_endpoint_request retries on retryable HTTP errors. + + This test verifies by: + 1. Mocking _make_request to return retryable=True first, then ok=True + 2. Asserting the request succeeds after retry + + Assumptions: + - Retryable non-ok responses trigger retry with backoff + - max_poll_interval=0.001 keeps retry sleep negligible + """ + client = Serverless(api_key="test-key", max_poll_interval=0.001) + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + client._get_session = AsyncMock() + client.get_ssl_context = AsyncMock(return_value=None) + + with patch.object(ep, "_route", new_callable=AsyncMock) as mock_route: + mock_route.return_value = MagicMock( + status="READY", + request_idx=1, + get_url=MagicMock(return_value="https://w.vast.ai"), + body={"url": "https://w.vast.ai"}, + ) + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_req: + mock_req.side_effect = [ + { + "ok": False, + "retryable": True, + "status": 503, + "text": "Service Unavailable", + "json": None, + }, + {"ok": True, "json": {"result": "ok"}, "status": 200, "text": ""}, + ] + req = client.queue_endpoint_request( + endpoint=ep, + worker_route="/predict", + worker_payload={}, + retry=True, + ) + result = await req + assert result["ok"] is True + assert mock_req.call_count == 2 + + async def test_non_retryable_error_returns_result(self) -> None: + """ + Verifies that non-retryable HTTP errors return the result without retrying. + + This test verifies by: + 1. Mocking _make_request to return retryable=False, ok=False + 2. Asserting result returned directly with ok=False + + Assumptions: + - Non-retryable errors are returned to the caller immediately + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + client._get_session = AsyncMock() + client.get_ssl_context = AsyncMock(return_value=None) + + with patch.object(ep, "_route", new_callable=AsyncMock) as mock_route: + mock_route.return_value = MagicMock( + status="READY", + request_idx=1, + get_url=MagicMock(return_value="https://w.vast.ai"), + body={"url": "https://w.vast.ai"}, + ) + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_req: + mock_req.return_value = { + "ok": False, + "retryable": False, + "status": 400, + "text": "Bad Request", + "json": {"error": "invalid"}, + } + req = client.queue_endpoint_request( + endpoint=ep, + worker_route="/predict", + worker_payload={}, + ) + result = await req + assert result["ok"] is False + assert result["status"] == 400 + mock_req.assert_called_once() + + async def test_stream_mode_returns_stream(self) -> None: + """ + Verifies that stream=True uses result.get("stream") instead of result.get("json"). + + This test verifies by: + 1. Mocking _make_request with stream result + 2. Calling with stream=True + 3. Asserting response contains the stream object + + Assumptions: + - Stream mode extracts result["stream"] as response + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + client._get_session = AsyncMock() + client.get_ssl_context = AsyncMock(return_value=None) + mock_stream = MagicMock() + + with patch.object(ep, "_route", new_callable=AsyncMock) as mock_route: + mock_route.return_value = MagicMock( + status="READY", + request_idx=1, + get_url=MagicMock(return_value="https://w.vast.ai"), + body={"url": "https://w.vast.ai"}, + ) + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_req: + mock_req.return_value = { + "ok": True, + "stream": mock_stream, + "json": None, + "status": 200, + "text": "", + } + req = client.queue_endpoint_request( + endpoint=ep, + worker_route="/predict", + worker_payload={}, + stream=True, + ) + result = await req + assert result["response"] is mock_stream + + +# --------------------------------------------------------------------------- +# queue_endpoint_request – resilience edge cases +# --------------------------------------------------------------------------- + + +class TestQueueEndpointRequestResilience: + """Cover transport failure, generic exception retry, and cancellation paths + in queue_endpoint_request that the main tests do not exercise.""" + + async def test_session_worker_connection_error_raises(self) -> None: + """ + Verifies that a ClientConnectorError with an active session raises + ConnectionError and marks session.open = False. + + This test verifies by: + 1. Creating a Serverless client and Endpoint + 2. Creating a Session bound to that endpoint + 3. Mocking _make_request to raise ClientConnectorError + 4. Asserting ConnectionError is raised and session.open is False + + Assumptions: + - Session-bound requests cannot re-route to a different worker + - The session is marked closed on transport failure + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + client._get_session = AsyncMock() + client.get_ssl_context = AsyncMock(return_value=None) + + session = Session( + endpoint=ep, + session_id="sess-1", + lifetime=60.0, + expiration="2026-12-31T00:00:00Z", + url="https://worker1.vast.ai", + auth_data={"url": "https://worker1.vast.ai", "signature": "abc"}, + ) + assert session.open is True + + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_req: + conn_os_error = OSError("connection refused") + mock_req.side_effect = aiohttp.ClientConnectorError( + connection_key=MagicMock(), + os_error=conn_os_error, + ) + req = client.queue_endpoint_request( + endpoint=ep, + worker_route="/predict", + worker_payload={}, + session=session, + ) + with pytest.raises(ConnectionError, match="Session worker unavailable"): + await req + + assert session.open is False + + async def test_session_worker_server_disconnected_raises(self) -> None: + """ + Verifies that ServerDisconnectedError with an active session raises + ConnectionError. + + This test verifies by: + 1. Mocking _make_request to raise ServerDisconnectedError + 2. Asserting ConnectionError is raised + + Assumptions: + - ServerDisconnectedError is handled identically to ClientConnectorError + when a session is present + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + client._get_session = AsyncMock() + client.get_ssl_context = AsyncMock(return_value=None) + + session = Session( + endpoint=ep, + session_id="sess-2", + lifetime=60.0, + expiration="2026-12-31T00:00:00Z", + url="https://worker2.vast.ai", + auth_data={"url": "https://worker2.vast.ai", "signature": "xyz"}, + ) + + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_req: + mock_req.side_effect = aiohttp.ServerDisconnectedError() + req = client.queue_endpoint_request( + endpoint=ep, + worker_route="/predict", + worker_payload={}, + session=session, + ) + with pytest.raises(ConnectionError, match="Session worker unavailable"): + await req + + assert session.open is False + + async def test_no_session_connection_error_reroutes(self) -> None: + """ + Verifies that a ClientConnectorError WITHOUT a session triggers a + re-route instead of raising. + + This test verifies by: + 1. Mocking _make_request to raise ClientConnectorError once, then succeed + 2. Mocking _route to return READY both times + 3. Asserting the request succeeds and _route was called twice (initial + re-route) + + Assumptions: + - Without a session, transport errors trigger re-routing to a new worker + - request_idx resets so a fresh route is obtained + """ + client = Serverless(api_key="test-key", max_poll_interval=0.001) + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + client._get_session = AsyncMock() + client.get_ssl_context = AsyncMock(return_value=None) + + with patch.object(ep, "_route", new_callable=AsyncMock) as mock_route: + mock_route.return_value = MagicMock( + status="READY", + request_idx=1, + get_url=MagicMock(return_value="https://w.vast.ai"), + body={"url": "https://w.vast.ai"}, + ) + conn_os_error = OSError("connection refused") + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_req: + mock_req.side_effect = [ + aiohttp.ClientConnectorError( + connection_key=MagicMock(), + os_error=conn_os_error, + ), + { + "ok": True, + "json": {"result": "rerouted"}, + "status": 200, + "text": "", + }, + ] + req = client.queue_endpoint_request( + endpoint=ep, + worker_route="/predict", + worker_payload={}, + ) + result = await req + assert result["ok"] is True + assert result["response"]["result"] == "rerouted" + # _route called twice: initial route + re-route after failure + assert mock_route.call_count == 2 + + async def test_generic_exception_retries(self) -> None: + """ + Verifies that a non-transport exception (e.g. RuntimeError) in + _make_request triggers a retry rather than failing immediately. + + This test verifies by: + 1. Mocking _make_request to raise RuntimeError once, then succeed + 2. Asserting the request eventually succeeds + 3. Asserting _make_request was called twice + + Assumptions: + - The bare `except Exception` clause in queue_endpoint_request sets + status to "Retrying" and continues the loop + """ + client = Serverless(api_key="test-key", max_poll_interval=0.001) + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + client._get_session = AsyncMock() + client.get_ssl_context = AsyncMock(return_value=None) + + with patch.object(ep, "_route", new_callable=AsyncMock) as mock_route: + mock_route.return_value = MagicMock( + status="READY", + request_idx=1, + get_url=MagicMock(return_value="https://w.vast.ai"), + body={"url": "https://w.vast.ai"}, + ) + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_req: + mock_req.side_effect = [ + RuntimeError("unexpected worker error"), + { + "ok": True, + "json": {"result": "recovered"}, + "status": 200, + "text": "", + }, + ] + req = client.queue_endpoint_request( + endpoint=ep, + worker_route="/predict", + worker_payload={}, + ) + result = await req + assert result["ok"] is True + assert result["response"]["result"] == "recovered" + assert mock_req.call_count == 2 + + async def test_cancelled_error_sets_status(self) -> None: + """ + Verifies that cancelling the background task sets request status + to "Cancelled". + + This test verifies by: + 1. Mocking _route to set an asyncio.Event when entered, then block + 2. Waiting on that event so ordering is deterministic (no fixed sleeps) + 3. Cancelling the ServerlessRequest and yielding until status is "Cancelled" + + Assumptions: + - CancelledError is caught by the outer try/except + - _propagate_cancel forwards the cancellation to the bg_task + """ + client = Serverless(api_key="test-key") + ep = Endpoint(client=client, name="ep", id=1, api_key="k") + client._get_session = AsyncMock() + client.get_ssl_context = AsyncMock(return_value=None) + + route_entered = asyncio.Event() + + async def _route_after_signal(**kwargs): + route_entered.set() + await asyncio.sleep(999) + + with patch.object(ep, "_route", side_effect=_route_after_signal): + req = client.queue_endpoint_request( + endpoint=ep, + worker_route="/predict", + worker_payload={}, + ) + await asyncio.wait_for(route_entered.wait(), timeout=5.0) + req.cancel() + for _ in range(200): + if req.status == "Cancelled": + break + await asyncio.sleep(0) + else: + pytest.fail("request status did not become Cancelled in time") + assert req.status == "Cancelled" + + +# --------------------------------------------------------------------------- +# get_ssl_context – hermetic SSL certificate loading +# --------------------------------------------------------------------------- + + +class TestGetSslContext: + """Cover the get_ssl_context method that downloads and caches the Vast root cert.""" + + async def test_downloads_and_caches_ssl_cert(self) -> None: + """ + Verifies that get_ssl_context fetches the cert, creates an SSLContext, + and caches it for subsequent calls. + + This test verifies by: + 1. Mocking the aiohttp.ClientSession used inside get_ssl_context + 2. Providing opaque bytes as the HTTP body (real parsing is mocked via + ``ssl.create_default_context`` returning a MagicMock) + 3. Calling get_ssl_context twice + 4. Asserting the HTTP fetch only happens once (cached) + + Assumptions: + - get_ssl_context uses a fresh aiohttp.ClientSession internally + - Production writes PEM to a temp file and loads it; here ``load_verify_locations`` + is never invoked on a real context because ``create_default_context`` is patched + """ + import ssl as _ssl + + fake_cert_bytes = b"fake-cert-data" + + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.read = AsyncMock(return_value=fake_cert_bytes) + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + mock_session = AsyncMock() + mock_session.get = MagicMock(return_value=mock_response) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + mock_ctx = MagicMock(spec=_ssl.SSLContext) + + client = Serverless(api_key="test-key") + assert client._ssl_context is None + + with patch( + "vastai.serverless.client.client.aiohttp.ClientSession", + return_value=mock_session, + ): + with patch( + "vastai.serverless.client.client.ssl.create_default_context", + return_value=mock_ctx, + ): + ctx1 = await client.get_ssl_context() + ctx2 = await client.get_ssl_context() + + # Should return the same cached context both times + assert ctx1 is ctx2 + assert ctx1 is mock_ctx + # The HTTP fetch should only happen once + mock_session.get.assert_called_once_with(Serverless.SSL_CERT_URL) + # The cert should have been loaded + mock_ctx.load_verify_locations.assert_called_once() + + async def test_ssl_cert_fetch_failure_raises(self) -> None: + """ + Verifies that a non-200 response from the cert URL raises an Exception. + + This test verifies by: + 1. Mocking the HTTP response with status 500 + 2. Asserting Exception is raised with appropriate message + + Assumptions: + - get_ssl_context raises when cert download fails + - _ssl_context remains None after failure + """ + mock_response = AsyncMock() + mock_response.status = 500 + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=False) + + mock_session = AsyncMock() + mock_session.get = MagicMock(return_value=mock_response) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + client = Serverless(api_key="test-key") + + with patch( + "vastai.serverless.client.client.aiohttp.ClientSession", + return_value=mock_session, + ): + with pytest.raises(Exception, match="Failed to fetch SSL cert: 500"): + await client.get_ssl_context() + + assert client._ssl_context is None diff --git a/tests/serverless/test_client_session.py b/tests/serverless/test_client_session.py new file mode 100644 index 00000000..3271eb48 --- /dev/null +++ b/tests/serverless/test_client_session.py @@ -0,0 +1,451 @@ +"""Unit tests for vastai.serverless.client.session Session class. + +Tests Session initialization, validation, async context manager, +is_open healthcheck, close idempotency, and request forwarding. +""" +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from vastai.serverless.client.client import ServerlessRequest +from vastai.serverless.client.session import Session + + +# --------------------------------------------------------------------------- +# Session.__init__ validation +# --------------------------------------------------------------------------- + + +class TestSessionInit: + """Verify Session creation and validation.""" + + def test_valid_creation(self, sample_endpoint) -> None: + """ + Verifies that Session is created with all valid parameters. + + This test verifies by: + 1. Creating Session with all required args + 2. Asserting attributes are set correctly including defaults + + Assumptions: + - Session sets open=True and _closing=False on creation + """ + session = Session( + endpoint=sample_endpoint, + session_id="sess-1", + lifetime=120.0, + expiration="2026-12-31T00:00:00Z", + url="https://worker.vast.ai", + auth_data={"url": "https://worker.vast.ai"}, + ) + assert session.endpoint is sample_endpoint + assert session.session_id == "sess-1" + assert session.lifetime == 120.0 + assert session.expiration == "2026-12-31T00:00:00Z" + assert session.url == "https://worker.vast.ai" + assert session.auth_data == {"url": "https://worker.vast.ai"} + assert session.open is True + assert session._closing is False + assert session.on_close_route is None + assert session.on_close_payload is None + + def test_creation_with_on_close_params(self, sample_endpoint) -> None: + """ + Verifies that Session stores on_close_route and on_close_payload. + + This test verifies by: + 1. Creating Session with on_close_route and on_close_payload + 2. Asserting they are stored correctly + + Assumptions: + - on_close_route and on_close_payload are optional kwargs + """ + session = Session( + endpoint=sample_endpoint, + session_id="sess-2", + lifetime=60.0, + expiration="2026-12-31", + url="https://w.vast.ai", + auth_data={}, + on_close_route="/cleanup", + on_close_payload={"action": "release"}, + ) + assert session.on_close_route == "/cleanup" + assert session.on_close_payload == {"action": "release"} + + def test_raises_when_endpoint_is_none(self) -> None: + """ + Verifies that Session raises ValueError when endpoint is None. + + This test verifies by: + 1. Calling Session(endpoint=None, ...) + 2. Asserting ValueError with expected message + + Assumptions: + - __init__ checks endpoint is not None + """ + with pytest.raises(ValueError, match="empty endpoint"): + Session( + endpoint=None, + session_id="s1", + lifetime=60, + expiration="x", + url="https://w.vast.ai", + auth_data={}, + ) + + def test_raises_when_session_id_is_none(self, sample_endpoint) -> None: + """ + Verifies that Session raises ValueError when session_id is None. + + This test verifies by: + 1. Calling Session with session_id=None + 2. Asserting ValueError with expected message + + Assumptions: + - __init__ checks session_id is not None + """ + with pytest.raises(ValueError, match="empty session_id"): + Session( + endpoint=sample_endpoint, + session_id=None, + lifetime=60, + expiration="x", + url="https://w.vast.ai", + auth_data={}, + ) + + def test_raises_when_url_is_none(self, sample_endpoint) -> None: + """ + Verifies that Session raises ValueError when url is None. + + This test verifies by: + 1. Calling Session with url=None + 2. Asserting ValueError with expected message + + Assumptions: + - __init__ checks url is not None + """ + with pytest.raises(ValueError, match="empty url"): + Session( + endpoint=sample_endpoint, + session_id="s1", + lifetime=60, + expiration="x", + url=None, + auth_data={}, + ) + + def test_accepts_empty_string_session_id(self, sample_endpoint) -> None: + """Documents that only ``None`` is rejected for ``session_id``, not ``""``.""" + s = Session( + endpoint=sample_endpoint, + session_id="", + lifetime=60, + expiration="x", + url="https://w.vast.ai", + auth_data={}, + ) + assert s.session_id == "" + + def test_accepts_empty_string_url(self, sample_endpoint) -> None: + """Documents that only ``None`` is rejected for ``url``, not ``""``.""" + s = Session( + endpoint=sample_endpoint, + session_id="s1", + lifetime=60, + expiration="x", + url="", + auth_data={}, + ) + assert s.url == "" + + +# --------------------------------------------------------------------------- +# Session async context manager +# --------------------------------------------------------------------------- + + +class TestSessionContextManager: + """Verify Session works as an async context manager.""" + + async def test_aenter_returns_self(self, sample_session) -> None: + """ + Verifies that __aenter__ returns the session itself. + + This test verifies by: + 1. Using async with on session + 2. Asserting yielded value is the session + + Assumptions: + - __aenter__ returns self + """ + # We need to mock close to avoid actual close logic + sample_session.endpoint.close_session = AsyncMock() + async with sample_session as s: + assert s is sample_session + + async def test_aexit_calls_close(self, sample_session) -> None: + """ + Verifies that __aexit__ calls close on the session. + + This test verifies by: + 1. Mocking endpoint.close_session + 2. Using async with + 3. After exit, asserting session.open is False + + Assumptions: + - __aexit__ calls self.close() which calls endpoint.close_session + """ + sample_session.endpoint.close_session = AsyncMock() + async with sample_session: + assert sample_session.open is True + assert sample_session.open is False + + async def test_aexit_returns_false(self, sample_session) -> None: + """ + Verifies that __aexit__ returns False (does not suppress exceptions). + + This test verifies by: + 1. Calling __aexit__ directly + 2. Asserting return is False + + Assumptions: + - __aexit__ returns False per implementation + """ + sample_session.endpoint.close_session = AsyncMock() + result = await sample_session.__aexit__(None, None, None) + assert result is False + + +# --------------------------------------------------------------------------- +# Session.is_open +# --------------------------------------------------------------------------- + + +class TestSessionIsOpen: + """Verify Session.is_open checks healthcheck and updates state.""" + + async def test_is_open_returns_true_when_healthcheck_passes( + self, sample_session, mock_serverless_client + ) -> None: + """ + Verifies that is_open returns True when healthcheck succeeds. + + This test verifies by: + 1. Configuring ``mock_serverless_client.get_endpoint_session`` to return a session + object (what :meth:`~Endpoint.session_healthcheck` awaits internally) + 2. Calling is_open + 3. Asserting result is True and session.open is True + + Assumptions: + - is_open delegates to endpoint.session_healthcheck → get_endpoint_session + """ + mock_serverless_client.get_endpoint_session.return_value = MagicMock() + result = await sample_session.is_open() + assert result is True + assert sample_session.open is True + + async def test_is_open_returns_false_when_healthcheck_fails( + self, sample_session, mock_serverless_client + ) -> None: + """ + Verifies that is_open returns False and updates open when healthcheck fails. + + This test verifies by: + 1. Configuring ``mock_serverless_client.get_endpoint_session`` to return ``None`` + 2. Calling is_open + 3. Asserting result is False and session.open is False + + Assumptions: + - is_open sets self.open from session_healthcheck (non-None → healthy) + """ + mock_serverless_client.get_endpoint_session.return_value = None + result = await sample_session.is_open() + assert result is False + assert sample_session.open is False + + +# --------------------------------------------------------------------------- +# Session.close +# --------------------------------------------------------------------------- + + +class TestSessionClose: + """Verify Session.close calls endpoint and is idempotent.""" + + async def test_close_calls_endpoint_close_session( + self, sample_session, mock_serverless_client + ) -> None: + """ + Verifies that close calls endpoint.close_session with self. + + This test verifies by: + 1. Calling close + 2. Asserting endpoint.close_session was called + 3. Asserting session.open is False + + Assumptions: + - close delegates to endpoint.close_session(self) + """ + await sample_session.close() + mock_serverless_client.end_endpoint_session.assert_called_once_with(session=sample_session) + assert sample_session.open is False + + async def test_close_is_idempotent( + self, sample_session, mock_serverless_client + ) -> None: + """ + Verifies that calling close multiple times only closes once. + + This test verifies by: + 1. Calling close twice + 2. Asserting endpoint.close_session called only once + + Assumptions: + - _closing guard prevents re-entry + """ + await sample_session.close() + await sample_session.close() + mock_serverless_client.end_endpoint_session.assert_called_once() + + async def test_close_noop_when_already_closed( + self, sample_session, mock_serverless_client + ) -> None: + """ + Verifies that close returns None when session already not open. + + This test verifies by: + 1. Setting session.open = False + 2. Calling close + 3. Asserting result is None and endpoint.close_session not called + + Assumptions: + - close checks self.open first + """ + sample_session.open = False + result = await sample_session.close() + assert result is None + mock_serverless_client.end_endpoint_session.assert_not_called() + + async def test_close_sets_open_false_even_on_error( + self, sample_session, mock_serverless_client + ) -> None: + """ + Verifies that close sets open=False even when endpoint.close_session raises. + + This test verifies by: + 1. Mocking endpoint.close_session to raise + 2. Calling close + 3. Asserting session.open is False (finally block) + + Assumptions: + - close has finally block that sets open=False + """ + mock_serverless_client.end_endpoint_session.side_effect = Exception("network error") + await sample_session.close() + assert sample_session.open is False + + +# --------------------------------------------------------------------------- +# Session.request +# --------------------------------------------------------------------------- + + +class TestSessionRequest: + """Verify Session.request forwards to endpoint and checks open state.""" + + def test_request_raises_when_session_closed(self, sample_session) -> None: + """ + Verifies that request raises ValueError when session is closed. + + This test verifies by: + 1. Setting session.open = False + 2. Calling request + 3. Asserting ValueError raised + + Assumptions: + - request checks self.open before proceeding + """ + sample_session.open = False + with pytest.raises(ValueError, match="closed session"): + sample_session.request(route="/predict", payload={"input": "test"}) + + def test_request_returns_awaitable_when_open( + self, sample_session, mock_serverless_client + ) -> None: + """ + Verifies that request returns a coroutine when session is open. + + This test verifies by: + 1. Calling request on an open session + 2. Asserting result is a coroutine (awaitable) + + Assumptions: + - request returns _wrapped_request() which is a coroutine + """ + mock_serverless_client.queue_endpoint_request = MagicMock(return_value=MagicMock()) + result = sample_session.request(route="/predict", payload={"input": "test"}) + assert asyncio.iscoroutine(result) + # Clean up the coroutine + result.close() + + async def test_request_delegates_to_endpoint_request( + self, sample_session, mock_serverless_client + ) -> None: + """ + Verifies that request forwards to the client via ``queue_endpoint_request``. + + This test verifies by: + 1. Stubbing ``queue_endpoint_request`` with a resolved :class:`ServerlessRequest` + (sync API on :class:`Serverless`; return value is awaitable as a Future) + 2. Awaiting ``session.request(...)`` + 3. Asserting ``queue_endpoint_request`` kwargs (worker_route, payload, session, …) + + Assumptions: + - _wrapped_request awaits the object returned by ``endpoint.request`` (a Future) + """ + resolved = ServerlessRequest() + resolved.set_result( + {"ok": True, "status": 200, "response": {"result": "ok"}} + ) + mock_serverless_client.queue_endpoint_request = MagicMock(return_value=resolved) + result = await sample_session.request( + route="/predict", + payload={"input": "test"}, + cost=75, + retry=False, + stream=True, + ) + mock_serverless_client.queue_endpoint_request.assert_called_once() + call_kwargs = mock_serverless_client.queue_endpoint_request.call_args[1] + assert call_kwargs["worker_route"] == "/predict" + assert call_kwargs["worker_payload"] == {"input": "test"} + assert call_kwargs["cost"] == 75 + assert call_kwargs["retry"] is False + assert call_kwargs["stream"] is True + assert call_kwargs["session"] is sample_session + + async def test_request_closes_session_on_410_status( + self, sample_session, mock_serverless_client + ) -> None: + """ + Verifies that request sets session.open=False and raises on HTTP 410. + + This test verifies by: + 1. Mocking queue_endpoint_request to return status=410 + 2. Awaiting request + 3. Asserting ValueError raised and session.open is False + + Assumptions: + - _wrapped_request checks status == 410 and closes session + """ + mock_serverless_client.queue_endpoint_request = AsyncMock( + return_value={"ok": False, "status": 410, "response": None} + ) + with pytest.raises(ValueError, match="closed session"): + await sample_session.request(route="/predict", payload={}) + assert sample_session.open is False diff --git a/tests/serverless/test_client_ssl.py b/tests/serverless/test_client_ssl.py new file mode 100644 index 00000000..6fb79541 --- /dev/null +++ b/tests/serverless/test_client_ssl.py @@ -0,0 +1,121 @@ +"""Unit tests for Serverless.get_ssl_context – specifically the VERIFY_X509_STRICT fix.""" +import ssl + +import pytest +from cryptography.x509 import load_pem_x509_certificate +from OpenSSL.crypto import ( + X509, + X509Store, + X509StoreContext, + X509StoreFlags, +) + +from vastai.serverless.client.client import Serverless + + +def _verify_leaf_with_ca(ca_pem: bytes, leaf_pem: bytes, *, strict: bool) -> None: + """Verify a leaf cert against a CA using OpenSSL's X509Store (raises on failure).""" + + def _to_openssl(pem: bytes) -> X509: + c = load_pem_x509_certificate(pem) + return X509.from_cryptography(c) + + store = X509Store() + store.add_cert(_to_openssl(ca_pem)) + if strict: + store.set_flags(X509StoreFlags.X509_STRICT) + + ctx = X509StoreContext(store, _to_openssl(leaf_pem)) + ctx.verify_certificate() + + +class TestGetSslContextClearsX509Strict: + """Verify that get_ssl_context clears VERIFY_X509_STRICT on the SSL context.""" + + @pytest.mark.asyncio + async def test_verify_x509_strict_is_cleared( + self, + serverless_ssl_self_signed_cert_pem, + patch_serverless_ssl_cert_download, + ) -> None: + """The SSL context returned by get_ssl_context must NOT have VERIFY_X509_STRICT set.""" + client = Serverless(api_key="test-key") + + with patch_serverless_ssl_cert_download(serverless_ssl_self_signed_cert_pem): + ctx = await client.get_ssl_context() + + assert not (ctx.verify_flags & ssl.VERIFY_X509_STRICT), ( + "VERIFY_X509_STRICT should be cleared so the Vast.ai root CA is accepted" + ) + + @pytest.mark.asyncio + async def test_ssl_context_still_verifies_certs( + self, + serverless_ssl_self_signed_cert_pem, + patch_serverless_ssl_cert_download, + ) -> None: + """Clearing X509_STRICT must not disable certificate verification entirely.""" + client = Serverless(api_key="test-key") + + with patch_serverless_ssl_cert_download(serverless_ssl_self_signed_cert_pem): + ctx = await client.get_ssl_context() + + assert ctx.verify_mode == ssl.CERT_REQUIRED + assert ctx.check_hostname is True + + @pytest.mark.asyncio + async def test_ssl_context_is_cached( + self, + serverless_ssl_self_signed_cert_pem, + patch_serverless_ssl_cert_download, + ) -> None: + """get_ssl_context should return the same context on subsequent calls.""" + client = Serverless(api_key="test-key") + + with patch_serverless_ssl_cert_download(serverless_ssl_self_signed_cert_pem): + ctx1 = await client.get_ssl_context() + ctx2 = await client.get_ssl_context() + + assert ctx1 is ctx2 + + +class TestCaWithoutKeyCertSign: + """Verify that a CA cert with BasicConstraints(ca=True, path_length=0) but + *without* keyCertSign in Key Usage is accepted when VERIFY_X509_STRICT is + cleared — reproducing the exact condition of the Vast.ai root CA.""" + + def test_strict_rejects_ca_without_key_cert_sign( + self, serverless_ssl_ca_chain_without_key_cert_sign + ) -> None: + """With X509_STRICT, OpenSSL rejects the CA that lacks keyCertSign.""" + from OpenSSL.crypto import X509StoreContextError + + ca_pem, leaf_pem, _leaf_key_pem = serverless_ssl_ca_chain_without_key_cert_sign + + with pytest.raises(X509StoreContextError, match="keyCertSign|invalid CA"): + _verify_leaf_with_ca(ca_pem, leaf_pem, strict=True) + + def test_non_strict_accepts_ca_without_key_cert_sign( + self, serverless_ssl_ca_chain_without_key_cert_sign + ) -> None: + """Without X509_STRICT the same CA+leaf chain is accepted — this is + the behaviour that get_ssl_context enables by clearing the flag.""" + ca_pem, leaf_pem, _leaf_key_pem = serverless_ssl_ca_chain_without_key_cert_sign + + _verify_leaf_with_ca(ca_pem, leaf_pem, strict=False) + + @pytest.mark.asyncio + async def test_get_ssl_context_accepts_ca_without_key_cert_sign( + self, + serverless_ssl_ca_chain_without_key_cert_sign, + patch_serverless_ssl_cert_download, + ) -> None: + """get_ssl_context clears VERIFY_X509_STRICT, so the context won't + have the strict flag that would reject this CA.""" + ca_pem, _leaf_pem, _leaf_key_pem = serverless_ssl_ca_chain_without_key_cert_sign + + client = Serverless(api_key="test-key") + with patch_serverless_ssl_cert_download(ca_pem): + ctx = await client.get_ssl_context() + + assert not (ctx.verify_flags & ssl.VERIFY_X509_STRICT) diff --git a/tests/serverless/test_connection.py b/tests/serverless/test_connection.py new file mode 100644 index 00000000..44446468 --- /dev/null +++ b/tests/serverless/test_connection.py @@ -0,0 +1,1487 @@ +"""Unit tests for vastai.serverless.client.connection module. + +Tests _retryable, _backoff_delay, _build_kwargs, _iter_sse_json, +_open_once, and _make_request. All network traffic is mocked. +""" +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from vastai.serverless.client.connection import ( + _backoff_delay, + _build_kwargs, + _iter_sse_json, + _make_request, + _open_once, + _retryable, +) + + +class TestRetryable: + """Verify _retryable correctly identifies retryable HTTP status codes.""" + + def test_retryable_returns_true_for_408(self) -> None: + """ + Verifies that _retryable returns True for 408 Request Timeout. + + This test verifies by: + 1. Calling _retryable(408) + 2. Asserting result is True + + Assumptions: + - 408 is a retryable status per implementation + """ + assert _retryable(408) is True + + def test_retryable_returns_true_for_429(self) -> None: + """ + Verifies that _retryable returns True for 429 Too Many Requests. + + This test verifies by: + 1. Calling _retryable(429) + 2. Asserting result is True + + Assumptions: + - 429 is a retryable status per implementation + """ + assert _retryable(429) is True + + def test_retryable_returns_true_for_5xx_status_codes(self) -> None: + """ + Verifies that _retryable returns True for 5xx server errors. + + This test verifies by: + 1. Calling _retryable for 500, 502, 503, 504, 599 + 2. Asserting each returns True + + Assumptions: + - 500 <= status < 600 are retryable per implementation + """ + for status in (500, 502, 503, 504, 599): + assert _retryable(status) is True + + def test_retryable_returns_false_for_2xx_status_codes(self) -> None: + """ + Verifies that _retryable returns False for 2xx success codes. + + This test verifies by: + 1. Calling _retryable for 200, 201, 204 + 2. Asserting each returns False + + Assumptions: + - 2xx codes are not retryable + """ + for status in (200, 201, 204): + assert _retryable(status) is False + + def test_retryable_returns_false_for_4xx_except_408_429(self) -> None: + """ + Verifies that _retryable returns False for non-retryable 4xx codes. + + This test verifies by: + 1. Calling _retryable for 400, 401, 403, 404, 422 + 2. Asserting each returns False + + Assumptions: + - Only 408 and 429 are retryable among 4xx + """ + for status in (400, 401, 403, 404, 422): + assert _retryable(status) is False + + +class TestBackoffDelay: + """Verify _backoff_delay returns capped exponential backoff with jitter.""" + + def test_backoff_delay_increases_with_attempt(self) -> None: + """ + Verifies that _backoff_delay increases as attempt increases. + + This test verifies by: + 1. Patching random.uniform to return 0.5 for deterministic output + 2. Calling _backoff_delay for attempt 0, 1, 2 + 3. Asserting each delay is greater than the previous + + Assumptions: + - Formula is min((2**attempt) + jitter, 5.0) + """ + with patch("vastai.serverless.client.connection.random.uniform", return_value=0.5): + d0 = _backoff_delay(0) + d1 = _backoff_delay(1) + d2 = _backoff_delay(2) + assert d0 < d1 < d2 + + def test_backoff_delay_includes_jitter(self) -> None: + """ + Verifies that _backoff_delay includes jitter (random component). + + This test verifies by: + 1. Patching random.uniform to return 0.0 + 2. Asserting delay equals base (2**attempt) for attempt 0 + 3. Patching random.uniform to return 1.0 + 4. Asserting delay is base + 1 when under cap + + Assumptions: + - Jitter is random.uniform(0, 1) added to base + """ + with patch("vastai.serverless.client.connection.random.uniform", return_value=0.0): + assert _backoff_delay(0) == 1.0 # 2**0 + 0 = 1 + with patch("vastai.serverless.client.connection.random.uniform", return_value=1.0): + assert _backoff_delay(0) == 2.0 # 2**0 + 1 = 2 + + def test_backoff_delay_capped_at_five_seconds(self) -> None: + """ + Verifies that _backoff_delay is capped at 5.0 seconds. + + This test verifies by: + 1. Patching random.uniform to return 1.0 + 2. Calling _backoff_delay for attempt 5 (2**5 + 1 = 33 > 5) + 3. Asserting result is 5.0 + + Assumptions: + - Cap is 5.0 seconds per _JITTER_CAP_SECONDS + """ + with patch("vastai.serverless.client.connection.random.uniform", return_value=1.0): + delay = _backoff_delay(5) + assert delay == 5.0 + + +class TestBuildKwargs: + """Verify _build_kwargs constructs correct request kwargs.""" + + def test_build_kwargs_includes_headers_params_ssl( + self, build_kwargs_defaults + ) -> None: + """ + Verifies that _build_kwargs includes headers, params, and ssl. + + This test verifies by: + 1. Calling _build_kwargs with known headers, params, ssl_context + 2. Asserting result contains those keys with correct values + + Assumptions: + - All kwargs are passed through + - build_kwargs_defaults fixture provides base kwargs + """ + headers = {"Authorization": "Bearer x"} + params = {"api_key": "x"} + ssl_ctx = MagicMock() + result = _build_kwargs( + **{**build_kwargs_defaults, "headers": headers, "params": params, "ssl_context": ssl_ctx}, + ) + assert result["headers"] == headers + assert result["params"] == params + assert result["ssl"] is ssl_ctx + + def test_build_kwargs_stream_true_sets_timeout_none( + self, build_kwargs_defaults + ) -> None: + """ + Verifies that _build_kwargs sets timeout=None when stream=True. + + This test verifies by: + 1. Calling _build_kwargs with stream=True and timeout=30 + 2. Asserting result["timeout"].total is None + + Assumptions: + - aiohttp.ClientTimeout(total=None) for streaming + - build_kwargs_defaults fixture provides base kwargs + """ + result = _build_kwargs( + **{**build_kwargs_defaults, "stream": True}, + ) + assert result["timeout"].total is None + + def test_build_kwargs_stream_false_sets_timeout_value( + self, build_kwargs_defaults + ) -> None: + """ + Verifies that _build_kwargs sets timeout when stream=False. + + This test verifies by: + 1. Calling _build_kwargs with stream=False and timeout=60.0 + 2. Asserting result["timeout"].total == 60.0 + + Assumptions: + - Non-streaming uses explicit timeout + - build_kwargs_defaults fixture provides base kwargs + """ + result = _build_kwargs( + **{**build_kwargs_defaults, "timeout": 60.0}, + ) + assert result["timeout"].total == 60.0 + + def test_build_kwargs_get_method_omits_json_body( + self, build_kwargs_defaults + ) -> None: + """ + Verifies that _build_kwargs does not include json for GET requests. + + This test verifies by: + 1. Calling _build_kwargs with method="GET" and body={"x": 1} + 2. Asserting "json" is not in result + + Assumptions: + - GET requests do not send JSON body + - build_kwargs_defaults fixture provides base kwargs + """ + result = _build_kwargs( + **{**build_kwargs_defaults, "body": {"x": 1}}, + ) + assert "json" not in result + + def test_build_kwargs_post_method_includes_json_body( + self, build_kwargs_defaults + ) -> None: + """ + Verifies that _build_kwargs includes json for POST requests with body. + + This test verifies by: + 1. Calling _build_kwargs with method="POST" and body={"key": "val"} + 2. Asserting result["json"] == {"key": "val"} + + Assumptions: + - POST with body adds json kwarg + - build_kwargs_defaults fixture provides base kwargs + """ + body = {"key": "val"} + result = _build_kwargs( + **{**build_kwargs_defaults, "method": "POST", "body": body}, + ) + assert result["json"] == body + + def test_build_kwargs_post_method_empty_body_omits_json( + self, build_kwargs_defaults + ) -> None: + """ + Verifies that _build_kwargs omits json when body is empty for POST. + + This test verifies by: + 1. Calling _build_kwargs with method="POST" and body={} + 2. Asserting "json" is not in result (empty body is falsy) + + Assumptions: + - body or {} is used; empty dict is falsy in "method != GET and body" + - build_kwargs_defaults fixture provides base kwargs + """ + result = _build_kwargs( + **{**build_kwargs_defaults, "method": "POST", "body": {}}, + ) + assert "json" not in result + + +class TestIterSseJson: + """Verify _iter_sse_json parses SSE stream into JSON objects.""" + + async def test_iter_sse_json_yields_data_prefix_lines( + self, make_sse_response + ) -> None: + """ + Verifies that _iter_sse_json parses lines with "data:" prefix. + + This test verifies by: + 1. Creating mock response with content "data: {"a":1}\\n" + 2. Consuming _iter_sse_json + 3. Asserting yielded object is {"a": 1} + + Assumptions: + - SSE format "data: {...}" is stripped to JSON + - make_sse_response fixture provides mock response factory + """ + mock_resp = make_sse_response([b'data: {"a": 1}\n']) + + collected = [] + async for obj in _iter_sse_json(mock_resp): + collected.append(obj) + + assert collected == [{"a": 1}] + + async def test_iter_sse_json_yields_raw_jsonl_lines( + self, make_sse_response + ) -> None: + """ + Verifies that _iter_sse_json parses raw JSONL (no data: prefix). + + This test verifies by: + 1. Creating mock response with content '{"b": 2}\\n' + 2. Consuming _iter_sse_json + 3. Asserting yielded object is {"b": 2} + + Assumptions: + - Raw JSONL lines are parsed directly + - make_sse_response fixture provides mock response factory + """ + mock_resp = make_sse_response([b'{"b": 2}\n']) + + collected = [] + async for obj in _iter_sse_json(mock_resp): + collected.append(obj) + + assert collected == [{"b": 2}] + + async def test_iter_sse_json_ignores_malformed_lines( + self, make_sse_response + ) -> None: + """ + Verifies that _iter_sse_json skips malformed lines without raising. + + This test verifies by: + 1. Creating mock response with valid JSON and invalid line + 2. Consuming _iter_sse_json + 3. Asserting only valid JSON is yielded + + Assumptions: + - json.loads fails on invalid lines; exception is caught and skipped + - make_sse_response fixture provides mock response factory + """ + mock_resp = make_sse_response([ + b'{"ok": 1}\n', + b'not valid json\n', + b'{"ok": 2}\n', + ]) + + collected = [] + async for obj in _iter_sse_json(mock_resp): + collected.append(obj) + + assert collected == [{"ok": 1}, {"ok": 2}] + + async def test_iter_sse_json_ignores_empty_lines( + self, make_sse_response + ) -> None: + """ + Verifies that _iter_sse_json skips empty lines. + + This test verifies by: + 1. Creating mock response with empty lines and valid JSON + 2. Consuming _iter_sse_json + 3. Asserting only non-empty lines are parsed + + Assumptions: + - Empty lines are skipped + - make_sse_response fixture provides mock response factory + """ + mock_resp = make_sse_response([ + b'\n', + b'{"x": 1}\n', + b' \n', + ]) + + collected = [] + async for obj in _iter_sse_json(mock_resp): + collected.append(obj) + + assert collected == [{"x": 1}] + + async def test_iter_sse_json_flushes_tail_buffer( + self, make_sse_response + ) -> None: + """ + Verifies that _iter_sse_json yields tail content without newline. + + This test verifies by: + 1. Creating mock response with JSON at end without trailing newline + 2. Consuming _iter_sse_json + 3. Asserting tail is yielded + + Assumptions: + - Buffer tail is flushed on stream end + - make_sse_response fixture provides mock response factory + """ + mock_resp = make_sse_response([b'{"tail": true}']) + + collected = [] + async for obj in _iter_sse_json(mock_resp): + collected.append(obj) + + assert collected == [{"tail": True}] + + async def test_iter_sse_json_handles_multiple_chunks( + self, make_sse_response + ) -> None: + """ + Verifies that _iter_sse_json assembles across chunk boundaries. + + This test verifies by: + 1. Yielding chunks that split a JSON line + 2. Asserting complete JSON objects are yielded + + Assumptions: + - Buffer accumulates until newline + - make_sse_response fixture provides mock response factory + """ + mock_resp = make_sse_response([ + b'data: {"a": 1}\n{"b": ', + b'2}\n', + ]) + + collected = [] + async for obj in _iter_sse_json(mock_resp): + collected.append(obj) + + assert collected == [{"a": 1}, {"b": 2}] + + async def test_iter_sse_json_skips_empty_chunks( + self, make_sse_response + ) -> None: + """ + Verifies that _iter_sse_json skips empty chunks from iter_any. + + This test verifies by: + 1. Yielding empty bytes, then valid JSON + 2. Asserting only valid JSON is yielded (empty chunks ignored) + + Assumptions: + - Empty chunks trigger "if not chunk: continue" + """ + async def mock_iter(): + yield b"" + yield b'{"a": 1}\n' + yield b"" + + mock_resp = MagicMock() + mock_resp.content.iter_any = mock_iter + + collected = [] + async for obj in _iter_sse_json(mock_resp): + collected.append(obj) + + assert collected == [{"a": 1}] + + async def test_iter_sse_json_tail_parse_failure_silent( + self, make_sse_response + ) -> None: + """ + Verifies that _iter_sse_json silently skips tail that fails to parse. + + This test verifies by: + 1. Yielding valid JSON then invalid tail without newline + 2. Asserting only valid JSON is yielded; no exception raised + + Assumptions: + - Tail parse exception is caught and ignored (pass) + """ + async def mock_iter(): + yield b'{"ok": 1}\n' + yield b"not valid json tail" + + mock_resp = MagicMock() + mock_resp.content.iter_any = mock_iter + + collected = [] + async for obj in _iter_sse_json(mock_resp): + collected.append(obj) + + assert collected == [{"ok": 1}] + + +class TestOpenOnce: + """Verify _open_once executes single HTTP request.""" + + async def test_open_once_uses_get_for_get_method( + self, make_aiohttp_client_session_mock + ) -> None: + """ + Verifies that _open_once calls session.get when method is GET. + + This test verifies by: + 1. Creating mock session with AsyncMock get/post + 2. Calling _open_once with method="GET" + 3. Asserting session.get was called with url+route + + Assumptions: + - GET uses session.get + - make_aiohttp_client_session_mock fixture provides mock session factory + """ + mock_session = make_aiohttp_client_session_mock(get_returns=MagicMock()) + + await _open_once( + session=mock_session, + method="GET", + url="https://example.com", + route="/api/", + kwargs={"headers": {}}, + ) + + mock_session.get.assert_called_once_with("https://example.com/api/", headers={}) + mock_session.post.assert_not_called() + + async def test_open_once_uses_post_for_post_method( + self, make_aiohttp_client_session_mock + ) -> None: + """ + Verifies that _open_once calls session.post when method is POST. + + This test verifies by: + 1. Creating mock session with AsyncMock get/post + 2. Calling _open_once with method="POST" + 3. Asserting session.post was called with url+route + + Assumptions: + - POST uses session.post + - make_aiohttp_client_session_mock fixture provides mock session factory + """ + mock_session = make_aiohttp_client_session_mock(post_returns=MagicMock()) + + kwargs = {"headers": {}, "json": {"x": 1}} + await _open_once( + session=mock_session, + method="POST", + url="https://example.com", + route="/submit", + kwargs=kwargs, + ) + + mock_session.post.assert_called_once_with("https://example.com/submit", **kwargs) + mock_session.get.assert_not_called() + + +class TestMakeRequest: + """Verify _make_request with mocked client and session.""" + + async def test_make_request_success_returns_ok_result( + self, + make_mock_http_response, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """ + Verifies that _make_request returns ok=True for 2xx response. + + This test verifies by: + 1. Mocking client._get_session and client.get_ssl_context + 2. Mocking session.get to return 200 with JSON body + 3. Calling _make_request + 4. Asserting result has ok=True, status=200, json + + Assumptions: + - No real network; fixtures provide mocked client/session + """ + mock_resp = make_mock_http_response( + status=200, + text='{"result": "ok"}', + json_data={"result": "ok"}, + ) + _, mock_client = make_request_http_mocks(mock_resp) + + result = await _make_request( + client=mock_client, + route="/test", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=1, + ) + + assert result["ok"] is True + assert result["status"] == 200 + assert result["json"] == {"result": "ok"} + + async def test_make_request_non_retryable_4xx_returns_ok_false( + self, + make_mock_http_response, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """ + Verifies that _make_request returns ok=False for non-retryable 4xx. + + This test verifies by: + 1. Mocking client and session + 2. Returning 404 response + 3. Asserting result has ok=False, status=404, retryable=False + + Assumptions: + - 404 is not retryable; no retries + - Fixtures provide mocked client/session + """ + mock_resp = make_mock_http_response( + status=404, + text="Not Found", + ) + _, mock_client = make_request_http_mocks(mock_resp) + + result = await _make_request( + client=mock_client, + route="/missing", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=1, + ) + + assert result["ok"] is False + assert result["status"] == 404 + assert result["retryable"] is False + + async def test_make_request_successful_json_parse_failure_raises( + self, + make_mock_http_response, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """ + Verifies that _make_request raises for invalid JSON on 2xx response. + + This test verifies by: + 1. Mocking 200 response with non-JSON body + 2. Calling _make_request + 3. Asserting Exception is raised with "Invalid JSON" + + Assumptions: + - 2xx with invalid JSON is a hard failure per implementation + - Fixtures provide mocked client/session + """ + mock_resp = make_mock_http_response( + status=200, + text="not json", + json_side_effect=Exception("json decode error"), + ) + _, mock_client = make_request_http_mocks(mock_resp) + + with pytest.raises(Exception, match="Invalid JSON"): + await _make_request( + client=mock_client, + route="/test", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=1, + ) + + async def test_make_request_sets_full_url_in_result( + self, + make_mock_http_response, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """ + Verifies that _make_request returns full_url as url + route. + + This test verifies by: + 1. Calling _make_request with url and route + 2. Asserting result["url"] == url + route + + Assumptions: + - full_url is url + route + - Fixtures provide mocked client/session + """ + mock_resp = make_mock_http_response( + status=200, + text="{}", + json_data={}, + ) + _, mock_client = make_request_http_mocks(mock_resp) + + result = await _make_request( + client=mock_client, + route="/v1/endpoint", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=1, + ) + + assert result["url"] == "https://api.example.com/v1/endpoint" + + async def test_make_request_stream_success_returns_stream_iterator( + self, + make_request_http_mocks, + make_sse_response, + patch_build_kwargs, + ) -> None: + """ + Verifies that _make_request with stream=True returns stream iterator on 2xx. + + This test verifies by: + 1. Mocking response with status 200 and content.iter_any + 2. Calling _make_request with stream=True + 3. Asserting result has ok=True and consumable stream + + Assumptions: + - Stream path uses _open_once; mock returns response with iter_any + """ + mock_resp = make_sse_response([b'{"x": 1}\n', b'{"x": 2}\n']) + mock_resp.status = 200 + mock_resp.headers = {} + mock_resp.release = MagicMock() + + _, mock_client = make_request_http_mocks(mock_resp) + + result = await _make_request( + client=mock_client, + route="/stream", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=1, + stream=True, + ) + + assert result["ok"] is True + assert result["status"] == 200 + assert "stream" in result + collected = [] + async for obj in result["stream"]: + collected.append(obj) + assert collected == [{"x": 1}, {"x": 2}] + + async def test_make_request_stream_non_2xx_returns_ok_false( + self, + make_mock_http_response, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """ + Verifies that _make_request with stream=True returns ok=False for non-2xx. + + This test verifies by: + 1. Mocking 500 response (retryable but retries=1) + 2. Calling _make_request with stream=True + 3. Asserting result has ok=False, retryable=True + + Assumptions: + - Stream path handles non-2xx same as non-stream + """ + mock_resp = make_mock_http_response( + status=500, + text="Internal Server Error", + ) + mock_resp.release = MagicMock() + _, mock_client = make_request_http_mocks(mock_resp) + + result = await _make_request( + client=mock_client, + route="/stream", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=1, + stream=True, + ) + + assert result["ok"] is False + assert result["status"] == 500 + assert result["retryable"] is True + + async def test_make_request_non_2xx_with_json_body_parses_best_effort( + self, + make_mock_http_response, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """ + Verifies that _make_request parses JSON from non-2xx when body looks like JSON. + + This test verifies by: + 1. Returning 400 with body '{"error": "bad request"}' + 2. Asserting result["json"] contains parsed JSON + + Assumptions: + - Best-effort JSON parse for non-2xx when text starts with { or [ + """ + mock_resp = make_mock_http_response( + status=400, + text='{"error": "bad request"}', + ) + _, mock_client = make_request_http_mocks(mock_resp) + + result = await _make_request( + client=mock_client, + route="/bad", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=1, + ) + + assert result["ok"] is False + assert result["json"] == {"error": "bad request"} + + async def test_make_request_timeout_on_last_attempt_raises( + self, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """ + Verifies that _make_request raises TimeoutError on last retry. + + This test verifies by: + 1. Mocking session.get to raise asyncio.TimeoutError + 2. Calling _make_request with retries=1 + 3. Asserting TimeoutError is raised with message + + Assumptions: + - Timeout on final attempt propagates as TimeoutError + """ + _, mock_client = make_request_http_mocks( + get_side_effect=asyncio.TimeoutError(), + ) + + with pytest.raises(TimeoutError, match="timed out after"): + await _make_request( + client=mock_client, + route="/slow", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=1, + timeout=30.0, + ) + + async def test_make_request_stream_timeout_on_last_attempt_raises( + self, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """ + Verifies that stream path raises TimeoutError on last retry. + + This test verifies by: + 1. Mocking session.get to raise asyncio.TimeoutError (stream path) + 2. Calling _make_request with stream=True, retries=1 + 3. Asserting TimeoutError is raised + + Assumptions: + - Stream path TimeoutError on final attempt propagates + """ + _, mock_client = make_request_http_mocks( + get_side_effect=asyncio.TimeoutError(), + ) + + with pytest.raises(TimeoutError, match="timed out after"): + await _make_request( + client=mock_client, + route="/stream", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=1, + stream=True, + ) + + async def test_make_request_stream_non_2xx_json_parse_fails_silent( + self, + make_mock_http_response, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """ + Verifies that stream path silently skips JSON parse when text is invalid. + + This test verifies by: + 1. Returning 500 with body that starts with { but is invalid JSON + 2. Asserting result["json"] is None (parse fails in except, pass) + + Assumptions: + - json.loads exception in best-effort parse is caught and ignored + """ + mock_resp = make_mock_http_response( + status=500, + text='{ invalid json }', + ) + mock_resp.release = MagicMock() + _, mock_client = make_request_http_mocks(mock_resp) + + result = await _make_request( + client=mock_client, + route="/stream", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=1, + stream=True, + ) + + assert result["ok"] is False + assert result["json"] is None + + async def test_make_request_exception_on_last_attempt_raises( + self, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """ + Verifies that _make_request raises on last retry for generic exception. + + This test verifies by: + 1. Mocking session.get to raise ConnectionError + 2. Calling _make_request with retries=1 + 3. Asserting ConnectionError is raised + + Assumptions: + - Generic exception on final attempt propagates + """ + _, mock_client = make_request_http_mocks( + get_side_effect=ConnectionError("refused"), + ) + + with pytest.raises(ConnectionError, match="refused"): + await _make_request( + client=mock_client, + route="/fail", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=1, + ) + + async def test_make_request_client_with_logger_logs_on_success( + self, + make_mock_http_response, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """ + Verifies that _make_request calls client.logger.debug when client has logger. + + This test verifies by: + 1. Adding logger to mock_client + 2. Calling _make_request with success response + 3. Asserting logger.debug was called + + Assumptions: + - hasattr(client, 'logger') triggers debug log on success + """ + mock_resp = make_mock_http_response( + status=200, + text='{"ok": true}', + json_data={"ok": True}, + ) + _, mock_client = make_request_http_mocks(mock_resp) + mock_client.logger = MagicMock() + + await _make_request( + client=mock_client, + route="/test", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=1, + ) + + mock_client.logger.debug.assert_called() + + async def test_make_request_method_uppercased( + self, + make_mock_http_response, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """ + Verifies that _make_request uppercases method (e.g. post -> POST). + + This test verifies by: + 1. Calling _make_request with method="post" + 2. Asserting session.post was used (not get) + + Assumptions: + - method.upper() normalizes to POST for aiohttp + """ + mock_resp = make_mock_http_response( + status=200, + text="{}", + json_data={}, + ) + + mock_session, mock_client = make_request_http_mocks( + post_return=mock_resp, + ) + + with patch("vastai.serverless.client.connection._build_kwargs") as mock_build: + mock_build.return_value = { + "headers": {}, + "params": {}, + "timeout": MagicMock(), + "json": {}, + } + + await _make_request( + client=mock_client, + route="/submit", + api_key="sk-test", + url="https://api.example.com", + method="post", + retries=1, + ) + + mock_session.post.assert_called() + mock_session.get.assert_not_called() + + async def test_make_request_stream_non_2xx_with_json_parses_best_effort( + self, + make_mock_http_response, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """ + Verifies that stream path parses JSON from non-2xx when body looks like JSON. + + This test verifies by: + 1. Returning 500 with body '{"error": "server"}' + 2. Asserting result["json"] contains parsed JSON + + Assumptions: + - Stream path best-effort JSON parse when text starts with { or [ + """ + mock_resp = make_mock_http_response( + status=500, + text='{"error": "server"}', + ) + mock_resp.release = MagicMock() + _, mock_client = make_request_http_mocks(mock_resp) + + result = await _make_request( + client=mock_client, + route="/stream", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=1, + stream=True, + ) + + assert result["ok"] is False + assert result["json"] == {"error": "server"} + + async def test_make_request_stream_retryable_retries_then_returns( + self, + make_mock_http_response, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """ + Verifies that stream path retries on retryable status then returns. + + This test verifies by: + 1. First attempt returns 503, second returns 404 (non-retryable) + 2. Patching asyncio.sleep to avoid delay + 3. Asserting result from final attempt + + Assumptions: + - retryable + attempt < retries triggers sleep and continue + """ + mock_resp_503 = make_mock_http_response(status=503, text="Unavailable") + mock_resp_503.release = MagicMock() + mock_resp_404 = make_mock_http_response(status=404, text="Not Found") + mock_resp_404.release = MagicMock() + + mock_session, mock_client = make_request_http_mocks( + get_side_effect=[mock_resp_503, mock_resp_404], + ) + + with patch( + "vastai.serverless.client.connection.asyncio.sleep", + new_callable=AsyncMock, + ): + result = await _make_request( + client=mock_client, + route="/stream", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=2, + stream=True, + ) + + assert result["ok"] is False + assert result["status"] == 404 + assert result["attempt"] == 2 + + async def test_make_request_stream_timeout_retries_then_succeeds( + self, + make_mock_http_response, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """ + Verifies that stream path retries after TimeoutError then succeeds. + + This test verifies by: + 1. First attempt raises TimeoutError, second returns 200 + 2. Patching asyncio.sleep + 3. Asserting success on second attempt + + Assumptions: + - TimeoutError triggers sleep and retry when attempts remain + """ + mock_resp = make_mock_http_response( + status=200, + text='{"ok": true}', + json_data={"ok": True}, + ) + + mock_session, mock_client = make_request_http_mocks( + get_side_effect=[asyncio.TimeoutError(), mock_resp], + ) + + with patch( + "vastai.serverless.client.connection.asyncio.sleep", + new_callable=AsyncMock, + ): + result = await _make_request( + client=mock_client, + route="/stream", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=2, + stream=True, + ) + + assert result["ok"] is True + assert result["status"] == 200 + + async def test_make_request_stream_exception_retries_then_succeeds( + self, + make_mock_http_response, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """ + Verifies that stream path retries after generic exception then succeeds. + + This test verifies by: + 1. First attempt raises ConnectionError, second returns 200 + 2. Patching asyncio.sleep + 3. Asserting success on second attempt + + Assumptions: + - Generic exception triggers sleep and retry when attempts remain + """ + mock_resp = make_mock_http_response( + status=200, + text='{"ok": true}', + json_data={"ok": True}, + ) + + mock_session, mock_client = make_request_http_mocks( + get_side_effect=[ConnectionError("reset"), mock_resp], + ) + + with patch( + "vastai.serverless.client.connection.asyncio.sleep", + new_callable=AsyncMock, + ): + result = await _make_request( + client=mock_client, + route="/stream", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=2, + stream=True, + ) + + assert result["ok"] is True + assert result["status"] == 200 + + async def test_make_request_stream_exception_on_last_attempt_raises( + self, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """ + Verifies that stream path raises when last attempt raises exception. + + This test verifies by: + 1. All attempts raise ConnectionError (retries=2) + 2. Asserting ConnectionError propagates + + Assumptions: + - Exception on final attempt is re-raised + """ + _, mock_client = make_request_http_mocks( + get_side_effect=ConnectionError("fail"), + ) + + with patch( + "vastai.serverless.client.connection.asyncio.sleep", + new_callable=AsyncMock, + ): + with pytest.raises(ConnectionError, match="fail"): + await _make_request( + client=mock_client, + route="/stream", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=2, + stream=True, + ) + + async def test_make_request_client_with_logger_logs_on_non_2xx( + self, + make_mock_http_response, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """ + Verifies that _make_request calls client.logger.debug on non-2xx. + + This test verifies by: + 1. Adding logger to mock_client + 2. Returning 404 response + 3. Asserting logger.debug was called + + Assumptions: + - hasattr(client, 'logger') triggers debug log on non-2xx + """ + mock_resp = make_mock_http_response( + status=404, + text="Not Found", + ) + _, mock_client = make_request_http_mocks(mock_resp) + mock_client.logger = MagicMock() + + await _make_request( + client=mock_client, + route="/missing", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=1, + ) + + mock_client.logger.debug.assert_called() + + async def test_make_request_non_stream_retryable_retries_then_returns( + self, + make_mock_http_response, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """ + Verifies that non-stream path retries on retryable status then returns. + + This test verifies by: + 1. First attempt returns 429, second returns 400 + 2. Patching asyncio.sleep + 3. Asserting result from final attempt + + Assumptions: + - Retryable triggers sleep and continue; final non-retryable returns + """ + mock_resp_429 = make_mock_http_response(status=429, text="Too Many") + mock_resp_400 = make_mock_http_response(status=400, text="Bad") + + mock_session, mock_client = make_request_http_mocks( + get_side_effect=[mock_resp_429, mock_resp_400], + ) + + with patch( + "vastai.serverless.client.connection.asyncio.sleep", + new_callable=AsyncMock, + ): + result = await _make_request( + client=mock_client, + route="/rate", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=2, + ) + + assert result["ok"] is False + assert result["status"] == 400 + assert result["attempt"] == 2 + + async def test_make_request_non_stream_exception_retries_then_raises( + self, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """ + Verifies that non-stream path retries on exception then raises on last. + + This test verifies by: + 1. First attempt raises, second raises + 2. Asserting exception propagates on last attempt + + Assumptions: + - Exception triggers sleep and retry; last attempt raises + """ + _, mock_client = make_request_http_mocks( + get_side_effect=OSError("network"), + ) + + with patch( + "vastai.serverless.client.connection.asyncio.sleep", + new_callable=AsyncMock, + ): + with pytest.raises(OSError, match="network"): + await _make_request( + client=mock_client, + route="/fail", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=2, + ) + + async def test_make_request_stream_zero_retries_returns_initial_result( + self, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """Stream branch with retries=0 never enters the attempt loop (fallthrough).""" + mock_resp = MagicMock() + mock_session, mock_client = make_request_http_mocks() + mock_session.get = AsyncMock(return_value=mock_resp) + + result = await _make_request( + client=mock_client, + route="/s", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=0, + stream=True, + ) + + assert result["ok"] is False + assert result["attempt"] == 0 + mock_session.get.assert_not_called() + + async def test_make_request_non_stream_zero_retries_returns_initial_result( + self, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """Non-stream branch with retries=0 skips HTTP attempts.""" + mock_session, mock_client = make_request_http_mocks() + + result = await _make_request( + client=mock_client, + route="/x", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=0, + ) + + assert result["ok"] is False + mock_session.get.assert_not_called() + + async def test_make_request_non_stream_non_2xx_json_parse_error_keeps_json_none( + self, + make_mock_http_response, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """Malformed JSON-looking 4xx body: best-effort parse fails silently.""" + mock_resp = make_mock_http_response( + status=400, + text='{"not": "closed', # starts with { but invalid JSON + ) + _, mock_client = make_request_http_mocks(mock_resp) + + result = await _make_request( + client=mock_client, + route="/bad", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=1, + ) + + assert result["ok"] is False + assert result["json"] is None + + async def test_make_request_non_stream_timeout_retries_then_succeeds( + self, + make_mock_http_response, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """Non-stream TimeoutError on a non-final attempt sleeps and retries.""" + mock_ok = make_mock_http_response( + status=200, + text="{}", + json_data={}, + ) + mock_session, mock_client = make_request_http_mocks() + mock_session.get = AsyncMock( + side_effect=[asyncio.TimeoutError(), mock_ok] + ) + + with patch( + "vastai.serverless.client.connection.asyncio.sleep", + new_callable=AsyncMock, + ): + result = await _make_request( + client=mock_client, + route="/t", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=2, + ) + + assert result["ok"] is True + assert mock_session.get.await_count == 2 + + async def test_make_request_non_stream_exception_retries_then_succeeds( + self, + make_mock_http_response, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """Non-stream generic exception on a non-final attempt retries.""" + mock_ok = make_mock_http_response( + status=200, + text="{}", + json_data={}, + ) + mock_session, mock_client = make_request_http_mocks() + mock_session.get = AsyncMock( + side_effect=[ConnectionError("reset"), mock_ok] + ) + + with patch( + "vastai.serverless.client.connection.asyncio.sleep", + new_callable=AsyncMock, + ): + result = await _make_request( + client=mock_client, + route="/e", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=2, + ) + + assert result["ok"] is True + + async def test_make_request_stream_iterator_swallows_release_exception( + self, + make_sse_response, + make_request_http_mocks, + patch_build_kwargs, + ) -> None: + """Closing the SSE response after iteration tolerates release() errors.""" + mock_resp = make_sse_response([b'{"a": 1}\n']) + mock_resp.status = 200 + mock_resp.headers = {} + mock_resp.release = MagicMock(side_effect=RuntimeError("release failed")) + + mock_session, mock_client = make_request_http_mocks() + mock_session.get = AsyncMock(return_value=mock_resp) + + result = await _make_request( + client=mock_client, + route="/stream", + api_key="sk-test", + url="https://api.example.com", + method="GET", + retries=1, + stream=True, + ) + + assert result["ok"] is True + collected = [] + async for obj in result["stream"]: + collected.append(obj) + assert collected == [{"a": 1}] diff --git a/tests/serverless/test_endpoint_client.py b/tests/serverless/test_endpoint_client.py new file mode 100644 index 00000000..a86e5cab --- /dev/null +++ b/tests/serverless/test_endpoint_client.py @@ -0,0 +1,487 @@ +"""Unit tests for vastai.serverless.client.endpoint (Endpoint, RouteResponse). + +Uses ``mock_serverless_client`` / ``make_delegate_endpoint`` for fast delegation-only checks. +``test_client_endpoint.py`` exercises the same surface with the real ``client`` fixture and +HTTP/route patches; keep the split to avoid duplicating heavy setup in every delegation test. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from vastai.serverless.client.endpoint import Endpoint, RouteResponse + + +class TestEndpointInit: + """Endpoint constructor validation and repr.""" + + def test_init_raises_without_client(self) -> None: + """ + Verifies Endpoint requires a client reference. + + This test verifies by: + 1. Passing client=None + 2. Asserting ValueError + + Assumptions: + - Validation message mentions client reference + """ + with pytest.raises(ValueError): + Endpoint(None, "n", 1, "k") + + def test_init_raises_on_empty_name(self, make_delegate_endpoint) -> None: + """ + Verifies Endpoint rejects an empty name. + + This test verifies by: + 1. Passing name="" + 2. Asserting ValueError + + Assumptions: + - Falsy string names are rejected + """ + with pytest.raises(ValueError): + make_delegate_endpoint(name=None, api_key="k") + + def test_init_raises_on_none_id(self, make_delegate_endpoint) -> None: + """ + Verifies Endpoint rejects a None id. + + This test verifies by: + 1. Passing id=None + 2. Asserting ValueError + + Assumptions: + - id must be non-None (including 0 is valid if passed explicitly) + """ + with pytest.raises(ValueError): + make_delegate_endpoint(name="n", endpoint_id=None, api_key="k") + + def test_repr_contains_name_and_id(self, make_delegate_endpoint) -> None: + """ + Verifies __repr__ includes endpoint name and id. + + This test verifies by: + 1. Constructing Endpoint + 2. Asserting repr substrings + + Assumptions: + - __repr__ format matches endpoint.py implementation + """ + ep = make_delegate_endpoint(name="my-ep", endpoint_id=42, api_key="k") + r = repr(ep) + assert "my-ep" in r + assert "42" in r + + +class TestEndpointDelegatesToClient: + """Endpoint methods forward to the Serverless client.""" + + def test_request_forwards_to_queue_endpoint_request( + self, mock_serverless_client, make_delegate_endpoint, make_session_mock + ) -> None: + """ + Verifies request passes route, payload, and options to client.queue_endpoint_request. + + This test verifies by: + 1. Calling ep.request with known arguments including session sentinel + 2. Asserting queue_endpoint_request kwargs + + Assumptions: + - client.queue_endpoint_request is synchronous and returns the mock return value + """ + ep = make_delegate_endpoint() + sess = make_session_mock() + out = ep.request( + "/do", + {"x": 1}, + serverless_request="sr", + cost=10, + retry=False, + stream=True, + timeout=5.0, + session=sess, + ) + assert out == "queued" + mock_serverless_client.queue_endpoint_request.assert_called_once() + kw = mock_serverless_client.queue_endpoint_request.call_args.kwargs + assert kw["endpoint"] is ep + assert kw["worker_route"] == "/do" + assert kw["worker_payload"] == {"x": 1} + assert kw["serverless_request"] == "sr" + assert kw["cost"] == 10 + assert kw["retry"] is False + assert kw["stream"] is True + assert kw["timeout"] == 5.0 + assert kw["session"] is sess + + def test_request_uses_defaults_for_optional_queue_kwargs( + self, mock_serverless_client, make_delegate_endpoint + ) -> None: + """Default ``cost``, ``retry``, ``stream``, ``timeout``, ``session``, ``serverless_request``.""" + ep = make_delegate_endpoint() + ep.request("/r", {}) + mock_serverless_client.queue_endpoint_request.assert_called_once() + kw = mock_serverless_client.queue_endpoint_request.call_args.kwargs + assert kw["cost"] == 100 + assert kw["retry"] is True + assert kw["stream"] is False + assert kw["timeout"] is None + assert kw["session"] is None + assert kw["serverless_request"] is None + + @pytest.mark.asyncio + async def test_close_session_forwards_to_client( + self, mock_serverless_client, make_delegate_endpoint, make_session_mock + ) -> None: + """ + Verifies close_session delegates to client.end_endpoint_session. + + This test verifies by: + 1. Calling ep.close_session(session) and awaiting the result + 2. Asserting end_endpoint_session was awaited with session= + + Assumptions: + - close_session returns the awaitable from end_endpoint_session + """ + ep = make_delegate_endpoint() + sess = make_session_mock() + await ep.close_session(sess) + mock_serverless_client.end_endpoint_session.assert_awaited_once_with( + session=sess + ) + + @pytest.mark.asyncio + async def test_get_session_forwards_kwargs( + self, mock_serverless_client, make_delegate_endpoint + ) -> None: + """ + Verifies get_session passes session_id, session_auth, timeout to client. + + This test verifies by: + 1. Awaiting ep.get_session with known values + 2. Asserting get_endpoint_session await args + + Assumptions: + - get_session returns the coroutine from client.get_endpoint_session + """ + ep = make_delegate_endpoint() + auth = {"url": "https://x"} + await ep.get_session(9, auth, timeout=3.0) + mock_serverless_client.get_endpoint_session.assert_awaited_once_with( + endpoint=ep, session_id=9, session_auth=auth, timeout=3.0 + ) + + @pytest.mark.asyncio + async def test_get_session_default_timeout_is_ten( + self, mock_serverless_client, make_delegate_endpoint + ) -> None: + ep = make_delegate_endpoint() + await ep.get_session(1, {"k": "v"}) + mock_serverless_client.get_endpoint_session.assert_awaited_once_with( + endpoint=ep, + session_id=1, + session_auth={"k": "v"}, + timeout=10.0, + ) + + @pytest.mark.asyncio + async def test_session_forwards_to_start_endpoint_session( + self, mock_serverless_client, make_delegate_endpoint + ) -> None: + """ + Verifies session() awaits client.start_endpoint_session (coroutine from real client). + + This test verifies by: + 1. Awaiting ep.session with cost, lifetime, on_close_* , timeout + 2. Asserting start_endpoint_session await kwargs + + Assumptions: + - ``Endpoint.session`` returns the coroutine from ``start_endpoint_session`` + """ + ep = make_delegate_endpoint() + out = await ep.session( + cost=20, + lifetime=45.0, + on_close_route="/bye", + on_close_payload={"a": 1}, + timeout=99.0, + ) + assert out == "started" + mock_serverless_client.start_endpoint_session.assert_awaited_once_with( + endpoint=ep, + cost=20, + lifetime=45.0, + on_close_route="/bye", + on_close_payload={"a": 1}, + timeout=99.0, + ) + + @pytest.mark.asyncio + async def test_session_uses_defaults_for_optional_start_kwargs( + self, mock_serverless_client, make_delegate_endpoint + ) -> None: + ep = make_delegate_endpoint() + await ep.session() + mock_serverless_client.start_endpoint_session.assert_awaited_once_with( + endpoint=ep, + cost=100, + lifetime=60, + on_close_route=None, + on_close_payload=None, + timeout=None, + ) + + @pytest.mark.asyncio + async def test_get_workers_forwards_self( + self, mock_serverless_client, make_delegate_endpoint + ) -> None: + """ + Verifies get_workers calls client.get_endpoint_workers with this endpoint. + + This test verifies by: + 1. Awaiting ep.get_workers() + + Assumptions: + - get_endpoint_workers is async on the client mock + """ + ep = make_delegate_endpoint() + await ep.get_workers() + mock_serverless_client.get_endpoint_workers.assert_awaited_once_with(ep) + + +@pytest.mark.asyncio +class TestEndpointSessionHealthcheck: + async def test_session_healthcheck_true_when_session_exists( + self, mock_serverless_client, make_delegate_endpoint, make_session_mock + ) -> None: + """ + Verifies session_healthcheck returns True when get_endpoint_session returns non-None. + + This test verifies by: + 1. Configuring get_endpoint_session to return an object + 2. Awaiting session_healthcheck + 3. Asserting True + + Assumptions: + - Health is defined as result is not None + """ + mock_serverless_client.get_endpoint_session = AsyncMock( + return_value=MagicMock() + ) + ep = make_delegate_endpoint() + sess = make_session_mock(session_id="sid", auth_data={"t": 1}) + ok = await ep.session_healthcheck(sess) + assert ok is True + + async def test_session_healthcheck_false_when_no_session( + self, mock_serverless_client, make_delegate_endpoint, make_session_mock + ) -> None: + """ + Verifies session_healthcheck returns False when get_endpoint_session returns None. + + This test verifies by: + 1. Configuring get_endpoint_session to return None + 2. Awaiting session_healthcheck + + Assumptions: + - Client returns None for missing/expired session + """ + mock_serverless_client.get_endpoint_session = AsyncMock(return_value=None) + ep = make_delegate_endpoint() + sess = make_session_mock(session_id="sid", auth_data={}) + ok = await ep.session_healthcheck(sess) + assert ok is False + + +@pytest.mark.asyncio +class TestEndpointRoute: + async def test_route_passes_default_body_fields_to_make_request( + self, mock_serverless_client, make_delegate_endpoint + ) -> None: + """No-arg ``_route()`` uses cost 0, request_idx 0, replay_timeout 60.""" + ep = make_delegate_endpoint() + fake = {"ok": True, "json": {"url": "https://w"}} + with patch( + "vastai.serverless.client.endpoint._make_request", + new_callable=AsyncMock, + return_value=fake, + ) as m: + await ep._route() + body = m.call_args.kwargs["body"] + assert body["cost"] == 0.0 + assert body["request_idx"] == 0 + assert body["replay_timeout"] == 60.0 + + async def test_route_returns_waiting_when_json_payload_missing( + self, mock_serverless_client, make_delegate_endpoint + ) -> None: + """``ok`` with no ``json`` key → empty body → WAITING (no ``url``).""" + ep = make_delegate_endpoint() + with patch( + "vastai.serverless.client.endpoint._make_request", + new_callable=AsyncMock, + return_value={"ok": True}, + ): + route = await ep._route() + assert route.status == "WAITING" + assert route.get_url() is None + + async def test_route_returns_waiting_when_json_is_none( + self, mock_serverless_client, make_delegate_endpoint + ) -> None: + """``json: None`` normalizes to ``{}`` → WAITING.""" + ep = make_delegate_endpoint() + with patch( + "vastai.serverless.client.endpoint._make_request", + new_callable=AsyncMock, + return_value={"ok": True, "json": None}, + ): + route = await ep._route() + assert route.status == "WAITING" + assert route.get_url() is None + + async def test_route_http_error_truncates_response_text_in_message( + self, mock_serverless_client, make_delegate_endpoint + ) -> None: + long = "E" * 700 + ep = make_delegate_endpoint() + with patch( + "vastai.serverless.client.endpoint._make_request", + new_callable=AsyncMock, + return_value={"ok": False, "status": 503, "text": long}, + ): + with pytest.raises(RuntimeError, match="HTTP 503") as ei: + await ep._route() + msg = str(ei.value) + assert "E" * 512 in msg + assert "E" * 513 not in msg + + async def test_route_returns_ready_route_response( + self, mock_serverless_client, make_delegate_endpoint + ) -> None: + """ + Verifies _route returns RouteResponse with READY when JSON includes url. + + This test verifies by: + 1. Patching endpoint module _make_request with ok json containing url + 2. Awaiting _route + 3. Asserting status READY and get_url() + + Assumptions: + - Patch applied where endpoint.py resolves _make_request + """ + ep = make_delegate_endpoint() + fake = {"ok": True, "json": {"request_idx": 3, "url": "https://w"}} + with patch( + "vastai.serverless.client.endpoint._make_request", + new_callable=AsyncMock, + return_value=fake, + ): + route = await ep._route(cost=1.0, req_idx=0, timeout=30.0) + assert route.status == "READY" + assert route.request_idx == 3 + assert route.get_url() == "https://w" + + async def test_route_wraps_make_request_failure_as_runtime_error( + self, mock_serverless_client, make_delegate_endpoint + ) -> None: + """ + Verifies _route wraps _make_request exceptions in RuntimeError. + + This test verifies by: + 1. Making _make_request raise OSError + 2. Asserting RuntimeError chain + + Assumptions: + - Outer message mentions failed to route + """ + ep = make_delegate_endpoint() + with patch( + "vastai.serverless.client.endpoint._make_request", + new_callable=AsyncMock, + side_effect=OSError("boom"), + ): + with pytest.raises(RuntimeError, match="Failed to route endpoint"): + await ep._route() + + async def test_route_raises_when_http_not_ok( + self, mock_serverless_client, make_delegate_endpoint + ) -> None: + """ + Verifies _route raises RuntimeError when result ok is False. + + This test verifies by: + 1. Returning ok=False from _make_request + 2. Awaiting _route + + Assumptions: + - Error text includes HTTP status + """ + ep = make_delegate_endpoint() + with patch( + "vastai.serverless.client.endpoint._make_request", + new_callable=AsyncMock, + return_value={"ok": False, "status": 502, "text": "bad"}, + ): + with pytest.raises(RuntimeError, match="502"): + await ep._route() + + +class TestRouteResponse: + """RouteResponse parsing helpers.""" + + def test_waiting_status_when_no_url(self) -> None: + """ + Verifies RouteResponse uses WAITING when body has no url key. + + This test verifies by: + 1. Constructing RouteResponse from body without url + 2. Asserting status WAITING and default request_idx 0 + + Assumptions: + - READY requires url in body per endpoint.py + """ + r = RouteResponse({"request_idx": 0}) + assert r.status == "WAITING" + assert r.request_idx == 0 + + def test_request_idx_defaults_when_absent(self) -> None: + """ + Verifies request_idx defaults to 0 when missing. + + This test verifies by: + 1. Passing empty dict + 2. Asserting request_idx == 0 + + Assumptions: + - Branch in RouteResponse.__init__ for missing request_idx + """ + r = RouteResponse({}) + assert r.request_idx == 0 + + def test_repr_contains_status(self) -> None: + """ + Verifies RouteResponse __repr__ includes status. + + This test verifies by: + 1. Building READY response + 2. Asserting repr substring + + Assumptions: + - __repr__ format stable for debugging + """ + r = RouteResponse({"url": "u"}) + assert "READY" in repr(r) + + def test_ready_with_url_defaults_request_idx_when_absent(self) -> None: + """URL present implies READY; missing ``request_idx`` uses 0.""" + r = RouteResponse({"url": "https://worker"}) + assert r.status == "READY" + assert r.request_idx == 0 + assert r.get_url() == "https://worker" + + def test_waiting_repr_contains_status(self) -> None: + assert "WAITING" in repr(RouteResponse({"pending": True})) diff --git a/tests/serverless/test_pyworker_metrics.py b/tests/serverless/test_pyworker_metrics.py new file mode 100644 index 00000000..bc603a3f --- /dev/null +++ b/tests/serverless/test_pyworker_metrics.py @@ -0,0 +1,1157 @@ +"""Unit tests for vastai.serverless.server.lib.metrics (pyworker server metrics). + +Covers get_url, Metrics request lifecycle hooks, model state helpers, HTTP session +lifecycle, both background loops, and reporting paths including retry/timeout/error +branches in __send_metrics_and_reset and __send_delete_requests_and_reset. +All HTTP and disk usage are mocked per unit-test-requirements. +""" +from __future__ import annotations + +import asyncio +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from vastai.serverless.server.lib.metrics import get_url + +pytestmark = pytest.mark.usefixtures("clear_get_url_cache") + + +class TestGetUrl: + """Verify get_url builds worker public URL from environment.""" + + def test_get_url_uses_http_when_use_ssl_false(self) -> None: + """ + Verifies that get_url returns an http URL when USE_SSL is not true. + + This test verifies by: + 1. Clearing get_url cache and patching os.environ with port and IP keys + 2. Calling get_url() + 3. Asserting the scheme is http and host/port match PUBLIC_IPADDR and mapped TCP port + + Assumptions: + - VAST_TCP_PORT_{WORKER_PORT} names the env key for the public port + """ + with patch.dict( + os.environ, + { + "USE_SSL": "false", + "WORKER_PORT": "8080", + "VAST_TCP_PORT_8080": "18080", + "PUBLIC_IPADDR": "192.168.1.5", + }, + clear=False, + ): + get_url.cache_clear() + assert get_url() == "http://192.168.1.5:18080" + + def test_get_url_uses_https_when_use_ssl_true(self) -> None: + """ + Verifies that get_url returns an https URL when USE_SSL is true. + + This test verifies by: + 1. Patching os.environ including USE_SSL=true + 2. Calling get_url() after cache clear + 3. Asserting scheme is https + + Assumptions: + - Same port mapping rules as HTTP + """ + with patch.dict( + os.environ, + { + "USE_SSL": "true", + "WORKER_PORT": "9000", + "VAST_TCP_PORT_9000": "19000", + "PUBLIC_IPADDR": "10.1.2.3", + }, + clear=False, + ): + get_url.cache_clear() + assert get_url() == "https://10.1.2.3:19000" + + +class TestMetricsRequestLifecycle: + """Verify Metrics hooks update ModelMetrics and flags correctly.""" + + def test_request_start_updates_pending_and_workload_for_plain_request( + self, make_pyworker_metrics, make_pyworker_request_metrics + ) -> None: + """ + Verifies that _request_start records workload and sets update_pending. + + This test verifies by: + 1. Creating Metrics and a plain RequestMetrics (no session) + 2. Calling _request_start + 3. Asserting pending/received, requests_working, and update_pending + + Assumptions: + - Plain requests use reqnum as dict key in requests_working + """ + m = make_pyworker_metrics() + req = make_pyworker_request_metrics( + request_idx=1, + reqnum=7, + workload=2.5, + status="", + ) + m._request_start(req) + assert m.model_metrics.workload_pending == 2.5 + assert m.model_metrics.workload_received == 2.5 + assert 7 in m.model_metrics.requests_recieved + assert m.model_metrics.requests_working[7] is req + assert req.status == "Started" + assert m.update_pending is True + + def test_request_end_removes_working_and_updates_last_request_served( + self, make_pyworker_metrics, make_pyworker_request_metrics + ) -> None: + """ + Verifies that _request_end decreases pending and queues delete for plain requests. + + This test verifies by: + 1. Starting then ending a plain request + 2. Asserting workload_pending returned to 0, request removed from working, appended to deleting + + Assumptions: + - last_request_served is set via time.time (patched for determinism) + """ + m = make_pyworker_metrics() + req = make_pyworker_request_metrics(request_idx=2, reqnum=3, workload=1.0, status="Started") + m._request_start(req) + with patch("vastai.serverless.server.lib.metrics.time") as mock_time: + mock_time.time.return_value = 12345.0 + m._request_end(req) + assert mock_time.time.called + assert m.model_metrics.workload_pending == 0.0 + assert 3 not in m.model_metrics.requests_working + assert m.model_metrics.requests_deleting == [req] + assert m.last_request_served == 12345.0 + + def test_request_success_increments_served_and_marks_status( + self, make_pyworker_metrics, make_pyworker_request_metrics + ) -> None: + """ + Verifies that _request_success updates workload_served and request flags. + + This test verifies by: + 1. Calling _request_success on a request + 2. Asserting workload_served, status, success, update_pending + + Assumptions: + - Success path does not require _request_start first for this unit + """ + m = make_pyworker_metrics() + req = make_pyworker_request_metrics(request_idx=1, reqnum=1, workload=4.0, status="Started") + m._request_success(req) + assert m.model_metrics.workload_served == 4.0 + assert req.status == "Success" + assert req.success is True + assert m.update_pending is True + + def test_request_errored_increments_errored_and_sets_status( + self, make_pyworker_metrics, make_pyworker_request_metrics + ) -> None: + """ + Verifies that _request_errored records error workload and status. + + This test verifies by: + 1. Calling _request_errored with a message + 2. Asserting workload_errored, status, success=False, update_pending + + Assumptions: + - Logging side effects are not asserted + """ + m = make_pyworker_metrics() + req = make_pyworker_request_metrics(request_idx=5, reqnum=5, workload=1.0, status="Started") + m._request_errored(req, "boom") + assert m.model_metrics.workload_errored == 1.0 + assert req.status == "Error" + assert req.success is False + assert m.update_pending is True + + def test_request_canceled_increments_cancelled( + self, make_pyworker_metrics, make_pyworker_request_metrics + ) -> None: + """ + Verifies that _request_canceled updates cancelled workload and status. + + This test verifies by: + 1. Calling _request_canceled + 2. Asserting workload_cancelled and status Cancelled + + Assumptions: + - Implementation sets success True for canceled (per pyworker semantics) + """ + m = make_pyworker_metrics() + req = make_pyworker_request_metrics(request_idx=9, reqnum=9, workload=0.5, status="Started") + m._request_canceled(req) + assert m.model_metrics.workload_cancelled == 0.5 + assert req.status == "Cancelled" + assert req.success is True + + def test_request_reject_updates_rejected_and_queues_delete( + self, make_pyworker_metrics, make_pyworker_request_metrics + ) -> None: + """ + Verifies that _request_reject records rejection for plain requests. + + This test verifies by: + 1. Calling _request_reject on a non-session request + 2. Asserting workload_rejected, requests_deleting, status Rejected + + Assumptions: + - Plain reject adds reqnum to requests_recieved and requests_deleting + """ + m = make_pyworker_metrics() + req = make_pyworker_request_metrics(request_idx=11, reqnum=11, workload=3.0, status="") + m._request_reject(req) + assert m.model_metrics.workload_rejected == 3.0 + assert 11 in m.model_metrics.requests_recieved + assert req in m.model_metrics.requests_deleting + assert req.status == "Rejected" + assert req.success is False + assert m.update_pending is True + + def test_request_start_skips_recieved_dict_when_request_has_session( + self, make_pyworker_metrics, make_pyworker_session, make_pyworker_request_metrics + ) -> None: + """ + Verifies that _request_start does not register requests that carry a Session reference. + + This test verifies by: + 1. Building RequestMetrics with session set (in-session request) + 2. Calling _request_start + 3. Asserting workload counters update but requests_recieved / requests_working skip + + Assumptions: + - Implementation uses `if not request.session` to gate registration + """ + m = make_pyworker_metrics() + sess = make_pyworker_session() + req = make_pyworker_request_metrics( + request_idx=100, + reqnum=0, + workload=1.0, + status="", + session=sess, + session_reqnum=1, + ) + m._request_start(req) + assert m.model_metrics.workload_received == 1.0 + assert len(m.model_metrics.requests_recieved) == 0 + assert len(m.model_metrics.requests_working) == 0 + + def test_request_end_skips_working_and_delete_when_request_has_session( + self, make_pyworker_metrics, make_pyworker_session, make_pyworker_request_metrics + ) -> None: + """ + Verifies _request_end still updates pending and last_request_served but skips + requests_working / requests_deleting when the request is tied to a Session. + + Assumptions: + - `if not request.session` gates pop/append; session-scoped traffic uses session lifecycle elsewhere + """ + m = make_pyworker_metrics() + sess = make_pyworker_session() + req = make_pyworker_request_metrics( + request_idx=10, + reqnum=0, + workload=2.0, + status="Started", + session=sess, + session_reqnum=1, + ) + m._request_start(req) + with patch("vastai.serverless.server.lib.metrics.time") as mock_time: + mock_time.time.return_value = 99.0 + m._request_end(req) + assert m.model_metrics.workload_pending == 0.0 + assert m.model_metrics.requests_deleting == [] + assert m.last_request_served == 99.0 + + def test_request_reject_skips_recieved_and_deleting_when_request_has_session( + self, make_pyworker_metrics, make_pyworker_session, make_pyworker_request_metrics + ) -> None: + """ + Verifies _request_reject increments rejected workload but does not touch + requests_recieved / requests_deleting for in-session requests. + + Assumptions: + - Same `if not request.session` gate as _request_start / _request_end + """ + m = make_pyworker_metrics() + sess = make_pyworker_session(session_id="s2", request_idx=2) + req = make_pyworker_request_metrics( + request_idx=20, + reqnum=0, + workload=1.5, + status="", + session=sess, + session_reqnum=1, + ) + m._request_reject(req) + assert m.model_metrics.workload_rejected == 1.5 + assert len(m.model_metrics.requests_recieved) == 0 + assert m.model_metrics.requests_deleting == [] + assert req.status == "Rejected" + + +class TestMetricsRequestIdFormatting: + """Verify _request_id string formatting for logs.""" + + def test_request_id_plain_request(self, make_pyworker_metrics, make_pyworker_request_metrics) -> None: + """ + Verifies _request_id for a non-session request. + + This test verifies by: + 1. Creating RequestMetrics without session + 2. Asserting formatted string contains request_idx + + Assumptions: + - is_session is False + """ + m = make_pyworker_metrics() + req = make_pyworker_request_metrics(request_idx=3, reqnum=3, workload=1.0, status="") + assert m._request_id(req) == "Request 3" + + def test_request_id_session_scope(self, make_pyworker_metrics, make_pyworker_request_metrics) -> None: + """ + Verifies _request_id for session-scoped metrics. + + This test verifies by: + 1. Setting is_session True + 2. Asserting output uses Session prefix + + Assumptions: + - request_idx identifies the session in this branch + """ + m = make_pyworker_metrics() + req = make_pyworker_request_metrics( + request_idx=42, + reqnum=0, + workload=1.0, + status="", + is_session=True, + ) + assert m._request_id(req) == "Session 42" + + def test_request_id_in_session_request( + self, make_pyworker_metrics, make_pyworker_session, make_pyworker_request_metrics + ) -> None: + """ + Verifies _request_id for a request belonging to a Session. + + This test verifies by: + 1. Attaching a Session with request_idx to RequestMetrics + 2. Setting session_reqnum + 3. Asserting composite label + + Assumptions: + - session.request_idx is the session identifier + """ + m = make_pyworker_metrics() + sess = make_pyworker_session(request_idx=7) + req = make_pyworker_request_metrics( + request_idx=10, + reqnum=1, + workload=2.0, + status="", + session=sess, + session_reqnum=3, + ) + assert m._request_id(req) == "Request 3 in Session 7" + + +class TestMetricsModelState: + """Verify model loaded/errored and metadata setters.""" + + def test_model_loaded_sets_timing_and_throughput(self, make_pyworker_metrics) -> None: + """ + Verifies _model_loaded records load duration and max_throughput. + + This test verifies by: + 1. Patching time.time to return controlled values around load + 2. Calling _model_loaded + 3. Asserting model_is_loaded, model_loading_time, max_throughput + + Assumptions: + - model_loading_start was set when SystemMetrics.empty() was created (patched time) + """ + with patch("vastai.serverless.server.lib.data_types.time") as dt_time: + dt_time.time.return_value = 1000.0 + m = make_pyworker_metrics() + assert m.system_metrics.model_loading_start == 1000.0 + with patch("vastai.serverless.server.lib.metrics.time") as m_time: + m_time.time.return_value = 1005.0 + m._model_loaded(max_throughput=128.0) + assert m.system_metrics.model_is_loaded is True + assert m.system_metrics.model_loading_time == 5.0 + assert m.model_metrics.max_throughput == 128.0 + + def test_model_errored_sets_error_and_marks_loaded(self, make_pyworker_metrics) -> None: + """ + Verifies _model_errored delegates to ModelMetrics.set_errored and sets loaded flag. + + This test verifies by: + 1. Calling _model_errored with a message + 2. Asserting error_msg and model_is_loaded + + Assumptions: + - set_errored resets counters and stores message per ModelMetrics implementation + """ + m = make_pyworker_metrics() + m.model_metrics.workload_received = 10.0 + m._model_errored("failed to load") + assert m.model_metrics.error_msg == "failed to load" + assert m.system_metrics.model_is_loaded is True + assert m.model_metrics.workload_received == 0.0 + + def test_set_version_and_mtoken(self, make_pyworker_metrics) -> None: + """ + Verifies _set_version and _set_mtoken update fields used in worker_status payload. + + This test verifies by: + 1. Calling _set_version and _set_mtoken + 2. Asserting attribute values + + Assumptions: + - No side effects beyond assignment + """ + m = make_pyworker_metrics() + m._set_version("v2") + m._set_mtoken("secret-token") + assert m.version == "v2" + assert m.mtoken == "secret-token" + + +@pytest.mark.asyncio +class TestSendMetricsAndReset: + """Verify __send_metrics_and_reset HTTP reporting and reset behavior.""" + + async def test_send_metrics_posts_worker_status_and_resets_on_success( + self, make_pyworker_metrics, make_metrics_aiohttp_post, metrics_worker_status_context + ) -> None: + """ + Verifies successful POST to report_addr resets model metrics and update_pending. + + This test verifies by: + 1. Mocking Metrics.http and session.post context manager with 200 + 2. Patching SystemMetrics.get_disk_usage_GB to avoid psutil + 3. Awaiting __send_metrics_and_reset + 4. Asserting post URL, JSON payload keys, and reset state + + Assumptions: + - Private name mangling: _Metrics__send_metrics_and_reset on the class + """ + m = make_pyworker_metrics() + m.mtoken = "tok" + m.version = "1" + m.model_metrics.workload_served = 2.0 + m.model_metrics.workload_received = 5.0 + m.update_pending = True + + mock_session, _ = make_metrics_aiohttp_post.session_ok() + + with metrics_worker_status_context(m, mock_session, disk_gb=10.0): + await m._Metrics__send_metrics_and_reset() + + mock_session.post.assert_called_once() + call_kw = mock_session.post.call_args + assert call_kw[0][0] == "http://report.test/worker_status/" + body = call_kw[1]["json"] + assert body["id"] == 1 + assert body["mtoken"] == "tok" + assert body["version"] == "1" + assert body["cur_perf"] == 2.0 + assert body["url"] == "http://worker.test:9000" + assert m.update_pending is False + assert m.model_metrics.workload_served == 0.0 + + async def test_send_metrics_does_not_reset_when_all_posts_fail( + self, make_pyworker_metrics, make_metrics_aiohttp_post, metrics_worker_status_context + ) -> None: + """ + Verifies metrics are not reset when every report address fails. + + This test verifies by: + 1. Configuring post to raise ClientResponseError + 2. Patching asyncio.sleep to avoid delay + 3. Asserting workload counters and update_pending unchanged + + Assumptions: + - aiohttp.ClientResponseError is raised from raise_for_status path + """ + m = make_pyworker_metrics(report_addr=["http://a.test", "http://b.test"]) + m.model_metrics.workload_served = 9.0 + m.update_pending = True + + mock_session = MagicMock() + mock_session.post = MagicMock( + return_value=make_metrics_aiohttp_post.context_client_error(), + ) + + with metrics_worker_status_context(m, mock_session, disk_gb=1.0, mock_asyncio_sleep=True): + await m._Metrics__send_metrics_and_reset() + + assert m.model_metrics.workload_served == 9.0 + assert m.update_pending is True + + async def test_send_metrics_retries_after_timeout_then_succeeds( + self, make_pyworker_metrics, make_metrics_aiohttp_post, metrics_worker_status_context + ) -> None: + """ + Verifies worker_status POST retries when async context raises TimeoutError. + + This test verifies by: + 1. Making the first session.post context __aenter__ raise asyncio.TimeoutError + 2. Making the second attempt succeed + 3. Patching asyncio.sleep in metrics to avoid delay + 4. Asserting post was called twice and metrics reset + + Assumptions: + - aiohttp-style post returns a synchronous async context manager + """ + m = make_pyworker_metrics() + m.update_pending = True + ctx_fail = make_metrics_aiohttp_post.context_timeout() + ctx_ok, _ = make_metrics_aiohttp_post.context_ok() + mock_session = MagicMock() + mock_session.post = MagicMock(side_effect=[ctx_fail, ctx_ok]) + + with metrics_worker_status_context(m, mock_session, disk_gb=1.0, mock_asyncio_sleep=True): + await m._Metrics__send_metrics_and_reset() + + assert mock_session.post.call_count == 2 + assert m.update_pending is False + + async def test_send_metrics_with_none_mtoken_obfuscates_log_field_to_empty( + self, make_pyworker_metrics, make_metrics_aiohttp_post, metrics_worker_status_context + ) -> None: + """ + Verifies send_data obfuscate() handles None mtoken for debug logging. + + This test verifies by: + 1. Assigning mtoken None on Metrics (runtime value despite type hint) + 2. Running __send_metrics_and_reset with mocked HTTP + 3. Asserting completion without error and successful reset + + Assumptions: + - asdict includes mtoken key with None; obfuscate returns empty string + """ + m = make_pyworker_metrics() + m.mtoken = None # type: ignore[assignment] + m.update_pending = True + mock_session, _ = make_metrics_aiohttp_post.session_ok() + + with metrics_worker_status_context(m, mock_session, disk_gb=1.0): + await m._Metrics__send_metrics_and_reset() + + body = mock_session.post.call_args[1]["json"] + assert body["mtoken"] is None + assert m.update_pending is False + + async def test_send_metrics_succeeds_on_second_report_after_first_exhausts_retries( + self, + make_pyworker_metrics, + make_metrics_aiohttp_post, + metrics_worker_status_context, + ) -> None: + """ + Verifies __send_metrics_and_reset tries each report_addr in order: if the first + host fails all retry attempts, the second successful host resets metrics. + + Assumptions: + - Outer loop breaks on first send_data() that returns True + """ + m = make_pyworker_metrics( + report_addr=["http://primary.test", "http://backup.test"], + ) + m.model_metrics.workload_served = 3.0 + m.update_pending = True + + aio = make_metrics_aiohttp_post + ctx_ok, _ = aio.context_ok() + mock_session = MagicMock() + mock_session.post = MagicMock( + side_effect=[ + aio.context_client_error(), + aio.context_client_error(), + aio.context_client_error(), + ctx_ok, + ], + ) + + with metrics_worker_status_context(m, mock_session, disk_gb=1.0, mock_asyncio_sleep=True): + await m._Metrics__send_metrics_and_reset() + + assert mock_session.post.call_count == 4 + assert mock_session.post.call_args_list[3][0][0] == "http://backup.test/worker_status/" + assert m.model_metrics.workload_served == 0.0 + assert m.update_pending is False + + async def test_send_metrics_debug_log_obfuscates_long_mtoken( + self, + make_pyworker_metrics, + make_metrics_aiohttp_post, + metrics_worker_status_context, + ) -> None: + """ + Verifies send_data's obfuscate() truncates secrets longer than 12 chars in debug logs. + + Assumptions: + - log.debug receives a string containing the masked mtoken form + """ + m = make_pyworker_metrics() + m.mtoken = "abcdefghijklmno" + m.update_pending = True + mock_session, _ = make_metrics_aiohttp_post.session_ok() + captured: list[str] = [] + + with patch("vastai.serverless.server.lib.metrics.log") as mock_log: + mock_log.debug = MagicMock(side_effect=lambda msg, *a, **k: captured.append(msg)) + with metrics_worker_status_context(m, mock_session, disk_gb=1.0): + await m._Metrics__send_metrics_and_reset() + + joined = "\n".join(captured) + assert "abcdefg..." in joined + assert mock_session.post.call_args[1]["json"]["mtoken"] == "abcdefghijklmno" + + async def test_send_metrics_debug_log_obfuscates_short_mtoken_with_stars( + self, + make_pyworker_metrics, + make_metrics_aiohttp_post, + metrics_worker_status_context, + ) -> None: + """Verifies obfuscate() uses asterisks for mtoken length <= 12.""" + m = make_pyworker_metrics() + m.mtoken = "short" + m.update_pending = True + mock_session, _ = make_metrics_aiohttp_post.session_ok() + captured: list[str] = [] + + with patch("vastai.serverless.server.lib.metrics.log") as mock_log: + mock_log.debug = MagicMock(side_effect=lambda msg, *a, **k: captured.append(msg)) + with metrics_worker_status_context(m, mock_session, disk_gb=1.0): + await m._Metrics__send_metrics_and_reset() + + joined = "\n".join(captured) + assert "*****" in joined + + +@pytest.mark.asyncio +class TestSendDeleteRequestsAndReset: + """Verify __send_delete_requests_and_reset behavior.""" + + async def test_delete_requests_skips_cloud_vast_and_sends_to_next( + self, make_pyworker_metrics, make_metrics_aiohttp_post, metrics_delete_send_context, make_pyworker_request_metrics + ) -> None: + """ + Verifies REPORT_ADDR entries for cloud.vast.ai are skipped. + + This test verifies by: + 1. Using report_addr list with cloud URL then a real test URL + 2. Mocking successful POST + 3. Asserting post called only for the second host + + Assumptions: + - First address matches the hardcoded skip in metrics.py + """ + m = make_pyworker_metrics( + report_addr=[ + "https://cloud.vast.ai/api/v0", + "http://internal.report", + ] + ) + req_ok = make_pyworker_request_metrics( + request_idx=1, + reqnum=1, + workload=1.0, + status="Success", + success=True, + ) + m.model_metrics.requests_deleting = [req_ok] + + mock_session, _ = make_metrics_aiohttp_post.session_ok() + + with metrics_delete_send_context(m, mock_session): + await m._Metrics__send_delete_requests_and_reset() + + assert mock_session.post.call_count == 1 + assert mock_session.post.call_args[0][0] == "http://internal.report/delete_requests/" + sent = mock_session.post.call_args[1]["json"] + assert len(sent["requests"]) == 1 + assert sent["requests"][0]["request_idx"] == 1 + assert sent["requests"][0]["success"] is True + assert m.model_metrics.requests_deleting == [] + + async def test_delete_requests_noop_when_queue_empty(self, make_pyworker_metrics) -> None: + """ + Verifies early return when requests_deleting is empty. + + This test verifies by: + 1. Leaving requests_deleting empty + 2. Awaiting __send_delete_requests_and_reset + 3. Asserting http() was never used to post + + Assumptions: + - Empty success/failed idx lists short-circuit before HTTP + """ + m = make_pyworker_metrics() + m.model_metrics.requests_deleting = [] + mock_http = AsyncMock() + with patch.object(m, "http", mock_http): + await m._Metrics__send_delete_requests_and_reset() + mock_http.assert_not_awaited() + + async def test_delete_requests_posts_only_failed_batch_when_no_successes( + self, make_pyworker_metrics, make_metrics_aiohttp_post, metrics_delete_send_context, make_pyworker_request_metrics + ) -> None: + """ + Verifies failed-only snapshot triggers POST with per-request success=false. + + This test verifies by: + 1. Queuing only RequestMetrics with success False + 2. Mocking HTTP success + 3. Asserting a single POST with the request's success flag set to False + + Assumptions: + - A single POST is made containing all requests with their individual success flags + """ + m = make_pyworker_metrics() + req_bad = make_pyworker_request_metrics( + request_idx=9, + reqnum=9, + workload=1.0, + status="Error", + success=False, + ) + m.model_metrics.requests_deleting = [req_bad] + mock_session, _ = make_metrics_aiohttp_post.session_ok() + + with metrics_delete_send_context(m, mock_session): + await m._Metrics__send_delete_requests_and_reset() + + assert mock_session.post.call_count == 1 + sent = mock_session.post.call_args[1]["json"] + assert len(sent["requests"]) == 1 + assert sent["requests"][0]["request_idx"] == 9 + assert sent["requests"][0]["success"] is False + assert m.model_metrics.requests_deleting == [] + + async def test_delete_requests_posts_success_and_failure_in_single_batch( + self, make_pyworker_metrics, make_metrics_aiohttp_post, metrics_delete_send_context, make_pyworker_request_metrics + ) -> None: + """ + Verifies mixed success/failure snapshot results in a single POST with per-request success flags. + + This test verifies by: + 1. Queuing one succeeded and one failed request + 2. Asserting a single POST with both requests and their individual success flags + + Assumptions: + - All requests are sent in one batch with per-request success/status fields + """ + m = make_pyworker_metrics() + req_ok = make_pyworker_request_metrics( + request_idx=1, + reqnum=1, + workload=1.0, + status="Success", + success=True, + ) + req_bad = make_pyworker_request_metrics( + request_idx=2, + reqnum=2, + workload=1.0, + status="Error", + success=False, + ) + m.model_metrics.requests_deleting = [req_ok, req_bad] + mock_session, _ = make_metrics_aiohttp_post.session_ok() + + with metrics_delete_send_context(m, mock_session): + await m._Metrics__send_delete_requests_and_reset() + + assert mock_session.post.call_count == 1 + sent = mock_session.post.call_args[1]["json"] + assert len(sent["requests"]) == 2 + by_idx = {r["request_idx"]: r for r in sent["requests"]} + assert by_idx[1]["success"] is True + assert by_idx[2]["success"] is False + assert m.model_metrics.requests_deleting == [] + + async def test_delete_requests_retries_after_timeout_then_succeeds( + self, make_pyworker_metrics, make_metrics_aiohttp_post, metrics_delete_send_context, make_pyworker_request_metrics + ) -> None: + """ + Verifies delete_requests inner POST retries on TimeoutError then returns True. + + This test verifies by: + 1. First post context raising TimeoutError, second succeeding + 2. Patching asyncio.sleep between attempts + 3. Asserting queue cleared after success + + Assumptions: + - Same retry loop structure as worker_status (3 attempts max) + """ + m = make_pyworker_metrics() + req = make_pyworker_request_metrics( + request_idx=5, + reqnum=5, + workload=1.0, + status="Success", + success=True, + ) + m.model_metrics.requests_deleting = [req] + aio = make_metrics_aiohttp_post + ctx_fail = aio.context_timeout() + ctx_ok, _ = aio.context_ok() + mock_session = MagicMock() + mock_session.post = MagicMock(side_effect=[ctx_fail, ctx_ok]) + + with metrics_delete_send_context(m, mock_session): + await m._Metrics__send_delete_requests_and_reset() + + assert mock_session.post.call_count == 2 + assert m.model_metrics.requests_deleting == [] + + async def test_delete_requests_retries_after_generic_exception_then_succeeds( + self, make_pyworker_metrics, make_metrics_aiohttp_post, metrics_delete_send_context, make_pyworker_request_metrics + ) -> None: + """ + Verifies delete_requests catches non-timeout exceptions and retries. + + This test verifies by: + 1. First __aenter__ raising ValueError, second succeeding + 2. Patching asyncio.sleep + 3. Asserting two post calls and cleared queue + + Assumptions: + - ClientResponseError and Exception share the same handler branch + """ + m = make_pyworker_metrics() + req = make_pyworker_request_metrics( + request_idx=3, + reqnum=3, + workload=1.0, + status="Success", + success=True, + ) + m.model_metrics.requests_deleting = [req] + aio = make_metrics_aiohttp_post + ctx_fail = aio.context_enter_raises(ValueError("boom")) + ctx_ok, _ = aio.context_ok() + mock_session = MagicMock() + mock_session.post = MagicMock(side_effect=[ctx_fail, ctx_ok]) + + with metrics_delete_send_context(m, mock_session): + await m._Metrics__send_delete_requests_and_reset() + + assert mock_session.post.call_count == 2 + assert m.model_metrics.requests_deleting == [] + + async def test_delete_requests_retains_queue_when_all_post_attempts_fail( + self, make_pyworker_metrics, make_metrics_aiohttp_post, metrics_delete_send_context, make_pyworker_request_metrics + ) -> None: + """ + Verifies requests_deleting is unchanged when every HTTP attempt fails. + + This test verifies by: + 1. Making each async context __aenter__ raise ValueError (all 3 attempts) + 2. Patching asyncio.sleep + 3. Asserting the original request remains in the queue + + Assumptions: + - Inner post() returns False so sent_success is False and queue is not pruned + """ + m = make_pyworker_metrics() + req = make_pyworker_request_metrics( + request_idx=7, + reqnum=7, + workload=1.0, + status="Success", + success=True, + ) + m.model_metrics.requests_deleting = [req] + ctx_fail = make_metrics_aiohttp_post.context_enter_raises(ValueError("always fail")) + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=ctx_fail) + + with metrics_delete_send_context(m, mock_session): + await m._Metrics__send_delete_requests_and_reset() + + assert mock_session.post.call_count == 3 + assert m.model_metrics.requests_deleting == [req] + + +@pytest.mark.asyncio +class TestSendDeleteRequestsLoop: + """Verify _send_delete_requests_loop scheduling.""" + + async def test_delete_loop_calls_send_when_queue_nonempty( + self, make_pyworker_metrics, make_pyworker_request_metrics + ) -> None: + """ + Verifies the delete loop awaits __send_delete_requests_and_reset when queue has items. + + This test verifies by: + 1. Patching sleep to return immediately + 2. Patching __send_delete_requests_and_reset to raise CancelledError after one run + 3. Asserting the private send method was awaited once + + Assumptions: + - CancelledError exits the infinite loop for test isolation + """ + m = make_pyworker_metrics() + m.model_metrics.requests_deleting = [ + make_pyworker_request_metrics( + request_idx=1, + reqnum=1, + workload=1.0, + status="Success", + success=True, + ) + ] + mock_send = AsyncMock(side_effect=asyncio.CancelledError()) + with patch.object( + m, + "_Metrics__send_delete_requests_and_reset", + mock_send, + ): + with patch( + "vastai.serverless.server.lib.metrics.sleep", + new_callable=AsyncMock, + ): + with pytest.raises(asyncio.CancelledError): + await m._send_delete_requests_loop() + + mock_send.assert_awaited_once() + + async def test_delete_loop_skips_send_when_queue_empty(self, make_pyworker_metrics) -> None: + """ + Verifies _send_delete_requests_loop does not call __send_delete_requests_and_reset + while requests_deleting stays empty (only sleep iterations). + + Assumptions: + - CancelledError exits the infinite loop after two wakeups + """ + m = make_pyworker_metrics() + m.model_metrics.requests_deleting = [] + mock_send = AsyncMock() + n_sleeps = {"n": 0} + + async def sleep_side_effect(*_args, **_kwargs): + n_sleeps["n"] += 1 + if n_sleeps["n"] >= 2: + raise asyncio.CancelledError() + + with patch.object( + m, + "_Metrics__send_delete_requests_and_reset", + mock_send, + ): + with patch( + "vastai.serverless.server.lib.metrics.sleep", + AsyncMock(side_effect=sleep_side_effect), + ): + with pytest.raises(asyncio.CancelledError): + await m._send_delete_requests_loop() + + mock_send.assert_not_awaited() + + +@pytest.mark.asyncio +class TestSendMetricsLoop: + """Verify _send_metrics_loop scheduling branches.""" + + async def test_metrics_loop_calls_send_when_elapsed_ge_10_and_model_not_loaded( + self, make_pyworker_metrics, patch_pyworker_metrics_loop + ) -> None: + """ + Verifies the loop invokes __send_metrics_and_reset when model not loaded and elapsed >= 10. + + This test verifies by: + 1. Setting last_metric_update so elapsed >= 10 under patched time.time + 2. Patching sleep to return immediately + 3. Patching __send_metrics_and_reset to raise CancelledError to exit loop + 4. Asserting the send coroutine was awaited + + Assumptions: + - CancelledError propagates from the loop after first successful branch + """ + m = make_pyworker_metrics() + m.system_metrics.model_is_loaded = False + m.last_metric_update = 0.0 + mock_send = AsyncMock(side_effect=asyncio.CancelledError()) + + with patch_pyworker_metrics_loop(m, mock_send, time_return=100.0): + with pytest.raises(asyncio.CancelledError): + await m._send_metrics_loop() + + mock_send.assert_awaited_once() + + async def test_metrics_loop_skips_send_when_loaded_no_pending_and_elapsed_le_10( + self, + make_pyworker_metrics, + ) -> None: + """ + Verifies the metrics loop sleeps without sending when the model is loaded, + update_pending is False, and elapsed time is at most 10 seconds. + + Assumptions: + - Neither `if` nor `elif` body runs; loop only advances via sleep + """ + m = make_pyworker_metrics() + m.system_metrics.model_is_loaded = True + m.update_pending = False + m.last_metric_update = 1_000.0 + mock_send = AsyncMock() + n_sleeps = {"n": 0} + + async def sleep_side_effect(*_args, **_kwargs): + n_sleeps["n"] += 1 + if n_sleeps["n"] >= 2: + raise asyncio.CancelledError() + + with patch("vastai.serverless.server.lib.metrics.time") as mock_time: + mock_time.time.return_value = 1_005.0 + with patch.object( + m, + "_Metrics__send_metrics_and_reset", + mock_send, + ): + with patch( + "vastai.serverless.server.lib.metrics.sleep", + AsyncMock(side_effect=sleep_side_effect), + ): + with pytest.raises(asyncio.CancelledError): + await m._send_metrics_loop() + + mock_send.assert_not_awaited() + + async def test_metrics_loop_calls_send_when_update_pending( + self, make_pyworker_metrics, patch_pyworker_metrics_loop + ) -> None: + """ + Verifies the loop sends when update_pending is True even if elapsed <= 10. + + This test verifies by: + 1. Setting model_is_loaded True, update_pending True, recent last_metric_update + 2. Using same sleep/send/CancelledError pattern + + Assumptions: + - Second branch (elif update_pending or elapsed > 10) is taken + """ + m = make_pyworker_metrics() + m.system_metrics.model_is_loaded = True + m.update_pending = True + m.last_metric_update = 1_000_000.0 + + mock_send = AsyncMock(side_effect=asyncio.CancelledError()) + + with patch_pyworker_metrics_loop(m, mock_send, time_return=1_000_005.0): + with pytest.raises(asyncio.CancelledError): + await m._send_metrics_loop() + + mock_send.assert_awaited_once() + + async def test_metrics_loop_sends_when_elapsed_gt_10_without_pending_or_loading_gate( + self, + make_pyworker_metrics, + patch_pyworker_metrics_loop, + ) -> None: + """ + Verifies metrics loop sends via elapsed>10 when model is loaded and update_pending is False. + + This test verifies by: + 1. Setting model_is_loaded True, update_pending False, last_metric_update stale + 2. Patching time.time so elapsed > 10 + 3. Using sleep and CancelledError pattern to exit the loop + + Assumptions: + - First branch (not loaded and elapsed>=10) is false; elif uses elapsed>10 alone + """ + m = make_pyworker_metrics() + m.system_metrics.model_is_loaded = True + m.update_pending = False + m.last_metric_update = 0.0 + mock_send = AsyncMock(side_effect=asyncio.CancelledError()) + + with patch_pyworker_metrics_loop(m, mock_send, time_return=50.0): + with pytest.raises(asyncio.CancelledError): + await m._send_metrics_loop() + + mock_send.assert_awaited_once() + + +@pytest.mark.asyncio +class TestMetricsHttpSession: + """Verify ClientSession lifecycle on Metrics.""" + + async def test_http_creates_session_once( + self, make_pyworker_metrics, make_metrics_client_session_instance + ) -> None: + """ + Verifies http() lazily creates and reuses ClientSession. + + This test verifies by: + 1. Patching ClientSession in the metrics module + 2. Calling await http() twice + 3. Asserting ClientSession constructed once + + Assumptions: + - Session is stored on _session until aclose + """ + m = make_pyworker_metrics() + mock_session_instance = make_metrics_client_session_instance() + with patch( + "vastai.serverless.server.lib.metrics.ClientSession", + return_value=mock_session_instance, + ) as mock_cls: + s1 = await m.http() + s2 = await m.http() + assert s1 is s2 is mock_session_instance + mock_cls.assert_called_once() + + async def test_aclose_closes_and_clears_session( + self, make_pyworker_metrics, make_metrics_client_session_instance + ) -> None: + """ + Verifies aclose awaits session.close and clears _session. + + This test verifies by: + 1. Using http() then aclose() + 2. Asserting close was awaited and _session is None + + Assumptions: + - ClientSession.close is an awaitable + """ + m = make_pyworker_metrics() + mock_session_instance = make_metrics_client_session_instance(close_async=True) + with patch( + "vastai.serverless.server.lib.metrics.ClientSession", + return_value=mock_session_instance, + ): + await m.http() + await m.aclose() + mock_session_instance.close.assert_awaited_once() + assert m._session is None + + async def test_aclose_when_session_never_opened_does_nothing(self, make_pyworker_metrics) -> None: + """ + Verifies aclose is safe when http() was never called (_session is None). + + This test verifies by: + 1. Instantiating Metrics without creating a session + 2. Awaiting aclose() + 3. Asserting _session remains None and no AttributeError is raised + + Assumptions: + - Guard on self._session is not None prevents close on missing session + """ + m = make_pyworker_metrics() + assert m._session is None + await m.aclose() + assert m._session is None diff --git a/tests/serverless/test_server.py b/tests/serverless/test_server.py new file mode 100644 index 00000000..fbf1f06a --- /dev/null +++ b/tests/serverless/test_server.py @@ -0,0 +1,375 @@ +"""Unit tests for vastai.serverless.server.lib.server start/stop wiring. + +Exercises route registration, SSL branch wiring, and failure beacon behavior with +everything that would bind ports or loop forever heavily mocked. +""" +from __future__ import annotations + +import asyncio +import inspect +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from vastai.serverless.server.lib import server as server_mod +from vastai.serverless.server.lib.server import start_server, start_server_async + + +@pytest.mark.asyncio +async def test_start_server_async_registers_session_routes_and_starts_sites( + serverless_backend_and_handler_default, + serverless_metrics_test_env, + serverless_tracked_runner_and_tcp_site, + serverless_aiohttp_route_path_tuples, + run_serverless_start_server_async_patched, +) -> None: + """ + Verifies start_server_async builds apps with session endpoints and starts TCPSites. + + This test verifies by: + 1. Patching AppRunner, TCPSite, and _start_tracking so nothing listens on real ports + 2. Capturing Application instances passed to AppRunner + 3. Asserting main app includes POST /session/create, /session/end, /session/get, /session/health + 4. Asserting HTTP app includes POST /session/end + + Assumptions: + - backend._start_tracking is mocked; WORKER_PORT and related env are set + """ + backend, _ = serverless_backend_and_handler_default + routes: list = [] + st = serverless_tracked_runner_and_tcp_site + apps_seen = st.apps_seen + route_paths = serverless_aiohttp_route_path_tuples + + env = {**serverless_metrics_test_env, "WORKER_PORT": "9100", "WORKER_HTTP_PORT": "9101"} + mock_track = await run_serverless_start_server_async_patched( + backend, + routes, + env, + ) + + mock_track.assert_awaited_once() + assert len(apps_seen) >= 2 + main_app = apps_seen[0] + http_app = apps_seen[1] + paths = route_paths(main_app) + for suffix in ("/session/create", "/session/end", "/session/get", "/session/health"): + assert any(suffix in p for _, p in paths) + http_paths = route_paths(http_app) + assert any("/session/end" in p for _, p in http_paths) + + +@pytest.mark.asyncio +async def test_start_server_async_defaults_http_port_to_worker_plus_one( + serverless_backend_and_handler_default, + serverless_metrics_test_env, + serverless_tracked_runner_and_tcp_site, + run_serverless_start_server_async_patched, +) -> None: + """ + Verifies WORKER_HTTP_PORT defaults to int(WORKER_PORT) + 1 when unset. + + This test verifies by: + 1. Removing WORKER_HTTP_PORT from the environment for the duration of the call + 2. Capturing TCPSite kwargs for the HTTP-only app + 3. Asserting its port is WORKER_PORT + 1 while the TLS/plain worker uses WORKER_PORT + + Assumptions: + - Same patched runner/_start_tracking path as other start_server_async tests + """ + backend, _ = serverless_backend_and_handler_default + routes: list = [] + st = serverless_tracked_runner_and_tcp_site + tcp_calls = st.tcp_calls + + env = {**serverless_metrics_test_env, "WORKER_PORT": "7122"} + old_http = os.environ.pop("WORKER_HTTP_PORT", None) + try: + await run_serverless_start_server_async_patched( + backend, + routes, + env, + ) + finally: + if old_http is not None: + os.environ["WORKER_HTTP_PORT"] = old_http + assert tcp_calls[0]["port"] == 7122 + assert tcp_calls[1]["port"] == 7123 + + +@pytest.mark.asyncio +async def test_start_server_async_ssl_branch_loads_cert_chain( + serverless_backend_and_handler_default, + serverless_metrics_test_env, + serverless_tracked_runner_and_tcp_site, + run_serverless_start_server_async_patched, +) -> None: + """ + Verifies USE_SSL=true passes an ssl.SSLContext into TCPSite for the main listener. + + This test verifies by: + 1. Enabling USE_SSL and patching ssl.create_default_context to return a mock context + 2. Recording kwargs passed to TCPSite for the first site (HTTPS worker) + + Assumptions: + - Certificate load succeeds under patch; second TCPSite remains plain HTTP + """ + backend, _ = serverless_backend_and_handler_default + routes: list = [] + st = serverless_tracked_runner_and_tcp_site + tcp_calls = st.tcp_calls + + mock_ctx = MagicMock() + mock_ctx.load_cert_chain = MagicMock() + + env = { + **serverless_metrics_test_env, + "WORKER_PORT": "9200", + "USE_SSL": "true", + } + await run_serverless_start_server_async_patched( + backend, + routes, + env, + ssl_create_default_context_patch=patch.object( + server_mod.ssl, "create_default_context", return_value=mock_ctx + ), + ) + + mock_ctx.load_cert_chain.assert_called_once_with( + certfile="/etc/instance.crt", + keyfile="/etc/instance.key", + ) + assert tcp_calls[0].get("ssl_context") is mock_ctx + assert tcp_calls[1].get("ssl_context") is None + + +@pytest.mark.asyncio +async def test_start_server_async_ssl_cert_load_failure_enters_error_beacon( + serverless_backend_and_handler_default, + serverless_metrics_test_env, + serverless_error_beacon_mocks, +) -> None: + """ + Verifies SSL certificate load errors are caught like other launch failures and enter the beacon. + + This test verifies by: + 1. Enabling USE_SSL with load_cert_chain raising OSError + 2. Patching Metrics send/sleep like other beacon tests + 3. Asserting _model_errored mentions SSL certificate failure + + Assumptions: + - Outer try/except wraps all startup failures; beacon runs for any Exception + """ + mock_err = serverless_error_beacon_mocks + backend, _ = serverless_backend_and_handler_default + routes: list = [] + mock_ctx = MagicMock() + mock_ctx.load_cert_chain = MagicMock(side_effect=OSError("no cert file")) + + env = { + **serverless_metrics_test_env, + "WORKER_PORT": "9201", + "VAST_TCP_PORT_9201": "9201", + "USE_SSL": "true", + } + with patch.dict(os.environ, env, clear=False): + with patch.object(server_mod.ssl, "create_default_context", return_value=mock_ctx): + with pytest.raises(RuntimeError, match="stop-beacon"): + await start_server_async(backend, routes) + + mock_err.assert_called() + for c in mock_err.call_args_list: + assert "SSL Certificate" in c[0][0] + + +@pytest.mark.asyncio +async def test_start_server_async_gather_failure_runs_beacon_until_sleep_stops( + serverless_backend_and_handler_default, + serverless_metrics_test_env, + serverless_tracked_runner_and_tcp_site, + serverless_error_beacon_mocks, +) -> None: + """ + Verifies launch failure enters the metrics beacon loop (error reporting path). + + This test verifies by: + 1. Patching Backend._start_tracking to raise after mocked TCPSite starts (same stage as + a real failure once the listener stack is built) + 2. Patching asyncio.sleep in the server module so the second iteration raises + 3. Asserting _model_errored was invoked with the launch error message + + Assumptions: + - Metrics in beacon needs CONTAINER_ID etc.; send_metrics_reset is mocked to avoid I/O + """ + mock_err = serverless_error_beacon_mocks + backend, _ = serverless_backend_and_handler_default + routes: list = [] + st = serverless_tracked_runner_and_tcp_site + app_runner, tcp_site = st.app_runner, st.tcp_site + + env = { + **serverless_metrics_test_env, + "WORKER_PORT": "9300", + "VAST_TCP_PORT_9300": "9300", + } + with patch.dict(os.environ, env, clear=False): + with patch.object(server_mod.web, "AppRunner", side_effect=app_runner): + with patch.object(server_mod.web, "TCPSite", side_effect=tcp_site): + with patch.object( + backend, + "_start_tracking", + AsyncMock(side_effect=RuntimeError("bind failed")), + ): + with pytest.raises(RuntimeError, match="stop-beacon"): + await start_server_async(backend, routes) + + mock_err.assert_called() + for c in mock_err.call_args_list: + assert "bind failed" in c[0][0] + + +def test_start_server_invokes_asyncio_run( + serverless_backend_and_handler_default, +) -> None: + """ + Verifies start_server delegates to asyncio.run(start_server_async(...)). + + This test verifies by: + 1. Patching asyncio.run in the server module + 2. Replacing start_server_async with a trivial async function + 3. Calling start_server(backend, routes, host="127.0.0.1") and asserting run received a coroutine + + Assumptions: + - kwargs are forwarded into the coroutine factory call before run() sees it + """ + backend, _ = serverless_backend_and_handler_default + routes: list = [] + + async def fake_start_server_async(b, r, **kwargs): + assert kwargs.get("host") == "127.0.0.1" + return None + + ran = [] + + def _run_impl(coro): + ran.append(coro) + policy = asyncio.get_event_loop_policy() + loop = policy.new_event_loop() + try: + loop.run_until_complete(coro) + finally: + loop.close() + + with patch.object(server_mod, "run", side_effect=_run_impl): + with patch.object(server_mod, "start_server_async", fake_start_server_async): + start_server(backend, routes, host="127.0.0.1") + + assert len(ran) == 1 + assert asyncio.iscoroutine(ran[0]) + + +# --------------------------------------------------------------------------- +# serverless_gather_await_all (conftest stub semantics) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_serverless_gather_await_all_runs_awaitables_in_order( + serverless_gather_await_all, +) -> None: + g = serverless_gather_await_all + order: list[int] = [] + + async def first() -> None: + order.append(1) + + async def second() -> None: + order.append(2) + + await g(first(), second()) + assert order == [1, 2] + + +@pytest.mark.asyncio +async def test_serverless_gather_await_all_cancels_later_tasks_on_exception( + serverless_gather_await_all, +) -> None: + g = serverless_gather_await_all + + async def ok_coro() -> None: + await asyncio.sleep(0) + + async def boom() -> None: + raise ValueError("stop") + + t_late = asyncio.create_task(asyncio.sleep(999)) + with pytest.raises(ValueError, match="stop"): + await g(ok_coro(), asyncio.create_task(boom()), t_late) + try: + await t_late + except asyncio.CancelledError: + pass + assert t_late.cancelled() + + +@pytest.mark.asyncio +async def test_serverless_gather_await_all_closes_bare_coroutine_on_exception( + serverless_gather_await_all, +) -> None: + g = serverless_gather_await_all + + async def ok_coro() -> None: + await asyncio.sleep(0) + + async def boom() -> None: + raise OSError("boom") + + async def never_run() -> None: + await asyncio.sleep(999) + + coro = never_run() + with pytest.raises(OSError, match="boom"): + await g(ok_coro(), boom(), coro) + assert inspect.getcoroutinestate(coro) == inspect.CORO_CLOSED + + +@pytest.mark.asyncio +async def test_serverless_gather_await_all_cancels_pending_future_on_exception( + serverless_gather_await_all, +) -> None: + g = serverless_gather_await_all + loop = asyncio.get_running_loop() + fut: asyncio.Future = loop.create_future() + + async def boom() -> None: + raise RuntimeError("fail") + + with pytest.raises(RuntimeError, match="fail"): + await g(boom(), fut) + assert fut.cancelled() + + +@pytest.mark.asyncio +async def test_serverless_gather_await_all_accepts_ignored_gather_kwargs( + serverless_gather_await_all, +) -> None: + g = serverless_gather_await_all + + async def ok() -> None: + await asyncio.sleep(0) + + await g(ok(), return_exceptions=True) + + +def test_beacon_model_errored_assertion_requires_each_call_contains_marker() -> None: + """Regression: substring checks must scan every call, not only the last.""" + good = [("SSL Certificate failure",), ("SSL Certificate retry",)] + for call in good: + assert "SSL Certificate" in call[0] + + bad = [("SSL Certificate ok",), ("unrelated noise",)] + with pytest.raises(AssertionError): + for msg in bad: + assert "SSL Certificate" in msg[0] diff --git a/tests/serverless/test_server_data_types.py b/tests/serverless/test_server_data_types.py new file mode 100644 index 00000000..b201088c --- /dev/null +++ b/tests/serverless/test_server_data_types.py @@ -0,0 +1,1053 @@ +"""Unit tests for vastai.serverless.server.lib.data_types (pyworker server types).""" +from __future__ import annotations + +from dataclasses import dataclass +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from vastai.serverless.server.lib.data_types import ( + ApiPayload, + AuthData, + BenchmarkResult, + EndpointHandler, + JsonDataException, + LogAction, + ModelMetrics, + RequestMetrics, + Session, + SystemMetrics, + WorkerStatusData, +) + + +# --------------------------------------------------------------------------- +# Test doubles for EndpointHandler.get_data_from_request +# --------------------------------------------------------------------------- + + +@dataclass +class DummyPayload(ApiPayload): + """Minimal ApiPayload for exercising deserialization in tests.""" + + value: int = 0 + + @classmethod + def for_test(cls): + return cls(value=1) + + def generate_payload_json(self): + return {"value": self.value} + + def count_workload(self): + return float(self.value) + + @classmethod + def from_json_msg(cls, json_msg): + if "value" not in json_msg: + raise JsonDataException({"value": "missing parameter"}) + return cls(value=int(json_msg["value"])) + + +@dataclass +class DummyHandler(EndpointHandler): + """Concrete handler so get_data_from_request can be called on a real class.""" + + @property + def endpoint(self) -> str: + return "/predict" + + @property + def healthcheck_endpoint(self): + return None + + @classmethod + def payload_cls(cls): + return DummyPayload + + def make_benchmark_payload(self): + return DummyPayload.for_test() + + async def generate_client_response(self, client_request, model_response): + return MagicMock() + + async def call_remote_dispatch_function(self, params: dict): + return None + + +@dataclass +class FalsyPayload(ApiPayload): + """Payload whose from_json_msg returns None to exercise deserialize fallback.""" + + @classmethod + def for_test(cls): + return cls() + + def generate_payload_json(self): + return {} + + def count_workload(self): + return 0.0 + + @classmethod + def from_json_msg(cls, json_msg): + return None + + +@dataclass +class FalsyPayloadHandler(EndpointHandler): + """Handler pairing with FalsyPayload for get_data_from_request edge case.""" + + @property + def endpoint(self) -> str: + return "/x" + + @property + def healthcheck_endpoint(self): + return None + + @classmethod + def payload_cls(cls): + return FalsyPayload + + def make_benchmark_payload(self): + return FalsyPayload.for_test() + + async def generate_client_response(self, client_request, model_response): + return MagicMock() + + async def call_remote_dispatch_function(self, params: dict): + return None + + +class TestJsonDataException: + """JsonDataException stores structured error payloads.""" + + def test_init_stores_message_dict(self) -> None: + """ + Verifies that JsonDataException keeps the JSON error map on .message. + + This test verifies by: + 1. Constructing JsonDataException with a dict of field errors + 2. Asserting exception.message is the same dict + + Assumptions: + - No external I/O; pure exception construction + """ + err = {"field": "bad"} + exc = JsonDataException(err) + assert exc.message == err + + +class TestAuthDataFromJsonMsg: + """AuthData.from_json_msg validates required fields.""" + + def test_from_json_msg_with_all_fields_returns_auth_data( + self, valid_auth_data_dict + ) -> None: + """ + Verifies successful construction when every dataclass field is present. + + This test verifies by: + 1. Building a dict with all AuthData field names + 2. Calling AuthData.from_json_msg + 3. Asserting each attribute matches the input + + Assumptions: + - inspect.signature(AuthData) matches dataclass fields used for validation + """ + data = valid_auth_data_dict + auth = AuthData.from_json_msg(data) + assert auth.cost == data["cost"] + assert auth.endpoint == data["endpoint"] + assert auth.reqnum == data["reqnum"] + assert auth.request_idx == data["request_idx"] + assert auth.signature == data["signature"] + assert auth.url == data["url"] + + def test_from_json_msg_missing_field_raises_json_data_exception( + self, valid_auth_data_dict + ) -> None: + """ + Verifies missing required parameters produce JsonDataException with per-field errors. + + This test verifies by: + 1. Omitting one required key from the input dict + 2. Asserting JsonDataException is raised + 3. Asserting the exception message maps missing keys to 'missing parameter' + + Assumptions: + - 'signature' is a required AuthData field + """ + data = {**valid_auth_data_dict} + del data["signature"] + with pytest.raises(JsonDataException) as ctx: + AuthData.from_json_msg(data) + assert "signature" in ctx.value.message + assert ctx.value.message["signature"] == "missing parameter" + + def test_from_json_msg_ignores_unknown_keys(self, valid_auth_data_dict) -> None: + """ + Verifies extra keys in JSON do not break construction or appear on the instance. + + This test verifies by: + 1. Adding an extra key not in AuthData + 2. Calling from_json_msg + 3. Asserting the returned object has only expected attributes + + Assumptions: + - Filtering uses inspect.signature parameters only + """ + data = {**valid_auth_data_dict, "extra": "ignored"} + auth = AuthData.from_json_msg(data) + assert not hasattr(auth, "extra") + + +class TestEndpointHandlerGetDataFromRequest: + """EndpointHandler.get_data_from_request parses auth, payload, and optional session_id.""" + + def test_get_data_from_request_returns_auth_payload_and_none_session( + self, valid_auth_data_dict + ) -> None: + """ + Verifies happy path returns auth, payload, and None session_id when omitted. + + This test verifies by: + 1. Passing valid auth_data and payload dicts + 2. Calling DummyHandler.get_data_from_request + 3. Asserting a 3-tuple (auth, payload, session_id) with session_id None + + Assumptions: + - Backend unpacks three values (see vastai.serverless.server.lib.backend) + """ + req = { + "auth_data": valid_auth_data_dict, + "payload": {"value": 7}, + } + auth, payload, session_id = DummyHandler.get_data_from_request(req) + assert isinstance(auth, AuthData) + assert isinstance(payload, DummyPayload) + assert payload.value == 7 + assert session_id is None + + def test_get_data_from_request_includes_session_id_when_present( + self, valid_auth_data_dict + ) -> None: + """ + Verifies session_id from req_data is returned as the third element. + + This test verifies by: + 1. Including session_id in the request dict + 2. Calling get_data_from_request + 3. Asserting the third tuple element matches + + Assumptions: + - session_id is optional and passed through without validation + """ + req = { + "auth_data": valid_auth_data_dict, + "payload": {"value": 1}, + "session_id": "sess-abc", + } + _, _, session_id = DummyHandler.get_data_from_request(req) + assert session_id == "sess-abc" + + def test_get_data_from_request_missing_auth_data_raises(self) -> None: + """ + Verifies absent auth_data yields JsonDataException. + + This test verifies by: + 1. Omitting auth_data key + 2. Asserting JsonDataException with errors describing auth_data + + Assumptions: + - Payload is valid so only auth errors are present + """ + req = {"payload": {"value": 1}} + with pytest.raises(JsonDataException) as ctx: + DummyHandler.get_data_from_request(req) + assert ctx.value.message["auth_data"] == "field missing" + + def test_get_data_from_request_missing_payload_raises( + self, valid_auth_data_dict + ) -> None: + """ + Verifies absent payload yields JsonDataException. + + This test verifies by: + 1. Omitting payload key with valid auth_data + 2. Asserting JsonDataException mentions payload + + Assumptions: + - auth_data alone is insufficient + """ + req = {"auth_data": valid_auth_data_dict} + with pytest.raises(JsonDataException) as ctx: + DummyHandler.get_data_from_request(req) + assert ctx.value.message["payload"] == "field missing" + + def test_get_data_from_request_invalid_auth_merges_errors(self) -> None: + """ + Verifies AuthData validation failures appear under errors['auth_data']. + + This test verifies by: + 1. Supplying incomplete auth_data + 2. Asserting JsonDataException.message['auth_data'] is the nested error dict + + Assumptions: + - JsonDataException from AuthData is caught and re-wrapped per field + """ + bad_auth = {"cost": "1"} # missing other required fields + req = {"auth_data": bad_auth, "payload": {"value": 1}} + with pytest.raises(JsonDataException) as ctx: + DummyHandler.get_data_from_request(req) + inner = ctx.value.message["auth_data"] + assert isinstance(inner, dict) + assert "endpoint" in inner + + def test_get_data_from_request_invalid_payload_merges_errors( + self, valid_auth_data_dict + ) -> None: + """ + Verifies payload from_json_msg JsonDataException maps to errors['payload']. + + This test verifies by: + 1. Passing payload missing required 'value' for DummyPayload + 2. Asserting merged errors contain payload field errors + + Assumptions: + - DummyPayload.from_json_msg raises JsonDataException like real payloads + """ + req = {"auth_data": valid_auth_data_dict, "payload": {}} + with pytest.raises(JsonDataException) as ctx: + DummyHandler.get_data_from_request(req) + assert "value" in ctx.value.message["payload"] + + def test_get_data_from_request_merges_auth_and_payload_errors_together(self) -> None: + """ + Verifies invalid auth and invalid payload both appear in one JsonDataException. + + This test verifies by: + 1. Sending incomplete auth_data and empty payload in the same req_data + 2. Asserting exception.message contains both 'auth_data' and 'payload' entries + + Assumptions: + - Each try/except block records its own JsonDataException before the combined raise + """ + bad_auth = {"cost": "1"} + req = {"auth_data": bad_auth, "payload": {}} + with pytest.raises(JsonDataException) as ctx: + DummyHandler.get_data_from_request(req) + msg = ctx.value.message + assert "auth_data" in msg + assert "payload" in msg + assert isinstance(msg["auth_data"], dict) + assert "endpoint" in msg["auth_data"] + assert "value" in msg["payload"] + + def test_get_data_from_request_raises_generic_exception_when_payload_falsy( + self, + valid_auth_data_dict, + ) -> None: + """ + Verifies generic Exception when no field errors but payload deserialize is falsy. + + This test verifies by: + 1. Using a payload type whose from_json_msg returns None with valid auth + 2. Asserting Exception with message 'error deserializing request data' + + Assumptions: + - Branch at data_types.get_data_from_request when auth_data truthy but payload falsy + """ + req = {"auth_data": valid_auth_data_dict, "payload": {"ignored": True}} + with pytest.raises(Exception, match="error deserializing request data"): + FalsyPayloadHandler.get_data_from_request(req) + + +class TestApiPayloadAbstract: + """ApiPayload remains abstract; concrete subclasses supply behavior.""" + + def test_api_payload_cannot_be_instantiated_without_concrete_methods(self) -> None: + """ + Verifies direct instantiation of ApiPayload raises TypeError (abstract methods). + + This test verifies by: + 1. Calling ApiPayload() with no subclass + 2. Asserting TypeError from ABC machinery + + Assumptions: + - Python ABC prevents instantiating incomplete implementations + """ + with pytest.raises(TypeError): + ApiPayload() + + +class TestEndpointHandlerAbstract: + """EndpointHandler concrete instance exposes defaults and implemented API.""" + + def test_endpoint_handler_cannot_be_instantiated_without_concrete_methods( + self, + ) -> None: + """ + Verifies EndpointHandler is abstract until all methods are implemented. + + This test verifies by: + 1. Attempting EndpointHandler() + 2. Asserting TypeError + + Assumptions: + - ABC enforces endpoint, payload_cls, async hooks, etc. + """ + with pytest.raises(TypeError): + EndpointHandler() + + @pytest.mark.asyncio + async def test_dummy_handler_exposes_endpoint_and_async_hooks(self) -> None: + """ + Verifies DummyHandler implements the abstract server contract for callers. + + This test verifies by: + 1. Instantiating DummyHandler and reading endpoint and healthcheck_endpoint + 2. Awaiting generate_client_response and call_remote_dispatch_function + + Assumptions: + - aiohttp objects are mocked; no real server or HTTP + """ + h = DummyHandler() + assert h.endpoint == "/predict" + assert h.healthcheck_endpoint is None + assert isinstance(h.make_benchmark_payload(), DummyPayload) + resp = await h.generate_client_response(MagicMock(), MagicMock()) + assert resp is not None + assert await h.call_remote_dispatch_function({}) is None + + +class TestSystemMetrics: + """SystemMetrics.empty, disk usage helpers, and reset behavior.""" + + def test_empty_sets_loading_start_and_disk_from_helpers(self) -> None: + """ + Verifies empty() uses time.time and get_disk_usage_GB for initial fields. + + This test verifies by: + 1. Patching time.time and SystemMetrics.get_disk_usage_GB to fixed values + 2. Calling SystemMetrics.empty() + 3. Asserting model_loading_start, last_disk_usage, and defaults + + Assumptions: + - Patches restore automatically via context managers (RAII) + """ + with patch( + "vastai.serverless.server.lib.data_types.time.time", return_value=12345.0 + ): + with patch.object( + SystemMetrics, "get_disk_usage_GB", return_value=99.5 + ) as mock_disk: + m = SystemMetrics.empty() + mock_disk.assert_called() + assert m.model_loading_start == 12345.0 + assert m.model_loading_time is None + assert m.last_disk_usage == 99.5 + assert m.additional_disk_usage == 0.0 + assert m.model_is_loaded is False + + def test_update_disk_usage_sets_additional_and_last(self) -> None: + """ + Verifies update_disk_usage computes delta from last_disk_usage. + + This test verifies by: + 1. Constructing SystemMetrics with known last_disk_usage + 2. Patching get_disk_usage_GB to a larger value + 3. Calling update_disk_usage and asserting additional_disk_usage and last + + Assumptions: + - get_disk_usage_GB is the source of current usage (mocked, no real psutil) + """ + m = SystemMetrics( + model_loading_start=0.0, + model_loading_time=None, + last_disk_usage=10.0, + additional_disk_usage=0.0, + model_is_loaded=False, + ) + with patch.object(SystemMetrics, "get_disk_usage_GB", return_value=13.0): + m.update_disk_usage() + assert m.last_disk_usage == 13.0 + assert m.additional_disk_usage == 3.0 + + def test_reset_clears_model_loading_time_when_matches_expected(self) -> None: + """ + Verifies reset(None) clears model_loading_time when it is already None. + + This test verifies by: + 1. Building metrics with model_loading_time None + 2. Calling reset(None) + 3. Asserting model_loading_time stays None + + Assumptions: + - Condition is equality check against expected argument + """ + m = SystemMetrics( + model_loading_start=0.0, + model_loading_time=None, + last_disk_usage=0.0, + additional_disk_usage=0.0, + model_is_loaded=True, + ) + m.reset(None) + assert m.model_loading_time is None + + def test_reset_clears_model_loading_time_when_equal_to_expected(self) -> None: + """ + Verifies reset(expected) sets model_loading_time to None when it equals expected. + + This test verifies by: + 1. Setting model_loading_time to a known float + 2. Calling reset with that same float + 3. Asserting model_loading_time becomes None + + Assumptions: + - Autoscaler one-shot semantics per data_types docstring + """ + m = SystemMetrics( + model_loading_start=0.0, + model_loading_time=42.0, + last_disk_usage=0.0, + additional_disk_usage=0.0, + model_is_loaded=True, + ) + m.reset(42.0) + assert m.model_loading_time is None + + def test_reset_leaves_model_loading_time_when_expected_mismatch(self) -> None: + """ + Verifies reset does not clear model_loading_time when value differs from expected. + + This test verifies by: + 1. Setting model_loading_time to 10.0 + 2. Calling reset(99.0) + 3. Asserting model_loading_time remains 10.0 + + Assumptions: + - Inequality means no reset of loading time + """ + m = SystemMetrics( + model_loading_start=0.0, + model_loading_time=10.0, + last_disk_usage=0.0, + additional_disk_usage=0.0, + model_is_loaded=True, + ) + m.reset(99.0) + assert m.model_loading_time == 10.0 + + def test_get_disk_usage_gb_converts_psutil_used_bytes_to_gb(self) -> None: + """ + Verifies get_disk_usage_GB reads root mount usage and converts to gigabytes. + + This test verifies by: + 1. Patching psutil.disk_usage to return a mock with .used in bytes + 2. Calling SystemMetrics.get_disk_usage_GB() + 3. Asserting disk_usage was called with '/' and the ratio used / 2**30 + + Assumptions: + - Real psutil is never invoked; patch target is data_types.psutil.disk_usage + """ + mock_usage = MagicMock() + mock_usage.used = 5 * (2**30) + with patch( + "vastai.serverless.server.lib.data_types.psutil.disk_usage", + return_value=mock_usage, + ) as mock_du: + gb = SystemMetrics.get_disk_usage_GB() + mock_du.assert_called_once_with("/") + assert gb == 5.0 + + +class TestModelMetrics: + """ModelMetrics factories, derived properties, and reset/set_errored.""" + + def test_empty_initializes_counters_and_collections(self) -> None: + """ + Verifies ModelMetrics.empty sets workload fields and optional state. + + This test verifies by: + 1. Calling ModelMetrics.empty() + 2. Asserting numeric counters, error_msg, max_throughput, and empty sets/dicts + + Assumptions: + - Field defaults apply for requests_recieved and requests_working + """ + mm = ModelMetrics.empty() + assert mm.workload_pending == 0.0 + assert mm.workload_served == 0.0 + assert mm.workload_received == 0.0 + assert mm.workload_cancelled == 0.0 + assert mm.workload_errored == 0.0 + assert mm.workload_rejected == 0.0 + assert mm.error_msg is None + assert mm.max_throughput == 0.0 + assert mm.requests_recieved == set() + assert mm.requests_working == {} + + def test_workload_processing_is_non_negative_difference(self) -> None: + """ + Verifies workload_processing is max(received - cancelled, 0). + + This test verifies by: + 1. Setting workload_received and workload_cancelled + 2. Asserting property matches formula for normal and over-cancelled cases + + Assumptions: + - Uses max(..., 0.0) when cancelled exceeds received + """ + mm = ModelMetrics.empty() + mm.workload_received = 10.0 + mm.workload_cancelled = 3.0 + assert mm.workload_processing == 7.0 + mm.workload_cancelled = 15.0 + assert mm.workload_processing == 0.0 + + def test_wait_time_zero_when_no_active_requests(self) -> None: + """ + Verifies wait_time is 0.0 when requests_working is empty. + + This test verifies by: + 1. Using ModelMetrics.empty() (empty requests_working) + 2. Asserting wait_time == 0.0 + + Assumptions: + - Early return on len(requests_working) == 0 + """ + mm = ModelMetrics.empty() + assert mm.wait_time == 0.0 + + def test_wait_time_uses_minimum_divisor_when_max_throughput_is_zero(self) -> None: + """ + Verifies wait_time divides by max(max_throughput, 0.00001) when throughput is zero. + + This test verifies by: + 1. Setting max_throughput to 0.0 with one non-session request workload + 2. Asserting wait_time equals workload / 0.00001 + + Assumptions: + - Avoids division by zero per data_types implementation + """ + mm = ModelMetrics.empty() + mm.max_throughput = 0.0 + mm.requests_working[1] = RequestMetrics( + request_idx=1, + reqnum=1, + workload=3.0, + status="x", + is_session=False, + ) + assert mm.wait_time == 3.0 / 0.00001 + + def test_wait_time_mixed_session_and_non_session_only_counts_non_session(self) -> None: + """ + Verifies wait_time numerator includes only non-session workloads when both exist. + + This test verifies by: + 1. Adding one session and one non-session request to requests_working + 2. Asserting wait_time uses only the non-session workload in the sum + + Assumptions: + - cur_load would include both; wait_time filters by is_session + """ + mm = ModelMetrics.empty() + mm.max_throughput = 5.0 + mm.requests_working[1] = RequestMetrics( + request_idx=1, + reqnum=1, + workload=100.0, + status="x", + is_session=True, + ) + mm.requests_working[2] = RequestMetrics( + request_idx=2, + reqnum=2, + workload=10.0, + status="x", + is_session=False, + ) + assert mm.wait_time == 2.0 + assert mm.cur_load == 110.0 + + def test_working_request_idxs_empty_when_no_active_requests(self) -> None: + """ + Verifies working_request_idxs is empty when requests_working is empty. + + This test verifies by: + 1. Using ModelMetrics.empty() + 2. Asserting working_request_idxs == [] + + Assumptions: + - List comprehension over empty dict values yields [] + """ + mm = ModelMetrics.empty() + assert mm.working_request_idxs == [] + + def test_wait_time_averages_non_session_workloads_over_throughput(self) -> None: + """ + Verifies wait_time sums non-session workloads divided by max_throughput floor. + + This test verifies by: + 1. Adding two RequestMetrics with is_session False and known workloads + 2. Setting max_throughput to 10.0 + 3. Asserting wait_time == (w1 + w2) / 10.0 + + Assumptions: + - Session requests are excluded from the numerator + """ + mm = ModelMetrics.empty() + mm.max_throughput = 10.0 + mm.requests_working[1] = RequestMetrics( + request_idx=1, + reqnum=1, + workload=4.0, + status="x", + is_session=False, + ) + mm.requests_working[2] = RequestMetrics( + request_idx=2, + reqnum=2, + workload=6.0, + status="x", + is_session=False, + ) + assert mm.wait_time == 1.0 + + def test_wait_time_excludes_session_requests_from_numerator(self) -> None: + """ + Verifies session-flagged requests do not contribute to wait_time sum. + + This test verifies by: + 1. Placing only is_session=True metrics in requests_working + 2. Asserting wait_time is 0.0 despite positive workloads + + Assumptions: + - Filter is `if not request.is_session` + """ + mm = ModelMetrics.empty() + mm.max_throughput = 100.0 + mm.requests_working[1] = RequestMetrics( + request_idx=1, + reqnum=1, + workload=50.0, + status="x", + is_session=True, + ) + assert mm.wait_time == 0.0 + + def test_cur_load_sums_request_workloads(self) -> None: + """ + Verifies cur_load is the sum of workloads in requests_working. + + This test verifies by: + 1. Adding multiple RequestMetrics with distinct workloads + 2. Asserting cur_load equals the sum + + Assumptions: + - All entries in requests_working contribute regardless of is_session + """ + mm = ModelMetrics.empty() + mm.requests_working[0] = RequestMetrics( + request_idx=0, reqnum=0, workload=2.5, status="a" + ) + mm.requests_working[1] = RequestMetrics( + request_idx=1, reqnum=1, workload=3.5, status="b" + ) + assert mm.cur_load == 6.0 + + def test_working_request_idxs_lists_indices(self) -> None: + """ + Verifies working_request_idxs collects request_idx from values. + + This test verifies by: + 1. Populating requests_working with known request_idx values + 2. Asserting property returns list of those indices (order follows .values()) + + Assumptions: + - Dict iteration order is insertion order (Python 3.7+) + """ + mm = ModelMetrics.empty() + mm.requests_working[10] = RequestMetrics( + request_idx=10, reqnum=1, workload=1.0, status="x" + ) + mm.requests_working[20] = RequestMetrics( + request_idx=20, reqnum=2, workload=2.0, status="x" + ) + assert mm.working_request_idxs == [10, 20] + + def test_reset_zeros_transient_workload_fields_and_updates_last_update(self) -> None: + """ + Verifies reset clears counters that autoscaler consumes and refreshes last_update. + + This test verifies by: + 1. Setting non-zero workload_served, workload_received, etc. + 2. Patching time.time for deterministic last_update + 3. Calling reset() and asserting zeros and new last_update + + Assumptions: + - reset does not clear error_msg or long-lived fields beyond listed counters + """ + mm = ModelMetrics.empty() + mm.workload_served = 1.0 + mm.workload_received = 2.0 + mm.workload_cancelled = 3.0 + mm.workload_errored = 4.0 + mm.workload_rejected = 5.0 + mm.last_update = 0.0 + with patch( + "vastai.serverless.server.lib.data_types.time.time", return_value=777.0 + ): + mm.reset() + assert mm.workload_served == 0 + assert mm.workload_received == 0 + assert mm.workload_cancelled == 0 + assert mm.workload_errored == 0 + assert mm.workload_rejected == 0 + assert mm.last_update == 777.0 + + def test_set_errored_calls_reset_and_sets_error_msg(self) -> None: + """ + Verifies set_errored resets counters and stores the error string. + + This test verifies by: + 1. Setting non-zero workload_served + 2. Calling set_errored('boom') + 3. Asserting counters cleared and error_msg set + + Assumptions: + - set_errored delegates to reset() first + """ + mm = ModelMetrics.empty() + mm.workload_served = 5.0 + mm.set_errored("boom") + assert mm.workload_served == 0 + assert mm.error_msg == "boom" + + +class TestDummyPayloadBehavior: + """Concrete ApiPayload used by tests implements JSON and workload helpers.""" + + def test_for_test_generate_payload_json_and_count_workload(self) -> None: + """ + Verifies DummyPayload helpers used by benchmarks and forwarding paths. + + This test verifies by: + 1. Calling for_test(), generate_payload_json(), and count_workload() + 2. Asserting JSON shape and workload match the instance + + Assumptions: + - Mirrors expectations for real server payload implementations + """ + p = DummyPayload.for_test() + assert p.generate_payload_json() == {"value": 1} + assert p.count_workload() == 1.0 + p2 = DummyPayload.from_json_msg({"value": 42}) + assert p2.value == 42 + assert p2.count_workload() == 42.0 + + +class TestBenchmarkResult: + """BenchmarkResult.is_successful reflects response status.""" + + def test_is_successful_true_when_response_status_200(self) -> None: + """ + Verifies is_successful is True when response exists and status is 200. + + This test verifies by: + 1. Building BenchmarkResult with a mock ClientResponse (status 200) + 2. Asserting is_successful is True + + Assumptions: + - No real HTTP; MagicMock only + """ + resp = MagicMock() + resp.status = 200 + br = BenchmarkResult(request_idx=0, workload=1.0, task=AsyncMock(), response=resp) + assert br.is_successful is True + + def test_is_successful_false_when_response_none(self) -> None: + """ + Verifies is_successful is False when response was never set. + + This test verifies by: + 1. Using default response=None + 2. Asserting is_successful is False + + Assumptions: + - Property checks `response is not None` + """ + br = BenchmarkResult(request_idx=0, workload=1.0, task=AsyncMock()) + assert br.is_successful is False + + def test_is_successful_false_when_status_not_200(self) -> None: + """ + Verifies non-200 HTTP status yields is_successful False. + + This test verifies by: + 1. Mocking response.status to 500 + 2. Asserting is_successful is False + + Assumptions: + - Strict equality with 200 + """ + resp = MagicMock() + resp.status = 500 + br = BenchmarkResult(request_idx=0, workload=1.0, task=AsyncMock(), response=resp) + assert br.is_successful is False + + def test_is_successful_false_when_response_status_not_equal_200(self) -> None: + """ + Verifies is_successful is False when response.status is present but not 200. + + This test verifies by: + 1. Using SimpleNamespace(status=None) so the comparison to 200 fails + 2. Asserting is_successful is False + + Assumptions: + - Property requires both a non-None response and status == 200 + """ + resp = SimpleNamespace(status=None) + br = BenchmarkResult(request_idx=0, workload=1.0, task=AsyncMock(), response=resp) + assert br.is_successful is False + + +class TestSessionAndRequestMetricsDataclasses: + """Lightweight construction checks for Session and RequestMetrics.""" + + def test_session_defaults_and_fields(self) -> None: + """ + Verifies Session stores core fields and default_factory for requests. + + This test verifies by: + 1. Constructing Session with explicit scalar fields + 2. Asserting requests list default is empty and request counters initialized + + Assumptions: + - created_at uses time.time at construction; not asserted to a fixed value + """ + s = Session( + session_id="s1", + lifetime=30.0, + auth_data={}, + expiration=100.0, + on_close_route="/close", + on_close_payload={}, + ) + assert s.session_id == "s1" + assert s.requests == [] + assert s.request_idx == 0 + assert s.session_reqnum == 0 + + def test_request_metrics_optional_session_fields(self) -> None: + """ + Verifies RequestMetrics accepts workload and status with default success False. + + This test verifies by: + 1. Instantiating RequestMetrics with required fields only + 2. Asserting success is False and session fields default as in dataclass + + Assumptions: + - Matches server usage as optional session tracking + """ + rm = RequestMetrics(request_idx=3, reqnum=9, workload=2.0, status="WORKING") + assert rm.success is False + assert rm.is_session is False + assert rm.session is None + assert rm.session_reqnum is None + + def test_request_metrics_with_session_reference(self) -> None: + """ + Verifies RequestMetrics can attach session and session_reqnum for session flows. + + This test verifies by: + 1. Building a Session and RequestMetrics with is_session True + 2. Asserting session link and session_reqnum are stored + + Assumptions: + - Server tracks long-lived session requests alongside metrics + """ + s = Session( + session_id="sid", + lifetime=1.0, + auth_data={}, + expiration=99.0, + on_close_route="/done", + on_close_payload={}, + ) + rm = RequestMetrics( + request_idx=1, + reqnum=2, + workload=0.5, + status="S", + success=True, + is_session=True, + session=s, + session_reqnum=3, + ) + assert rm.session is s + assert rm.session_reqnum == 3 + assert rm.is_session is True + + +class TestWorkerStatusData: + """WorkerStatusData is a plain report DTO.""" + + def test_worker_status_data_holds_report_fields(self) -> None: + """ + Verifies WorkerStatusData stores all fields passed to the constructor. + + This test verifies by: + 1. Building an instance with representative values + 2. Asserting each attribute matches + + Assumptions: + - No validation logic on the dataclass + """ + ws = WorkerStatusData( + id=1, + mtoken="t", + version="v1", + loadtime=1.0, + cur_load=2.0, + rej_load=3.0, + new_load=4.0, + error_msg="", + max_perf=5.0, + cur_perf=6.0, + cur_capacity=7.0, + max_capacity=8.0, + num_requests_working=9, + num_requests_recieved=10, + additional_disk_usage=11.0, + working_request_idxs=[1, 2], + url="http://worker", + ) + assert ws.id == 1 + assert ws.working_request_idxs == [1, 2] + assert ws.url == "http://worker" + + +class TestLogAction: + """LogAction enum values used for backend log routing.""" + + def test_log_action_enum_values(self) -> None: + """ + Verifies LogAction members have stable int values for API contracts. + + This test verifies by: + 1. Comparing ModelLoaded, ModelError, Info to documented integers + 2. Asserting distinct values + + Assumptions: + - Values match vastai.serverless.server.lib.data_types definitions + """ + assert LogAction.ModelLoaded.value == 1 + assert LogAction.ModelError.value == 2 + assert LogAction.Info.value == 3 + assert len({LogAction.ModelLoaded, LogAction.ModelError, LogAction.Info}) == 3 diff --git a/tests/serverless/test_server_lib.py b/tests/serverless/test_server_lib.py new file mode 100644 index 00000000..c6d50b09 --- /dev/null +++ b/tests/serverless/test_server_lib.py @@ -0,0 +1,21 @@ +"""Tests for vastai.serverless.server.lib.server entrypoints.""" +from __future__ import annotations + +import asyncio +from unittest.mock import MagicMock, patch + +from vastai.serverless.server.lib import server as server_lib + + +def test_start_server_invokes_asyncio_run_with_start_server_async() -> None: + """start_server should delegate to asyncio.run(start_server_async(...)).""" + backend = MagicMock() + routes = [] + + with patch.object(server_lib, "run") as mock_run: + server_lib.start_server(backend, routes, host="127.0.0.1", port=8080) + + mock_run.assert_called_once() + (coro,) = mock_run.call_args[0] + assert asyncio.iscoroutine(coro) + coro.close() diff --git a/tests/serverless/test_serverless_client.py b/tests/serverless/test_serverless_client.py new file mode 100644 index 00000000..622724e6 --- /dev/null +++ b/tests/serverless/test_serverless_client.py @@ -0,0 +1,1550 @@ +"""Unit tests for vastai.serverless.client.client (Serverless, ServerlessRequest). + +HTTP and routing are mocked via _make_request, queue_endpoint_request, or aiohttp session mocks. + +This module is the primary home for queue/session API coverage added for serverless client work. +Broader tests (SSL, subprocess env, ``get_ssl_context``, extra debug branches) live in +``test_client.py``; add new narrow queue/session behavior here first to avoid drift. +""" + +from __future__ import annotations + +import asyncio +import itertools +import logging +from unittest.mock import AsyncMock, MagicMock, patch + +import aiohttp +import pytest + +from vastai.serverless.client.client import Serverless, ServerlessRequest +from vastai.serverless.client.endpoint import Endpoint + + +class TestServerlessRequest: + """ServerlessRequest future wrapper behavior.""" + + @pytest.mark.asyncio + async def test_then_invokes_callback_on_success(self) -> None: + """ + Verifies ServerlessRequest.then registers a done callback that receives the result. + + This test verifies by: + 1. Creating a ServerlessRequest and chaining .then with a MagicMock callback + 2. Calling set_result with a payload + 3. Yielding to the loop so the callback runs, then asserting call args + + Assumptions: + - asyncio schedules done callbacks on the next loop iteration + """ + cb = MagicMock() + req = ServerlessRequest() + req.then(cb) + req.set_result({"ok": True}) + await asyncio.sleep(0) + cb.assert_called_once_with({"ok": True}) + + +class TestServerlessInitAndConfig: + """Constructor, API key, and instance URL selection.""" + + def test_init_raises_when_api_key_missing(self) -> None: + """ + Verifies Serverless rejects a missing or empty api_key. + + This test verifies by: + 1. Instantiating Serverless(api_key=None) and Serverless(api_key="") + 2. Asserting AttributeError mentioning API key each time + + Assumptions: + - __init__ treats None and empty string as missing + """ + with pytest.raises(AttributeError, match="API key missing"): + Serverless(api_key=None) + with pytest.raises(AttributeError, match="API key missing"): + Serverless(api_key="") + + def test_init_accepts_explicit_api_key(self, client) -> None: + """ + Verifies explicit api_key is stored on the client. + + This test verifies by: + 1. Constructing Serverless(api_key=...) via client fixture + 2. Asserting client.api_key matches + + Assumptions: + - No environment key is required when api_key is passed + """ + assert client.api_key == "k" + + @pytest.mark.parametrize( + ("instance", "autoscaler_substr"), + [ + ("prod", "run.vast.ai"), + ("alpha", "run-alpha.vast.ai"), + ("candidate", "run-candidate.vast.ai"), + ("local", "localhost:8080"), + ], + ) + def test_instance_selects_autoscaler_url( + self, instance: str, autoscaler_substr: str + ) -> None: + """ + Verifies instance keyword maps to expected autoscaler base URL. + + This test verifies by: + 1. Creating Serverless with instance=... + 2. Asserting autoscaler_url contains the expected host fragment + + Assumptions: + - Mapping matches current client.py match/case branches + """ + sl = Serverless(api_key="k", instance=instance) + assert autoscaler_substr in sl.autoscaler_url + + +@pytest.mark.asyncio +class TestServerlessEndpoints: + """get_endpoints and get_endpoint.""" + + async def test_get_endpoints_parses_results_into_endpoints(self, client) -> None: + """ + Verifies get_endpoints builds Endpoint objects from JSON results. + + This test verifies by: + 1. Patching _make_request to return ok JSON with one result row + 2. Calling await client.get_endpoints() + 3. Asserting one Endpoint with matching name, id, api_key + + Assumptions: + - _make_request is patched at the module where client.py uses it + """ + fake = { + "ok": True, + "json": { + "results": [ + { + "endpoint_name": "a", + "id": 1, + "api_key": "ek1", + "cold_workers": 1, + "max_workers": 20, + "min_load": 100, + "target_util": 0.9, + "cold_mult": 1.5, + "max_queue_time": 30, + "target_queue_time": 5, + "endpoint_state": "running", + "inactivity_timeout": 600, + "user_id": 5, + "created_at": 129401, + }, + { + "endpoint_name": "b", + "id": 2, + "api_key": "ek2", + "cold_workers": 1, + "max_workers": 20, + "min_load": 100, + "target_util": 0.9, + "cold_mult": 1.5, + "max_queue_time": 30, + "target_queue_time": 5, + "endpoint_state": "running", + "inactivity_timeout": 600, + "user_id": 5, + "created_at": 129401, + }, + ] + }, + } + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value=fake, + ): + endpoints = await client.get_endpoints() + assert len(endpoints) == 2 + assert endpoints[0].name == "a" + assert endpoints[0].id == 1 + assert endpoints[0].api_key == "ek1" + assert endpoints[0].client is client + + async def test_get_endpoints_raises_when_http_not_ok(self, client) -> None: + """ + Verifies get_endpoints wraps failed HTTP into Exception. + + This test verifies by: + 1. Returning ok=False from _make_request + 2. Asserting Exception is raised with status context + + Assumptions: + - Client surfaces HTTP failures as generic Exception + """ + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": False, "status": 500, "text": "err"}, + ): + with pytest.raises(Exception, match="Failed to get endpoints"): + await client.get_endpoints() + + async def test_get_endpoint_returns_matching_endpoint(self, client) -> None: + """ + Verifies get_endpoint selects by name from get_endpoints. + + This test verifies by: + 1. Patching get_endpoints to return two Endpoint mocks + 2. Calling get_endpoint('two') + 3. Asserting the correct endpoint is returned + + Assumptions: + - get_endpoint only compares e.name + """ + e1 = Endpoint(client, "one", 1, "k") + e2 = Endpoint(client, "two", 2, "k") + with patch.object( + client, "get_endpoints", new_callable=AsyncMock, return_value=[e1, e2] + ): + got = await client.get_endpoint("two") + assert got is e2 + + async def test_get_endpoint_raises_when_name_not_found(self, client) -> None: + """ + Verifies get_endpoint raises when no endpoint matches the name. + + This test verifies by: + 1. Patching get_endpoints to return an empty list + 2. Asserting Exception mentioning the endpoint name + + Assumptions: + - Empty list yields no match + """ + with patch.object( + client, "get_endpoints", new_callable=AsyncMock, return_value=[] + ): + with pytest.raises(Exception, match="could not be found"): + await client.get_endpoint("nope") + + +@pytest.mark.asyncio +class TestServerlessWorkersAndSessions: + """get_endpoint_workers, get_endpoint_session, end_endpoint_session, start_endpoint_session.""" + + async def test_get_endpoint_workers_requires_endpoint_type(self, client) -> None: + """ + Verifies get_endpoint_workers rejects non-Endpoint values. + + This test verifies by: + 1. Passing a MagicMock instead of Endpoint + 2. Asserting ValueError + + Assumptions: + - isinstance check runs before HTTP + """ + with pytest.raises(ValueError, match="endpoint must be an Endpoint"): + await client.get_endpoint_workers(MagicMock()) + + async def test_get_endpoint_workers_returns_worker_list( + self, client, make_mock_http_response, make_serverless_endpoint + ) -> None: + """ + Verifies get_endpoint_workers parses JSON list into Worker models. + + This test verifies by: + 1. Attaching a mock aiohttp session with post returning worker dicts + 2. Calling await client.get_endpoint_workers(endpoint) + 3. Asserting Worker.id and count + + Assumptions: + - _session.post is used with JSON body containing endpoint id and api_key + """ + mock_sess = MagicMock() + mock_sess.post = MagicMock( + return_value=make_mock_http_response( + status=200, + json_data=[{"id": 99, "status": "RUNNING"}], + ) + ) + client._session = mock_sess + ep = make_serverless_endpoint(client, endpoint_id=3) + workers = await client.get_endpoint_workers(ep) + assert len(workers) == 1 + assert workers[0].id == 99 + + async def test_get_endpoint_workers_error_msg_returns_empty_list( + self, client, make_mock_http_response, make_serverless_endpoint + ) -> None: + """ + Verifies get_endpoint_workers returns [] when API returns error_msg dict. + + This test verifies by: + 1. Returning JSON dict with error_msg key from post() + 2. Asserting empty list result + + Assumptions: + - Client treats error_msg as soft failure for not-ready endpoints + """ + mock_sess = MagicMock() + mock_sess.post = MagicMock( + return_value=make_mock_http_response( + status=200, json_data={"error_msg": "not ready"} + ) + ) + client._session = mock_sess + ep = make_serverless_endpoint(client, endpoint_id=3) + workers = await client.get_endpoint_workers(ep) + assert workers == [] + + async def test_get_endpoint_session_builds_session( + self, client, make_serverless_endpoint + ) -> None: + """ + Verifies get_endpoint_session calls _make_request and constructs Session. + + This test verifies by: + 1. Patching _make_request with ok JSON containing auth_data with url + 2. Awaiting get_endpoint_session + 3. Asserting Session fields + + Assumptions: + - session_auth dict includes url used for worker request and Session.url + """ + ep = make_serverless_endpoint(client) + auth = {"url": "https://worker/s", "token": "t"} + fake = { + "ok": True, + "json": { + "auth_data": auth, + "lifetime": 120.0, + "expiration": "2099-01-01", + }, + } + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value=fake, + ): + sess = await client.get_endpoint_session(ep, 42, auth, timeout=5.0) + assert sess.endpoint is ep + assert sess.session_id == 42 + assert sess.auth_data == auth + assert sess.url == "https://worker/s" + + async def test_end_endpoint_session_calls_make_request( + self, client, make_serverless_endpoint, make_serverless_bound_session + ) -> None: + """ + Verifies end_endpoint_session POSTs to session.url via _make_request. + + This test verifies by: + 1. Patching _make_request to return ok + 2. Building a minimal Session with url and auth_data + 3. Awaiting end_endpoint_session and asserting _make_request kwargs + + Assumptions: + - Route is /session/end and body includes session_id and session_auth + """ + ep = make_serverless_endpoint(client) + sess = make_serverless_bound_session( + client, + endpoint=ep, + url="https://worker/end", + auth_data={"a": 1}, + ) + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_mr: + mock_mr.return_value = {"ok": True, "json": {}} + await client.end_endpoint_session(sess, timeout=8.0) + mock_mr.assert_awaited_once() + call_kw = mock_mr.await_args.kwargs + assert call_kw["url"] == "https://worker/end" + assert call_kw["route"] == "/session/end" + assert call_kw["body"]["session_id"] == "sid" + + async def test_start_endpoint_session_uses_queue_result( + self, client, make_serverless_endpoint + ) -> None: + """ + Verifies start_endpoint_session awaits queue_endpoint_request and returns Session. + + This test verifies by: + 1. Patching queue_endpoint_request to return a pre-resolved ServerlessRequest + 2. Awaiting start_endpoint_session + 3. Asserting Session session_id, url, and auth_data + + Assumptions: + - queue_endpoint_request result dict matches successful worker create shape + """ + ep = make_serverless_endpoint(client) + fut = ServerlessRequest() + fut.set_result( + { + "ok": True, + "response": {"session_id": "new-sid", "expiration": "ex"}, + "url": "https://w/u", + "auth_data": {"k": "v"}, + } + ) + with patch.object(client, "queue_endpoint_request", return_value=fut): + sess = await client.start_endpoint_session(ep, cost=50, lifetime=30.0) + assert sess.session_id == "new-sid" + assert sess.expiration == "ex" + assert sess.url == "https://w/u" + assert sess.auth_data == {"k": "v"} + assert sess.lifetime == 30.0 + + +@pytest.mark.asyncio +class TestQueueEndpointRequest: + """Background task for worker requests (session-bound path).""" + + async def test_queue_endpoint_request_with_session_sets_result_on_success( + self, client, make_serverless_endpoint, make_serverless_bound_session + ) -> None: + """ + Verifies queue_endpoint_request completes when session is set and worker returns ok JSON. + + This test verifies by: + 1. Patching _make_request to return ok with JSON body + 2. Awaiting the returned ServerlessRequest future + 3. Asserting response payload, url, and auth_data echo session fields + + Assumptions: + - Session-bound path skips _route polling and posts directly to session.url + """ + ep = make_serverless_endpoint(client) + sess = make_serverless_bound_session( + client, + endpoint=ep, + session_id="sid-99", + expiration="2099-01-01", + url="https://worker/direct", + ) + with patch( + "vastai.serverless.client.client._make_request", new_callable=AsyncMock + ) as mock_mr: + mock_mr.return_value = {"ok": True, "json": {"answer": 42}} + fut = client.queue_endpoint_request( + ep, "/do", {"p": 1}, session=sess, worker_timeout=30.0 + ) + result = await fut + assert result["ok"] is True + assert result["response"] == {"answer": 42} + assert result["url"] == "https://worker/direct" + assert result["auth_data"] == {"token": "t"} + mock_mr.assert_awaited() + call_kw = mock_mr.await_args.kwargs + assert call_kw["url"] == "https://worker/direct" + assert call_kw["route"] == "/do" + + +@pytest.mark.asyncio +class TestServerlessContextAndSessionState: + """Async context manager, is_open, close.""" + + async def test_is_open_false_without_session(self, client) -> None: + """ + Verifies is_open is False before _get_session creates a session. + + This test verifies by: + 1. Constructing Serverless + 2. Calling is_open() synchronously + + Assumptions: + - _session starts as None + """ + assert client.is_open() is False + + async def test_close_noop_when_no_session(self, client) -> None: + """ + Verifies close does not fail when no session exists. + + This test verifies by: + 1. Calling await close() on a new client + 2. Completing without error + + Assumptions: + - close checks _session truthiness and closed flag + """ + await client.close() + + async def test_aenter_calls_get_session(self, client) -> None: + """ + Verifies __aenter__ awaits _get_session and returns self. + + This test verifies by: + 1. Patching _get_session with AsyncMock + 2. Using async with Serverless(...) + 3. Asserting _get_session awaited and entered object is the client + + Assumptions: + - __aenter__ only opens session, does not require real aiohttp + """ + with patch.object(client, "_get_session", new_callable=AsyncMock) as mock_gs: + async with client as entered: + assert entered is client + mock_gs.assert_awaited() + + +@pytest.mark.asyncio +class TestServerlessSessionApiErrors: + """Failure paths for get / end / start endpoint session (wrapped exceptions).""" + + async def test_get_endpoint_session_raises_when_worker_http_not_ok( + self, client, make_serverless_endpoint + ) -> None: + ep = make_serverless_endpoint(client) + auth = {"url": "https://worker/s"} + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": False, "status": 503, "text": "unavailable"}, + ): + with pytest.raises(Exception, match="Error on /session/get"): + await client.get_endpoint_session(ep, 1, auth) + + async def test_get_endpoint_session_raises_when_auth_data_missing( + self, client, make_serverless_endpoint + ) -> None: + ep = make_serverless_endpoint(client) + auth = {"url": "https://worker/s"} + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": True, "json": {"lifetime": 1.0}}, + ): + with pytest.raises(Exception, match="Missing auth_data"): + await client.get_endpoint_session(ep, 1, auth) + + async def test_end_endpoint_session_raises_when_worker_http_not_ok( + self, client, make_serverless_endpoint, make_serverless_bound_session + ) -> None: + ep = make_serverless_endpoint(client) + sess = make_serverless_bound_session( + client, + endpoint=ep, + session_id="s", + lifetime=1.0, + url="https://worker/x", + auth_data={}, + ) + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": False, "json": {"error": "gone"}}, + ): + with pytest.raises(Exception, match="Error on /session/end"): + await client.end_endpoint_session(sess) + + async def test_start_endpoint_session_raises_when_queue_returns_not_ok( + self, client, make_serverless_endpoint + ) -> None: + ep = make_serverless_endpoint(client) + fut = ServerlessRequest() + fut.set_result({"ok": False, "text": "nope"}) + with patch.object(client, "queue_endpoint_request", return_value=fut): + with pytest.raises(Exception, match="Error on /session/create"): + await client.start_endpoint_session(ep) + + async def test_start_endpoint_session_raises_when_url_missing( + self, client, make_serverless_endpoint + ) -> None: + ep = make_serverless_endpoint(client) + fut = ServerlessRequest() + fut.set_result( + { + "ok": True, + "response": {"session_id": "id", "expiration": "e"}, + "auth_data": {"k": "v"}, + } + ) + with patch.object(client, "queue_endpoint_request", return_value=fut): + with pytest.raises(Exception, match="Missing URL"): + await client.start_endpoint_session(ep) + + async def test_start_endpoint_session_raises_when_auth_data_missing( + self, client, make_serverless_endpoint + ) -> None: + ep = make_serverless_endpoint(client) + fut = ServerlessRequest() + fut.set_result( + { + "ok": True, + "response": {"session_id": "id", "expiration": "e"}, + "url": "https://w", + } + ) + with patch.object(client, "queue_endpoint_request", return_value=fut): + with pytest.raises(Exception, match="Missing auth data"): + await client.start_endpoint_session(ep) + + async def test_start_endpoint_session_raises_when_session_id_missing( + self, client, make_serverless_endpoint + ) -> None: + ep = make_serverless_endpoint(client) + fut = ServerlessRequest() + fut.set_result( + { + "ok": True, + "response": {"expiration": "e"}, + "url": "https://w", + "auth_data": {"k": "v"}, + } + ) + with patch.object(client, "queue_endpoint_request", return_value=fut): + with pytest.raises(Exception, match="Missing session id"): + await client.start_endpoint_session(ep) + + @pytest.mark.parametrize( + "queue_payload", + [ + {"ok": True, "url": "https://w", "auth_data": {"k": "v"}}, + { + "ok": True, + "response": None, + "url": "https://w", + "auth_data": {"k": "v"}, + }, + ], + ) + async def test_start_endpoint_session_raises_when_response_body_missing( + self, client, make_serverless_endpoint, queue_payload: dict + ) -> None: + """ + Missing or null ``response`` raises + """ + ep = make_serverless_endpoint(client) + fut = ServerlessRequest() + fut.set_result(queue_payload) + with patch.object(client, "queue_endpoint_request", return_value=fut): + with pytest.raises(Exception, match="No response from /session/create"): + await client.start_endpoint_session(ep) + + async def test_start_endpoint_session_wraps_when_response_not_mapping( + self, client, make_serverless_endpoint + ) -> None: + """Non-dict ``response`` is rejected and wrapped as ``Failed to create session``.""" + ep = make_serverless_endpoint(client) + fut = ServerlessRequest() + fut.set_result( + { + "ok": True, + "response": [], + "url": "https://w", + "auth_data": {"k": "v"}, + } + ) + with patch.object(client, "queue_endpoint_request", return_value=fut): + with pytest.raises(Exception, match="Failed to create session"): + await client.start_endpoint_session(ep) + + +@pytest.mark.asyncio +class TestStartEndpointSessionQueueContract: + """Ensure session creation request matches the worker API contract.""" + + async def test_start_endpoint_session_forwards_on_close_and_cost_to_queue( + self, client, make_serverless_endpoint + ) -> None: + ep = make_serverless_endpoint(client) + captured: dict = {} + + def _capture_queue(**kwargs): + captured.update(kwargs) + fut = ServerlessRequest() + fut.set_result( + { + "ok": True, + "response": {"session_id": "new", "expiration": "ex"}, + "url": "https://w", + "auth_data": {"t": 1}, + } + ) + return fut + + with patch.object(client, "queue_endpoint_request", side_effect=_capture_queue): + await client.start_endpoint_session( + ep, + cost=77, + lifetime=88.0, + on_close_route="/bye", + on_close_payload={"reason": "idle"}, + timeout=12.0, + ) + + assert captured["endpoint"] is ep + assert captured["worker_route"] == "/session/create" + assert captured["cost"] == 77 + assert captured["timeout"] == 12.0 + wp = captured["worker_payload"] + assert wp["lifetime"] == 88.0 + assert wp["on_close_route"] == "/bye" + assert wp["on_close_payload"] == {"reason": "idle"} + + +# =========================================================================== +# Gap-closing tests – functionality not covered by the tests above +# =========================================================================== + + +class TestServerlessRequestExceptionPath: + """ServerlessRequest.then silences exceptions instead of forwarding them.""" + + @pytest.mark.asyncio + async def test_then_does_not_invoke_callback_when_future_has_exception( + self, + ) -> None: + """ + Verifies that .then callback is NOT called when the future resolves with an exception. + + This test verifies by: + 1. Attaching a MagicMock callback via .then + 2. Setting an exception on the future + 3. Yielding to the event loop and asserting the callback was not called + + Assumptions: + - The _done wrapper prints the exception and returns early without calling callback + """ + cb = MagicMock() + req = ServerlessRequest() + req.then(cb) + req.set_exception(RuntimeError("oops")) + await asyncio.sleep(0) + cb.assert_not_called() + + +class TestServerlessInitEdgeCases: + """Serverless.__init__ branches not covered by the parametrised instance tests.""" + + def test_unknown_instance_falls_back_to_prod_urls(self) -> None: + """ + Verifies that an unrecognised instance name falls through to the prod URLs. + + This test verifies by: + 1. Constructing Serverless with instance='staging' (not a known value) + 2. Asserting autoscaler_url and vast_web_url match prod defaults + + Assumptions: + - match/case _: branch uses run.vast.ai / console.vast.ai + """ + sl = Serverless(api_key="k", instance="staging") + assert "run.vast.ai" in sl.autoscaler_url + assert "console.vast.ai" in sl.vast_web_url + + def test_debug_mode_adds_stream_handler_and_disables_propagation(self) -> None: + """ + Verifies debug=True attaches a StreamHandler, sets DEBUG level, and stops propagation. + + This test verifies by: + 1. Constructing Serverless(debug=True) + 2. Checking logger.propagate is False and a StreamHandler is present + + Assumptions: + - Non-debug path sets propagate=True; debug path sets it False + """ + sl = Serverless(api_key="k", debug=True) + try: + assert sl.logger.propagate is False + assert sl.logger.level == logging.DEBUG + assert any(isinstance(h, logging.StreamHandler) for h in sl.logger.handlers) + finally: + while sl.logger.handlers: + sl.logger.removeHandler(sl.logger.handlers[0]) + + +class TestServerlessSessionLifecycle: + """close() and is_open() with a real (mocked) aiohttp session.""" + + @pytest.mark.asyncio + async def test_close_awaits_aiohttp_session_close_when_open(self, client) -> None: + """ + Verifies close() awaits session.close() when the internal session is open. + + This test verifies by: + 1. Attaching a mock session with closed=False + 2. Awaiting client.close() + 3. Asserting session.close was awaited once + + Assumptions: + - close() checks _session and not _session.closed before closing + """ + mock_sess = MagicMock() + mock_sess.closed = False + mock_sess.close = AsyncMock() + client._session = mock_sess + await client.close() + mock_sess.close.assert_awaited_once() + + @pytest.mark.asyncio + async def test_close_skips_when_session_already_closed(self, client) -> None: + """ + Verifies close() is a no-op when the internal session is already closed. + + This test verifies by: + 1. Attaching a mock session with closed=True + 2. Awaiting client.close() + 3. Asserting session.close was NOT called + + Assumptions: + - Guard: `if self._session and not self._session.closed` + """ + mock_sess = MagicMock() + mock_sess.closed = True + mock_sess.close = AsyncMock() + client._session = mock_sess + await client.close() + mock_sess.close.assert_not_awaited() + + def test_is_open_true_when_session_exists_and_not_closed(self, client) -> None: + """ + Verifies is_open() returns True when _session exists and is not closed. + + This test verifies by: + 1. Attaching a mock session with closed=False + 2. Calling is_open() synchronously + 3. Asserting True + + Assumptions: + - is_open checks _session is not None and not closed + """ + mock_sess = MagicMock() + mock_sess.closed = False + client._session = mock_sess + assert client.is_open() is True + + +@pytest.mark.asyncio +class TestServerlessGetSession: + """_get_session creates, reuses, and recreates the aiohttp ClientSession.""" + + async def test_get_session_creates_when_none(self, client) -> None: + """ + Verifies _get_session builds a new ClientSession when _session is None. + + This test verifies by: + 1. Patching get_ssl_context, aiohttp.TCPConnector, aiohttp.ClientSession + 2. Calling _get_session() + 3. Asserting the mock session is stored and returned + + Assumptions: + - TCPConnector is created with ssl=None (mocked context) + - ClientSession is called with connector= + """ + mock_connector = MagicMock() + mock_aio_session = MagicMock() + mock_aio_session.closed = False + with ( + patch.object( + client, "get_ssl_context", new_callable=AsyncMock, return_value=None + ), + patch( + "vastai.serverless.client.client.aiohttp.TCPConnector", + return_value=mock_connector, + ), + patch( + "vastai.serverless.client.client.aiohttp.ClientSession", + return_value=mock_aio_session, + ) as mock_cs, + ): + result = await client._get_session() + assert result is mock_aio_session + assert client._session is mock_aio_session + mock_cs.assert_called_once_with(connector=mock_connector) + + async def test_get_session_reuses_existing_open_session(self, client) -> None: + """ + Verifies _get_session returns the existing session when it is open. + + This test verifies by: + 1. Assigning a mock open session to client._session + 2. Calling _get_session() + 3. Asserting the same object is returned without creating a new one + + Assumptions: + - The if-branch is skipped when session exists and is not closed + """ + existing = MagicMock() + existing.closed = False + client._session = existing + result = await client._get_session() + assert result is existing + + async def test_get_session_recreates_when_closed(self, client) -> None: + """ + Verifies _get_session creates a fresh session when the old one is closed. + + This test verifies by: + 1. Assigning a closed mock session + 2. Calling _get_session() + 3. Asserting a new session is created and stored + + Assumptions: + - `self._session.closed == True` triggers session recreation + """ + old = MagicMock() + old.closed = True + client._session = old + new_sess = MagicMock() + new_sess.closed = False + with ( + patch.object( + client, "get_ssl_context", new_callable=AsyncMock, return_value=None + ), + patch( + "vastai.serverless.client.client.aiohttp.TCPConnector", + return_value=MagicMock(), + ), + patch( + "vastai.serverless.client.client.aiohttp.ClientSession", + return_value=new_sess, + ), + ): + result = await client._get_session() + assert result is new_sess + + +@pytest.mark.asyncio +class TestServerlessGetEndpointsExceptionWrapping: + """get_endpoints wraps _make_request exceptions.""" + + async def test_get_endpoints_wraps_make_request_exception(self, client) -> None: + """ + Verifies get_endpoints wraps a _make_request transport failure in Exception. + + This test verifies by: + 1. Making _make_request raise RuntimeError + 2. Asserting Exception with 'Failed to get endpoints' message + + Assumptions: + - except clause at lines 166-167 converts any error to a clear message + """ + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + side_effect=RuntimeError("network down"), + ): + with pytest.raises(Exception, match="Failed to get endpoints"): + await client.get_endpoints() + + +@pytest.mark.asyncio +class TestGetEndpointWorkersErrors: + """get_endpoint_workers HTTP failure and unexpected-type paths.""" + + async def test_raises_on_non_200_http_status( + self, client, make_mock_http_response, make_serverless_endpoint + ) -> None: + """ + Verifies get_endpoint_workers raises RuntimeError on non-200 HTTP. + + This test verifies by: + 1. Returning status=503 from _session.post + 2. Asserting RuntimeError mentioning 'get_endpoint_workers failed' + + Assumptions: + - post() is used as async-context-manager; make_mock_http_response provides that + """ + mock_sess = MagicMock() + mock_sess.post = MagicMock( + return_value=make_mock_http_response(status=503, text="service unavailable") + ) + client._session = mock_sess + ep = make_serverless_endpoint(client) + with pytest.raises(RuntimeError, match="get_endpoint_workers failed"): + await client.get_endpoint_workers(ep) + + async def test_raises_on_unexpected_response_type( + self, client, make_mock_http_response, make_serverless_endpoint + ) -> None: + """ + Verifies get_endpoint_workers raises RuntimeError when response is not list or dict. + + This test verifies by: + 1. Returning json_data='unexpected-string' from the mock response + 2. Asserting RuntimeError mentioning 'Unexpected response type' + + Assumptions: + - isinstance(data, dict) is False; isinstance(data, list) is False for a str + """ + mock_sess = MagicMock() + mock_sess.post = MagicMock( + return_value=make_mock_http_response( + status=200, json_data="unexpected-string" + ) + ) + client._session = mock_sess + ep = make_serverless_endpoint(client) + with pytest.raises(RuntimeError, match="Unexpected response type"): + await client.get_endpoint_workers(ep) + + +@pytest.mark.asyncio +class TestTimeoutErrorPropagation: + """asyncio.TimeoutError must propagate through all three session API methods.""" + + async def test_get_endpoint_session_propagates_timeout( + self, client, make_serverless_endpoint + ) -> None: + """ + Verifies get_endpoint_session re-raises asyncio.TimeoutError. + + This test verifies by: + 1. Making _make_request raise asyncio.TimeoutError + 2. Asserting the same exception type escapes get_endpoint_session + + Assumptions: + - `except asyncio.TimeoutError: raise` at line 249 is the re-raise path + """ + ep = make_serverless_endpoint(client) + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + side_effect=asyncio.TimeoutError(), + ): + with pytest.raises(asyncio.TimeoutError): + await client.get_endpoint_session(ep, 1, {"url": "https://w"}) + + async def test_end_endpoint_session_propagates_timeout( + self, client, make_serverless_endpoint, make_serverless_bound_session + ) -> None: + """ + Verifies end_endpoint_session re-raises asyncio.TimeoutError. + + This test verifies by: + 1. Making _make_request raise asyncio.TimeoutError + 2. Asserting it escapes end_endpoint_session + + Assumptions: + - `except asyncio.TimeoutError: raise` at line 282 is the re-raise path + """ + ep = make_serverless_endpoint(client) + sess = make_serverless_bound_session( + client, + endpoint=ep, + session_id="s", + lifetime=1.0, + url="https://worker/x", + auth_data={}, + ) + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + side_effect=asyncio.TimeoutError(), + ): + with pytest.raises(asyncio.TimeoutError): + await client.end_endpoint_session(sess) + + async def test_start_endpoint_session_propagates_timeout( + self, client, make_serverless_endpoint + ) -> None: + """ + Verifies start_endpoint_session re-raises asyncio.TimeoutError from queue result. + + This test verifies by: + 1. Resolving the ServerlessRequest with an asyncio.TimeoutError exception + 2. Asserting it escapes start_endpoint_session + + Assumptions: + - `except asyncio.TimeoutError: raise` at line 324 handles this + """ + ep = make_serverless_endpoint(client) + fut = ServerlessRequest() + fut.set_exception(asyncio.TimeoutError("timed out")) + with patch.object(client, "queue_endpoint_request", return_value=fut): + with pytest.raises(asyncio.TimeoutError): + await client.start_endpoint_session(ep) + + +@pytest.mark.asyncio +class TestQueueEndpointRequestRoutingPath: + """queue_endpoint_request without a session: _route polling, error recovery, retries.""" + + async def test_no_session_immediate_ready_returns_success( + self, + client_with_session, + make_serverless_endpoint, + make_route_response_mock, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Verifies the no-session path completes when _route is immediately READY. + + This test verifies by: + 1. Making _route return a READY RouteResponse + 2. Making _make_request return ok JSON + 3. Asserting the future resolves with the expected response and url + + Assumptions: + - Session-less path calls endpoint._route to get worker_url and auth_data + """ + ep = make_serverless_endpoint(client_with_session) + ready = make_route_response_mock( + status="READY", url="https://w/", request_idx=5 + ) + with ( + patch.object(ep, "_route", new_callable=AsyncMock, return_value=ready), + patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": True, "json": {"result": "done"}}, + ), + ): + result = await client_with_session.queue_endpoint_request( + ep, "/predict", {"x": 1} + ) + assert result["ok"] is True + assert result["response"] == {"result": "done"} + assert result["url"] == "https://w/" + + async def test_no_session_polls_waiting_then_ready( + self, + client_with_session, + make_serverless_endpoint, + make_route_response_mock, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Verifies the no-session polling loop transitions from WAITING to READY. + + This test verifies by: + 1. First _route call returns WAITING; second returns READY + 2. Asserting the future eventually resolves ok + + Assumptions: + - while route.status != 'READY' loop re-calls _route and sleeps (sleep is mocked) + """ + ep = make_serverless_endpoint(client_with_session) + waiting = make_route_response_mock(status="WAITING", request_idx=1) + ready = make_route_response_mock( + status="READY", url="https://w/", request_idx=1 + ) + with ( + patch.object( + ep, "_route", new_callable=AsyncMock, side_effect=[waiting, ready] + ), + patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": True, "json": {"result": "ok"}}, + ), + ): + result = await client_with_session.queue_endpoint_request( + ep, "/predict", {"x": 1} + ) + assert result["ok"] is True + + async def test_no_session_times_out_before_route( + self, + client_with_session, + make_serverless_endpoint, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Verifies queue_endpoint_request raises TimeoutError when elapsed >= timeout before _route. + + This test verifies by: + 1. Patching time.time: first 2 calls return 0.0 (ServerlessRequest init + start_time); + all subsequent calls return 999.0 (timeout condition + error-message formatting + any extras) + 2. Asserting asyncio.TimeoutError propagates from the future + + Assumptions: + - Timeout check runs at top of while loop before calling _route + - ``queue_endpoint_request`` logs ``Queued endpoint request`` synchronously; a LogRecord + calls ``time.time()`` unless ``info`` is stubbed, which would otherwise desynchronize + the clock mock (CI often enables logging where local runs skip ``info``). + """ + ep = make_serverless_endpoint(client_with_session) + time_seq = itertools.chain((0.0,), itertools.repeat(999.0)) + with ( + patch.object(client_with_session.logger, "info"), + patch.object(client_with_session.logger, "disabled", True), + patch( + "vastai.serverless.client.client.time.time", + side_effect=lambda: next(time_seq), + ), + ): + fut = client_with_session.queue_endpoint_request( + ep, "/predict", {"x": 1}, timeout=1.0 + ) + with pytest.raises(asyncio.TimeoutError): + await fut + + async def test_session_connector_error_marks_session_closed_and_raises( + self, + client_with_session, + make_serverless_endpoint, + make_serverless_bound_session, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Verifies ConnectorError on a session-bound request marks session.open=False and raises. + + This test verifies by: + 1. Patching _make_request to raise ClientConnectorError + 2. Asserting ConnectionError escapes the future + 3. Asserting session.open is False + + Assumptions: + - Session-bound path cannot re-route; exception is fatal for the session + """ + ep = make_serverless_endpoint(client_with_session) + sess = make_serverless_bound_session( + client_with_session, endpoint=ep, session_id="s" + ) + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + side_effect=aiohttp.ClientConnectorError(MagicMock(), OSError("gone")), + ): + with pytest.raises(ConnectionError, match="Session worker unavailable"): + await client_with_session.queue_endpoint_request( + ep, "/predict", {"x": 1}, session=sess + ) + assert sess.open is False + + async def test_no_session_connector_error_retries_on_new_route( + self, + client_with_session, + make_serverless_endpoint, + make_route_response_mock, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Verifies ConnectorError without a session triggers a retry via a new route. + + This test verifies by: + 1. First _make_request call raises ClientConnectorError + 2. Second _make_request call returns success + 3. Asserting the future resolves ok + + Assumptions: + - No-session path resets request_idx and re-calls _route on ConnectorError + """ + ep = make_serverless_endpoint(client_with_session) + ready = make_route_response_mock( + status="READY", url="https://w/", request_idx=1 + ) + with ( + patch.object(ep, "_route", new_callable=AsyncMock, return_value=ready), + patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + side_effect=[ + aiohttp.ClientConnectorError(MagicMock(), OSError("gone")), + {"ok": True, "json": {"result": "retried"}}, + ], + ), + ): + result = await client_with_session.queue_endpoint_request( + ep, "/predict", {"x": 1} + ) + assert result["ok"] is True + assert result["response"] == {"result": "retried"} + + async def test_non_ok_non_retryable_returns_raw_http_result( + self, + client_with_session, + make_serverless_endpoint, + make_serverless_bound_session, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Verifies a non-ok, non-retryable response is returned as a raw result dict. + + This test verifies by: + 1. Making _make_request return ok=False, retryable=False + 2. Asserting the future resolves with ok=False and the correct status + + Assumptions: + - When retry=False or retryable=False, the raw HTTP result is set on the future + """ + ep = make_serverless_endpoint(client_with_session) + sess = make_serverless_bound_session(client_with_session, endpoint=ep) + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={ + "ok": False, + "status": 422, + "text": "invalid input", + "json": {"error": "bad"}, + "retryable": False, + }, + ): + result = await client_with_session.queue_endpoint_request( + ep, "/predict", {"x": 1}, session=sess, retry=False + ) + assert result["ok"] is False + assert result["status"] == 422 + assert result["response"] == {"error": "bad"} + + async def test_retryable_result_retries_then_succeeds( + self, + client_with_session, + make_serverless_endpoint, + make_serverless_bound_session, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Verifies a retryable non-ok response causes a retry and eventually succeeds. + + This test verifies by: + 1. First _make_request returns ok=False, retryable=True + 2. Second _make_request returns ok=True + 3. Asserting the future resolves ok + + Assumptions: + - retry=True and retryable=True triggers sleep + continue; sleep is mocked instant + """ + ep = make_serverless_endpoint(client_with_session) + sess = make_serverless_bound_session(client_with_session, endpoint=ep) + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + side_effect=[ + { + "ok": False, + "status": 503, + "text": "overloaded", + "json": None, + "retryable": True, + }, + {"ok": True, "json": {"done": True}, "status": 200, "text": ""}, + ], + ): + result = await client_with_session.queue_endpoint_request( + ep, "/predict", {"x": 1}, session=sess, retry=True + ) + assert result["ok"] is True + assert result["response"] == {"done": True} + + async def test_exception_from_route_sets_future_exception( + self, + client_with_session, + make_serverless_endpoint, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Verifies an exception escaping _route is captured as the future's exception. + + This test verifies by: + 1. Making endpoint._route raise RuntimeError + 2. Asserting the awaited future raises the same RuntimeError + + Assumptions: + - Outer except Exception in task() calls request.set_exception(ex) + """ + ep = make_serverless_endpoint(client_with_session) + with patch.object( + ep, + "_route", + new_callable=AsyncMock, + side_effect=RuntimeError("routing failed"), + ): + with pytest.raises(RuntimeError, match="routing failed"): + await client_with_session.queue_endpoint_request(ep, "/predict", {}) + + async def test_cancel_propagates_to_background_task( + self, + client_with_session, + make_serverless_endpoint, + make_serverless_bound_session, + ) -> None: + """ + Verifies cancelling the returned future also cancels the background asyncio task. + + This test verifies by: + 1. Stalling _make_request on an asyncio.Future (never resolves until cancelled) + 2. Cancelling the ServerlessRequest future after the bg task has started + 3. Asserting the future ends in cancelled state + + Assumptions: + - _propagate_cancel done-callback calls bg_task.cancel() (line 512) + """ + ep = make_serverless_endpoint(client_with_session) + sess = make_serverless_bound_session(client_with_session, endpoint=ep) + reached = asyncio.Event() + + async def _stall(*args, **kwargs): + reached.set() + await asyncio.Future() # blocks until the task is cancelled + + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + side_effect=_stall, + ): + fut = client_with_session.queue_endpoint_request( + ep, "/predict", {}, session=sess + ) + await reached.wait() + fut.cancel() + + async def _until_cancelled() -> None: + while not fut.cancelled(): + await asyncio.sleep(0) + + await asyncio.wait_for(_until_cancelled(), timeout=2.0) + assert fut.cancelled() + + async def test_no_session_ready_with_zero_request_idx_still_completes( + self, + client_with_session, + make_serverless_endpoint, + make_route_response_mock, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Verifies routing succeeds when autoscaler returns READY but request_idx is 0. + + The client logs a missing-index warning for falsy request_idx; work still proceeds. + """ + ep = make_serverless_endpoint(client_with_session) + ready = make_route_response_mock( + status="READY", url="https://w/", request_idx=0 + ) + with ( + patch.object(ep, "_route", new_callable=AsyncMock, return_value=ready), + patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": True, "json": {"v": 0}, "status": 200, "text": ""}, + ), + ): + result = await client_with_session.queue_endpoint_request( + ep, "/predict", {"x": 1} + ) + assert result["ok"] is True + assert result["response"] == {"v": 0} + assert result["request_idx"] == 0 + + async def test_no_session_times_out_while_polling_for_ready( + self, + client_with_session, + make_serverless_endpoint, + make_route_response_mock, + patch_serverless_queue_async_stubs, + ) -> None: + """Timeout inside the WAITING→READY poll loop surfaces as asyncio.TimeoutError.""" + ep = make_serverless_endpoint(client_with_session) + waiting = make_route_response_mock(status="WAITING", request_idx=1) + + def fake_time(): + fake_time.n += 1 + # First four reads stay at t=0; thereafter pretend we are past the deadline. + return 0.0 if fake_time.n <= 4 else 100.0 + + fake_time.n = 0 + + with ( + patch.object(client_with_session.logger, "info"), + patch.object(client_with_session.logger, "debug"), + patch.object(client_with_session.logger, "disabled", True), + patch.object(ep, "_route", new_callable=AsyncMock, return_value=waiting), + patch("vastai.serverless.client.client.time.time", side_effect=fake_time), + ): + fut = client_with_session.queue_endpoint_request( + ep, "/predict", {"x": 1}, timeout=5.0 + ) + with pytest.raises(asyncio.TimeoutError, match="become ready"): + await fut + + async def test_no_session_worker_generic_exception_retries_then_succeeds( + self, + client_with_session, + make_serverless_endpoint, + make_route_response_mock, + patch_serverless_queue_async_stubs, + ) -> None: + """ + Non-transport exceptions from _make_request trigger a retry (outer loop), not failure. + """ + ep = make_serverless_endpoint(client_with_session) + ready = make_route_response_mock( + status="READY", url="https://w/", request_idx=2 + ) + with ( + patch.object(ep, "_route", new_callable=AsyncMock, return_value=ready), + patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + side_effect=[ + ValueError("worker glitch"), + { + "ok": True, + "json": {"recovered": True}, + "status": 200, + "text": "", + }, + ], + ), + ): + result = await client_with_session.queue_endpoint_request( + ep, "/predict", {"x": 1} + ) + assert result["ok"] is True + assert result["response"] == {"recovered": True} + + async def test_retryable_worker_response_times_out_before_retry( + self, + client_with_session, + make_serverless_endpoint, + make_serverless_bound_session, + patch_serverless_queue_async_stubs, + ) -> None: + """If overall timeout is exhausted before a retryable sleep, raise TimeoutError.""" + ep = make_serverless_endpoint(client_with_session) + sess = make_serverless_bound_session(client_with_session, endpoint=ep) + + def fake_time(): + fake_time.n += 1 + return 0.0 if fake_time.n <= 3 else 2.0 + + fake_time.n = 0 + + with ( + patch.object(client_with_session.logger, "info"), + patch.object(client_with_session.logger, "debug"), + patch.object(client_with_session.logger, "disabled", True), + patch("vastai.serverless.client.client.time.time", side_effect=fake_time), + patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={ + "ok": False, + "retryable": True, + "status": 503, + "text": "busy", + "json": None, + }, + ), + ): + fut = client_with_session.queue_endpoint_request( + ep, "/p", {}, session=sess, retry=True, timeout=1.0 + ) + with pytest.raises(asyncio.TimeoutError, match="Request timed out"): + await fut + + async def test_session_stream_true_places_stream_body_in_response( + self, + client_with_session, + make_serverless_endpoint, + make_serverless_bound_session, + patch_serverless_queue_async_stubs, + ) -> None: + """When stream=True, the future result uses result['stream'] as the response payload.""" + ep = make_serverless_endpoint(client_with_session) + sess = make_serverless_bound_session(client_with_session, endpoint=ep) + stream_body = object() + with patch( + "vastai.serverless.client.client._make_request", + new_callable=AsyncMock, + return_value={"ok": True, "stream": stream_body, "status": 200, "text": ""}, + ): + result = await client_with_session.queue_endpoint_request( + ep, "/predict", {}, session=sess, stream=True + ) + assert result["ok"] is True + assert result["response"] is stream_body diff --git a/tests/serverless/test_session.py b/tests/serverless/test_session.py new file mode 100644 index 00000000..0de8172d --- /dev/null +++ b/tests/serverless/test_session.py @@ -0,0 +1,451 @@ +"""Unit tests for vastai.serverless.client.session.Session. + +All endpoint I/O is mocked; no real network or API calls. +""" + +from __future__ import annotations + +import logging +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from vastai.serverless.client.session import Session + + +class TestSessionInit: + """Verify Session constructor validation and attribute assignment.""" + + def test_init_raises_when_endpoint_is_none(self) -> None: + """ + Verifies that Session rejects a None endpoint. + + This test verifies by: + 1. Calling Session.__init__ with endpoint=None and other valid fields + 2. Asserting ValueError with the expected message + + Assumptions: + - Validation order checks endpoint before other fields + """ + with pytest.raises(ValueError, match="empty endpoint"): + Session( + endpoint=None, + session_id="s", + lifetime=1.0, + expiration="e", + url="https://u", + auth_data={}, + ) + + def test_init_raises_when_session_id_is_none( + self, make_mock_endpoint_for_session + ) -> None: + """ + Verifies that Session rejects a None session_id. + + This test verifies by: + 1. Calling Session with a mock endpoint and session_id=None + 2. Asserting ValueError with the expected message + + Assumptions: + - Mock endpoint satisfies non-None endpoint check + """ + with pytest.raises(ValueError, match="empty session_id"): + Session( + endpoint=make_mock_endpoint_for_session(), + session_id=None, + lifetime=1.0, + expiration="e", + url="https://u", + auth_data={}, + ) + + def test_init_accepts_empty_string_session_id_documents_contract( + self, make_mock_endpoint_for_session, make_client_session + ) -> None: + """ + Documents that only ``session_id is None`` is rejected; falsy strings are allowed. + + Callers cannot infer from the ``None`` check alone that ``""`` is invalid. + """ + ep = make_mock_endpoint_for_session() + session = make_client_session(endpoint=ep, session_id="") + assert session.session_id == "" + + def test_init_raises_when_url_is_none(self, make_mock_endpoint_for_session) -> None: + """ + Verifies that Session rejects a None url. + + This test verifies by: + 1. Calling Session with valid endpoint and session_id but url=None + 2. Asserting ValueError with the expected message + + Assumptions: + - Endpoint and session_id are valid so url validation is reached + """ + with pytest.raises(ValueError, match="empty url"): + Session( + endpoint=make_mock_endpoint_for_session(), + session_id="s", + lifetime=1.0, + expiration="e", + url=None, + auth_data={}, + ) + + def test_init_sets_attributes_and_open_state( + self, make_mock_endpoint_for_session, make_client_session + ) -> None: + """ + Verifies that a valid Session stores constructor arguments and starts open. + + This test verifies by: + 1. Building a Session with known endpoint, ids, lifetime, expiration, url, auth + 2. Asserting fields match and open is True; optional on_close_* defaults + + Assumptions: + - Mock endpoint is non-None; on_close_route/payload omitted default to None + """ + ep = make_mock_endpoint_for_session() + session = make_client_session( + endpoint=ep, + session_id="abc", + lifetime=120.5, + expiration="exp", + url="https://x", + auth_data={"k": "v"}, + ) + assert session.endpoint is ep + assert session.session_id == "abc" + assert session.lifetime == 120.5 + assert session.expiration == "exp" + assert session.url == "https://x" + assert session.auth_data == {"k": "v"} + assert session.open is True + assert session.on_close_route is None + assert session.on_close_payload is None + + def test_init_sets_on_close_route_and_payload( + self, make_mock_endpoint_for_session, make_client_session + ) -> None: + """Optional teardown hints from ``start_endpoint_session`` are stored on Session.""" + ep = make_mock_endpoint_for_session() + session = make_client_session( + endpoint=ep, + on_close_route="/cleanup", + on_close_payload={"reason": "idle"}, + ) + assert session.on_close_route == "/cleanup" + assert session.on_close_payload == {"reason": "idle"} + + +class TestSessionAsyncContext: + """Verify async context manager behavior.""" + + @pytest.mark.asyncio + async def test_aenter_returns_self(self, make_client_session) -> None: + """ + Verifies that __aenter__ returns the Session instance. + + This test verifies by: + 1. Calling __aenter__ on a Session + 2. Asserting the return value is the same object + + Assumptions: + - No I/O occurs in __aenter__ + """ + session = make_client_session() + entered = await session.__aenter__() + assert entered is session + + @pytest.mark.asyncio + async def test_aexit_returns_false_so_exceptions_propagate( + self, make_client_session + ) -> None: + """``__aexit__`` must not swallow exceptions from the ``async with`` body.""" + session = make_client_session() + await session.__aenter__() + assert await session.__aexit__(None, None, None) is False + + @pytest.mark.asyncio + async def test_aexit_awaits_close(self, session_on_mock_endpoint) -> None: + """ + Verifies that __aexit__ invokes close so the session is shut down. + + This test verifies by: + 1. Using a mock endpoint with AsyncMock close_session + 2. Awaiting __aexit__(None, None, None) + 3. Asserting close_session was awaited and session.open is False + + Assumptions: + - close() delegates to endpoint.close_session as implemented + """ + ep, session = session_on_mock_endpoint + await session.__aexit__(None, None, None) + ep.close_session.assert_awaited_once_with(session) + assert session.open is False + + +class TestSessionIsOpen: + """Verify session_healthcheck integration.""" + + @pytest.mark.asyncio + async def test_is_open_returns_true_and_sets_open_when_healthcheck_true( + self, session_on_mock_endpoint + ) -> None: + """ + Verifies is_open delegates to endpoint.session_healthcheck and updates open. + + This test verifies by: + 1. Configuring session_healthcheck to return True + 2. Calling await session.is_open() + 3. Asserting return value True and session.open is True + + Assumptions: + - session_healthcheck receives the Session instance as argument + """ + ep, session = session_on_mock_endpoint + ep.session_healthcheck = AsyncMock(return_value=True) + result = await session.is_open() + assert result is True + assert session.open is True + ep.session_healthcheck.assert_awaited_once_with(session) + + @pytest.mark.asyncio + async def test_is_open_returns_false_and_sets_open_when_healthcheck_false( + self, session_on_mock_endpoint + ) -> None: + """ + Verifies is_open reflects a failed health check. + + This test verifies by: + 1. Configuring session_healthcheck to return False + 2. Calling await session.is_open() + 3. Asserting return value False and session.open is False + + Assumptions: + - Implementation assigns self.open from the healthcheck result + """ + ep, session = session_on_mock_endpoint + ep.session_healthcheck = AsyncMock(return_value=False) + result = await session.is_open() + assert result is False + assert session.open is False + + +class TestSessionClose: + """Verify close() idempotency and error handling.""" + + @pytest.mark.asyncio + async def test_close_returns_none_when_already_closed( + self, session_on_mock_endpoint + ) -> None: + """ + Verifies close is a no-op when the session is already marked closed. + + This test verifies by: + 1. Setting session.open to False + 2. Awaiting close() + 3. Asserting None return and close_session not called + + Assumptions: + - Early return uses self.open before touching _closing + """ + ep, session = session_on_mock_endpoint + session.open = False + out = await session.close() + assert out is None + ep.close_session.assert_not_awaited() + + @pytest.mark.asyncio + async def test_close_awaits_endpoint_close_session_and_clears_open( + self, session_on_mock_endpoint + ) -> None: + """ + Verifies close calls endpoint.close_session and sets open to False. + + This test verifies by: + 1. Awaiting close() on an open session + 2. Asserting close_session awaited once with session and open is False + + Assumptions: + - close_session completes without raising + """ + ep, session = session_on_mock_endpoint + await session.close() + ep.close_session.assert_awaited_once_with(session) + assert session.open is False + + @pytest.mark.asyncio + async def test_close_sets_open_false_when_close_session_raises( + self, session_on_mock_endpoint, caplog + ) -> None: + """ + Verifies close still clears open if endpoint.close_session fails. + + This test verifies by: + 1. Making close_session raise RuntimeError + 2. Awaiting close() and asserting session.open is False + 3. Asserting a warning was logged so logging regressions are visible + + Assumptions: + - finally block in close() always sets open False + """ + ep, session = session_on_mock_endpoint + ep.close_session = AsyncMock(side_effect=RuntimeError("network")) + logging.getLogger("vastai").propagate = True + with caplog.at_level( + logging.WARNING, logger="vastai.serverless.client.session" + ): + await session.close() + assert session.open is False + assert any( + "Error closing session" in r.message and "network" in r.message + for r in caplog.records + ) + + @pytest.mark.asyncio + async def test_close_second_call_does_not_await_close_session_again( + self, session_on_mock_endpoint + ) -> None: + """ + Verifies sequential close calls only hit the endpoint once. + + This test verifies by: + 1. Awaiting close() twice + 2. Asserting close_session await count is 1 + + Assumptions: + - After first close, open is False so second call returns immediately + """ + ep, session = session_on_mock_endpoint + await session.close() + await session.close() + assert ep.close_session.await_count == 1 + + @pytest.mark.asyncio + async def test_close_skips_when_closing_guard_set( + self, session_on_mock_endpoint + ) -> None: + """ + Verifies a second close while _closing is True does not call close_session again. + + This test verifies by: + 1. Setting _closing True while open remains True (simulates in-flight close) + 2. Awaiting close() + 3. Asserting close_session was not invoked + + Assumptions: + - Guard check uses _closing before starting work; used for re-entrancy + """ + ep, session = session_on_mock_endpoint + session._closing = True + await session.close() + ep.close_session.assert_not_awaited() + + +class TestSessionRequest: + """Verify request() forwards to the endpoint and handles 410.""" + + def test_request_raises_when_session_closed(self, make_client_session) -> None: + """ + Verifies request refuses immediately when the session is closed. + + This test verifies by: + 1. Setting session.open to False + 2. Calling request() synchronously + 3. Asserting ValueError before any await + + Assumptions: + - Closed check runs before building the inner coroutine + """ + session = make_client_session() + session.open = False + with pytest.raises(ValueError, match="closed session"): + session.request("/r", {"a": 1}) + + @pytest.mark.asyncio + async def test_request_awaitable_returns_endpoint_result( + self, session_on_mock_endpoint + ) -> None: + """ + Verifies awaiting request() returns the JSON dict from endpoint.request. + + This test verifies by: + 1. Configuring endpoint.request AsyncMock to return a known dict + 2. Awaiting session.request(...) + + Assumptions: + - endpoint.request is awaitable in tests via AsyncMock + """ + ep, session = session_on_mock_endpoint + ep.request = AsyncMock(return_value={"status": 200, "data": 42}) + coro = session.request("/path", {"x": 1}, cost=50, retry=False, stream=True) + result = await coro + assert result == {"status": 200, "data": 42} + ep.request.assert_awaited_once() + call_kw = ep.request.await_args.kwargs + assert call_kw["route"] == "/path" + assert call_kw["payload"] == {"x": 1} + assert call_kw["cost"] == 50 + assert call_kw["retry"] is False + assert call_kw["stream"] is True + assert call_kw["session"] is session + assert call_kw["serverless_request"] is None + + @pytest.mark.asyncio + async def test_request_passes_serverless_request_through( + self, session_on_mock_endpoint + ) -> None: + """ + Verifies request forwards serverless_request to endpoint.request. + + This test verifies by: + 1. Passing a sentinel object as serverless_request + 2. Awaiting the returned coroutine + 3. Asserting the same object appears in endpoint.request kwargs + + Assumptions: + - Session does not wrap or replace serverless_request + """ + ep, session = session_on_mock_endpoint + sr = MagicMock(name="serverless_request") + await session.request("/r", {}, serverless_request=sr) + assert ep.request.await_args.kwargs["serverless_request"] is sr + + @pytest.mark.asyncio + async def test_request_status_410_marks_closed_and_raises( + self, session_on_mock_endpoint + ) -> None: + """ + Verifies a 410 response marks the session closed and raises ValueError. + + This test verifies by: + 1. Returning {"status": 410} from endpoint.request + 2. Awaiting session.request(...) + 3. Asserting ValueError and session.open is False + + Assumptions: + - Wrapped handler treats HTTP gone (status 410) as a closed session + """ + ep, session = session_on_mock_endpoint + ep.request = AsyncMock(return_value={"status": 410}) + with pytest.raises(ValueError, match="closed session"): + await session.request("/r", {}) + assert session.open is False + + @pytest.mark.asyncio + @pytest.mark.parametrize("bad_result", [None, "not-a-dict", 404]) + async def test_request_propagates_attribute_error_when_result_not_mapping( + self, session_on_mock_endpoint, bad_result + ) -> None: + """ + ``_wrapped_request`` uses ``result.get``; non-mapping results surface as AttributeError. + + Endpoint.request is expected to return a mapping with optional ``status``. + This documents current behavior; a stricter API might raise ``ValueError`` instead. + """ + ep, session = session_on_mock_endpoint + ep.request = AsyncMock(return_value=bad_result) + with pytest.raises(AttributeError): + await session.request("/r", {}) diff --git a/tests/serverless/test_worker_client_type.py b/tests/serverless/test_worker_client_type.py new file mode 100644 index 00000000..4dbe2681 --- /dev/null +++ b/tests/serverless/test_worker_client_type.py @@ -0,0 +1,268 @@ +"""Unit tests for vastai.serverless.client.worker.Worker dataclass and from_dict factory.""" +import pytest + +from vastai.serverless.client.worker import Worker + + +class TestWorkerFromDict: + """Verify Worker.from_dict parses dict input into Worker instances correctly.""" + + def test_from_dict_with_full_valid_dict_returns_worker_with_all_fields( + self, client_worker_dict + ) -> None: + """ + Verifies that from_dict creates a Worker with all fields when given a complete dict. + + This test verifies by: + 1. Using full_client_worker_dict fixture with all expected Worker fields + 2. Calling Worker.from_dict with that dict + 3. Asserting each field matches the input value + + Assumptions: + - full_client_worker_dict fixture provides valid data for all fields + """ + full_client_worker_dict = client_worker_dict("full") + worker = Worker.from_dict(full_client_worker_dict) + assert worker.id == full_client_worker_dict["id"] + assert worker.status == full_client_worker_dict["status"] + assert worker.cur_load == full_client_worker_dict["cur_load"] + assert worker.new_load == full_client_worker_dict["new_load"] + assert worker.cur_load_rolling_avg == full_client_worker_dict["cur_load_rolling_avg"] + assert worker.cur_perf == full_client_worker_dict["cur_perf"] + assert worker.perf == full_client_worker_dict["perf"] + assert worker.measured_perf == full_client_worker_dict["measured_perf"] + assert worker.dlperf == full_client_worker_dict["dlperf"] + assert worker.reliability == full_client_worker_dict["reliability"] + assert worker.reqs_working == full_client_worker_dict["reqs_working"] + assert worker.disk_usage == full_client_worker_dict["disk_usage"] + assert worker.loaded_at == full_client_worker_dict["loaded_at"] + assert worker.started_at == full_client_worker_dict["started_at"] + + def test_from_dict_with_minimal_dict_uses_defaults_for_missing_fields( + self, client_worker_dict + ) -> None: + """ + Verifies that from_dict uses default values for missing optional fields. + + This test verifies by: + 1. Using minimal_client_worker_dict with only required id field + 2. Calling Worker.from_dict + 3. Asserting numeric fields default to 0.0 or 0 and status defaults to "UNKNOWN" + + Assumptions: + - minimal_client_worker_dict provides id only + """ + minimal_client_worker_dict = client_worker_dict("minimal") + worker = Worker.from_dict(minimal_client_worker_dict) + assert worker.id == minimal_client_worker_dict["id"] + assert worker.status == "UNKNOWN" + assert worker.cur_load == 0.0 + assert worker.new_load == 0.0 + assert worker.cur_load_rolling_avg == 0.0 + assert worker.cur_perf == 0.0 + assert worker.perf == 0.0 + assert worker.measured_perf == 0.0 + assert worker.dlperf == 0.0 + assert worker.reliability == 0.0 + assert worker.reqs_working == 0 + assert worker.disk_usage == 0.0 + assert worker.loaded_at == 0.0 + assert worker.started_at == 0.0 + + def test_from_dict_with_status_none_uses_unknown( + self, client_worker_dict + ) -> None: + """ + Verifies that from_dict treats None or falsy status as "UNKNOWN". + + This test verifies by: + 1. Passing a dict with status=None + 2. Asserting worker.status == "UNKNOWN" + 3. Similarly for status="" + + Assumptions: + - d.get("status") or "UNKNOWN" handles None and empty string + """ + minimal_client_worker_dict = client_worker_dict("minimal") + worker_none = Worker.from_dict({**minimal_client_worker_dict, "status": None}) + assert worker_none.status == "UNKNOWN" + + worker_empty = Worker.from_dict({"id": 2, "status": ""}) + assert worker_empty.status == "UNKNOWN" + + def test_from_dict_with_extra_fields_ignores_them(self) -> None: + """ + Verifies that from_dict is resilient to extra fields in the input dict. + + This test verifies by: + 1. Passing a dict with extra keys not in the Worker schema + 2. Asserting Worker is created successfully with correct known fields + 3. Confirming no error is raised + + Assumptions: + - Extra fields are ignored per implementation comment + """ + data = { + "id": 10, + "status": "IDLE", + "extra_field": "ignored", + "another_unknown": 999, + } + worker = Worker.from_dict(data) + assert worker.id == 10 + assert worker.status == "IDLE" + + def test_from_dict_with_string_numeric_values_coerces_to_numbers(self) -> None: + """ + Verifies that from_dict coerces string numeric values to int/float. + + This test verifies by: + 1. Passing a dict with id, cur_load, etc. as strings + 2. Asserting Worker fields are proper int/float types with correct values + + Assumptions: + - int() and float() handle numeric strings correctly + """ + data = { + "id": "100", + "status": "RUNNING", + "cur_load": "0.75", + "reqs_working": "5", + } + worker = Worker.from_dict(data) + assert worker.id == 100 + assert isinstance(worker.id, int) + assert worker.cur_load == 0.75 + assert isinstance(worker.cur_load, float) + assert worker.reqs_working == 5 + assert isinstance(worker.reqs_working, int) + + def test_from_dict_with_missing_id_raises(self) -> None: + """ + Verifies that from_dict raises when id is missing. + + This test verifies by: + 1. Passing a dict without an id key + 2. Asserting TypeError is raised (int(None) raises TypeError) + + Assumptions: + - id has no default; missing id causes int(d.get("id")) to fail + """ + with pytest.raises(TypeError, match="int"): + Worker.from_dict({"status": "RUNNING"}) + + def test_from_dict_with_empty_dict_raises(self) -> None: + """ + Verifies that from_dict raises when given an empty dict. + + This test verifies by: + 1. Passing an empty dict + 2. Asserting TypeError is raised + + Assumptions: + - id is required and missing in empty dict + """ + with pytest.raises(TypeError): + Worker.from_dict({}) + + def test_from_dict_with_various_status_values_preserves_status( + self, client_worker_dict + ) -> None: + """ + Verifies that from_dict preserves non-empty status values. + + This test verifies by: + 1. Passing status values like "RUNNING", "IDLE", "LOADING" + 2. Asserting each is stored correctly + + Assumptions: + - status is passed through when truthy + """ + minimal_client_worker_dict = client_worker_dict("minimal") + for status in ("RUNNING", "IDLE", "LOADING", "OFFLINE"): + worker = Worker.from_dict({**minimal_client_worker_dict, "status": status}) + assert worker.status == status + + def test_from_dict_with_id_zero_accepted(self) -> None: + """ + Verifies that from_dict accepts id=0 as a valid worker id. + + This test verifies by: + 1. Passing a dict with id=0 + 2. Asserting Worker is created with id 0 + + Assumptions: + - id 0 is a valid worker identifier + """ + worker = Worker.from_dict({"id": 0, "status": "IDLE"}) + assert worker.id == 0 + + def test_from_dict_with_invalid_id_string_raises(self) -> None: + """ + Verifies that from_dict raises when id cannot be converted to int. + + This test verifies by: + 1. Passing a dict with id as non-numeric string + 2. Asserting ValueError is raised + + Assumptions: + - int() raises ValueError for invalid string input + """ + with pytest.raises((ValueError, TypeError)): + Worker.from_dict({"id": "not_a_number", "status": "RUNNING"}) + + def test_from_dict_with_invalid_float_value_raises( + self, client_worker_dict + ) -> None: + """ + Verifies that from_dict raises when a numeric field has invalid value. + + This test verifies by: + 1. Passing a dict with cur_load as non-numeric string (id from fixture) + 2. Asserting ValueError is raised + + Assumptions: + - float() raises ValueError for invalid string input + """ + minimal_client_worker_dict = client_worker_dict("minimal") + with pytest.raises(ValueError): + Worker.from_dict({**minimal_client_worker_dict, "cur_load": "not_a_number"}) + + def test_from_dict_with_negative_numeric_values_accepted( + self, client_worker_dict + ) -> None: + """ + Verifies that from_dict accepts negative values for numeric fields. + + This test verifies by: + 1. Extending minimal_client_worker_dict with negative cur_load, cur_perf + 2. Asserting Worker is created with those values + + Assumptions: + - Negative numbers are valid (e.g. for load metrics) + """ + minimal_client_worker_dict = client_worker_dict("minimal") + data = { + **minimal_client_worker_dict, + "status": "RUNNING", + "cur_load": -0.5, + "cur_perf": -1.0, + } + worker = Worker.from_dict(data) + assert worker.cur_load == -0.5 + assert worker.cur_perf == -1.0 + + def test_from_dict_with_integer_id_in_dict(self) -> None: + """ + Verifies that from_dict handles id passed as integer (not string). + + This test verifies by: + 1. Passing id as int 42 + 2. Asserting worker.id == 42 and is int type + + Assumptions: + - int() accepts int input and returns it unchanged + """ + worker = Worker.from_dict({"id": 42}) + assert worker.id == 42 + assert isinstance(worker.id, int) diff --git a/tests/serverless/test_worker_config.py b/tests/serverless/test_worker_config.py new file mode 100644 index 00000000..a2d418fd --- /dev/null +++ b/tests/serverless/test_worker_config.py @@ -0,0 +1,1186 @@ +"""Unit tests for vastai.serverless.server.worker components. + +Tests LogActionConfig, WorkerConfig, HandlerConfig, BenchmarkConfig, +EndpointHandlerFactory, and created handler/payload behavior. +Does not start the full Worker server. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from vastai.serverless.server.lib.data_types import LogAction, JsonDataException +from vastai.serverless.server.worker import ( + LogActionConfig, + WorkerConfig, + HandlerConfig, + BenchmarkConfig, + EndpointHandlerFactory, +) + + +class TestLogActionConfig: + """Verify LogActionConfig builds log_actions correctly.""" + + def test_log_actions_empty_when_no_config(self) -> None: + """ + Verifies that log_actions returns empty list when no actions configured. + + This test verifies by: + 1. Creating LogActionConfig with default (empty) lists + 2. Asserting log_actions is empty + + Assumptions: + - Default LogActionConfig has empty on_load, on_error, on_info + """ + config = LogActionConfig() + assert config.log_actions == [] + + def test_log_actions_includes_on_load_messages(self) -> None: + """ + Verifies that on_load messages are mapped to LogAction.ModelLoaded. + + This test verifies by: + 1. Creating LogActionConfig with on_load messages + 2. Asserting log_actions contains (ModelLoaded, msg) for each + + Assumptions: + - log_actions property builds list from on_load, on_error, on_info + """ + config = LogActionConfig(on_load=["Model loaded", "Ready"]) + actions = config.log_actions + assert (LogAction.ModelLoaded, "Model loaded") in actions + assert (LogAction.ModelLoaded, "Ready") in actions + assert len(actions) == 2 + + def test_log_actions_includes_on_error_messages(self) -> None: + """ + Verifies that on_error messages are mapped to LogAction.ModelError. + + This test verifies by: + 1. Creating LogActionConfig with on_error messages + 2. Asserting log_actions contains (ModelError, msg) for each + + Assumptions: + - log_actions property builds list from on_load, on_error, on_info + """ + config = LogActionConfig(on_error=["Error occurred"]) + actions = config.log_actions + assert (LogAction.ModelError, "Error occurred") in actions + + def test_log_actions_includes_on_info_messages(self) -> None: + """ + Verifies that on_info messages are mapped to LogAction.Info. + + This test verifies by: + 1. Creating LogActionConfig with on_info messages + 2. Asserting log_actions contains (Info, msg) for each + + Assumptions: + - log_actions property builds list from on_load, on_error, on_info + """ + config = LogActionConfig(on_info=["Info message"]) + actions = config.log_actions + assert (LogAction.Info, "Info message") in actions + + def test_log_actions_combines_all_action_types(self) -> None: + """ + Verifies that log_actions combines on_load, on_error, on_info in order. + + This test verifies by: + 1. Creating LogActionConfig with all three list types populated + 2. Asserting log_actions contains correct tuples in expected order + + Assumptions: + - extend order: on_load first, then on_error, then on_info + """ + config = LogActionConfig( + on_load=["load1"], + on_error=["err1"], + on_info=["info1"], + ) + actions = config.log_actions + assert actions == [ + (LogAction.ModelLoaded, "load1"), + (LogAction.ModelError, "err1"), + (LogAction.Info, "info1"), + ] + + +class TestEndpointHandlerFactory: + """Verify EndpointHandlerFactory creates handlers from WorkerConfig.""" + + def test_factory_with_empty_handlers_creates_default_route( + self, server_worker_config + ) -> None: + """ + Verifies that empty handlers list creates a default handler at /. + + This test verifies by: + 1. Creating EndpointHandlerFactory with config that has no handlers + 2. Asserting get_handler("/") returns a handler + 3. Asserting handler.endpoint == "/" + + Assumptions: + - minimal_worker_config fixture provides config with empty handlers + """ + factory = EndpointHandlerFactory(server_worker_config("minimal")) + handler = factory.get_handler("/") + assert handler is not None + assert handler.endpoint == "/" + + def test_get_handler_returns_none_for_unknown_route( + self, server_worker_config + ) -> None: + """ + Verifies that get_handler returns None for unregistered route. + + This test verifies by: + 1. Creating factory with minimal config + 2. Calling get_handler with unknown route + 3. Asserting result is None + + Assumptions: + - get_handler uses dict.get, returns None for missing key + """ + factory = EndpointHandlerFactory(server_worker_config("minimal")) + assert factory.get_handler("/unknown") is None + + def test_get_all_handlers_returns_copy(self, server_worker_config) -> None: + """ + Verifies that get_all_handlers returns a copy of handlers dict. + + This test verifies by: + 1. Creating factory and getting handlers + 2. Mutating the returned dict + 3. Asserting factory's internal state is unchanged + + Assumptions: + - get_all_handlers returns .copy() + """ + factory = EndpointHandlerFactory(server_worker_config("minimal")) + handlers = factory.get_all_handlers() + handlers["/"] = None + assert factory.get_handler("/") is not None + + def test_has_handlers_true_when_handlers_exist(self, server_worker_config) -> None: + """ + Verifies that has_handlers returns True when handlers are registered. + + This test verifies by: + 1. Creating factory with default handler + 2. Asserting has_handlers() is True + + Assumptions: + - minimal_worker_config creates default / handler + """ + factory = EndpointHandlerFactory(server_worker_config("minimal")) + assert factory.has_handlers() is True + + def test_model_server_base_url_formats_correctly( + self, server_worker_config + ) -> None: + """ + Verifies that model_server_base_url returns url:port format. + + This test verifies by: + 1. Creating factory with url and port + 2. Asserting model_server_base_url == "http://localhost:8000" + + Assumptions: + - minimal_worker_config has url and port set + """ + factory = EndpointHandlerFactory(server_worker_config("minimal")) + assert factory.model_server_base_url == "http://localhost:8000" + + def test_get_benchmark_handler_returns_none_when_no_handlers( + self, server_worker_config + ) -> None: + """ + Verifies that get_benchmark_handler returns None when _handlers is empty. + + This test verifies by: + 1. Creating factory (which has default handler) + 2. Clearing _handlers to simulate empty state + 3. Calling get_benchmark_handler() + 4. Asserting result is None + + Assumptions: + - get_benchmark_handler returns None when no handlers are registered + """ + factory = EndpointHandlerFactory(server_worker_config("minimal")) + factory._handlers.clear() + assert factory.get_benchmark_handler() is None + + def test_has_handlers_false_when_no_handlers(self, server_worker_config) -> None: + """ + Verifies that has_handlers returns False when _handlers is empty. + + This test verifies by: + 1. Creating factory and clearing _handlers + 2. Asserting has_handlers() is False + + Assumptions: + - has_handlers returns len(self._handlers) > 0 + """ + factory = EndpointHandlerFactory(server_worker_config("minimal")) + factory._handlers.clear() + assert factory.has_handlers() is False + + def test_get_benchmark_handler_raises_when_none_has_benchmark( + self, server_worker_config + ) -> None: + """ + Verifies that get_benchmark_handler raises when no handler has benchmark. + + This test verifies by: + 1. Creating factory with empty handlers (default handler has no BenchmarkConfig) + 2. Calling get_benchmark_handler + 3. Asserting Exception is raised with "Missing EndpointHandler" + + Assumptions: + - minimal_worker_config creates default handler with no BenchmarkConfig + """ + factory = EndpointHandlerFactory(server_worker_config("minimal")) + with pytest.raises( + Exception, match="Missing EndpointHandler with BenchmarkConfig" + ): + factory.get_benchmark_handler() + + def test_factory_with_handler_and_benchmark_config_creates_handler( + self, server_worker_config + ) -> None: + """ + Verifies that HandlerConfig with BenchmarkConfig creates benchmark handler. + + This test verifies by: + 1. Using server_worker_config fixture to build config with /predict handler + 2. Creating EndpointHandlerFactory + 3. Asserting get_benchmark_handler returns the handler + 4. Asserting get_handler returns handler for the route + + Assumptions: + - server_worker_config creates valid config with BenchmarkConfig + """ + config = server_worker_config( + "handler", route="/predict", dataset=[{"input": "test"}] + ) + factory = EndpointHandlerFactory(config) + benchmark_handler = factory.get_benchmark_handler() + assert benchmark_handler is not None + assert benchmark_handler.endpoint == "/predict" + assert benchmark_handler.has_benchmark is True + assert factory.get_handler("/predict") is benchmark_handler + + def test_get_benchmark_handler_raises_when_multiple_have_benchmark( + self, server_worker_config + ) -> None: + """ + Verifies that get_benchmark_handler raises when multiple handlers have BenchmarkConfig. + + This test verifies by: + 1. Using server_worker_config with extra_handlers to add second benchmark + 2. Creating EndpointHandlerFactory + 3. Calling get_benchmark_handler + 4. Asserting Exception is raised with "Cannot define BenchmarkConfig" + + Assumptions: + - Exactly one handler may have BenchmarkConfig + """ + config = server_worker_config( + "handler", + route="/a", + dataset=[{"x": 1}], + extra_handlers=[ + HandlerConfig( + route="/b", + benchmark_config=BenchmarkConfig(dataset=[{"x": 2}]), + ), + ], + ) + factory = EndpointHandlerFactory(config) + with pytest.raises( + Exception, match="Cannot define BenchmarkConfig for more than one" + ): + factory.get_benchmark_handler() + + def test_factory_with_explicit_handler_config_creates_handler( + self, server_worker_config + ) -> None: + """ + Verifies that explicit HandlerConfig creates handler at specified route. + + This test verifies by: + 1. Using server_worker_config for /v1/chat route + 2. Creating EndpointHandlerFactory + 3. Asserting get_handler("/v1/chat") returns handler with that endpoint + + Assumptions: + - server_worker_config creates config with handlers list + """ + config = server_worker_config( + "handler", route="/v1/chat", dataset=[{"messages": []}] + ) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/v1/chat") + assert handler is not None + assert handler.endpoint == "/v1/chat" + + +class TestWorkerConfigAndDataclasses: + """Verify WorkerConfig, HandlerConfig, BenchmarkConfig construction.""" + + def test_worker_config_defaults(self) -> None: + """ + Verifies that WorkerConfig has expected default values. + + This test verifies by: + 1. Creating WorkerConfig with no args + 2. Asserting key defaults + + Assumptions: + - Defaults match dataclass field defaults + """ + config = WorkerConfig() + assert config.model_server_url is None + assert config.model_server_port is None + assert config.handlers == [] + assert config.max_sessions == 10 + + def test_handler_config_with_benchmark(self) -> None: + """ + Verifies that HandlerConfig accepts benchmark_config. + + This test verifies by: + 1. Creating HandlerConfig with BenchmarkConfig + 2. Asserting benchmark_config is set + + Assumptions: + - benchmark_config is optional + """ + bc = BenchmarkConfig(dataset=[{"a": 1}], runs=4) + hc = HandlerConfig(route="/", benchmark_config=bc) + assert hc.benchmark_config is bc + assert hc.benchmark_config.runs == 4 + + def test_benchmark_config_with_generator(self) -> None: + """ + Verifies that BenchmarkConfig accepts generator callable. + + This test verifies by: + 1. Creating BenchmarkConfig with generator + 2. Asserting generator is set + + Assumptions: + - generator is optional, alternative to dataset + """ + + def gen() -> dict: + return {"sample": 1} + + config = BenchmarkConfig(generator=gen) + assert config.generator is gen + assert config.generator() == {"sample": 1} + + +class TestEndpointHandlerFactoryCreatedPayload: + """Verify payload class created by _create_handler (GenericApiPayload) behavior.""" + + def test_payload_for_test_with_dataset_returns_sample( + self, server_worker_config + ) -> None: + """ + Verifies that payload_cls().for_test() returns a payload with data from dataset. + + This test verifies by: + 1. Creating factory with HandlerConfig that has BenchmarkConfig(dataset=[...]) + 2. Getting handler and calling payload_cls().for_test() multiple times + 3. Asserting returned payload has .input in the dataset and generate_payload_json matches + + Assumptions: + - GenericApiPayload.for_test uses random.choice(benchmark_config.dataset) + """ + dataset = [{"a": 1}, {"b": 2}] + config = server_worker_config("handler", route="/predict", dataset=dataset) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/predict") + payload_cls = handler.payload_cls() + + for _ in range(10): + payload = payload_cls.for_test() + assert payload.input in dataset + assert payload.generate_payload_json() == payload.input + + def test_payload_for_test_with_generator_uses_generator( + self, server_worker_config + ) -> None: + """ + Verifies that payload_cls().for_test() uses benchmark_config.generator when set. + + This test verifies by: + 1. Creating HandlerConfig with BenchmarkConfig(generator=callable) and no dataset + 2. Building config with that single handler and creating factory + 3. Calling payload_cls().for_test() and asserting input matches generator return + + Assumptions: + - generator is called once per for_test(); we use a deterministic generator + """ + + def gen(): + return {"from_generator": True} + + config = server_worker_config( + "from_handlers", + handlers=[ + HandlerConfig( + route="/gen", + benchmark_config=BenchmarkConfig(generator=gen), + ), + ], + ) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/gen") + payload = handler.payload_cls().for_test() + assert payload.input == {"from_generator": True} + assert payload.generate_payload_json() == {"from_generator": True} + + def test_payload_for_test_without_dataset_or_generator_raises( + self, server_worker_config + ) -> None: + """ + Verifies that payload_cls().for_test() raises when BenchmarkConfig has no dataset or generator. + + This test verifies by: + 1. Creating HandlerConfig with BenchmarkConfig() (no dataset, no generator) + 2. Creating factory and getting handler + 3. Calling payload_cls().for_test() + 4. Asserting an Exception is raised (missing data path raises) + + Assumptions: + - Exactly one handler must have BenchmarkConfig for get_benchmark_handler; this config has one + - Implementation may raise Exception("Missing BenchmarkConfig!") or UnboundLocalError + """ + config = server_worker_config( + "from_handlers", + handlers=[ + HandlerConfig( + route="/", + benchmark_config=BenchmarkConfig(), + ), + ], + ) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/") + with pytest.raises(Exception): + handler.payload_cls().for_test() + + def test_payload_for_test_raises_when_benchmark_config_is_none( + self, server_worker_config + ) -> None: + """ + Verifies that for_test() hits the ``Missing BenchmarkConfig!`` branch when + ``HandlerConfig.benchmark_config`` is omitted (None). + + Generic ``for_test`` only enters that else-branch when ``benchmark_config`` is + falsy; an empty ``BenchmarkConfig()`` is still truthy, so this case is distinct + from ``test_payload_for_test_without_dataset_or_generator_raises``. + """ + config = server_worker_config( + "from_handlers", + handlers=[ + HandlerConfig(route="/nobench", benchmark_config=None), + ], + ) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/nobench") + with pytest.raises(Exception, match="Missing BenchmarkConfig"): + handler.payload_cls().for_test() + + def test_payload_from_json_msg_wraps_json_data_exception_from_from_dict( + self, server_worker_config + ) -> None: + """ + Verifies that ``from_json_msg`` converts ``JsonDataException`` from ``from_dict`` + into a new ``JsonDataException`` (the ``except`` block after ``from_dict``). + """ + config = server_worker_config( + "from_handlers", + handlers=[ + HandlerConfig( + route="/jd", + benchmark_config=BenchmarkConfig(dataset=[{"a": 1}]), + ), + ], + ) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/jd") + payload_cls = handler.payload_cls() + with patch.object( + payload_cls, + "from_dict", + side_effect=JsonDataException({"field": "bad"}), + ): + with pytest.raises( + JsonDataException, match="Error in user response handler" + ): + payload_cls.from_json_msg({"ok": True}) + + def test_payload_from_dict_and_generate_payload_json( + self, server_worker_config + ) -> None: + """ + Verifies that payload from_dict builds payload and generate_payload_json returns input. + + This test verifies by: + 1. Getting handler from factory with benchmark config + 2. Creating payload via payload_cls().from_dict(input) + 3. Asserting generate_payload_json() returns that input + + Assumptions: + - GenericApiPayload.from_dict(input) creates payload with input=input + """ + config = server_worker_config("handler", route="/", dataset=[{"x": 1}]) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/") + payload_cls = handler.payload_cls() + payload = payload_cls.from_dict({"key": "value"}) + assert payload.input == {"key": "value"} + assert payload.generate_payload_json() == {"key": "value"} + + def test_payload_from_json_msg_without_parser(self, server_worker_config) -> None: + """ + Verifies that from_json_msg parses dict into payload when no request_parser. + + This test verifies by: + 1. Getting handler (no request_parser) + 2. Calling payload_cls().from_json_msg({"key": "v"}) + 3. Asserting payload.input == {"key": "v"} + + Assumptions: + - from_json_msg without parser calls from_dict(json_msg) directly + """ + config = server_worker_config("handler", route="/", dataset=[{"a": 1}]) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/") + payload = handler.payload_cls().from_json_msg({"key": "v"}) + assert payload.input == {"key": "v"} + + def test_payload_from_json_msg_with_parser_applies_parser( + self, server_worker_config + ) -> None: + """ + Verifies that from_json_msg applies request_parser when provided. + + This test verifies by: + 1. Creating HandlerConfig with request_parser that rewrites the dict + 2. Creating factory and getting handler + 3. Calling from_json_msg with raw dict + 4. Asserting payload reflects parsed (rewritten) dict + + Assumptions: + - request_parser is called with json_msg and result passed to from_dict + """ + + def parser(raw): + return {"parsed": raw.get("raw_key", "")} + + config = server_worker_config( + "from_handlers", + handlers=[ + HandlerConfig( + route="/p", + benchmark_config=BenchmarkConfig(dataset=[{"a": 1}]), + request_parser=parser, + ), + ], + ) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/p") + payload = handler.payload_cls().from_json_msg({"raw_key": "value"}) + assert payload.input == {"parsed": "value"} + + def test_payload_from_json_msg_non_dict_raises_json_data_exception( + self, server_worker_config + ) -> None: + """ + Verifies that from_json_msg raises JsonDataException when message is not a dict. + + This test verifies by: + 1. Getting handler from factory + 2. Calling from_json_msg with a list (or non-dict) + 3. Asserting JsonDataException is raised + + Assumptions: + - worker raises JsonDataException({"data": "payload must be a dictionary"}) for non-dict + """ + config = server_worker_config("handler", route="/", dataset=[{"a": 1}]) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/") + with pytest.raises(JsonDataException) as exc_info: + handler.payload_cls().from_json_msg([1, 2, 3]) + assert exc_info.value.message.get("data") == "payload must be a dictionary" + + def test_payload_count_workload_default(self, server_worker_config) -> None: + """ + Verifies that count_workload returns 100.0 when no workload_calculator. + + This test verifies by: + 1. Getting handler without workload_calculator + 2. Creating payload and calling count_workload() + 3. Asserting result == 100.0 + + Assumptions: + - Default workload in GenericApiPayload is 100.0 + """ + config = server_worker_config("handler", route="/", dataset=[{"a": 1}]) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/") + payload = handler.payload_cls().from_dict({"x": 1}) + assert payload.count_workload() == 100.0 + + def test_payload_count_workload_uses_calculator(self, server_worker_config) -> None: + """ + Verifies that count_workload uses workload_calculator when provided. + + This test verifies by: + 1. Creating HandlerConfig with workload_calculator that returns 7.0 for input + 2. Creating payload and calling count_workload() + 3. Asserting result == 7.0 + + Assumptions: + - workload_calculator(input) is called and its return used + """ + + def calc(data): + return float(data.get("tokens", 0)) + + config = server_worker_config( + "from_handlers", + handlers=[ + HandlerConfig( + route="/w", + benchmark_config=BenchmarkConfig(dataset=[{"tokens": 7}]), + workload_calculator=calc, + ), + ], + ) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/w") + payload = handler.payload_cls().from_dict({"tokens": 7}) + assert payload.count_workload() == 7.0 + + def test_payload_for_test_raises_when_generator_raises( + self, server_worker_config + ) -> None: + """ + Verifies that payload_cls().for_test() raises with expected message when generator raises. + + This test verifies by: + 1. Creating HandlerConfig with BenchmarkConfig(generator=callable_that_raises) + 2. Creating factory and getting handler + 3. Calling payload_cls().for_test() + 4. Asserting Exception is raised with "Error generating benchmark data" + + Assumptions: + - Generator exception is wrapped in Exception with route name + """ + + def bad_gen(): + raise ValueError("generator failed") + + config = server_worker_config( + "from_handlers", + handlers=[ + HandlerConfig( + route="/badgen", + benchmark_config=BenchmarkConfig(generator=bad_gen), + ), + ], + ) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/badgen") + with pytest.raises(Exception, match="Error generating benchmark data"): + handler.payload_cls().for_test() + + def test_payload_from_json_msg_raises_when_request_parser_raises( + self, server_worker_config + ) -> None: + """ + Verifies that from_json_msg raises Exception when request_parser raises. + + This test verifies by: + 1. Creating HandlerConfig with request_parser that raises + 2. Calling payload_cls().from_json_msg(...) + 3. Asserting Exception is raised with "Error in user response handler" + + Assumptions: + - Parser exception is wrapped in Exception + """ + + def failing_parser(_): + raise RuntimeError("parser error") + + config = server_worker_config( + "from_handlers", + handlers=[ + HandlerConfig( + route="/p", + benchmark_config=BenchmarkConfig(dataset=[{"a": 1}]), + request_parser=failing_parser, + ), + ], + ) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/p") + with pytest.raises(Exception, match="Error in user response handler"): + handler.payload_cls().from_json_msg({"x": 1}) + + def test_factory_with_payload_class_uses_user_payload_class( + self, server_worker_config + ) -> None: + """ + Verifies that HandlerConfig with payload_class uses that class instead of GenericApiPayload. + + This test verifies by: + 1. Defining a custom ApiPayload subclass with distinct for_test/from_json_msg behavior + 2. Creating HandlerConfig with payload_class=ThatClass and BenchmarkConfig + 3. Creating factory and getting handler + 4. Asserting payload_cls() is the custom class and for_test/from_json_msg use its logic + + Assumptions: + - When payload_class is set, _create_handler uses it and does not create GenericApiPayload + """ + from vastai.serverless.server.lib.data_types import ApiPayload + from dataclasses import dataclass + + @dataclass + class CustomPayload(ApiPayload): + value: int = 0 + + @classmethod + def for_test(cls): + return cls(value=99) + + def generate_payload_json(self): + return {"value": self.value} + + def count_workload(self) -> float: + return float(self.value) + + @classmethod + def from_dict(cls, input_dict): + return cls(value=input_dict.get("value", 0)) + + @classmethod + def from_json_msg(cls, json_msg): + if not isinstance(json_msg, dict): + raise JsonDataException({"data": "must be dict"}) + return cls.from_dict(json_msg) + + config = server_worker_config( + "from_handlers", + handlers=[ + HandlerConfig( + route="/custom", + benchmark_config=BenchmarkConfig(dataset=[{"ignored": 1}]), + payload_class=CustomPayload, + ), + HandlerConfig( + route="/bench", + benchmark_config=BenchmarkConfig(dataset=[{"a": 1}]), + ), + ], + ) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/custom") + assert handler.payload_cls() is CustomPayload + payload = handler.payload_cls().for_test() + assert payload.value == 99 + assert payload.count_workload() == 99.0 + payload2 = handler.payload_cls().from_json_msg({"value": 3}) + assert payload2.value == 3 + + +class TestEndpointHandlerFactoryCreatedHandler: + """Verify handler instance created by _create_handler (GenericEndpointHandler) behavior.""" + + def test_generic_handler_healthcheck_endpoint_is_none( + self, server_worker_config + ) -> None: + """GenericEndpointHandler does not expose a model health URL by default.""" + config = server_worker_config("handler", route="/h", dataset=[{"a": 1}]) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/h") + assert handler.healthcheck_endpoint is None + + def test_make_benchmark_payload_calls_payload_for_test( + self, server_worker_config + ) -> None: + """ + Verifies that make_benchmark_payload returns payload_cls().for_test(). + + This test verifies by: + 1. Creating factory with known dataset + 2. Calling handler.make_benchmark_payload() + 3. Asserting result is payload with .input in dataset + + Assumptions: + - make_benchmark_payload delegates to PayloadClass.for_test() + """ + dataset = [{"bench": 1}] + config = server_worker_config("handler", route="/predict", dataset=dataset) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/predict") + payload = handler.make_benchmark_payload() + assert payload.input in dataset + + @pytest.mark.asyncio + async def test_generate_client_response_uses_user_response_generator( + self, + server_worker_config, + make_mock_web_request, + make_mock_model_response, + ) -> None: + """ + Verifies that generate_client_response calls user_response_generator when provided. + + This test verifies by: + 1. Creating HandlerConfig with response_generator that returns a fixed web.Response + 2. Getting handler and calling generate_client_response with mock request/response + 3. Asserting returned response is the one from the generator + + Assumptions: + - user_response_generator(client_request, model_response) is awaited and returned + """ + from aiohttp import web + + async def my_generator(_req, _model_resp): + return web.Response(text="custom", status=201) + + config = server_worker_config( + "from_handlers", + handlers=[ + HandlerConfig( + route="/r", + benchmark_config=BenchmarkConfig(dataset=[{"a": 1}]), + response_generator=my_generator, + ), + ], + ) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/r") + mock_req = make_mock_web_request(spec_request=True) + mock_model_resp = make_mock_model_response() + response = await handler.generate_client_response(mock_req, mock_model_resp) + assert response.status == 201 + # aiohttp.web.Response body is in .body + assert response.body == b"custom" + + @pytest.mark.asyncio + async def test_generate_client_response_default_non_streaming( + self, + server_worker_config, + make_mock_web_request, + make_mock_model_response, + ) -> None: + """ + Verifies that default generate_client_response returns web.Response for non-streaming. + + This test verifies by: + 1. Getting handler without response_generator + 2. Mocking model_response with content_type application/json and read() returning body + 3. Calling generate_client_response + 4. Asserting result is web.Response with same body and status + + Assumptions: + - When content_type is not streaming and no chunked, body is read and web.Response returned + """ + from aiohttp import web + + config = server_worker_config("handler", route="/", dataset=[{"a": 1}]) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/") + mock_req = make_mock_web_request(spec_request=True) + mock_model_resp = make_mock_model_response( + content_type="application/json", + body=b'{"ok": true}', + status=200, + ) + + response = await handler.generate_client_response(mock_req, mock_model_resp) + assert isinstance(response, web.Response) + assert response.status == 200 + assert response.body == b'{"ok": true}' + + @pytest.mark.asyncio + async def test_call_remote_dispatch_function_raises_when_not_configured( + self, server_worker_config + ) -> None: + """ + Verifies that call_remote_dispatch_function raises RuntimeError when remote_function is None. + + This test verifies by: + 1. Getting handler created without remote_function + 2. Calling call_remote_dispatch_function(params) + 3. Asserting RuntimeError with "remote_function is not configured" + + Assumptions: + - remote_dispatch_function None triggers RuntimeError + """ + config = server_worker_config("handler", route="/", dataset=[{"a": 1}]) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/") + with pytest.raises(RuntimeError, match="remote_function is not configured"): + await handler.call_remote_dispatch_function({}) + + @pytest.mark.asyncio + async def test_call_remote_dispatch_function_calls_remote( + self, server_worker_config + ) -> None: + """ + Verifies that call_remote_dispatch_function calls remote_function and returns result. + + This test verifies by: + 1. Creating HandlerConfig with remote_function async that returns a value + 2. Getting handler and calling call_remote_dispatch_function + 3. Asserting return value matches + + Assumptions: + - remote_function(**params) is awaited and result returned + """ + + async def remote(**kwargs): + return kwargs.get("x", 0) + 1 + + config = server_worker_config( + "from_handlers", + handlers=[ + HandlerConfig( + route="/remote", + benchmark_config=BenchmarkConfig(dataset=[{"a": 1}]), + remote_function=remote, + ), + ], + ) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/remote") + result = await handler.call_remote_dispatch_function({"x": 10}) + assert result == 11 + + @pytest.mark.asyncio + async def test_generate_client_response_raises_when_user_response_generator_raises( + self, + server_worker_config, + make_mock_web_request, + make_mock_model_response, + ) -> None: + """ + Verifies that generate_client_response raises when user_response_generator raises. + + This test verifies by: + 1. Creating HandlerConfig with response_generator that raises + 2. Calling generate_client_response + 3. Asserting Exception is raised with "Error in user response generator" + + Assumptions: + - Exception from generator is wrapped and re-raised + """ + from aiohttp import web + + async def bad_generator(_req, _resp): + raise ValueError("generator failed") + + config = server_worker_config( + "from_handlers", + handlers=[ + HandlerConfig( + route="/r", + benchmark_config=BenchmarkConfig(dataset=[{"a": 1}]), + response_generator=bad_generator, + ), + ], + ) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/r") + mock_req = make_mock_web_request(spec_request=True) + mock_resp = make_mock_model_response() + with pytest.raises(Exception, match="Error in user response generator"): + await handler.generate_client_response(mock_req, mock_resp) + + @pytest.mark.asyncio + async def test_call_remote_dispatch_function_raises_when_remote_raises( + self, server_worker_config + ) -> None: + """ + Verifies that call_remote_dispatch_function raises RuntimeError when remote_function raises. + + This test verifies by: + 1. Creating HandlerConfig with remote_function that raises + 2. Calling call_remote_dispatch_function + 3. Asserting RuntimeError with "Error calling remote dispatch function" + + Assumptions: + - Exception from remote is wrapped in RuntimeError + """ + + async def remote_raises(**kwargs): + raise ValueError("remote failed") + + config = server_worker_config( + "from_handlers", + handlers=[ + HandlerConfig( + route="/remote", + benchmark_config=BenchmarkConfig(dataset=[{"a": 1}]), + remote_function=remote_raises, + ), + ], + ) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/remote") + with pytest.raises(RuntimeError): + await handler.call_remote_dispatch_function({}) + + @pytest.mark.asyncio + async def test_generate_client_response_default_streaming_passthrough( + self, + server_worker_config, + make_mock_web_request, + make_mock_model_response, + ) -> None: + """ + Verifies that default generate_client_response uses streaming path for stream content-type. + + This test verifies by: + 1. Getting handler without response_generator + 2. Mocking model_response with content_type text/event-stream and async iter_any + 3. Patching web.StreamResponse to capture construction and avoid real aiohttp prepare + 4. Calling generate_client_response and asserting StreamResponse was created with status/content_type + + Assumptions: + - When content_type indicates streaming, code creates StreamResponse and iterates model_response.content.iter_any + - We patch StreamResponse to avoid real I/O while still exercising the streaming branch + """ + from aiohttp import web + + config = server_worker_config("handler", route="/", dataset=[{"a": 1}]) + factory = EndpointHandlerFactory(config) + handler = factory.get_handler("/") + mock_req = make_mock_web_request(spec_request=False) + # Empty chunk triggers continue in handler + mock_model_resp = make_mock_model_response( + content_type="text/event-stream", + status=200, + stream_chunks=[b"chunk1", b"", b"chunk2"], + ) + + with patch( + "vastai.serverless.server.worker.web.StreamResponse" + ) as mock_stream_cls: + mock_stream = MagicMock() + mock_stream.prepare = AsyncMock() + mock_stream.write = AsyncMock() + mock_stream.write_eof = AsyncMock() + mock_stream.status = 200 + mock_stream.content_type = "text/event-stream" + mock_stream_cls.return_value = mock_stream + + response = await handler.generate_client_response(mock_req, mock_model_resp) + + mock_stream_cls.assert_called_once() + assert mock_stream_cls.call_args[1]["status"] == 200 + mock_stream.prepare.assert_called_once_with(mock_req) + # chunk1, empty (skipped), chunk2 + assert mock_stream.write.call_count == 2 + mock_stream.write_eof.assert_called_once() + assert response is mock_stream + + +class TestEndpointHandlerFactoryCustomHandlerClass: + """Verify EndpointHandlerFactory uses handler_class when provided.""" + + def test_factory_uses_handler_class_instance(self, server_worker_config) -> None: + """ + Verifies that HandlerConfig with handler_class registers an instance of that class. + + This test verifies by: + 1. Defining a minimal concrete EndpointHandler subclass + 2. Creating WorkerConfig with HandlerConfig(route="/custom", handler_class=ThatClass) + 3. Creating factory and get_handler("/custom") + 4. Asserting returned handler is instance of ThatClass (and not GenericEndpointHandler) + + Assumptions: + - When handler_class is not None, factory creates handler_class() and stores it + """ + from vastai.serverless.server.lib.data_types import ( + EndpointHandler, + ApiPayload, + ClientResponse, + ) + from aiohttp import web + from dataclasses import dataclass + from typing import Type, Optional, Union + + @dataclass + class DummyPayload(ApiPayload): + value: str = "" + + @classmethod + def for_test(cls): + return cls(value="test") + + def generate_payload_json(self): + return {"value": self.value} + + def count_workload(self) -> float: + return 1.0 + + @classmethod + def from_json_msg(cls, json_msg: dict): + return cls(value=json_msg.get("value", "")) + + class DummyHandler(EndpointHandler[DummyPayload]): + has_benchmark = False # so only /bench is the benchmark handler + + @property + def endpoint(self) -> str: + return "/custom" + + @property + def healthcheck_endpoint(self) -> Optional[str]: + return None + + @classmethod + def payload_cls(cls) -> Type[DummyPayload]: + return DummyPayload + + def make_benchmark_payload(self) -> DummyPayload: + return DummyPayload.for_test() + + async def generate_client_response( + self, + client_request: web.Request, + model_response: ClientResponse, + ) -> Union[web.Response, web.StreamResponse]: + return web.Response(text="ok") + + async def call_remote_dispatch_function(self, params: dict): + raise RuntimeError("not configured") + + config = server_worker_config( + "from_handlers", + handlers=[ + HandlerConfig(route="/custom", handler_class=DummyHandler), + HandlerConfig( + route="/bench", + benchmark_config=BenchmarkConfig(dataset=[{"a": 1}]), + ), + ], + ) + factory = EndpointHandlerFactory(config) + custom_handler = factory.get_handler("/custom") + assert custom_handler is not None + assert isinstance(custom_handler, DummyHandler) + assert custom_handler.endpoint == "/custom" + # Benchmark handler is the /bench route (exactly one with BenchmarkConfig) + assert factory.get_handler("/bench") is not None diff --git a/tests/serverless/test_worker_server.py b/tests/serverless/test_worker_server.py new file mode 100644 index 00000000..b83eeb61 --- /dev/null +++ b/tests/serverless/test_worker_server.py @@ -0,0 +1,201 @@ +"""Unit tests for the serverless pyworker server Worker class. + +Tests Worker initialization (backend and routes), run_async, and run. +All server startup and backend behavior is mocked; no real network or server. +""" +from __future__ import annotations + +import logging +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from vastai.serverless.server.worker import ( + Worker, + LogActionConfig, +) +from vastai.serverless.server.lib.data_types import LogAction +from vastai.serverless.server.lib import backend as backend_mod +from vastai.serverless.server.lib import server as server_mod + + +# --------------------------------------------------------------------------- +# Worker initialization and run +# --------------------------------------------------------------------------- + + +class TestWorker: + """Verify Worker builds backend/routes and run_async/run call server correctly.""" + + def test_worker_init_creates_backend_with_config_values( + self, + server_worker_config, + patch_pyworker_backend_class, + ) -> None: + """ + Verifies that Worker.__init__ creates Backend with url, log file, benchmark handler, + log_actions, healthcheck_url, and max_sessions from WorkerConfig. + + This test verifies by: + 1. Building a WorkerConfig with handler and benchmark via server_worker_config + 2. Patching backend.Backend to a MagicMock + 3. Instantiating Worker(config) + 4. Asserting Backend was called once with the expected keyword arguments + + Assumptions: + - server_worker_config yields a config with one handler that has BenchmarkConfig + - Backend is constructed in Worker.__init__ with these arguments + """ + config = server_worker_config( + "handler", + route="/predict", + dataset=[{"input": "test"}], + ) + config.model_log_file = "/tmp/model.log" + config.model_healthcheck_url = "/health" + config.max_sessions = 5 + config.log_action_config = LogActionConfig(on_load=["Loaded"]) + + mock_backend_class = patch_pyworker_backend_class + Worker(config) + + mock_backend_class.assert_called_once() + call_kw = mock_backend_class.call_args[1] + assert call_kw["model_server_url"] == "http://localhost:8000" + assert call_kw["model_log_file"] == "/tmp/model.log" + assert call_kw["benchmark_handler"] is not None + assert call_kw["healthcheck_url"] == "/health" + assert call_kw["max_sessions"] == 5 + assert call_kw["log_actions"] == [(LogAction.ModelLoaded, "Loaded")] + + def test_worker_init_creates_routes_for_each_handler( + self, + server_worker_config, + patch_pyworker_backend_class, + ) -> None: + """ + Verifies that Worker attaches one route per handler via backend.create_handler. + + This test verifies by: + 1. Using config with one handler at /predict + 2. Patching Backend so the instance's create_handler returns a mock + 3. Instantiating Worker and asserting len(worker.routes) == 1 + + Assumptions: + - Each (route_path, handler) gets one web.post(route_path, backend.create_handler(handler)) + """ + mock_backend_class = patch_pyworker_backend_class + config = server_worker_config("handler", route="/predict", dataset=[{"x": 1}]) + mock_backend = MagicMock() + mock_backend_class.return_value = mock_backend + worker = Worker(config) + + assert len(worker.routes) == 1 + assert worker.backend is mock_backend + + @pytest.mark.asyncio + async def test_worker_run_async_calls_start_server_async( + self, + server_worker_config, + patch_pyworker_backend_class, + ) -> None: + """ + Verifies that Worker.run_async calls server.start_server_async with backend and routes. + + This test verifies by: + 1. Creating Worker with mocked Backend + 2. Patching server.start_server_async with AsyncMock + 3. Calling await worker.run_async(host="0.0.0.0") + 4. Asserting start_server_async was called once with backend, routes, and kwargs + + Assumptions: + - No real server is started; start_server_async is fully mocked + """ + config = server_worker_config("handler", route="/", dataset=[{"a": 1}]) + worker = Worker(config) + with patch.object( + server_mod, "start_server_async", new_callable=AsyncMock + ) as mock_start: + await worker.run_async(host="0.0.0.0") + + mock_start.assert_called_once() + assert mock_start.call_args[0][0] is worker.backend + assert mock_start.call_args[0][1] is worker.routes + assert mock_start.call_args[1].get("host") == "0.0.0.0" + + def test_worker_run_calls_start_server( + self, + server_worker_config, + patch_pyworker_backend_class, + ) -> None: + """ + Verifies that Worker.run calls server.start_server with backend and routes. + + This test verifies by: + 1. Creating Worker with mocked Backend + 2. Patching server.start_server (sync) with MagicMock + 3. Calling worker.run() + 4. Asserting start_server was called once with backend and routes + + Assumptions: + - start_server runs the event loop; we mock it so no server starts + """ + config = server_worker_config("handler", route="/", dataset=[{"a": 1}]) + worker = Worker(config) + with patch.object(server_mod, "start_server", MagicMock()) as mock_start: + worker.run() + + mock_start.assert_called_once() + assert mock_start.call_args[0][0] is worker.backend + assert mock_start.call_args[0][1] is worker.routes + + def test_worker_init_sets_handler_level_when_root_has_handlers( + self, + server_worker_config, + patch_pyworker_backend_class, + make_mock_root_logger, + ) -> None: + """ + Verifies that Worker.__init__ sets level on existing root logger handlers when present. + + This test verifies by: + 1. Patching logging.getLogger to return a root logger with existing handlers (mocks) + 2. Patching backend.Backend and instantiating Worker + 3. Asserting each existing handler's setLevel was called with logging.DEBUG + + Assumptions: + - When root_logger.handlers is non-empty, Worker uses the else branch and sets level on each + """ + config = server_worker_config("handler", route="/", dataset=[{"a": 1}]) + mock_root, mock_handler = make_mock_root_logger(with_handlers=True) + + with patch( + "vastai.serverless.server.worker.logging.getLogger", + return_value=mock_root, + ): + Worker(config) + + mock_handler.setLevel.assert_called_once_with(logging.DEBUG) + + def test_worker_init_adds_stream_handler_when_root_has_no_handlers( + self, + server_worker_config, + patch_pyworker_backend_class, + make_mock_root_logger, + ) -> None: + """ + Verifies the branch that attaches a StreamHandler when the root logger has + no handlers yet (pyworker Worker.__init__ logging bootstrap). + """ + config = server_worker_config("handler", route="/", dataset=[{"a": 1}]) + mock_root, _ = make_mock_root_logger(with_handlers=False) + + with patch( + "vastai.serverless.server.worker.logging.getLogger", + return_value=mock_root, + ): + Worker(config) + + mock_root.addHandler.assert_called_once() + added = mock_root.addHandler.call_args[0][0] + assert isinstance(added, logging.StreamHandler) diff --git a/tests/test_data_objects.py b/tests/test_data_objects.py new file mode 100644 index 00000000..94715ddd --- /dev/null +++ b/tests/test_data_objects.py @@ -0,0 +1,285 @@ +"""Unit tests for data objects. No API calls.""" + +from vastai.data.query import Query, Column +from vastai.data.endpoint import EndpointConfig, EndpointData +from vastai.data.deployment import DeploymentConfig, DeploymentData, DeploymentPutResponse +from vastai.data.workergroup import WorkergroupConfig +from vastai.data.offer import Offer + + +# ── Query / Column ────────────────────────────────────────────────────────── + +class TestColumn: + def test_eq(self): + q = Column("gpu_name") == "A100" + assert q.query == {"gpu_name": {"eq": "A100"}} + + def test_ne(self): + q = Column("gpu_name") != "A100" + assert q.query == {"gpu_name": {"neq": "A100"}} + + def test_gt(self): + q = Column("gpu_ram") > 24000 + assert q.query == {"gpu_ram": {"gt": 24000}} + + def test_ge(self): + q = Column("gpu_ram") >= 24000 + assert q.query == {"gpu_ram": {"gte": 24000}} + + def test_lt(self): + q = Column("dph_total") < 1.0 + assert q.query == {"dph_total": {"lt": 1.0}} + + def test_le(self): + q = Column("dph_total") <= 1.0 + assert q.query == {"dph_total": {"lte": 1.0}} + + def test_in(self): + q = Column("gpu_name").in_(["A100", "H100"]) + assert q.query == {"gpu_name": {"in": ["A100", "H100"]}} + + def test_notin(self): + q = Column("gpu_name").notin_(["A100"]) + assert q.query == {"gpu_name": {"notin": ["A100"]}} + + +class TestQuery: + def test_empty(self): + q = Query({}) + assert q.query == {} + + def test_search_defaults(self): + q = Query.search_defaults() + assert q.query == { + "verified": {"eq": True}, + "rentable": {"eq": True}, + "rented": {"eq": False}, + } + + def test_search_defaults_override(self): + q = Query.search_defaults(rented=None) + assert "rented" not in q.query + assert q.query["verified"] == {"eq": True} + + def test_extend(self): + q = Query.search_defaults() + q.extend(Column("gpu_ram") >= 24000) + assert q.query["gpu_ram"] == {"gte": 24000} + assert q.query["verified"] == {"eq": True} + + def test_extend_multiple_ops(self): + q = Query({}) + q.extend(Column("gpu_ram") >= 16000) + q.extend(Column("gpu_ram") <= 48000) + assert q.query["gpu_ram"] == {"gte": 16000, "lte": 48000} + + def test_extend_conflict_raises(self): + q = Query({}) + q.extend(Column("gpu_ram") >= 16000) + try: + q.extend(Column("gpu_ram") >= 24000) + assert False, "Should have raised ValueError" + except ValueError as e: + assert "gpu_ram" in str(e) + + def test_extend_returns_self(self): + q = Query({}) + result = q.extend(Column("gpu_ram") >= 16000) + assert result is q + + +# ── EndpointConfig ────────────────────────────────────────────────────────── + +class TestEndpointConfig: + def test_required_only(self): + cfg = EndpointConfig(endpoint_name="test") + d = cfg.to_dict() + assert d == {"endpoint_name": "test"} + + def test_with_optional(self): + cfg = EndpointConfig(endpoint_name="test", cold_workers=5, max_workers=10) + d = cfg.to_dict() + assert d["cold_workers"] == 5 + assert d["max_workers"] == 10 + + def test_none_excluded(self): + cfg = EndpointConfig(endpoint_name="test") + d = cfg.to_dict() + assert "cold_workers" not in d + assert "autoscaler_instance" not in d + + +# ── WorkergroupConfig ─────────────────────────────────────────────────────── + +class TestWorkergroupConfig: + def test_defaults(self): + cfg = WorkergroupConfig(endpoint_id=1, template_hash="abc") + d = cfg.to_dict() + assert d["endpoint_id"] == 1 + assert d["template_hash"] == "abc" + assert "search_params" in d # always included + + def test_search_params_default(self): + cfg = WorkergroupConfig() + d = cfg.to_dict() + assert d["search_params"] == "" + + def test_search_params_explicit(self): + cfg = WorkergroupConfig(search_params="gpu_ram>=8") + d = cfg.to_dict() + assert d["search_params"] == "gpu_ram>=8" + + def test_search_params_dict(self): + cfg = WorkergroupConfig(search_params={"gpu_ram": {"gte": 8}}) + d = cfg.to_dict() + assert d["search_params"] == {"gpu_ram": {"gte": 8}} + + +# ── DeploymentConfig ──────────────────────────────────────────────────────── + +class TestDeploymentConfig: + def test_required_fields(self): + cfg = DeploymentConfig(name="dep", image="pytorch/pytorch", file_hash="abc", file_size=1024) + d = cfg.to_dict() + assert d["name"] == "dep" + assert d["image"] == "pytorch/pytorch" + assert d["file_hash"] == "abc" + assert d["file_size"] == 1024 + + def test_none_excluded(self): + cfg = DeploymentConfig(name="dep", image="img", file_hash="h", file_size=1) + d = cfg.to_dict() + assert "tag" not in d + assert "ttl" not in d + assert "cold_workers" not in d + + def test_with_scaling_params(self): + cfg = DeploymentConfig( + name="dep", image="img", file_hash="h", file_size=1, + cold_workers=3, max_workers=10 + ) + d = cfg.to_dict() + assert d["cold_workers"] == 3 + assert d["max_workers"] == 10 + + +# ── EndpointData ──────────────────────────────────────────────────────────── + +class TestEndpointData: + def test_from_dict(self): + raw = { + "id": 42, + "endpoint_name": "my-ep", + "api_key": "abc123", + "user_id": 1, + "created_at": 1000.0, + "cold_workers": 3, + "max_workers": 20, + "min_load": 0.0, + "min_cold_load": 0.0, + "target_util": 0.9, + "cold_mult": 3.0, + "max_queue_time": 30.0, + "target_queue_time": 10.0, + "endpoint_state": "active", + "inactivity_timeout": None, + "auto_delete_in_seconds": 100.0, + "auto_delete_due_24h": False, + } + data = EndpointData.from_dict(raw) + assert data.id == 42 + assert data.api_key == "abc123" + assert data.config.endpoint_name == "my-ep" + assert data.config.cold_workers == 3 + assert data.config.endpoint_state == "active" + assert data.auto_delete_in_seconds == 100.0 + + def test_from_dict_missing_optional(self): + raw = { + "id": 1, + "endpoint_name": "ep", + "api_key": "k", + "user_id": 1, + "created_at": 0.0, + } + data = EndpointData.from_dict(raw) + assert data.config.cold_workers is None + assert data.auto_delete_in_seconds is None + assert data.auto_delete_due_24h is False + + +# ── DeploymentData ────────────────────────────────────────────────────────── + +class TestDeploymentData: + def test_from_dict(self): + raw = { + "id": 10, + "name": "my-dep", + "tag": "v1", + "endpoint_id": 42, + "endpoint_state": "active", + "worker_count": 3, + "s3_key": "some/key", + "env": "FOO=bar", + "image": "pytorch/pytorch", + "storage": 50.0, + "search_params": "gpu_ram>=8", + "file_hash": "abc", + "current_version_id": 1, + "last_healthy_version_id": 1, + "ttl": None, + "last_client_heartbeat": None, + "created_at": 1000.0, + "updated_at": 2000.0, + } + data = DeploymentData.from_dict(raw) + assert data.id == 10 + assert data.name == "my-dep" + assert data.endpoint_id == 42 + assert data.image == "pytorch/pytorch" + + +# ── DeploymentPutResponse ────────────────────────────────────────────────── + +class TestDeploymentPutResponse: + def test_from_dict(self): + raw = { + "success": True, + "action": "created", + "deployment_id": 5, + "endpoint_id": 10, + "upload_url": "https://s3.example.com/upload", + "upload_fields": {"key": "value"}, + } + resp = DeploymentPutResponse.from_dict(raw) + assert resp.action == "created" + assert resp.deployment_id == 5 + assert resp.upload_url == "https://s3.example.com/upload" + + def test_from_dict_minimal(self): + raw = { + "success": True, + "action": "exists", + "deployment_id": 5, + "endpoint_id": 10, + } + resp = DeploymentPutResponse.from_dict(raw) + assert resp.upload_url is None + assert resp.evicted_versions is None + + +# ── Offer ─────────────────────────────────────────────────────────────────── + +class TestOffer: + def test_from_dict_ignores_unknown(self): + """Offer.from_dict should accept a full API response dict and ignore extra keys.""" + import dataclasses + # Build a dict with all known fields set to None, then override a few + base = {f.name: None for f in dataclasses.fields(Offer)} + base.update({"id": 1, "gpu_name": "A100", "gpu_ram": 80000, "machine_id": 42, + "host_id": 1, "num_gpus": 8, "unknown_field": "ignored"}) + offer = Offer.from_dict(base) + assert offer.id == 1 + assert offer.gpu_name == "A100" + assert offer.num_gpus == 8 + assert not hasattr(offer, "unknown_field") diff --git a/tests/test_georegions.py b/tests/test_georegions.py new file mode 100644 index 00000000..d37e29e7 --- /dev/null +++ b/tests/test_georegions.py @@ -0,0 +1,231 @@ +"""Tests for georegion query expansion and result annotation. + +These tests verify that the georegion functionality ported from the original +vastai_sdk (commit 6575803) works identically to the old implementation. +""" + +import pytest +from vastai.utils import ( + _regions, + _regions_rev, + preprocess_search_query, + postprocess_search_results, +) + + +class TestRegionsMappings: + """Verify the region data itself is correct.""" + + def test_all_region_codes_present(self): + assert set(_regions.keys()) == {'AF', 'AS', 'EU', 'LC', 'NA', 'OC'} + + def test_na_countries(self): + assert _regions['NA'] == 'CA,US' + + def test_reverse_mapping_us(self): + assert _regions_rev['US'] == 'NA' + + def test_reverse_mapping_de(self): + assert _regions_rev['DE'] == 'EU' + + def test_reverse_mapping_jp(self): + assert _regions_rev['JP'] == 'AS' + + def test_reverse_mapping_br(self): + assert _regions_rev['BR'] == 'LC' + + def test_reverse_mapping_au(self): + # AU appears in both AS and OC in the original data; OC is processed + # last so it wins in the reverse mapping. + assert _regions_rev['AU'] in ('AS', 'OC') + + def test_reverse_mapping_za(self): + assert _regions_rev['ZA'] == 'AF' + + def test_every_country_maps_back(self): + """Every country in _regions must appear in _regions_rev.""" + for code, countries_str in _regions.items(): + for country in countries_str.split(','): + assert country in _regions_rev, f"{country} from region {code} not in reverse mapping" + + +class TestExpandQueryDirectives: + """Test the pre-processing query expansion hook.""" + + def test_none_query(self): + geo, chunked, q = preprocess_search_query(None) + assert geo is False + assert chunked is False + assert q is None + + def test_no_georegion_flag(self): + geo, chunked, q = preprocess_search_query('num_gpus = 1 gpu_name = RTX_4090') + assert geo is False + assert chunked is False + assert q == 'num_gpus = 1 gpu_name = RTX_4090' + + def test_georegion_na_expansion(self): + geo, chunked, q = preprocess_search_query('num_gpus = 1 geolocation = NA georegion = true') + assert geo is True + assert chunked is False + assert 'georegion' not in q + assert 'geolocation in [CA,US]' in q + assert 'num_gpus = 1' in q + + def test_georegion_eu_expansion(self): + geo, chunked, q = preprocess_search_query('geolocation = EU georegion = true') + assert geo is True + assert 'geolocation in [' in q + assert 'DE' in q + assert 'FR' in q + assert 'GB' in q + + def test_georegion_preserves_other_fields(self): + geo, chunked, q = preprocess_search_query('num_gpus = 2 gpu_name = A100 geolocation = NA georegion = true') + assert geo is True + assert 'num_gpus = 2' in q + assert 'gpu_name = A100' in q + + def test_georegion_false_not_set(self): + """georegion=false or georegion=anything-else should not trigger expansion.""" + geo, chunked, q = preprocess_search_query('geolocation = NA georegion = false') + assert geo is False + + def test_georegion_without_geolocation(self): + """georegion=true but no geolocation field — just strips georegion.""" + geo, chunked, q = preprocess_search_query('num_gpus = 1 georegion = true') + assert geo is True + assert 'georegion' not in q + assert 'num_gpus = 1' in q + + def test_chunked_only(self): + geo, chunked, q = preprocess_search_query('num_gpus = 1 chunked = true') + assert geo is False + assert chunked is True + assert 'chunked' not in q + assert 'num_gpus = 1' in q + + def test_georegion_and_chunked(self): + geo, chunked, q = preprocess_search_query('geolocation = NA georegion = true chunked = true') + assert geo is True + assert chunked is True + assert 'georegion' not in q + assert 'chunked' not in q + assert 'geolocation in [CA,US]' in q + + +class TestAnnotateSearchResults: + """Test the post-processing result annotation hook.""" + + def test_datacenter_field_always_added(self): + results = [{'hosting_type': 1, 'id': 1}, {'hosting_type': 0, 'id': 2}] + annotated = postprocess_search_results(results) + assert annotated[0]['datacenter'] is True + assert annotated[1]['datacenter'] is False + + def test_annotate_us(self): + results = [{'geolocation': 'US', 'hosting_type': 0, 'id': 1}] + annotated = postprocess_search_results(results, georegion_active=True) + assert annotated[0]['geolocation'] == 'US, NA' + + def test_annotate_de(self): + results = [{'geolocation': 'DE', 'hosting_type': 0, 'id': 2}] + annotated = postprocess_search_results(results, georegion_active=True) + assert annotated[0]['geolocation'] == 'DE, EU' + + def test_annotate_multiple(self): + results = [ + {'geolocation': 'US', 'hosting_type': 1, 'id': 1}, + {'geolocation': 'DE', 'hosting_type': 0, 'id': 2}, + {'geolocation': 'JP', 'hosting_type': 0, 'id': 3}, + ] + annotated = postprocess_search_results(results, georegion_active=True) + assert annotated[0]['geolocation'] == 'US, NA' + assert annotated[1]['geolocation'] == 'DE, EU' + assert annotated[2]['geolocation'] == 'JP, AS' + + def test_annotate_unknown_country(self): + """Unknown countries should be left unchanged (not crash).""" + results = [{'geolocation': 'XX', 'hosting_type': 0, 'id': 1}] + annotated = postprocess_search_results(results, georegion_active=True) + assert annotated[0]['geolocation'] == 'XX' + + def test_annotate_empty_geolocation(self): + results = [{'geolocation': '', 'hosting_type': 0, 'id': 1}] + annotated = postprocess_search_results(results, georegion_active=True) + assert annotated[0]['geolocation'] == '' + + def test_annotate_missing_geolocation(self): + results = [{'hosting_type': 0, 'id': 1}] + annotated = postprocess_search_results(results, georegion_active=True) + assert 'geolocation' not in annotated[0] or annotated[0].get('geolocation', '') == '' + + def test_annotate_longer_geolocation_string(self): + """Handles geolocation values like 'California, US' by taking last 2 chars.""" + results = [{'geolocation': 'California, US', 'hosting_type': 0, 'id': 1}] + annotated = postprocess_search_results(results, georegion_active=True) + assert annotated[0]['geolocation'] == 'California, US, NA' + + def test_chunked_filters_low_resources(self): + results = [ + {'hosting_type': 1, 'cpu_ram': 128 * 1024, 'cpu_cores': 64, 'min_bid': 1, 'gpu_ram': 24576, 'disk_space': 500, 'id': 1}, + {'hosting_type': 0, 'cpu_ram': 16 * 1024, 'cpu_cores': 8, 'min_bid': 0, 'gpu_ram': 8192, 'disk_space': 100, 'id': 2}, + ] + annotated = postprocess_search_results(results, chunked=True) + # Second result should be filtered out (cpu_ram < 64*1024) + assert len(annotated) == 1 + assert annotated[0]['id'] == 1 + + def test_chunked_rounds_gpu_ram_and_disk(self): + results = [ + {'hosting_type': 1, 'cpu_ram': 128 * 1024, 'cpu_cores': 64, 'min_bid': 1, 'gpu_ram': 24577, 'disk_space': 513, 'id': 1}, + ] + annotated = postprocess_search_results(results, chunked=True) + assert annotated[0]['gpu_ram'] == 24577 & 0xffffffffff0 + assert annotated[0]['disk_space'] == 513 & 0xffffffffffc0 + + def test_no_georegion_skips_annotation(self): + """Without georegion_active, geolocation should not be modified.""" + results = [{'geolocation': 'US', 'hosting_type': 0, 'id': 1}] + annotated = postprocess_search_results(results, georegion_active=False) + assert annotated[0]['geolocation'] == 'US' + + +class TestEndToEndGeoregionFlow: + """Test the full expand → parse_query → annotate pipeline.""" + + def test_full_pipeline_na(self): + from vastai.api.query import parse_query, offers_fields, offers_alias, offers_mult + + # Step 1: Expand + geo, chunked, query_str = preprocess_search_query( + 'num_gpus = 1 geolocation = NA georegion = true' + ) + assert geo is True + + # Step 2: Parse into query dict + query = parse_query(query_str, {}, offers_fields, offers_alias, offers_mult) + assert 'geolocation' in query + assert 'in' in query['geolocation'] + countries = query['geolocation']['in'] + assert 'CA' in countries + assert 'US' in countries + + # Step 3: Annotate results + fake_results = [ + {'geolocation': 'US', 'hosting_type': 1, 'id': 1}, + {'geolocation': 'CA', 'hosting_type': 0, 'id': 2}, + ] + annotated = postprocess_search_results(fake_results, georegion_active=True) + assert annotated[0]['geolocation'] == 'US, NA' + assert annotated[1]['geolocation'] == 'CA, NA' + + def test_full_pipeline_no_georegion(self): + from vastai.api.query import parse_query, offers_fields, offers_alias, offers_mult + + geo, chunked, query_str = preprocess_search_query('num_gpus = 1 gpu_name = RTX_4090') + assert geo is False + + query = parse_query(query_str, {}, offers_fields, offers_alias, offers_mult) + assert 'georegion' not in query + assert query['num_gpus'] == {'eq': '1'} diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 00000000..df5a141e --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,60 @@ +"""Integration tests for search API. Read-only — no resources created.""" + +import pytest + +from vastai.data.query import Query, Column + + +pytestmark = pytest.mark.integration + + +class TestSyncSearch: + def test_search_returns_offers(self, sync_client): + q = Query.search_defaults() + q.extend(Column("num_gpus") >= 1) + offers = sync_client.search(q, limit=5) + assert isinstance(offers, list) + assert len(offers) <= 5 + for o in offers: + assert o.num_gpus >= 1 + assert o.id is not None + assert o.gpu_name is not None + + def test_search_with_gpu_filter(self, sync_client): + q = Query.search_defaults() + q.extend(Column("gpu_ram") >= 20000) + offers = sync_client.search(q, limit=3) + for o in offers: + assert o.gpu_ram >= 20000 + + def test_search_order_by_price(self, sync_client): + q = Query.search_defaults() + offers = sync_client.search(q, order=[["dph_total", "asc"]], limit=10) + prices = [o.dph_total for o in offers if o.dph_total is not None] + assert prices == sorted(prices) + + def test_search_empty_result(self, sync_client): + q = Query.search_defaults() + q.extend(Column("gpu_ram") >= 999999999) + offers = sync_client.search(q, limit=5) + assert offers == [] + + +class TestAsyncSearch: + @pytest.mark.asyncio + async def test_search_returns_offers(self, async_client): + q = Query.search_defaults() + q.extend(Column("num_gpus") >= 1) + offers = await async_client.search(q, limit=5) + assert isinstance(offers, list) + assert len(offers) <= 5 + for o in offers: + assert o.num_gpus >= 1 + + @pytest.mark.asyncio + async def test_search_with_gpu_filter(self, async_client): + q = Query.search_defaults() + q.extend(Column("gpu_ram") >= 20000) + offers = await async_client.search(q, limit=3) + for o in offers: + assert o.gpu_ram >= 20000 diff --git a/tests/test_serverless_lifecycle.py b/tests/test_serverless_lifecycle.py new file mode 100644 index 00000000..57fda4e1 --- /dev/null +++ b/tests/test_serverless_lifecycle.py @@ -0,0 +1,131 @@ +"""Integration tests for serverless endpoint lifecycle. + +Creates real resources (endpoints, workergroups) — incurs costs. +Uses the session-scoped managed_endpoint fixture for request tests, +and creates/tears down ephemeral endpoints for lifecycle tests. + +These tests use the session-scoped serverless_client and _serverless_loop +fixtures rather than pytest.mark.asyncio, since the client is session-scoped +and must use a consistent event loop. +""" + +import pytest + +from vastai.data.endpoint import EndpointConfig +from vastai.data.workergroup import WorkergroupConfig +from vastai.serverless.client.managed import ManagedEndpoint + + +pytestmark = [pytest.mark.integration, pytest.mark.serverless] + + +class TestEndpointLifecycle: + """Tests for creating and deleting endpoints (no workergroups).""" + + def test_create_and_delete_endpoint(self, serverless_client, _serverless_loop): + async def _test(): + ep = await serverless_client.create_endpoint( + EndpointConfig(endpoint_name="sdk-test-lifecycle") + ) + assert ep.id > 0 + assert isinstance(ep, ManagedEndpoint) + await ep.delete() + + _serverless_loop.run_until_complete(_test()) + + def test_create_endpoint_with_config(self, serverless_client, _serverless_loop): + async def _test(): + ep = await serverless_client.create_endpoint( + EndpointConfig( + endpoint_name="sdk-test-configured", + cold_workers=2, + max_workers=5, + ) + ) + try: + assert ep.id > 0 + finally: + await ep.delete() + + _serverless_loop.run_until_complete(_test()) + + +class TestWorkerGroupLifecycle: + """Tests for creating and deleting workergroups on an endpoint.""" + + def test_add_and_delete_workergroup(self, serverless_client, template_hash, _serverless_loop): + async def _test(): + ep = await serverless_client.create_endpoint( + EndpointConfig(endpoint_name="sdk-test-wg") + ) + try: + wg_id = await ep.add_workergroup(template_hash) + assert isinstance(wg_id, int) + assert wg_id > 0 + await serverless_client.delete_workergroup(wg_id) + finally: + await ep.delete() + + _serverless_loop.run_until_complete(_test()) + + def test_add_workergroup_with_config(self, serverless_client, template_hash, _serverless_loop): + async def _test(): + ep = await serverless_client.create_endpoint( + EndpointConfig(endpoint_name="sdk-test-wg-cfg") + ) + try: + wg_id = await ep.add_workergroup( + WorkergroupConfig( + template_hash=template_hash, + search_params="gpu_ram>=8", + gpu_ram=8.0, + ) + ) + assert wg_id > 0 + await serverless_client.delete_workergroup(wg_id) + finally: + await ep.delete() + + _serverless_loop.run_until_complete(_test()) + + +class TestEndpointRequest: + """Tests for sending requests to a live serverless endpoint. + + Uses the session-scoped managed_endpoint fixture which has a workergroup + already attached. The first request may take a while as the autoscaler + provisions a worker. + """ + + def test_request_returns_response(self, managed_endpoint, _serverless_loop): + async def _test(): + result = await managed_endpoint.request( + "/v1/chat/completions", + { + "model": "meta-llama/Llama-3.1-8B-Instruct", + "messages": [{"role": "user", "content": "Say hello in exactly one word."}], + "max_tokens": 16, + }, + timeout=300, + ) + assert result["ok"] is True + assert result["status"] == 200 + assert "response" in result + + _serverless_loop.run_until_complete(_test()) + + def test_request_has_latency(self, managed_endpoint, _serverless_loop): + async def _test(): + result = await managed_endpoint.request( + "/v1/chat/completions", + { + "model": "meta-llama/Llama-3.1-8B-Instruct", + "messages": [{"role": "user", "content": "Reply with just the word 'ok'."}], + "max_tokens": 8, + }, + timeout=300, + ) + assert result["latency"] is not None + assert result["latency"] > 0 + + _serverless_loop.run_until_complete(_test()) diff --git a/tests/test_tar_utils.py b/tests/test_tar_utils.py new file mode 100644 index 00000000..c615488e --- /dev/null +++ b/tests/test_tar_utils.py @@ -0,0 +1,699 @@ +from __future__ import annotations + +import hashlib +import io +import json +import os +import stat +import subprocess +import tarfile +import tempfile + +import pytest + +from vastai.serverless.remote.base import Config +from vastai.serverless.remote.utils import ( + _sanitize_info, + _scan_python_line, + add_file, + add_folder, + add_path, + add_string, + compute_deployment_hash, + create_deployment_tarball, + deployment_arcname, + filter_ignored_lines, + is_python_module, + is_python_package, + read_file_for_hash, + serialize_config, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def sample_config(): + return Config( + name="test-deployment", + pip_installs=["torch", "numpy"], + apt_gets=["libgl1"], + envs=[["KEY", "VALUE"]], + runs=["echo hello", ["bash", "-c", "echo world"]], + ) + + +@pytest.fixture +def tmp_dir(tmp_path): + """Provide a tmp_path with some files pre-created.""" + (tmp_path / "hello.txt").write_text("hello world") + (tmp_path / "script.py").write_text("x = 1\n") + return tmp_path + + +@pytest.fixture +def module_path(tmp_path): + """A single .py file acting as a deployment module.""" + p = tmp_path / "my_deploy.py" + p.write_text("def handler(): pass\n") + return str(p) + + +@pytest.fixture +def package_path(tmp_path): + """A Python package directory acting as a deployment.""" + pkg = tmp_path / "my_pkg" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "main.py").write_text("def handler(): pass\n") + sub = pkg / "sub" + sub.mkdir() + (sub / "__init__.py").write_text("") + (sub / "helper.py").write_text("def help(): pass\n") + return str(pkg) + + +@pytest.fixture +def tar_path(): + """Provide a closed NamedTemporaryFile path, matching the deploy.py pattern.""" + with tempfile.NamedTemporaryFile(delete_on_close=False, suffix=".tar") as f: + path = f.name + f.close() + yield path + if os.path.exists(path): + os.unlink(path) + + +# --------------------------------------------------------------------------- +# Helper to open a writable tar at a given path (for add_* unit tests) +# --------------------------------------------------------------------------- + + +def _write_tar(path: str) -> tarfile.TarFile: + """Open a new tarball for writing at the given path.""" + return tarfile.open(path, "w:") + + +def _open_tar_from_path(path: str) -> tarfile.TarFile: + """Open a tarball for reading (detect compression).""" + return tarfile.open(path, "r:*") + + +# --------------------------------------------------------------------------- +# _sanitize_info +# --------------------------------------------------------------------------- + + +class TestSanitizeInfo: + def test_sets_root_ownership(self): + info = tarfile.TarInfo(name="test") + info.uid = 1000 + info.gid = 1000 + info.uname = "user" + info.gname = "user" + result = _sanitize_info(info) + assert result is info + assert info.uid == 0 + assert info.gid == 0 + assert info.uname == "root" + assert info.gname == "root" + + +# --------------------------------------------------------------------------- +# add_file / add_folder / add_string / add_path +# --------------------------------------------------------------------------- + + +class TestAddFile: + def test_file_appears_at_arcname(self, tmp_dir, tar_path): + tf = _write_tar(tar_path) + add_file(tf, str(tmp_dir / "hello.txt"), "dest/hello.txt") + tf.close() + with _open_tar_from_path(tar_path) as rtf: + assert "dest/hello.txt" in rtf.getnames() + assert rtf.extractfile("dest/hello.txt").read() == b"hello world" + + def test_uid_gid_sanitized(self, tmp_dir, tar_path): + tf = _write_tar(tar_path) + add_file(tf, str(tmp_dir / "hello.txt"), "f.txt") + tf.close() + with _open_tar_from_path(tar_path) as rtf: + info = rtf.getmember("f.txt") + assert info.uid == 0 + assert info.gid == 0 + + +class TestAddFolder: + def test_recursive_contents(self, package_path, tar_path): + tf = _write_tar(tar_path) + add_folder(tf, package_path, "pkg") + tf.close() + with _open_tar_from_path(tar_path) as rtf: + names = rtf.getnames() + assert "pkg" in names + assert "pkg/__init__.py" in names + assert "pkg/main.py" in names + assert "pkg/sub" in names + assert "pkg/sub/__init__.py" in names + assert "pkg/sub/helper.py" in names + + def test_uid_gid_sanitized_on_all_entries(self, package_path, tar_path): + tf = _write_tar(tar_path) + add_folder(tf, package_path, "pkg") + tf.close() + with _open_tar_from_path(tar_path) as rtf: + for member in rtf.getmembers(): + assert member.uid == 0 + assert member.gid == 0 + + +class TestAddString: + def test_content_roundtrip(self, tar_path): + tf = _write_tar(tar_path) + add_string(tf, "hello world", "msg.txt") + tf.close() + with _open_tar_from_path(tar_path) as rtf: + assert rtf.extractfile("msg.txt").read() == b"hello world" + + def test_default_mode(self, tar_path): + tf = _write_tar(tar_path) + add_string(tf, "x", "f.txt") + tf.close() + with _open_tar_from_path(tar_path) as rtf: + assert rtf.getmember("f.txt").mode == 0o644 + + def test_executable_mode(self, tar_path): + tf = _write_tar(tar_path) + add_string(tf, "#!/bin/bash", "run.sh", executable=True) + tf.close() + with _open_tar_from_path(tar_path) as rtf: + assert rtf.getmember("run.sh").mode == 0o755 + + +class TestAddPath: + def test_dispatches_to_file(self, tmp_dir, tar_path): + tf = _write_tar(tar_path) + add_path(tf, str(tmp_dir / "hello.txt"), "f.txt") + tf.close() + with _open_tar_from_path(tar_path) as rtf: + assert rtf.extractfile("f.txt").read() == b"hello world" + + def test_dispatches_to_folder(self, package_path, tar_path): + tf = _write_tar(tar_path) + add_path(tf, package_path, "pkg") + tf.close() + with _open_tar_from_path(tar_path) as rtf: + assert "pkg/__init__.py" in rtf.getnames() + + def test_raises_on_nonexistent(self, tar_path): + tf = _write_tar(tar_path) + try: + with pytest.raises(FileNotFoundError): + add_path(tf, "/no/such/path", "x") + finally: + tf.close() + + +# --------------------------------------------------------------------------- +# serialize_config +# --------------------------------------------------------------------------- + + +class TestSerializeConfig: + def test_roundtrips_through_json(self, sample_config): + s = serialize_config(sample_config) + d = json.loads(s) + assert d["name"] == "test-deployment" + assert d["pip_installs"] == ["torch", "numpy"] + assert d["apt_gets"] == ["libgl1"] + assert d["envs"] == [["KEY", "VALUE"]] + assert d["runs"] == ["echo hello", ["bash", "-c", "echo world"]] + + def test_tuples_become_lists(self): + config = Config( + name="t", + pip_installs=[], + apt_gets=[], + envs=[("A", "B")], + runs=[("ls", "-la")], + ) + d = json.loads(serialize_config(config)) + assert d["envs"] == [["A", "B"]] + assert d["runs"] == [["ls", "-la"]] + + +# --------------------------------------------------------------------------- +# is_python_package / is_python_module / deployment_arcname +# --------------------------------------------------------------------------- + + +class TestIsPythonPackage: + def test_true_for_package(self, package_path): + assert is_python_package(package_path) is True + + def test_false_for_plain_directory(self, tmp_path): + d = tmp_path / "not_a_pkg" + d.mkdir() + assert is_python_package(str(d)) is False + + def test_false_for_file(self, module_path): + assert is_python_package(module_path) is False + + def test_false_for_nonexistent(self): + assert is_python_package("/no/such/path") is False + + +class TestIsPythonModule: + def test_true_for_py_file(self, module_path): + assert is_python_module(module_path) is True + + def test_false_for_non_py_file(self, tmp_dir): + assert is_python_module(str(tmp_dir / "hello.txt")) is False + + def test_false_for_directory(self, package_path): + assert is_python_module(package_path) is False + + def test_false_for_nonexistent(self): + assert is_python_module("/no/such/file.py") is False + + +class TestDeploymentArcname: + def test_package_arcname(self, package_path): + assert deployment_arcname(package_path) == "./deployment" + + def test_module_arcname(self, module_path): + assert deployment_arcname(module_path) == "./deployment.py" + + def test_raises_for_plain_directory(self, tmp_path): + d = tmp_path / "not_a_pkg" + d.mkdir() + with pytest.raises(ValueError, match="neither a Python package"): + deployment_arcname(str(d)) + + def test_raises_for_non_py_file(self, tmp_dir): + with pytest.raises(ValueError, match="neither a Python package"): + deployment_arcname(str(tmp_dir / "hello.txt")) + + def test_raises_for_nonexistent(self): + with pytest.raises(ValueError, match="neither a Python package"): + deployment_arcname("/no/such/path") + + +# --------------------------------------------------------------------------- +# _scan_python_line / filter_ignored_lines +# --------------------------------------------------------------------------- + + +class TestScanPythonLine: + def test_bare_marker(self): + hit, state = _scan_python_line(b"x = 1 #!VAST_IGNORE_CHANGES", None) + assert hit is True + assert state is None + + def test_marker_with_spaces(self): + hit, _ = _scan_python_line(b"x = 1 # !VAST_IGNORE_CHANGES ", None) + assert hit is True + + def test_marker_alone_on_line(self): + hit, _ = _scan_python_line(b"#!VAST_IGNORE_CHANGES", None) + assert hit is True + + def test_partial_comment_not_matched(self): + hit, _ = _scan_python_line(b"# something !VAST_IGNORE_CHANGES", None) + assert hit is False + + def test_trailing_text_not_matched(self): + hit, _ = _scan_python_line(b"# !VAST_IGNORE_CHANGES extra", None) + assert hit is False + + def test_inside_single_quoted_string(self): + hit, _ = _scan_python_line(b'x = "#!VAST_IGNORE_CHANGES"', None) + assert hit is False + + def test_inside_double_quoted_string(self): + hit, _ = _scan_python_line(b"x = '#!VAST_IGNORE_CHANGES'", None) + assert hit is False + + def test_after_string_with_hash(self): + hit, _ = _scan_python_line(b'x = "a#b" #!VAST_IGNORE_CHANGES', None) + assert hit is True + + def test_triple_quote_open_closes_same_line(self): + hit, state = _scan_python_line(b'x = """hello""" #!VAST_IGNORE_CHANGES', None) + assert hit is True + assert state is None + + def test_triple_quote_opens_multiline(self): + hit, state = _scan_python_line(b'x = """start of string', None) + assert hit is False + assert state == b'"""' + + def test_inside_multiline_no_close(self): + hit, state = _scan_python_line(b"still in string #!VAST_IGNORE_CHANGES", b'"""') + assert hit is False + assert state == b'"""' + + def test_multiline_closes(self): + hit, state = _scan_python_line(b'end of string"""', b'"""') + assert hit is False + assert state is None + + def test_multiline_closes_then_comment(self): + hit, state = _scan_python_line(b'end""" #!VAST_IGNORE_CHANGES', b'"""') + assert hit is True + assert state is None + + def test_single_quote_triple(self): + hit, state = _scan_python_line(b"x = '''start", None) + assert hit is False + assert state == b"'''" + + def test_single_quote_triple_closes(self): + hit, state = _scan_python_line(b"end''' #!VAST_IGNORE_CHANGES", b"'''") + assert hit is True + + def test_escaped_quote_in_single_string(self): + hit, _ = _scan_python_line(rb'x = "hello\"#!VAST_IGNORE_CHANGES"', None) + assert hit is False + + def test_escaped_backslash_before_close(self): + # String is "hello\\" (ends with literal backslash), then comment follows + hit, _ = _scan_python_line(rb'x = "hello\\" #!VAST_IGNORE_CHANGES', None) + assert hit is True + + def test_plain_code_no_match(self): + hit, state = _scan_python_line(b"x = 1 + 2", None) + assert hit is False + assert state is None + + +class TestFilterIgnoredLines: + def test_strips_marker_lines(self): + data = b"keep\nx = 1 #!VAST_IGNORE_CHANGES\nalso keep" + assert filter_ignored_lines(data) == b"keep\nalso keep" + + def test_preserves_non_marker_lines(self): + data = b"a\nb\nc" + assert filter_ignored_lines(data) == data + + def test_multiline_string_not_filtered(self): + data = b'x = """\n#!VAST_IGNORE_CHANGES\n"""\nkeep' + assert filter_ignored_lines(data) == data + + def test_multiple_markers(self): + data = b"a #!VAST_IGNORE_CHANGES\nkeep\nb #!VAST_IGNORE_CHANGES" + assert filter_ignored_lines(data) == b"keep" + + def test_multiline_spans_correctly(self): + lines = [ + b"before", + b'x = """', + b"#!VAST_IGNORE_CHANGES", + b"still in string", + b'"""', + b"after #!VAST_IGNORE_CHANGES", + b"end", + ] + data = b"\n".join(lines) + result = filter_ignored_lines(data) + # The marker inside the triple-quoted string is kept; + # the marker after the string closes is stripped. + expected = b"\n".join( + [ + b"before", + b'x = """', + b"#!VAST_IGNORE_CHANGES", + b"still in string", + b'"""', + b"end", + ] + ) + assert result == expected + + +# --------------------------------------------------------------------------- +# read_file_for_hash +# --------------------------------------------------------------------------- + + +class TestReadFileForHash: + def test_no_filter_returns_raw(self, tmp_path): + p = tmp_path / "test.py" + p.write_bytes(b"x = 1 #!VAST_IGNORE_CHANGES\n") + assert ( + read_file_for_hash(str(p), filter_comments=False) + == b"x = 1 #!VAST_IGNORE_CHANGES\n" + ) + + def test_filter_on_py_file(self, tmp_path): + p = tmp_path / "test.py" + p.write_bytes(b"keep\nx = 1 #!VAST_IGNORE_CHANGES\nalso keep") + assert read_file_for_hash(str(p), filter_comments=True) == b"keep\nalso keep" + + def test_filter_ignored_on_non_py(self, tmp_path): + p = tmp_path / "data.txt" + p.write_bytes(b"#!VAST_IGNORE_CHANGES\n") + assert ( + read_file_for_hash(str(p), filter_comments=True) + == b"#!VAST_IGNORE_CHANGES\n" + ) + + +# --------------------------------------------------------------------------- +# hash determinism and sensitivity +# --------------------------------------------------------------------------- + + +class TestHashUpdateDirectory: + def test_deterministic(self, package_path): + h1 = hashlib.sha256() + h2 = hashlib.sha256() + from vastai.serverless.remote.utils import hash_update_directory + + hash_update_directory(h1, package_path, "pkg") + hash_update_directory(h2, package_path, "pkg") + assert h1.hexdigest() == h2.hexdigest() + + def test_content_change_changes_hash(self, package_path): + from vastai.serverless.remote.utils import hash_update_directory + + h1 = hashlib.sha256() + hash_update_directory(h1, package_path, "pkg") + # Modify a file + with open(os.path.join(package_path, "main.py"), "w") as f: + f.write("def handler(): return 42\n") + h2 = hashlib.sha256() + hash_update_directory(h2, package_path, "pkg") + assert h1.hexdigest() != h2.hexdigest() + + +# --------------------------------------------------------------------------- +# compute_deployment_hash +# --------------------------------------------------------------------------- + + +class TestComputeDeploymentHash: + def test_deterministic(self, sample_config, module_path): + h1 = compute_deployment_hash(sample_config, module_path) + h2 = compute_deployment_hash(sample_config, module_path) + assert h1 == h2 + + def test_config_change_changes_hash(self, sample_config, module_path): + h1 = compute_deployment_hash(sample_config, module_path) + sample_config.name = "different" + h2 = compute_deployment_hash(sample_config, module_path) + assert h1 != h2 + + def test_deployment_change_changes_hash(self, sample_config, module_path): + h1 = compute_deployment_hash(sample_config, module_path) + with open(module_path, "w") as f: + f.write("def handler(): return 42\n") + h2 = compute_deployment_hash(sample_config, module_path) + assert h1 != h2 + + def test_extra_file_change_changes_hash(self, sample_config, module_path, tmp_path): + extra = tmp_path / "extra.txt" + extra.write_text("v1") + h1 = compute_deployment_hash( + sample_config, module_path, [(str(extra), "/opt/extra.txt")] + ) + extra.write_text("v2") + h2 = compute_deployment_hash( + sample_config, module_path, [(str(extra), "/opt/extra.txt")] + ) + assert h1 != h2 + + def test_ignore_marker_in_deployment_does_not_change_hash( + self, sample_config, tmp_path + ): + p = tmp_path / "deploy.py" + p.write_text("x = 1 #!VAST_IGNORE_CHANGES\ndef handler(): pass\n") + h1 = compute_deployment_hash(sample_config, str(p)) + p.write_text("x = 999 #!VAST_IGNORE_CHANGES\ndef handler(): pass\n") + h2 = compute_deployment_hash(sample_config, str(p)) + assert h1 == h2 + + def test_ignore_marker_in_extra_py_still_changes_hash( + self, sample_config, module_path, tmp_path + ): + extra = tmp_path / "lib.py" + extra.write_text("x = 1 #!VAST_IGNORE_CHANGES\n") + h1 = compute_deployment_hash( + sample_config, module_path, [(str(extra), "/opt/lib.py")] + ) + extra.write_text("x = 999 #!VAST_IGNORE_CHANGES\n") + h2 = compute_deployment_hash( + sample_config, module_path, [(str(extra), "/opt/lib.py")] + ) + assert h1 != h2 + + def test_works_with_package(self, sample_config, package_path): + h = compute_deployment_hash(sample_config, package_path) + assert isinstance(h, str) and len(h) == 64 + + +# --------------------------------------------------------------------------- +# create_deployment_tarball +# --------------------------------------------------------------------------- + + +class TestCreateDeploymentTarball: + def test_contains_config_json(self, sample_config, module_path, tar_path): + create_deployment_tarball(tar_path, sample_config, module_path, compress=False) + with _open_tar_from_path(tar_path) as rtf: + data = json.loads(rtf.extractfile("./config.json").read()) + assert data["name"] == "test-deployment" + + def test_module_becomes_deployment_py(self, sample_config, module_path, tar_path): + create_deployment_tarball(tar_path, sample_config, module_path, compress=False) + with _open_tar_from_path(tar_path) as rtf: + assert "./deployment.py" in rtf.getnames() + content = rtf.extractfile("./deployment.py").read() + assert b"def handler" in content + + def test_package_becomes_deployment_dir(self, sample_config, package_path, tar_path): + create_deployment_tarball(tar_path, sample_config, package_path, compress=False) + with _open_tar_from_path(tar_path) as rtf: + names = rtf.getnames() + assert "./deployment" in names + assert "./deployment/__init__.py" in names + assert "./deployment/main.py" in names + assert "./deployment/sub/helper.py" in names + + def test_extra_files_absolute_dest_paths(self, sample_config, module_path, tmp_dir, tar_path): + extras = [ + (str(tmp_dir / "hello.txt"), "/opt/data/hello.txt"), + (str(tmp_dir / "script.py"), "/opt/scripts/run.py"), + ] + create_deployment_tarball( + tar_path, sample_config, module_path, extras, compress=False + ) + with _open_tar_from_path(tar_path) as rtf: + names = rtf.getnames() + assert "/opt/data/hello.txt" in names + assert "/opt/scripts/run.py" in names + + def test_extra_files_relative_dest_paths(self, sample_config, module_path, tmp_dir, tar_path): + extras = [ + (str(tmp_dir / "hello.txt"), "data/hello.txt"), + (str(tmp_dir / "script.py"), "./scripts/run.py"), + ] + create_deployment_tarball( + tar_path, sample_config, module_path, extras, compress=False + ) + with _open_tar_from_path(tar_path) as rtf: + names = rtf.getnames() + assert "data/hello.txt" in names + assert "./scripts/run.py" in names + + def test_compressed_tarball_is_valid(self, sample_config, module_path, tar_path): + create_deployment_tarball(tar_path, sample_config, module_path, compress=True) + with tarfile.open(tar_path, "r:gz") as rtf: + assert "./config.json" in rtf.getnames() + + def test_uncompressed_tarball_is_valid(self, sample_config, module_path, tar_path): + create_deployment_tarball(tar_path, sample_config, module_path, compress=False) + with tarfile.open(tar_path, "r:*") as rtf: + assert "./config.json" in rtf.getnames() + + def test_tar_extract_module_deployment(self, sample_config, module_path, tmp_path, tar_path): + create_deployment_tarball(tar_path, sample_config, module_path, compress=False) + extract_dir = tmp_path / "extract" + extract_dir.mkdir() + subprocess.run( + ["tar", "-xPf", tar_path, "-C", str(extract_dir)], + check=True, + ) + assert (extract_dir / "config.json").is_file() + config_data = json.loads((extract_dir / "config.json").read_text()) + assert config_data["name"] == "test-deployment" + assert (extract_dir / "deployment.py").is_file() + assert "def handler" in (extract_dir / "deployment.py").read_text() + + def test_tar_extract_package_deployment( + self, sample_config, package_path, tmp_path, tar_path + ): + create_deployment_tarball(tar_path, sample_config, package_path, compress=False) + extract_dir = tmp_path / "extract" + extract_dir.mkdir() + subprocess.run( + ["tar", "-xPf", tar_path, "-C", str(extract_dir)], + check=True, + ) + assert (extract_dir / "deployment" / "__init__.py").is_file() + assert (extract_dir / "deployment" / "main.py").is_file() + assert (extract_dir / "deployment" / "sub" / "helper.py").is_file() + + def test_tar_extract_absolute_extra_files( + self, sample_config, module_path, tmp_dir, tmp_path, tar_path + ): + abs_dest = str(tmp_path / "abs_output" / "data" / "hello.txt") + extras = [ + (str(tmp_dir / "hello.txt"), abs_dest), + ] + create_deployment_tarball( + tar_path, sample_config, module_path, extras, compress=False + ) + subprocess.run(["tar", "-xPf", tar_path], check=True) + assert os.path.isfile(abs_dest) + with open(abs_dest) as f: + assert f.read() == "hello world" + os.unlink(abs_dest) + + def test_tar_extract_relative_extra_files( + self, sample_config, module_path, tmp_dir, tmp_path, tar_path + ): + extras = [ + (str(tmp_dir / "hello.txt"), "data/hello.txt"), + ] + create_deployment_tarball( + tar_path, sample_config, module_path, extras, compress=False + ) + extract_dir = tmp_path / "extract" + extract_dir.mkdir() + subprocess.run( + ["tar", "-xPf", tar_path, "-C", str(extract_dir)], + check=True, + ) + extracted = extract_dir / "data" / "hello.txt" + assert extracted.is_file() + assert extracted.read_text() == "hello world" + + def test_tar_extract_compressed(self, sample_config, module_path, tmp_path, tar_path): + create_deployment_tarball(tar_path, sample_config, module_path, compress=True) + extract_dir = tmp_path / "extract" + extract_dir.mkdir() + subprocess.run( + ["tar", "-xPzf", tar_path, "-C", str(extract_dir)], + check=True, + ) + assert (extract_dir / "config.json").is_file() + assert (extract_dir / "deployment.py").is_file() + + def test_tarball_closed_on_error(self, sample_config, tmp_path, tar_path): + bad_path = str(tmp_path / "nonexistent.py") + with pytest.raises(ValueError, match="neither a Python package"): + create_deployment_tarball(tar_path, sample_config, bad_path) diff --git a/tests/test_unparse_query.py b/tests/test_unparse_query.py new file mode 100644 index 00000000..58e3e44b --- /dev/null +++ b/tests/test_unparse_query.py @@ -0,0 +1,260 @@ +"""Unit tests for Query.unparse_query() — verifies roundtrip with parse_query.""" + +import re +import sys +from typing import Dict + +import pytest + +from vastai.data.query import Query, Column + + +# --------------------------------------------------------------------------- +# Local copy of parse_query (from vast/web/parse.py) so vast-sdk tests don't +# depend on the vast repo at runtime. +# --------------------------------------------------------------------------- + +def numeric_version(version_str): + try: + major, minor, patch = version_str.split('.') + major = major.zfill(3) + minor = minor.zfill(3) + patch = patch.zfill(3) + return int(f"{major}{minor}{patch}") + except ValueError: + return None + +offers_fields = { + "bw_nvlink", "compute_cap", "cpu_arch", "cpu_cores", "cpu_cores_effective", + "cpu_ghz", "cpu_ram", "cuda_max_good", "datacenter", "direct_port_count", + "driver_version", "disk_bw", "disk_space", "dlperf", "dlperf_per_dphtotal", + "dph_total", "duration", "external", "flops_per_dphtotal", "gpu_arch", + "gpu_display_active", "gpu_frac", "gpu_mem_bw", "gpu_name", "gpu_ram", + "gpu_total_ram", "gpu_max_power", "gpu_max_temp", "has_avx", "host_id", + "id", "inet_down", "inet_down_cost", "inet_up", "inet_up_cost", + "machine_id", "min_bid", "mobo_name", "num_gpus", "pci_gen", "pcie_bw", + "reliability", "rentable", "rented", "storage_cost", "static_ip", + "total_flops", "ubuntu_version", "verification", "verified", "geolocation", +} + +offers_alias = { + "cuda_vers": "cuda_max_good", + "display_active": "gpu_display_active", + "dlperf_usd": "dlperf_per_dphtotal", + "dph": "dph_total", + "flops_usd": "flops_per_dphtotal", +} + +offers_mult = { + "cpu_ram": 1000, + "gpu_ram": 1000, + "gpu_total_ram": 1000, + "duration": 24.0 * 60.0 * 60.0, +} + + +def parse_query(query_str: str, res: Dict = None) -> Dict: + if query_str is None: + return res + if res is None: + res = {} + if type(query_str) == list: + query_str = " ".join(query_str) + query_str = query_str.strip() + + pattern = r"([a-zA-Z0-9_]+)( *[=>=": "gte", ">": "gt", "gt": "gt", "gte": "gte", + "<=": "lte", "<": "lt", "lt": "lt", "lte": "lte", + "!=": "neq", "==": "eq", "=": "eq", "eq": "eq", + "neq": "neq", "noteq": "neq", "not eq": "neq", + "notin": "notin", "not in": "notin", "nin": "notin", + "in": "in", + } + + joined = "".join("".join(x) for x in opts) + if joined != query_str: + raise ValueError("Unconsumed text. Did you forget to quote your query? " + repr(joined) + " != " + repr(query_str)) + + for field, op, _, value, _ in opts: + value = value.strip(",[]") + v = res.setdefault(field, {}) + op = op.strip() + op_name = op_names.get(op) + + if field in offers_alias: + res.pop(field) + field = offers_alias[field] + + if (field == "driver_version") and ('.' in value): + value = numeric_version(value) + + if not op_name: + raise ValueError("Unknown operator. Did you forget to quote your query? " + repr(op).strip("u")) + if op_name in ["in", "notin"]: + value = [x.strip() for x in value.split(",") if x.strip()] + if not value: + raise ValueError("Value cannot be blank. Did you forget to quote your query? " + repr((field, op, value))) + if not field: + raise ValueError("Field cannot be blank. Did you forget to quote your query? " + repr((field, op, value))) + if value in ["?", "*", "any"]: + if op_name != "eq": + raise ValueError("Wildcard only makes sense with equals.") + if field in v: + del v[field] + if field in res: + del res[field] + continue + + if isinstance(value, str): + value = value.replace('_', ' ') + value = value.strip('\"') + elif isinstance(value, list): + value = [x.replace('_', ' ') for x in value] + value = [x.strip('\"') for x in value] + + if field in offers_mult: + value = float(value) * offers_mult[field] + v[op_name] = value + else: + if (value == 'true') or (value == 'True'): + v[op_name] = True + elif (value == 'false') or (value == 'False'): + v[op_name] = False + elif (value == 'None') or (value == 'null'): + v[op_name] = None + else: + v[op_name] = value + + if field not in res: + res[field] = v + else: + res[field].update(v) + return res + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + +def assert_roundtrip(query: Query): + """Assert that unparsing then re-parsing yields the original query dict.""" + unparsed = query.unparse_query() + reparsed = parse_query(unparsed) + assert reparsed == query.query, ( + f"Roundtrip failed.\n" + f" unparsed string: {unparsed!r}\n" + f" reparsed dict: {reparsed}\n" + f" original dict: {query.query}" + ) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestUnparseQueryRoundtrip: + """Each test builds a Query by hand and checks parse_query(q.unparse_query()) == q.query.""" + + def test_eq_string(self): + assert_roundtrip(Query({"gpu_name": {"eq": "RTX 3090"}})) + + def test_eq_numeric_string(self): + assert_roundtrip(Query({"num_gpus": {"eq": "2"}})) + + def test_eq_boolean_true(self): + assert_roundtrip(Query({"rentable": {"eq": True}})) + + def test_eq_boolean_false(self): + assert_roundtrip(Query({"rented": {"eq": False}})) + + def test_eq_none(self): + assert_roundtrip(Query({"verification": {"eq": None}})) + + def test_gte(self): + assert_roundtrip(Query({"num_gpus": {"gte": "4"}})) + + def test_lte(self): + assert_roundtrip(Query({"num_gpus": {"lte": "8"}})) + + def test_gt(self): + assert_roundtrip(Query({"reliability": {"gt": "0.95"}})) + + def test_lt(self): + assert_roundtrip(Query({"dph_total": {"lt": "1.5"}})) + + def test_neq(self): + assert_roundtrip(Query({"gpu_name": {"neq": "RTX 3090"}})) + + def test_in_list(self): + assert_roundtrip(Query({"gpu_name": {"in": ["RTX 3090", "RTX 4090"]}})) + + def test_notin_list(self): + assert_roundtrip(Query({"gpu_name": {"notin": ["RTX 3090", "A100"]}})) + + def test_in_single_element(self): + assert_roundtrip(Query({"gpu_name": {"in": ["RTX 4090"]}})) + + def test_mult_field_cpu_ram(self): + """cpu_ram is multiplied by 1000 during parsing — verify roundtrip.""" + assert_roundtrip(Query({"cpu_ram": {"gte": 32000.0}})) + + def test_mult_field_gpu_ram(self): + assert_roundtrip(Query({"gpu_ram": {"gte": 24000.0, "lte": 48000.0}})) + + def test_mult_field_gpu_total_ram(self): + assert_roundtrip(Query({"gpu_total_ram": {"gte": 80000.0}})) + + def test_mult_field_duration(self): + """duration is multiplied by 86400 during parsing.""" + assert_roundtrip(Query({"duration": {"gte": 259200.0}})) + + def test_multiple_columns(self): + assert_roundtrip(Query({ + "num_gpus": {"gte": "4"}, + "gpu_ram": {"gte": 24000.0}, + "rentable": {"eq": True}, + "rented": {"eq": False}, + })) + + def test_multiple_ops_same_column(self): + assert_roundtrip(Query({"gpu_ram": {"gte": 8000.0, "lte": 48000.0}})) + + def test_search_defaults(self): + """The common search_defaults() query should roundtrip.""" + assert_roundtrip(Query.search_defaults()) + + def test_search_defaults_extended(self): + q = Query.search_defaults() + q.extend(Column("num_gpus") >= "4") + q.extend(Column("gpu_ram") >= 24000.0) + assert_roundtrip(q) + + def test_string_with_spaces(self): + """Values containing spaces are encoded as underscores in query strings.""" + assert_roundtrip(Query({"gpu_name": {"eq": "NVIDIA A100"}})) + + def test_empty_query(self): + q = Query({}) + assert q.unparse_query() == "" + assert parse_query("") == {} + + def test_all_comparison_ops(self): + """Verify every operator kind in a single query.""" + q = Query({ + "num_gpus": {"eq": "4"}, + "gpu_ram": {"gte": 16000.0}, + "cpu_ram": {"lte": 128000.0}, + "reliability": {"gt": "0.9"}, + "dph_total": {"lt": "2.0"}, + "gpu_name": {"neq": "RTX 3060"}, + "gpu_arch": {"in": ["ampere", "hopper"]}, + "cpu_arch": {"notin": ["arm"]}, + }) + assert_roundtrip(q) + + def test_fractional_mult_value(self): + """Non-integer multiplied values (e.g. 0.5 days = 43200s) roundtrip.""" + assert_roundtrip(Query({"duration": {"gte": 43200.0}})) diff --git a/tests/test_vastai_sdk.py b/tests/test_vastai_sdk.py deleted file mode 100644 index 077490a7..00000000 --- a/tests/test_vastai_sdk.py +++ /dev/null @@ -1,97 +0,0 @@ -import unittest -import io -import contextlib -from unittest.mock import patch, MagicMock -from vastai_sdk import VastAI -from vastai_base import VastAIBase - - -class TestVastAIRealFunctions(unittest.TestCase): - def setUp(self): - # Create an instance of VastAI - self.api_key = 'dummy_api_key' - self.vast_ai = VastAI(self.api_key) - self.vast_ai_base = VastAIBase() - - def test_all_imported_methods_are_in_base(self): - """Check if all dynamically imported methods are declared in VastAIBase. This test case is supposed to prevent the developer from forgetting to declare the methods in the base class.""" - # Now check if each dynamically imported method is declared in the base class - for method in self.vast_ai.imported_methods: - # Check if the method exists in VastAIBase as a callable attribute - # This assumes that VastAIBase should have these methods declared - self.assertTrue(hasattr(VastAIBase, method) and callable(getattr(VastAIBase, method)), - f"VastAIBase should have a method named '{method}'") - - def test_all_base_methods_are_in_imported(self): - """Check if all methods in VastAIBase are dynamically imported methods on VastAI.""" - base_methods = [method for method in dir(VastAIBase) if callable(getattr(VastAIBase, method)) and not method.startswith('__')] - for method in base_methods: - self.assertTrue(method in self.vast_ai.imported_methods, - f"Method '{method}' declared in VastAIBase should either be present in the dynamically imported methods of VastAI or should be deleted from VastAIBase.") - - - - -class TestVastAIFakeFunctions(unittest.TestCase): - def setUp(self): - # Mock the vast module and its components to avoid actual import and network operations - self.vast_module_mock = MagicMock() - self.parser_mock = MagicMock() - self.subparsers_mock = MagicMock() - self.subparser_mock = MagicMock() - - # Setup the mock relationships - self.vast_module_mock.parser = self.parser_mock - self.parser_mock.subparsers_ = self.subparsers_mock - self.subparsers_mock.choices = {'command': self.subparser_mock} - self.subparser_mock.default = MagicMock(return_value="Function Output") - self.subparser_mock._defaults = {'func': MagicMock(return_value="Function Output")} - self.subparser_mock._actions = [] - - # Prepare to dynamically add a method - func = MagicMock() - func.__name__ = "test_function" - self.subparser_mock.default = func - self.subparser_mock._defaults = {'func': func} - - # Mock importlib to return the mocked vast module - patcher = patch('importlib.import_module', return_value=self.vast_module_mock) - self.addCleanup(patcher.stop) # Ensure that patcher is stopped after tests - self.mock_import_module = patcher.start() - - # Create an instance of VastAI - self.api_key = 'dummy_api_key' - self.vast_ai = VastAI(self.api_key) - - def test_methods_imported(self): - """Tests that new function is imported and bound to the VastAI instance.""" - # Check if the method is attached - self.assertTrue(hasattr(self.vast_ai, 'test_function'), "Method test_function should be dynamically bound to VastAI instances.") - - # Check if it's callable - self.assertTrue(callable(getattr(self.vast_ai, 'test_function')), "test_function should be callable.") - - def test_method_execution(self): - """Tests that the dynamically imported method can be executed.""" - # Modify the mock to print instead of returning a value - func = self.subparser_mock._defaults['func'] - func.side_effect = lambda args: print("Function Output") # This lambda function now prints - - # Now execute the method - output = self.vast_ai.test_function() - print(f"Output: {output}") # Should capture "Function Output" - - # Verify the output - self.assertEqual(output.strip(), "Function Output", "The test_function should execute and print 'Function Output'.") - - def test_stdout_redirection(self): - """Tests that stdout is redirected to a buffer.""" - with io.StringIO() as buf, contextlib.redirect_stdout(buf): - print("Test output") - captured = buf.getvalue() - self.assertEqual(captured.strip(), "Test output") - - - -if __name__ == '__main__': - unittest.main() diff --git a/vast.ai-logo.xcf b/vast.ai-logo.xcf deleted file mode 100644 index 31545162..00000000 Binary files a/vast.ai-logo.xcf and /dev/null differ diff --git a/vast.py b/vast.py old mode 100755 new mode 100644 index 79a60784..44c4a64e --- a/vast.py +++ b/vast.py @@ -1,7 +1,10 @@ #!/usr/bin/env python3 + +# DEPRECATED: This file is kept for backwards compatibility. +# Please use the vastai package instead. # PYTHON_ARGCOMPLETE_OK -from __future__ import unicode_literals, print_function +from __future__ import unicode_literals, print_function, annotations import re import json @@ -10,7 +13,7 @@ import os import time from typing import Dict, List, Tuple, Optional -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone import hashlib import math import threading @@ -18,6 +21,7 @@ import requests import getpass import subprocess +from time import sleep from subprocess import PIPE import urllib3 import atexit @@ -29,7 +33,14 @@ import textwrap from pathlib import Path import warnings +import importlib.metadata + +from copy import deepcopy + +PYPI_BASE_PATH = "https://pypi.org" +# INFO - Change to False if you don't want to check for update each run. +should_check_for_update = False ARGS = None TABCOMPLETE = False try: @@ -39,6 +50,11 @@ # No tab-completion for you pass +try: + import curlify +except ImportError: + pass + try: from urllib import quote_plus # Python 2.X except ImportError: @@ -56,8 +72,8 @@ #server_url_default = "https://vast.ai" -server_url_default = "https://console.vast.ai" -# server_url_default = "http://localhost:5002" +server_url_default = os.getenv("VAST_URL") or "https://console.vast.ai" +#server_url_default = "http://localhost:5002" #server_url_default = "host.docker.internal" #server_url_default = "http://localhost:5002" #server_url_default = "https://vast.ai/api/v0" @@ -67,7 +83,129 @@ format="%(levelname)s - %(message)s" ) +def parse_version(version: str) -> tuple[int, ...]: + parts = version.split(".") + + if len(parts) < 3: + print(f"Invalid version format: {version}", file=sys.stderr) + + return tuple(int(part) for part in parts) + + +def get_git_version(): + try: + result = subprocess.run( + ["git", "describe", "--tags", "--abbrev=0"], + capture_output=True, + text=True, + check=True, + ) + tag = result.stdout.strip() + + return tag[1:] if tag.startswith("v") else tag + except Exception: + return "0.0.0" + + +def get_pip_version(): + try: + return importlib.metadata.version("vastai") + except Exception: + return "0.0.0" + + +def is_pip_package(): + try: + return importlib.metadata.metadata("vastai") is not None + except Exception: + return False + +def get_update_command(stable_version: str) -> str: + if is_pip_package(): + if "test.pypi.org" in PYPI_BASE_PATH: + return f"{sys.executable} -m pip install --force-reinstall --no-cache-dir -i {PYPI_BASE_PATH} vastai=={stable_version}" + else: + return f"{sys.executable} -m pip install --force-reinstall --no-cache-dir vastai=={stable_version}" + else: + return f"git fetch --all --tags --prune && git checkout tags/v{stable_version}" + + +def get_local_version(): + if is_pip_package(): + return get_pip_version() + return get_git_version() + + +def get_project_data(project_name: str) -> dict[str, dict[str, str]]: + url = PYPI_BASE_PATH + f"/pypi/{project_name}/json" + response = requests.get(url, headers={"Accept": "application/json"}) + + # this will raise for HTTP status 4xx and 5xx + response.raise_for_status() + + # this will raise for HTTP status >200,<=399 + if response.status_code != 200: + raise Exception( + f"Could not get PyPi Project: {project_name}. Response: {response.status_code}" + ) + + response_data: dict[str, dict[str, str]] = response.json() + return response_data + + +def get_pypi_version(project_data: dict[str, dict[str, str]]) -> str: + info_data = project_data.get("info") + + if not info_data: + raise Exception("Could not get PyPi Project") + + version_data: str = str(info_data.get("version")) + + return str(version_data) +def check_for_update(): + pypi_data = get_project_data("vastai") + pypi_version = get_pypi_version(pypi_data) + + local_version = get_local_version() + + local_tuple = parse_version(local_version) + pypi_tuple = parse_version(pypi_version) + + if local_tuple >= pypi_tuple: + return + + user_wants_update = input( + f"Update available from {local_version} to {pypi_version}. Would you like to update [Y/n]: " + ).lower() + + if user_wants_update not in ["y", ""]: + print("You selected no. If you don't want to check for updates each time, update should_check_for_update in vast.py") + return + + update_command = get_update_command(pypi_version) + + print("Updating...") + _ = subprocess.run( + update_command, + shell=True, + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + print("Update completed successfully!\nAttempt to run your command again!") + sys.exit(0) + APP_NAME = "vastai" +VERSION = get_local_version() + +# define emoji support and fallbacks +_HAS_EMOJI = sys.stdout.encoding and 'utf' in sys.stdout.encoding.lower() +SUCCESS = "✅" if _HAS_EMOJI else "[OK]" +WARN = "⚠️" if _HAS_EMOJI else "[!]" +FAIL = "❌" if _HAS_EMOJI else "[X]" +INFO = "ℹ️" if _HAS_EMOJI else "[i]" try: # Although xdg-base-dirs is the newer name, there's @@ -85,9 +223,11 @@ except: # Reasonable defaults. + from pathlib import Path + _home = str(Path.home()) DIRS = { - 'config': os.path.join(os.getenv('HOME'), '.config'), - 'temp': os.path.join(os.getenv('HOME'), '.cache'), + 'config': os.path.join(_home, '.config'), + 'temp': os.path.join(_home, '.cache'), } for key in DIRS.keys(): @@ -100,8 +240,10 @@ APIKEY_FILE = os.path.join(DIRS['config'], "vast_api_key") APIKEY_FILE_HOME = os.path.expanduser("~/.vast_api_key") # Legacy +TFAKEY_FILE = os.path.join(DIRS['config'], "vast_tfa_key") -if os.path.exists(APIKEY_FILE_HOME): +if not os.path.exists(APIKEY_FILE) and os.path.exists(APIKEY_FILE_HOME): + #print(f'copying key from {APIKEY_FILE_HOME} -> {APIKEY_FILE}') shutil.copyfile(APIKEY_FILE_HOME, APIKEY_FILE) @@ -113,6 +255,25 @@ class Object(object): pass +def validate_seconds(value): + """Validate that the input value is a valid number for seconds between yesterday and Jan 1, 2100.""" + try: + val = int(value) + + # Calculate min_seconds as the start of yesterday in seconds + yesterday = datetime.now() - timedelta(days=1) + min_seconds = int(yesterday.timestamp()) + + # Calculate max_seconds for Jan 1st, 2100 in seconds + max_date = datetime(2100, 1, 1, 0, 0, 0) + max_seconds = int(max_date.timestamp()) + + if not (min_seconds <= val <= max_seconds): + raise argparse.ArgumentTypeError(f"{value} is not a valid second timestamp.") + return val + except ValueError: + raise argparse.ArgumentTypeError(f"{value} is not a valid integer.") + def strip_strings(value): if isinstance(value, str): return value.strip() @@ -133,6 +294,10 @@ def string_to_unix_epoch(date_string): date_object = datetime.strptime(date_string, "%m/%d/%Y") return time.mktime(date_object.timetuple()) +def unix_to_readable(ts): + # ts: integer or float, Unix timestamp + return datetime.fromtimestamp(ts).strftime('%H:%M:%S|%h-%d-%Y') + def fix_date_fields(query: Dict[str, Dict], date_fields: List[str]): """Takes in a query and date fields to correct and returns query with appropriate epoch dates""" new_query: Dict[str, Dict] = {} @@ -148,10 +313,10 @@ def fix_date_fields(query: Dict[str, Dict], date_fields: List[str]): class argument(object): - def __init__(self, *args, **kwargs): + def __init__(self, *args, mutex_group=None, **kwargs): self.args = args self.kwargs = kwargs - + self.mutex_group = mutex_group # Name of the mutually exclusive group this arg belongs to class hidden_aliases(object): # just a bit of a hack @@ -170,21 +335,30 @@ def __nonzero__(self): def append(self, x): self.l.append(x) -def http_get(args, req_url, headers = None, json = None): +def http_request(verb, args, req_url, headers: dict[str, str] | None = None, json_data = None): t = 0.15 for i in range(0, args.retry): - r = requests.get(req_url, headers=headers, json=json) - if (r.status_code == 429): - time.sleep(t) - t *= 1.5 + req = requests.Request(method=verb, url=req_url, headers=headers, json=json_data) + session = requests.Session() + prep = session.prepare_request(req) + if args.explain: + print(f"\n{INFO} Prepared Request:") + print(f"{prep.method} {prep.url}") + print(f"Headers: {json.dumps(headers, indent=1)}") + print(f"Body: {json.dumps(json_data, indent=1)}" + "\n" + "_"*100 + "\n") + + if ARGS.curl: + as_curl = curlify.to_curl(prep) + simple = re.sub(r" -H '[^']*'", '', as_curl) + parts = re.split(r'(?=\s+-\S+)', simple) + pp = parts[-1].split("'") + pp[-3] += "\n " + parts = [*parts[:-1], *[x.rstrip() for x in "'".join(pp).split("\n")]] + print("\n" + ' \\\n '.join(parts).strip() + "\n") + sys.exit(0) else: - break - return r + r = session.send(prep) -def http_put(args, req_url, headers, json): - t = 0.3 - for i in range(0, int(args.retry)): - r = requests.put(req_url, headers=headers, json=json) if (r.status_code == 429): time.sleep(t) t *= 1.5 @@ -192,29 +366,17 @@ def http_put(args, req_url, headers, json): break return r -def http_post(args, req_url, headers, json={}): - t = 0.3 - for i in range(0, int(args.retry)): - #if (args.explain): - # print(req_url) - r = requests.post(req_url, headers=headers, json=json) - if (r.status_code == 429): - time.sleep(t) - t *= 1.5 - else: - break - return r +def http_get(args, req_url, headers = None, json = None): + return http_request('GET', args, req_url, headers, json) -def http_del(args, req_url, headers, json={}): - t = 0.3 - for i in range(0, int(args.retry)): - r = requests.delete(req_url, headers=headers, json=json) - if (r.status_code == 429): - time.sleep(t) - t *= 1.5 - else: - break - return r +def http_put(args, req_url, headers = None, json = {}): + return http_request('PUT', args, req_url, headers, json) + +def http_post(args, req_url, headers = None, json={}): + return http_request('POST', args, req_url, headers, json) + +def http_del(args, req_url, headers = None, json={}): + return http_request('DELETE', args, req_url, headers, json) def load_permissions_from_file(file_path): @@ -232,7 +394,8 @@ def complete_sshkeys(prefix=None, action=None, parser=None, parsed_args=None): class apwrap(object): def __init__(self, *args, **kwargs): - kwargs["formatter_class"] = argparse.RawDescriptionHelpFormatter + if "formatter_class" not in kwargs: + kwargs["formatter_class"] = MyWideHelpFormatter self.parser = argparse.ArgumentParser(*args, **kwargs) self.parser.set_defaults(func=self.fail_with_help) self.subparsers_ = None @@ -250,7 +413,15 @@ def add_argument(self, *a, **kw): if not kw.get("parent_only"): for x in self.subparser_objs: try: - x.add_argument(*a, **kw) + # Create a global options group for better visual separation + if not hasattr(x, '_global_options_group'): + x._global_options_group = x.add_argument_group('Global options (available for all commands)') + # Use SUPPRESS as default for subparsers so they don't overwrite + # values already set by the main parser when the argument is placed + # before the subcommand (e.g., `vastai --url get wrkgrp-logs`) + subparser_kw = kw.copy() + subparser_kw['default'] = argparse.SUPPRESS + x._global_options_group.add_argument(*a, **subparser_kw) except argparse.ArgumentError: # duplicate - or maybe other things, hopefully not pass @@ -290,9 +461,9 @@ def inner(func): for x in aliases: verb, _, obj = x.partition(" ") aliases_transformed.append(self.get_name(verb, obj)) + if "formatter_class" not in kwargs: + kwargs["formatter_class"] = MyWideHelpFormatter - kwargs["formatter_class"] = argparse.RawDescriptionHelpFormatter - sp = self.subparsers().add_parser(name, aliases=aliases_transformed, help=help_, **kwargs) # TODO: Sometimes the parser.command has a help parameter. Ideally @@ -302,20 +473,8 @@ def inner(func): setattr(func, "mysignature_help", help_) self.subparser_objs.append(sp) - for arg in arguments: - tsp = sp.add_argument(*arg.args, **arg.kwargs) - myCompleter= None - comparator = arg.args[0].lower() - if comparator.startswith('machine'): - myCompleter = complete_instance_machine - elif comparator.startswith('id') or comparator.endswith('id'): - myCompleter = complete_instance - elif comparator.startswith('ssh'): - myCompleter = complete_sshkeys - - if myCompleter: - setattr(tsp, 'completer', myCompleter) - + + self._process_arguments_with_groups(sp, arguments) sp.set_defaults(func=func) return func @@ -340,8 +499,62 @@ def parse_args(self, argv=None, *a, **kw): func(args) return args + def _process_arguments_with_groups(self, parser_obj, arguments): + """Process arguments and handle mutually exclusive groups""" + mutex_groups_to_required = {} + arg_to_group = {} + + # Determine if any mutex groups are required + for arg in arguments: + key = arg.args[0] + if arg.mutex_group: + is_required = arg.kwargs.pop('required', False) + group_name = arg.mutex_group + arg_to_group[key] = group_name + if mutex_groups_to_required.get(group_name): + continue # if marked as required then it stays required + else: + mutex_groups_to_required[group_name] = is_required + + name_to_group_parser = {} # Create mutually exclusive group parsers + for group_name, is_required in mutex_groups_to_required.items(): + mutex_group = parser_obj.add_mutually_exclusive_group(required=is_required) + name_to_group_parser[group_name] = mutex_group + + for arg in arguments: # Add args via the appropriate parser + key = arg.args[0] + if arg_to_group.get(key): + group_parser = name_to_group_parser[arg_to_group[key]] + tsp = group_parser.add_argument(*arg.args, **arg.kwargs) + else: + tsp = parser_obj.add_argument(*arg.args, **arg.kwargs) + self._add_completer(tsp, arg) + + + def _add_completer(self, tsp, arg): + """Helper function to add completers based on argument names""" + myCompleter = None + comparator = arg.args[0].lower() + if comparator.startswith('machine'): + myCompleter = complete_instance_machine + elif comparator.startswith('id') or comparator.endswith('id'): + myCompleter = complete_instance + elif comparator.startswith('ssh'): + myCompleter = complete_sshkeys + + if myCompleter: + setattr(tsp, 'completer', myCompleter) + + +class MyWideHelpFormatter(argparse.RawTextHelpFormatter): + def __init__(self, prog): + super().__init__(prog, width=128, max_help_position=50, indent_increment=1) -parser = apwrap(epilog="Use 'vast COMMAND --help' for more info about a command") + +parser = apwrap( + epilog="Use 'vast COMMAND --help' for more info about a command. AI agent? See https://raw.githubusercontent.com/vast-ai/vast-cli/master/vastai/SKILL.md", + formatter_class=MyWideHelpFormatter +) def translate_null_strings_to_blanks(d: Dict) -> Dict: """Map over a dict and translate any null string values into ' '. @@ -378,6 +591,8 @@ def apiurl(args: argparse.Namespace, subpath: str, query_args: Dict = None) -> s query_args = {} if args.api_key is not None: query_args["api_key"] = args.api_key + if not re.match(r"^/api/v(\d)+/", subpath): + subpath = "/api/v0" + subpath query_json = None @@ -395,15 +610,15 @@ def apiurl(args: argparse.Namespace, subpath: str, query_args: Dict = None) -> s "{x}={y}".format(x=x, y=quote_plus(y if isinstance(y, str) else json.dumps(y))) for x, y in query_args.items()) - result = args.url + "/api/v0" + subpath + "?" + query_json + result = args.url + subpath + "?" + query_json else: - result = args.url + "/api/v0" + subpath + result = args.url + subpath if (args.explain): print("query args:") print(query_args) print("") - print(f"base: {args.url + '/api/v0' + subpath + '?'} + query: ") + print(f"base: {args.url + subpath + '?'} + query: ") print(result) print("") return result @@ -420,7 +635,7 @@ def apiheaders(args: argparse.Namespace) -> Dict: return result -def deindent(message: str) -> str: +def deindent(message: str, add_separator: bool = True) -> str: """ Deindent a quoted string. Scans message and finds the smallest number of whitespace characters in any line and removes that many from the start of every line. @@ -432,6 +647,10 @@ def deindent(message: str) -> str: indents = [len(x) for x in re.findall("^ *(?=[^ ])", message, re.MULTILINE) if len(x)] a = min(indents) message = re.sub(r"^ {," + str(a) + "}", "", message, flags=re.MULTILINE) + if add_separator: + # For help epilogs - cleanly separating extra help from options + line_width = min(150, shutil.get_terminal_size((80, 20)).columns) + message = "_"*line_width + "\n"*2 + message.strip() + "\n" + "_"*line_width return message.strip() @@ -446,6 +665,7 @@ def deindent(message: str) -> str: ("cpu_ghz", "cpu_ghz", "{:0.1f}", None, True), ("cpu_cores_effective", "vCPUs", "{:0.1f}", None, True), ("cpu_ram", "RAM", "{:0.1f}", lambda x: x / 1000, False), + ("gpu_ram", "VRAM", "{:0.1f}", lambda x: x / 1000, False), ("disk_space", "Disk", "{:.0f}", None, True), ("dph_total", "$/hr", "{:0.4f}", None, True), ("dlperf", "DLP", "{:0.1f}", None, True), @@ -495,6 +715,10 @@ def deindent(message: str) -> str: vol_offers_fields = { "cpu_arch", "cuda_vers", + "cluster_id", + "nw_disk_min_bw", + "nw_disk_avg_bw", + "nw_disk_max_bw", "datacenter", "disk_bw", "disk_space", @@ -503,6 +727,7 @@ def deindent(message: str) -> str: "geolocation", "gpu_arch", "has_avx", + "host_id", "id", "inet_down", "inet_up", @@ -537,6 +762,23 @@ def deindent(message: str) -> str: ("geolocation", "country", "{}", None, True), ) +nw_vol_displayable_fields = ( + ("id", "ID", "{}", None, True), + ("disk_space", "Disk", "{:.0f}", None, True), + ("storage_cost", "$/Gb/Month", "{:.2f}", None, True), + ("inet_up", "Net_up", "{:0.1f}", None, True), + ("inet_down", "Net_down", "{:0.1f}", None, True), + ("reliability", "R", "{:0.1f}", lambda x: x * 100, True), + ("duration", "Max_Days", "{:0.1f}", lambda x: x / (24.0 * 60.0 * 60.0), True), + ("verification", "status", "{}", None, True), + ("host_id", "host_id", "{}", None, True), + ("cluster_id", "cluster_id", "{}", None, True), + ("geolocation", "country", "{}", None, True), + ("nw_disk_min_bw", "Min BW MiB/s", "{}", None, True), + ("nw_disk_max_bw", "Max BW MiB/s", "{}", None, True), + ("nw_disk_avg_bw", "Avg BW MiB/s", "{}", None, True), + +) # Need to add bw_nvlink, machine_id, direct_port_count to output. @@ -565,8 +807,38 @@ def deindent(message: str) -> str: ("uptime_mins", "uptime(mins)", "{:0.2f}", None, True), ) +cluster_fields = ( + ("id", "ID", "{}", None, True), + ("subnet", "Subnet", "{}", None, True), + ("node_count", "Nodes", "{}", None, True), + ("manager_id", "Manager ID", "{}", None, True), + ("manager_ip", "Manager IP", "{}", None, True), + ("machine_ids", "Machine ID's", "{}", None, True) +) + +network_disk_fields = ( + ("network_disk_id", "Network Disk ID", "{}", None, True), + ("free_space", "Free Space (GB)", "{}", None, True), + ("total_space", "Total Space (GB)", "{}", None, True), +) + +network_disk_machine_fields = ( + ("machine_id", "Machine ID", "{}", None, True), + ("mount_point", "Mount Point", "{}", None, True), +) + +overlay_fields = ( + ("overlay_id", "Overlay ID", "{}", None, True), + ("name", "Name", "{}", None, True), + ("subnet", "Subnet", "{}", None, True), + ("cluster_id", "Cluster ID", "{}", None, True), + ("instance_count", "Instances", "{}", None, True), + ("instances", "Instance IDs", "{}", None, True), +) volume_fields = ( ("id", "ID", "{}", None, True), + ("cluster_id", "Cluster ID", "{}", None, True), + ("label", "Name", "{}", None, True), ("disk_space", "Disk", "{:.0f}", None, True), ("status", "status", "{}", None, True), ("disk_name", "Disk Name", "{}", None, True), @@ -610,7 +882,6 @@ def deindent(message: str) -> str: ("end_time", "End (Date/Time)", "{}", lambda x: datetime.fromtimestamp(x).strftime('%Y-%m-%d/%H:%M'), True), ("duration_hours", "Duration (Hrs)", "{}", None, True), ("maintenance_category", "Category", "{}", None, True), - ("maintenance_reason", "Reason", "{}", None, True), ) @@ -628,6 +899,19 @@ def deindent(message: str) -> str: ("args", "args", "{}", None, True), ) + +scheduled_jobs_fields = ( + ("id", "Scheduled Job ID", "{}", None, True), + ("instance_id", "Instance ID", "{}", None, True), + ("api_endpoint", "API Endpoint", "{}", None, True), + ("start_time", "Start (Date/Time in UTC)", "{}", lambda x: datetime.fromtimestamp(x).strftime('%Y-%m-%d/%H:%M'), True), + ("end_time", "End (Date/Time in UTC)", "{}", lambda x: datetime.fromtimestamp(x).strftime('%Y-%m-%d/%H:%M'), True), + ("day_of_the_week", "Day of the Week", "{}", None, True), + ("hour_of_the_day", "Hour of the Day in UTC", "{}", None, True), + ("min_of_the_hour", "Minute of the Hour", "{}", None, True), + ("frequency", "Frequency", "{}", None, True), +) + invoice_fields = ( ("description", "Description", "{}", None, True), ("quantity", "Quantity", "{}", None, True), @@ -746,7 +1030,8 @@ def version_string_sort(a, b) -> int: "verification", "verified", "vms_enabled", - "geolocation" + "geolocation", + "cluster_id" } offers_alias = { @@ -883,8 +1168,13 @@ def parse_query(query_str: str, res: Dict = None, fields = {}, field_alias = {}, #print(res) return res +# ANSI escape codes for background/foreground colors +BG_DARK_GRAY = '\033[40m' # Dark gray background +BG_LIGHT_GRAY = '\033[48;5;240m' # Light gray background +FG_WHITE = '\033[97m' # Bright white text +BG_RESET = '\033[0m' # Reset all formatting -def display_table(rows: list, fields: Tuple, replace_spaces: bool = True) -> None: +def display_table(rows: list, fields: Tuple, replace_spaces: bool = True, auto_width: bool = True) -> None: """Basically takes a set of field names and rows containing the corresponding data and prints a nice tidy table of it. @@ -915,17 +1205,69 @@ def display_table(rows: list, fields: Tuple, replace_spaces: bool = True) -> Non idx = len(row) lengths[idx] = max(len(s), lengths[idx]) row.append(s) - for row in out_rows: - out = [] - for l, s, f in zip(lengths, row, fields): - _, _, _, _, ljust = f - if ljust: - s = s.ljust(l) - else: - s = s.rjust(l) - out.append(s) - print(" ".join(out)) - + + if auto_width: + width = shutil.get_terminal_size((80, 20)).columns + start_col_idxs = [0] + total_len = 4 # +6ch for row label and -2ch for missing last sep in " ".join() + for i, l in enumerate(lengths): + total_len += l + 2 + if total_len > width: + start_col_idxs.append(i) # index for the start of the next group + total_len = l + 6 # l + 2 + the 4 from the initial length + + groups = {} + for row in out_rows: + grp_num = 0 + for i in range(len(start_col_idxs)): + start = start_col_idxs[i] + end = start_col_idxs[i+1] if i+1 < len(start_col_idxs) else len(lengths) + groups.setdefault(grp_num, []).append(row[start:end]) + grp_num += 1 + + for i, group in groups.items(): + idx = start_col_idxs[i] + group_lengths = lengths[idx:idx+len(group[0])] + for row_num, row in enumerate(group): + bg_color = BG_DARK_GRAY if (row_num - 1) % 2 else BG_LIGHT_GRAY + row_label = " #" if row_num == 0 else f"{row_num:3d}" + out = [row_label] + for l, s, f in zip(group_lengths, row, fields[idx:idx+len(row)]): + _, _, _, _, ljust = f + if ljust: s = s.ljust(l) + else: s = s.rjust(l) + out.append(s) + print(bg_color + FG_WHITE + " ".join(out) + BG_RESET) + print() + else: + for row in out_rows: + out = [] + for l, s, f in zip(lengths, row, fields): + _, _, _, _, ljust = f + if ljust: + s = s.ljust(l) + else: + s = s.rjust(l) + out.append(s) + print(" ".join(out)) + + +def print_or_page(args, text): + """ Print text to terminal, or pipe to pager_cmd if too long. """ + line_threshold = shutil.get_terminal_size(fallback=(80, 24)).lines + lines = text.splitlines() + if not args.full and len(lines) > line_threshold: + pager_cmd = ['less', '-R'] if shutil.which('less') else None + if pager_cmd: + proc = subprocess.Popen(pager_cmd, stdin=subprocess.PIPE) + proc.communicate(input=text.encode()) + return True + else: + print(text) + return False + else: + print(text) + return False class VRLException(Exception): pass @@ -941,22 +1283,27 @@ def parse_vast_url(url_str): instance_id = None path = url_str + #print(f'url_str: {url_str}') if (":" in url_str): url_parts = url_str.split(":", 2) if len(url_parts) == 2: (instance_id, path) = url_parts else: raise VRLException("Invalid VRL (Vast resource locator).") + else: try: - instance_id = int(instance_id) + instance_id = int(path) + path = "/" except: - raise VRLException("Instance id must be an integer.") + pass valid_unix_path_regex = re.compile('^(/)?([^/\0]+(/)?)+$') # Got this regex from https://stackoverflow.com/questions/537772/what-is-the-most-correct-regular-expression-for-a-unix-file-path if (path != "/") and (valid_unix_path_regex.match(path) is None): raise VRLException(f"Path component: {path} of VRL is not a valid Unix style path.") - + + #print(f'instance_id: {instance_id}') + #print(f'path: {path}') return (instance_id, path) def get_ssh_key(argstr): @@ -975,7 +1322,7 @@ def get_ssh_key(argstr): has around 200 or so "base64" characters and ends with some-user@some-where. "Generate public ssh key" would be a good search term if you don't know how to do this. - """)) + """, add_separator=False)) if not ssh_key.lower().startswith('ssh'): raise ValueError(deindent(""" @@ -988,7 +1335,7 @@ def get_ssh_key(argstr): {} And welp, that just don't look right. - """.format(ssh_key))) + """.format(ssh_key), add_separator=False)) return ssh_key @@ -996,16 +1343,17 @@ def get_ssh_key(argstr): @parser.command( argument("instance_id", help="id of instance to attach to", type=int), argument("ssh_key", help="ssh key to attach to instance", type=str), - usage="vastai attach instance_id ssh_key", + usage="vastai attach ssh instance_id ssh_key", help="Attach an ssh key to an instance. This will allow you to connect to the instance with the ssh key", epilog=deindent(""" Attach an ssh key to an instance. This will allow you to connect to the instance with the ssh key. Examples: - vast attach 12371 ssh-rsa AAAAB3NzaC1yc2EAAA... - vast attach 12371 ssh-rsa $(cat ~/.ssh/id_rsa) + vast attach ssh 12371 AAAAB3NzaC1yc2EAAA... + vast attach ssh 12371 $(cat ~/.ssh/id_rsa.pub) + vast attach ssh 12371 ~/.ssh/id_rsa.pub - The first example attaches the ssh key to instance 12371 + All examples attaches the ssh key to instance 12371 """), ) def attach__ssh(args): @@ -1014,6 +1362,8 @@ def attach__ssh(args): req_json = {"ssh_key": ssh_key} r = http_post(args, url, headers=headers, json=req_json) r.raise_for_status() + if args.raw: + return r print(r.json()) @parser.command( @@ -1101,11 +1451,56 @@ def cancel__sync(args: argparse.Namespace): print(r.text); print("failed with error {r.status_code}".format(**locals())); +def default_start_date(): + return datetime.now(timezone.utc).strftime("%Y-%m-%d") + +def default_end_date(): + return (datetime.now(timezone.utc) + timedelta(days=7)).strftime("%Y-%m-%d") + +def convert_timestamp_to_date(unix_timestamp): + utc_datetime = datetime.fromtimestamp(unix_timestamp, tz=timezone.utc) + return utc_datetime.strftime("%Y-%m-%d") + +def parse_day_cron_style(value): + """ + Accepts an integer string 0-6 or '*' to indicate 'Every day'. + Returns 0-6 as int, or None if '*'. + """ + val = str(value).strip() + if val == "*": + return None + try: + day = int(val) + if 0 <= day <= 6: + return day + except ValueError: + pass + raise argparse.ArgumentTypeError("Day must be 0-6 (0=Sunday) or '*' for every day.") +def parse_hour_cron_style(value): + """ + Accepts an integer string 0-23 or '*' to indicate 'Every hour'. + Returns 0-23 as int, or None if '*'. + """ + val = str(value).strip() + if val == "*": + return None + try: + hour = int(val) + if 0 <= hour <= 23: + return hour + except ValueError: + pass + raise argparse.ArgumentTypeError("Hour must be 0-23 or '*' for every hour.") @parser.command( argument("id", help="id of instance type to change bid", type=int), argument("--price", help="per machine bid price in $/hour", type=float), + argument("--schedule", choices=["HOURLY", "DAILY", "WEEKLY"], help="try to schedule a command to run hourly, daily, or monthly. Valid values are HOURLY, DAILY, WEEKLY For ex. --schedule DAILY"), + argument("--start_date", type=str, default=default_start_date(), help="Start date/time in format 'YYYY-MM-DD HH:MM:SS PM' (UTC). Default is now. (optional)"), + argument("--end_date", type=str, default=default_end_date(), help="End date/time in format 'YYYY-MM-DD HH:MM:SS PM' (UTC). Default is 7 days from now. (optional)"), + argument("--day", type=parse_day_cron_style, help="Day of week you want scheduled job to run on (0-6, where 0=Sunday) or \"*\". Default will be 0. For ex. --day 0", default=0), + argument("--hour", type=parse_hour_cron_style, help="Hour of day you want scheduled job to run on (0-23) or \"*\" (UTC). Default will be 0. For ex. --hour 16", default=0), usage="vastai change bid id [--price PRICE]", help="Change the bid price for a spot/interruptible instance", epilog=deindent(""" @@ -1125,35 +1520,93 @@ def change__bid(args: argparse.Namespace): if (args.explain): print("request json: ") print(json_blob) + + if (args.schedule): + validate_frequency_values(args.day, args.hour, args.schedule) + cli_command = "change bid" + api_endpoint = "/api/v0/instances/bid_price/{id}/".format(id=args.id) + json_blob["instance_id"] = args.id + add_scheduled_job(args, json_blob, cli_command, api_endpoint, "PUT", instance_id=args.id) + return + r = http_put(args, url, headers=headers, json=json_blob) r.raise_for_status() print("Per gpu bid price changed".format(r.json())) +@parser.command( + argument("source", help="id of volume contract being cloned", type=int), + argument("dest", help="id of volume offer volume is being copied to", type=int), + argument("-s", "--size", help="Size of new volume contract, in GB. Must be greater than or equal to the source volume, and less than or equal to the destination offer.", type=float), + argument("-d", "--disable_compression", action="store_true", help="Do not compress volume data before copying."), + usage="vastai clone volume [options]", + help="Clone an existing volume", + epilog=deindent(""" + Create a new volume with the given offer, by copying the existing volume. + Size defaults to the size of the existing volume, but can be increased if there is available space. + """) +) +def clone__volume(args: argparse.Namespace): + json_blob={ + "src_id" : args.source, + "dst_id": args.dest, + } + if args.size: + json_blob["size"] = args.size + if args.disable_compression: + json_blob["disable_compression"] = True + + + url = apiurl(args, "/volumes/copy/") + + if (args.explain): + print("request json: ") + print(json_blob) + r = http_post(args, url, headers=headers,json=json_blob) + r.raise_for_status() + if args.raw: + return r + else: + print("Created. {}".format(r.json())) + @parser.command( - argument("src", help="instance_id:/path to source of object to copy", type=str), - argument("dst", help="instance_id:/path to target of copy operation", type=str), + argument("src", help="Source location for copy operation (supports multiple formats)", type=str), + argument("dst", help="Target location for copy operation (supports multiple formats)", type=str), argument("-i", "--identity", help="Location of ssh private key", type=str), usage="vastai copy SRC DST", help="Copy directories between instances and/or local", epilog=deindent(""" Copies a directory from a source location to a target location. Each of source and destination directories can be either local or remote, subject to appropriate read and write - permissions required to carry out the action. The format for both src and dst is [instance_id:]path. - + permissions required to carry out the action. + + Supported location formats: + - [instance_id:]path (legacy format, still supported) + - C.instance_id:path (container copy format) + - cloud_service:path (cloud service format) + - cloud_service.cloud_service_id:path (cloud service with ID) + - local:path (explicit local path) + - V.volume_id:path (volume copy, see restrictions) + You should not copy to /root or / as a destination directory, as this can mess up the permissions on your instance ssh folder, breaking future copy operations (as they use ssh authentication) You can see more information about constraints here: https://vast.ai/docs/gpu-instances/data-movement#constraints - + Volume copy is currently only supported for copying to other volumes or instances, not cloud services or local. + Examples: vast copy 6003036:/workspace/ 6003038:/workspace/ - vast copy 11824:/data/test data/test - vast copy data/test 11824:/data/test + vast copy C.11824:/data/test local:data/test + vast copy local:data/test C.11824:/data/test + vast copy drive:/folder/file.txt C.6003036:/workspace/ + vast copy s3.101:/data/ C.6003036:/workspace/ + vast copy V.1234:/file C.5678:/workspace/ The first example copy syncs all files from the absolute directory '/workspace' on instance 6003036 to the directory '/workspace' on instance 6003038. - The second example copy syncs the relative directory 'data/test' on the local machine from '/data/test' in instance 11824. - The third example copy syncs the directory '/data/test' in instance 11824 from the relative directory 'data/test' on the local machine. + The second example copy syncs files from container 11824 to the local machine using structured syntax. + The third example copy syncs files from local to container 11824 using structured syntax. + The fourth example copy syncs files from Google Drive to an instance. + The fifth example copy syncs files from S3 bucket with id 101 to an instance. """), ) def copy(args: argparse.Namespace): @@ -1167,8 +1620,9 @@ def copy(args: argparse.Namespace): (src_id, src_path) = parse_vast_url(args.src) (dst_id, dst_path) = parse_vast_url(args.dst) if (src_id is None) and (dst_id is None): - print("invalid arguments") - return + pass + #print("invalid arguments") + #return print(f"copying {str(src_id)+':' if src_id else ''}{src_path} {str(dst_id)+':' if dst_id else ''}{dst_path}") @@ -1189,45 +1643,45 @@ def copy(args: argparse.Namespace): r = http_put(args, url, headers=headers,json=req_json) r.raise_for_status() if (r.status_code == 200): - rj = r.json(); + rj = r.json() #print(json.dumps(rj, indent=1, sort_keys=True)) - if (rj["success"]) and ((src_id is None) or (dst_id is None)): + if (rj["success"]) and ((src_id is None or src_id == "local") or (dst_id is None or dst_id == "local")): homedir = subprocess.getoutput("echo $HOME") #print(f"homedir: {homedir}") remote_port = None - identity = args.identity if (args.identity is not None) else f"{homedir}/.ssh/id_rsa" - if (src_id is None): + identity = f"-i {args.identity}" if (args.identity is not None) else "" + if (src_id is None or src_id == "local"): #result = subprocess.run(f"mkdir -p {src_path}", shell=True) remote_port = rj["dst_port"] remote_addr = rj["dst_addr"] - cmd = f"sudo rsync -arz -v --progress --rsh=ssh -e 'sudo ssh -i {identity} -p {remote_port} -o StrictHostKeyChecking=no' {src_path} vastai_kaalia@{remote_addr}::{dst_id}/{dst_path}" + cmd = f"rsync -arz -v --progress --rsh=ssh -e 'ssh {identity} -p {remote_port} -o StrictHostKeyChecking=no' {src_path} vastai_kaalia@{remote_addr}::{dst_id}/{dst_path}" print(cmd) result = subprocess.run(cmd, shell=True) #result = subprocess.run(["sudo", "rsync" "-arz", "-v", "--progress", "-rsh=ssh", "-e 'sudo ssh -i {homedir}/.ssh/id_rsa -p {remote_port} -o StrictHostKeyChecking=no'", src_path, "vastai_kaalia@{remote_addr}::{dst_id}"], shell=True) - elif (dst_id is None): + elif (dst_id is None or dst_id == "local"): result = subprocess.run(f"mkdir -p {dst_path}", shell=True) remote_port = rj["src_port"] remote_addr = rj["src_addr"] - cmd = f"sudo rsync -arz -v --progress --rsh=ssh -e 'sudo ssh -i {identity} -p {remote_port} -o StrictHostKeyChecking=no' vastai_kaalia@{remote_addr}::{src_id}/{src_path} {dst_path}" + cmd = f"rsync -arz -v --progress --rsh=ssh -e 'ssh {identity} -p {remote_port} -o StrictHostKeyChecking=no' vastai_kaalia@{remote_addr}::{src_id}/{src_path} {dst_path}" print(cmd) result = subprocess.run(cmd, shell=True) - #result = subprocess.run(["sudo", "rsync" "-arz", "-v", "--progress", "-rsh=ssh", "-e 'sudo ssh -i {homedir}/.ssh/id_rsa -p {remote_port} -o StrictHostKeyChecking=no'", "vastai_kaalia@{remote_addr}::{src_id}", dst_path], shell=True) + #result = subprocess.run(["sudo", "rsync" "-arz", "-v", "--progress", "-rsh=ssh", "-e 'ssh -i {homedir}/.ssh/id_rsa -p {remote_port} -o StrictHostKeyChecking=no'", "vastai_kaalia@{remote_addr}::{src_id}", dst_path], shell=True) else: if (rj["success"]): print("Remote to Remote copy initiated - check instance status bar for progress updates (~30 seconds delayed).") else: if rj["msg"] == "src_path not supported VMs.": - print("src instance is a VM, use `vm copy` command for VM to VM copies") + print("copy between VM instances does not currently support subpaths (only full disk copy)") elif rj["msg"] == "dst_path not supported for VMs.": - print("dst instance is a VM, use `vm copy` command for VM to VM copies") + print("copy between VM instances does not currently support subpaths (only full disk copy)") else: - print(rj["msg"]); + print(rj["msg"]) else: - print(r.text); + print(r.text) print("failed with error {r.status_code}".format(**locals())); - +''' @parser.command( argument("src", help="instance_id of source VM.", type=int), argument("dst", help="instance_id of destination VM", type=int), @@ -1279,7 +1733,7 @@ def vm__copy(args: argparse.Namespace): else: print(r.text); print("failed with error {r.status_code}".format(**locals())); - +''' @parser.command( argument("--src", help="path to source of object to copy", type=str), @@ -1292,6 +1746,11 @@ def vm__copy(args: argparse.Namespace): argument("--ignore-existing", help="skip all files that exist on destination", action="store_true"), argument("--update", help="skip files that are newer on the destination", action="store_true"), argument("--delete-excluded", help="delete files on dest excluded from transfer", action="store_true"), + argument("--schedule", choices=["HOURLY", "DAILY", "WEEKLY"], help="try to schedule a command to run hourly, daily, or monthly. Valid values are HOURLY, DAILY, WEEKLY For ex. --schedule DAILY"), + argument("--start_date", type=str, default=default_start_date(), help="Start date/time in format 'YYYY-MM-DD HH:MM:SS PM' (UTC). Default is now. (optional)"), + argument("--end_date", type=str, help="End date/time in format 'YYYY-MM-DD HH:MM:SS PM' (UTC). Default is contract's end. (optional)"), + argument("--day", type=parse_day_cron_style, help="Day of week you want scheduled job to run on (0-6, where 0=Sunday) or \"*\". Default will be 0. For ex. --day 0", default=0), + argument("--hour", type=parse_hour_cron_style, help="Hour of day you want scheduled job to run on (0-23) or \"*\" (UTC). Default will be 0. For ex. --hour 16", default=0), usage="vastai cloud copy --src SRC --dst DST --instance INSTANCE_ID -connection CONNECTION_ID --transfer TRANSFER_TYPE", help="Copy files/folders to and from cloud providers", epilog=deindent(""" @@ -1306,7 +1765,7 @@ def vm__copy(args: argparse.Namespace): 1001 test_dir drive 1003 data_dir drive - vastai cloud_copy --src /folder --dst /workspace --instance 6003036 --connection 1001 --transfer "Instance To Cloud" + vastai cloud copy --src /folder --dst /workspace --instance 6003036 --connection 1001 --transfer "Instance To Cloud" The example copies all contents of /folder into /workspace on instance 6003036 from gdrive connection 'test_dir'. """), @@ -1355,16 +1814,203 @@ def cloud__copy(args: argparse.Namespace): if (args.explain): print("request json: ") print(req_json) - + + if (args.schedule): + validate_frequency_values(args.day, args.hour, args.schedule) + req_url = apiurl(args, "/instances/{id}/".format(id=args.instance) , {"owner": "me"} ) + r = http_get(args, req_url) + r.raise_for_status() + row = r.json()["instances"] + + if args.transfer.lower() == "instance to cloud": + if row: + # Get the cost per TB of internet upload + up_cost = row.get("internet_up_cost_per_tb", None) + if up_cost is not None: + confirm = input( + f"Internet upload cost is ${up_cost} per TB. " + "Are you sure you want to schedule a cloud backup? (y/n): " + ).strip().lower() + if confirm != "y": + print("Cloud backup scheduling aborted.") + return + else: + print("Warning: Could not retrieve internet upload cost. Proceeding without confirmation. You can use show scheduled-jobs and delete scheduled-job commands to delete scheduled cloud backup job.") + + cli_command = "cloud copy" + api_endpoint = "/api/v0/commands/rclone/" + contract_end_date = row.get("end_date", None) + add_scheduled_job(args, req_json, cli_command, api_endpoint, "POST", instance_id=args.instance, contract_end_date=contract_end_date) + return + else: + print("Instance not found. Please check the instance ID.") + return + r = http_post(args, url, headers=headers,json=req_json) r.raise_for_status() if (r.status_code == 200): print("Cloud Copy Started - check instance status bar for progress updates (~30 seconds delayed).") - print("When the operation is finished you should see 'Cloud Cody Operation Finished' in the instance status bar.") + print("When the operation is finished you should see 'Cloud Copy Operation Finished' in the instance status bar.") + else: + print(r.text); + print("failed with error {r.status_code}".format(**locals())); + + +@parser.command( + argument("instance_id", help="instance_id of the container instance to snapshot", type=str), + argument("--container_registry", help="Container registry to push the snapshot to. Default will be docker.io", type=str, default="docker.io"), + argument("--repo", help="repo to push the snapshot to", type=str), + argument("--docker_login_user",help="Username for container registry with repo", type=str), + argument("--docker_login_pass",help="Password or token for container registry with repo", type=str), + argument("--pause", help="Pause container's processes being executed by the CPU to take snapshot (true/false). Default will be true", type=str, default="true"), + usage="vastai take snapshot INSTANCE_ID " + "--repo REPO --docker_login_user USER --docker_login_pass PASS" + "[--container_registry REGISTRY] [--pause true|false]", + help="Schedule a snapshot of a running container and push it to your repo in a container registry", + epilog=deindent(""" + Takes a snapshot of a running container instance and pushes snapshot to the specified repository in container registry. + + Use pause=true to pause the container during commit (safer but slower), + or pause=false to leave it running (faster but may produce a filesystem- +// safer snapshot). + """), +) +def take__snapshot(args: argparse.Namespace): + """ + Take a container snapshot and push. + + @param instance_id: instance identifier. + @param repo: Docker repository for the snapshot. + @param container_registry: Container registry + @param docker_login_user: Docker registry username. + @param docker_login_pass: Docker registry password/token. + @param pause: "true" or "false" to pause the container during commit. + """ + instance_id = args.instance_id + repo = args.repo + container_registry = args.container_registry + user = args.docker_login_user + password = args.docker_login_pass + pause_flag = args.pause + + print(f"Taking snapshot for instance {instance_id} and pushing to repo {repo} in container registry {container_registry}") + req_json = { + "id": instance_id, + "container_registry": container_registry, + "personal_repo": repo, + "docker_login_user":user, + "docker_login_pass":password, + "pause": pause_flag + } + + url = apiurl(args, f"/instances/take_snapshot/{instance_id}/") + if args.explain: + print("Request JSON:") + print(json.dumps(req_json, indent=2)) + + # POST to the snapshot endpoint + r = http_post(args, url, headers=headers, json=req_json) + r.raise_for_status() + + if r.status_code == 200: + data = r.json() + if data.get("success"): + print(f"Snapshot request sent successfully. Please check your repo {repo} in container registry {container_registry} in 5-10 mins. It can take longer than 5-10 mins to push your snapshot image to your repo depending on the size of your image.") + else: + print(data.get("msg", "Unknown error with snapshot request")) else: print(r.text); print("failed with error {r.status_code}".format(**locals())); +def validate_frequency_values(day_of_the_week, hour_of_the_day, frequency): + + # Helper to raise an error with a consistent message. + def raise_frequency_error(): + msg = "" + if frequency == "HOURLY": + msg += "For HOURLY jobs, day and hour must both be \"*\"." + elif frequency == "DAILY": + msg += "For DAILY jobs, day must be \"*\" and hour must have a value between 0-23." + elif frequency == "WEEKLY": + msg += "For WEEKLY jobs, day must have a value between 0-6 and hour must have a value between 0-23." + sys.exit(msg) + + if frequency == "HOURLY": + if not (day_of_the_week is None and hour_of_the_day is None): + raise_frequency_error() + if frequency == "DAILY": + if not (day_of_the_week is None and hour_of_the_day is not None): + raise_frequency_error() + if frequency == "WEEKLY": + if not (day_of_the_week is not None and hour_of_the_day is not None): + raise_frequency_error() + + +def add_scheduled_job(args, req_json, cli_command, api_endpoint, request_method, instance_id, contract_end_date): + start_timestamp, end_timestamp = convert_dates_to_timestamps(args) + if args.end_date is None: + end_timestamp=contract_end_date + args.end_date = convert_timestamp_to_date(contract_end_date) + + if start_timestamp >= end_timestamp: + raise ValueError("--start_date must be less than --end_date.") + + day, hour, frequency = args.day, args.hour, args.schedule + + schedule_job_url = apiurl(args, f"/commands/schedule_job/") + + request_body = { + "start_time": start_timestamp, + "end_time": end_timestamp, + "api_endpoint": api_endpoint, + "request_method": request_method, + "request_body": req_json, + "day_of_the_week": day, + "hour_of_the_day": hour, + "frequency": frequency, + "instance_id": instance_id + } + # Send a POST request + response = requests.post(schedule_job_url, headers=headers, json=request_body) + + if args.explain: + print("request json: ") + print(request_body) + + # Handle the response based on the status code + if response.status_code == 200: + print(f"add_scheduled_job insert: success - Scheduling {frequency} job to {cli_command} from {args.start_date} UTC to {args.end_date} UTC") + elif response.status_code == 401: + print(f"add_scheduled_job insert: failed status_code: {response.status_code}. It could be because you aren't using a valid api_key.") + elif response.status_code == 422: + user_input = input("Existing scheduled job found. Do you want to update it (y|n)? ") + if user_input.strip().lower() == "y": + scheduled_job_id = response.json()["scheduled_job_id"] + schedule_job_url = apiurl(args, f"/commands/schedule_job/{scheduled_job_id}/") + response = update_scheduled_job(cli_command, schedule_job_url, frequency, args.start_date, args.end_date, request_body) + else: + print("Job update aborted by the user.") + else: + # print(r.text) + print(f"add_scheduled_job insert: failed error: {response.status_code}. Response body: {response.text}") + +def update_scheduled_job(cli_command, schedule_job_url, frequency, start_date, end_date, request_body): + response = requests.put(schedule_job_url, headers=headers, json=request_body) + + # Raise an exception for HTTP errors + response.raise_for_status() + if response.status_code == 200: + print(f"add_scheduled_job update: success - Scheduling {frequency} job to {cli_command} from {start_date} UTC to {end_date} UTC") + print(response.json()) + elif response.status_code == 401: + print(f"add_scheduled_job update: failed status_code: {response.status_code}. It could be because you aren't using a valid api_key.") + else: + # print(r.text) + print(f"add_scheduled_job update: failed status_code: {response.status_code}.") + print(response.json()) + + return response + @parser.command( argument("--name", help="name of the api-key", type=str), @@ -1391,63 +2037,233 @@ def create__api_key(args): except Exception as e: print("An unexpected error occurred:", e) + @parser.command( - argument("name", help="Environment variable name", type=str), - argument("value", help="Environment variable value", type=str), - usage="vastai create env-var ", - help="Create a new user environment variable", + argument("subnet", help="local subnet for cluster, ex: '0.0.0.0/24'", type=str), + argument("manager_id", help="Machine ID of manager node in cluster. Must exist already.", type=int), + usage="vastai create cluster SUBNET MANAGER_ID", + help="Create Vast cluster", + epilog=deindent(""" + Create Vast Cluster by defining a local subnet and manager id.""") ) -def create__env_var(args): - """Create a new environment variable for the current user.""" - url = apiurl(args, "/secrets/") - data = {"key": args.name, "value": args.value} - r = http_post(args, url, headers=headers, json=data) - r.raise_for_status() +def create__cluster(args: argparse.Namespace): - result = r.json() + json_blob = { + "subnet": args.subnet, + "manager_id": args.manager_id + } + + #TODO: this should happen at the decorator level for all CLI commands to reduce boilerplate + if args.explain: + print("request json: ") + print(json_blob) + + req_url = apiurl(args, "/cluster/") + r = http_post(args, req_url, json=json_blob) + r.raise_for_status() + + if args.raw: + return r + + print(r.json()["msg"]) + +@parser.command( + argument("name", help="Environment variable name", type=str), + argument("value", help="Environment variable value", type=str), + usage="vastai create env-var ", + help="Create a new user environment variable", +) +def create__env_var(args): + """Create a new environment variable for the current user.""" + url = apiurl(args, "/secrets/") + data = {"key": args.name, "value": args.value} + r = http_post(args, url, headers=headers, json=data) + r.raise_for_status() + + result = r.json() if result.get("success"): print(result.get("msg", "Environment variable created successfully.")) else: print(f"Failed to create environment variable: {result.get('msg', 'Unknown error')}") @parser.command( - argument("ssh_key", help="add the public key of your ssh key to your account (form the .pub file)", type=str), - usage="vastai create ssh-key ssh_key", + argument("ssh_key", help="add your existing ssh public key to your account (from the .pub file). If no public key is provided, a new key pair will be generated.", type=str, nargs='?'), + argument("-y", "--yes", help="automatically answer yes to prompts", action="store_true"), + usage="vastai create ssh-key [ssh_public_key] [-y]", help="Create a new ssh-key", epilog=deindent(""" - Use this command to create a new ssh key for your account. - All ssh keys are stored in your account and can be used to connect to instances they've been added to - All ssh keys should be added in rsa format + You may use this command to add an existing public key, or create a new ssh key pair and add that public key, to your Vast account. + + If you provide an ssh_public_key.pub argument, that public key will be added to your Vast account. All ssh public keys should be in OpenSSH format. + + Example: $vastai create ssh-key 'ssh_public_key.pub' + + If you don't provide an ssh_public_key.pub argument, a new Ed25519 key pair will be generated. + + Example: $vastai create ssh-key + + The generated keys are saved as ~/.ssh/id_ed25519 (private) and ~/.ssh/id_ed25519.pub (public). Any existing id_ed25519 keys are backed up as .backup_. + The public key will be added to your Vast account. + + All ssh public keys are stored in your Vast account and can be used to connect to instances they've been added to. """) ) + def create__ssh_key(args): + ssh_key_content = args.ssh_key + + # If no SSH key provided, generate one + if not ssh_key_content: + ssh_key_content = generate_ssh_key(args.yes) + else: + print("Adding provided SSH public key to account...") + + # Send the SSH key to the API url = apiurl(args, "/ssh/") - r = http_post(args, url, headers=headers, json={"ssh_key": args.ssh_key}) + r = http_post(args, url, headers=headers, json={"ssh_key": ssh_key_content}) r.raise_for_status() - print("ssh-key created {}".format(r.json())) + + # Print json response + print("ssh-key created {}\nNote: You may need to add the new public key to any pre-existing instances".format(r.json())) + + +def generate_ssh_key(auto_yes=False): + """ + Generate a new SSH key pair using ssh-keygen and return the public key content. + + Args: + auto_yes (bool): If True, automatically answer yes to prompts + + Returns: + str: The content of the generated public key + + Raises: + SystemExit: If ssh-keygen is not available or key generation fails + """ + + print("No SSH key provided. Generating a new SSH key pair and adding public key to account...") + + # Define paths + ssh_dir = Path.home() / '.ssh' + private_key_path = ssh_dir / 'id_ed25519' + public_key_path = ssh_dir / 'id_ed25519.pub' + + # Create .ssh directory if it doesn't exist + try: + ssh_dir.mkdir(mode=0o700, exist_ok=True) + except OSError as e: + print(f"Error creating .ssh directory: {e}", file=sys.stderr) + sys.exit(1) + + # Check if any part of the key pair already exists and backup if needed + if private_key_path.exists() or public_key_path.exists(): + print(f"An SSH key pair 'id_ed25519' already exists in {ssh_dir}") + if auto_yes: + print("Auto-answering yes to backup existing key pair.") + response = 'y' + else: + response = input("Would you like to generate a new key pair and backup your existing id_ed25519 key pair. [y/N]: ").lower() + if response not in ['y', 'yes']: + print("Aborted. No new key generated.") + sys.exit(0) + + # Generate timestamp for backup + timestamp = int(time.time()) + backup_private_path = ssh_dir / f'id_ed25519.backup_{timestamp}' + backup_public_path = ssh_dir / f'id_ed25519.pub.backup_{timestamp}' + + try: + # Backup existing private key if it exists + if private_key_path.exists(): + private_key_path.rename(backup_private_path) + print(f"Backed up existing private key to: {backup_private_path}") + + # Backup existing public key if it exists + if public_key_path.exists(): + public_key_path.rename(backup_public_path) + print(f"Backed up existing public key to: {backup_public_path}") + + except OSError as e: + print(f"Error backing up existing SSH keys: {e}", file=sys.stderr) + sys.exit(1) + + print("Generating new SSH key pair and adding public key to account...") + + # Check if ssh-keygen is available + try: + subprocess.run(['ssh-keygen', '--help'], capture_output=True, check=False) + except FileNotFoundError: + print("Error: ssh-keygen not found. Please install OpenSSH client tools.", file=sys.stderr) + sys.exit(1) + + # Generate the SSH key pair + try: + cmd = [ + 'ssh-keygen', + '-t', 'ed25519', # Ed25519 key type + '-f', str(private_key_path), # Output file path + '-N', '', # Empty passphrase + '-C', f'{os.getenv("USER") or os.getenv("USERNAME", "user")}-vast.ai' # User + ] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + input='y\n', # Automatically answer 'yes' to overwrite prompts + check=True + ) + + except subprocess.CalledProcessError as e: + print(f"Error generating SSH key: {e}", file=sys.stderr) + if e.stderr: + print(f"ssh-keygen error: {e.stderr}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Unexpected error during key generation: {e}", file=sys.stderr) + sys.exit(1) + + # Set proper permissions for the private key + try: + private_key_path.chmod(0o600) # Read/write for owner only + except OSError as e: + print(f"Warning: Could not set permissions for private key: {e}", file=sys.stderr) + + # Read and return the public key content + try: + with open(public_key_path, 'r') as f: + public_key_content = f.read().strip() + + return public_key_content + + except IOError as e: + print(f"Error reading generated public key: {e}", file=sys.stderr) + sys.exit(1) @parser.command( argument("--template_hash", help="template hash (required, but **Note**: if you use this field, you can skip search_params, as they are automatically inferred from the template)", type=str), argument("--template_id", help="template id (optional)", type=int), argument("-n", "--no-default", action="store_true", help="Disable default search param query args"), argument("--launch_args", help="launch args string for create instance ex: \"--onstart onstart_wget.sh --env '-e ONSTART_PATH=https://s3.amazonaws.com/vast.ai/onstart_OOBA.sh' --image atinoda/text-generation-webui:default-nightly --disk 64\"", type=str), - argument("--endpoint_name", help="deployment endpoint name (allows multiple autoscale groups to share same deployment endpoint)", type=str), - argument("--endpoint_id", help="deployment endpoint id (allows multiple autoscale groups to share same deployment endpoint)", type=int), - argument("--test_workers",help="number of workers to create to get an performance estimate for while initializing autogroup (default 3)", type=int, default=3), + argument("--endpoint_name", help="deployment endpoint name (allows multiple workergroups to share same deployment endpoint)", type=str), + argument("--endpoint_id", help="deployment endpoint id (allows multiple workergroups to share same deployment endpoint)", type=int), + argument("--test_workers",help="number of workers to create to get an performance estimate for while initializing workergroup (default 3)", type=int, default=3), argument("--gpu_ram", help="estimated GPU RAM req (independent of search string)", type=float), argument("--search_params", help="search param string for search offers ex: \"gpu_ram>=23 num_gpus=2 gpu_name=RTX_4090 inet_down>200 direct_port_count>2 disk_space>=64\"", type=str), - argument("--min_load", help="[NOTE: this field isn't currently used at the autojob level] minimum floor load in perf units/s (token/s for LLms)", type=float), - argument("--target_util", help="[NOTE: this field isn't currently used at the autojob level] target capacity utilization (fraction, max 1.0, default 0.9)", type=float), - argument("--cold_mult", help="[NOTE: this field isn't currently used at the autojob level]cold/stopped instance capacity target as multiple of hot capacity target (default 2.0)", type=float), - usage="vastai autogroup create [OPTIONS]", + argument("--min_load", help="[NOTE: this field isn't currently used at the workergroup level] minimum floor load in perf units/s (token/s for LLms)", type=float), + argument("--target_util", help="[NOTE: this field isn't currently used at the workergroup level] target capacity utilization (fraction, max 1.0, default 0.9)", type=float), + argument("--cold_mult", help="[NOTE: this field isn't currently used at the workergroup level]cold/stopped instance capacity target as multiple of hot capacity target (default 2.0)", type=float), + argument("--cold_workers", help="min number of workers to keep 'cold' for this workergroup", type=int), + argument("--auto_instance", help=argparse.SUPPRESS, type=str, default="prod"), + usage="vastai workergroup create [OPTIONS]", help="Create a new autoscale group", epilog=deindent(""" Create a new autoscaling group to manage a pool of worker instances. - Example: vastai create autogroup --template_hash HASH --endpoint_name "LLama" --test_workers 5 + Example: vastai create workergroup --template_hash HASH --endpoint_name "LLama" --test_workers 5 """), ) -def create__autogroup(args): +def create__workergroup(args): url = apiurl(args, "/autojobs/" ) # if args.launch_args_dict: @@ -1460,8 +2276,8 @@ def create__autogroup(args): #query = {"verified": {"eq": True}, "external": {"eq": False}, "rentable": {"eq": True}, "rented": {"eq": False}} search_params = (args.search_params if args.search_params is not None else "" + query).strip() - json_blob = {"client_id": "me", "min_load": args.min_load, "target_util": args.target_util, "cold_mult": args.cold_mult, "test_workers" : args.test_workers, "template_hash": args.template_hash, "template_id": args.template_id, "search_params": search_params, "launch_args": args.launch_args, "gpu_ram": args.gpu_ram, "endpoint_name": args.endpoint_name, "endpoint_id": args.endpoint_id} - + json_blob = {"client_id": "me", "min_load": args.min_load, "target_util": args.target_util, "cold_mult": args.cold_mult, "cold_workers" : args.cold_workers, "test_workers" : args.test_workers, "template_hash": args.template_hash, "template_id": args.template_id, "search_params": search_params, "launch_args": args.launch_args, "gpu_ram": args.gpu_ram, "endpoint_name": args.endpoint_name, "endpoint_id": args.endpoint_id, "autoscaler_instance": args.auto_instance} + if (args.explain): print("request json: ") print(json_blob) @@ -1469,7 +2285,7 @@ def create__autogroup(args): r.raise_for_status() if 'application/json' in r.headers.get('Content-Type', ''): try: - print("autogroup create {}".format(r.json())) + print("workergroup create {}".format(r.json())) except requests.exceptions.JSONDecodeError: print("The response is not valid JSON.") print(r) @@ -1481,11 +2297,17 @@ def create__autogroup(args): @parser.command( argument("--min_load", help="minimum floor load in perf units/s (token/s for LLms)", type=float, default=0.0), + argument("--min_cold_load", help="minimum floor load in perf units/s (token/s for LLms), but allow handling with cold workers", type=float, default=0.0), argument("--target_util", help="target capacity utilization (fraction, max 1.0, default 0.9)", type=float, default=0.9), argument("--cold_mult", help="cold/stopped instance capacity target as multiple of hot capacity target (default 2.5)", type=float, default=2.5), argument("--cold_workers", help="min number of workers to keep 'cold' when you have no load (default 5)", type=int, default=5), argument("--max_workers", help="max number of workers your endpoint group can have (default 20)", type=int, default=20), argument("--endpoint_name", help="deployment endpoint name (allows multiple autoscale groups to share same deployment endpoint)", type=str), + argument("--max_queue_time", help="maximum seconds requests may be queued on each worker (default 30.0)", type=float), + argument("--target_queue_time", help="target seconds for the queue to be cleared (default 10.0)", type=float), + argument("--inactivity_timeout", help="seconds of no traffic before the endpoint can scale to zero active workers", type=int), + argument("--auto_instance", help=argparse.SUPPRESS, type=str, default="prod"), + usage="vastai create endpoint [OPTIONS]", help="Create a new endpoint group", epilog=deindent(""" @@ -1497,8 +2319,8 @@ def create__autogroup(args): def create__endpoint(args): url = apiurl(args, "/endptjobs/" ) - json_blob = {"client_id": "me", "min_load": args.min_load, "target_util": args.target_util, "cold_mult": args.cold_mult, "cold_workers" : args.cold_workers, "max_workers" : args.max_workers, "endpoint_name": args.endpoint_name} - + json_blob = {"client_id": "me", "min_load": args.min_load, "min_cold_load":args.min_cold_load, "target_util": args.target_util, "cold_mult": args.cold_mult, "cold_workers" : args.cold_workers, "max_workers" : args.max_workers, "endpoint_name": args.endpoint_name, "max_queue_time": args.max_queue_time, "target_queue_time": args.target_queue_time, "inactivity_timeout": args.inactivity_timeout, "autoscaler_instance": args.auto_instance} + if (args.explain): print("request json: ") print(json_blob) @@ -1522,7 +2344,7 @@ def get_runtype(args): if (args.args == '') or (args.args == ['']) or (args.args == []): runtype = 'args' args.args = None - if args.jupyter_dir or args.jupyter_lab: + if not args.jupyter and (args.jupyter_dir or args.jupyter_lab): args.jupyter = True if args.jupyter and runtype == 'args': print("Error: Can't use --jupyter and --args together. Try --onstart or --onstart-cmd instead of --args.", file=sys.stderr) @@ -1530,12 +2352,49 @@ def get_runtype(args): if args.jupyter: runtype = 'jupyter_direc ssh_direc ssh_proxy' if args.direct else 'jupyter_proxy ssh_proxy' - - if args.ssh: + elif args.ssh: runtype = 'ssh_direc ssh_proxy' if args.direct else 'ssh_proxy' return runtype +def validate_volume_params(args): + if args.volume_size and not args.create_volume: + raise argparse.ArgumentTypeError("Error: --volume-size can only be used with --create-volume. Please specify a volume ask ID to create a new volume of that size.") + if (args.create_volume or args.link_volume) and not args.mount_path: + raise argparse.ArgumentTypeError("Error: --mount-path is required when creating or linking a volume.") + + # This regex matches absolute or relative Linux file paths (no null bytes) + valid_linux_path_regex = re.compile(r'^(/)?([^/\0]+(/)?)+$') + if not valid_linux_path_regex.match(args.mount_path): + raise argparse.ArgumentTypeError(f"Error: --mount-path '{args.mount_path}' is not a valid Linux file path.") + + volume_info = { + "mount_path": args.mount_path, + "create_new": True if args.create_volume else False, + "volume_id": args.create_volume if args.create_volume else args.link_volume + } + if args.volume_label: + volume_info["name"] = args.volume_label + if args.volume_size: + volume_info["size"] = args.volume_size + elif args.create_volume: # If creating a new volume and size is not passed in, default size is 15GB + volume_info["size"] = 15 + + return volume_info + +def validate_portal_config(json_blob): + # jupyter runtypes already self-correct + if 'jupyter' in json_blob['runtype']: + return + + # remove jupyter configs from portal_config if not a jupyter runtype + portal_config = json_blob['env']['PORTAL_CONFIG'].split("|") + filtered_config = [config_str for config_str in portal_config if 'jupyter' not in config_str.lower()] + + if not filtered_config: + raise ValueError("Error: env variable PORTAL_CONFIG must contain at least one non-jupyter related config string if runtype is not jupyter") + else: + json_blob['env']['PORTAL_CONFIG'] = "|".join(filtered_config) @parser.command( argument("id", help="id of instance type to launch (returned from search offers)", type=int), @@ -1562,6 +2421,12 @@ def get_runtype(args): argument("--force", help="Skip sanity checks when creating from an existing instance", action="store_true"), argument("--cancel-unavail", help="Return error if scheduling fails (rather than creating a stopped instance)", action="store_true"), argument("--bid_price", help="(OPTIONAL) create an INTERRUPTIBLE instance with per machine bid price in $/hour", type=float), + argument("--create-volume", metavar="VOLUME_ASK_ID", help="Create a new local volume using an ID returned from the \"search volumes\" command and link it to the new instance", type=int), + argument("--link-volume", metavar="EXISTING_VOLUME_ID", help="ID of an existing rented volume to link to the instance during creation. (returned from \"show volumes\" cmd)", type=int), + argument("--volume-size", help="Size of the volume to create in GB. Only usable with --create-volume (default 15GB)", type=int), + argument("--mount-path", help="The path to the volume from within the new instance container. e.g. /root/volume", type=str), + argument("--volume-label", help="(optional) A name to give the new volume. Only usable with --create-volume", type=str), + usage="vastai create instance ID [OPTIONS] [--args ...]", help="Create a new instance", epilog=deindent(""" @@ -1630,6 +2495,9 @@ def create__instance(args: argparse.Namespace): "user": args.user } + if args.create_volume or args.link_volume: + volume_info = validate_volume_params(args) + json_blob["volume_info"] = volume_info if args.template_hash is None: runtype = get_runtype(args) @@ -1640,6 +2508,9 @@ def create__instance(args: argparse.Namespace): if (args.args != None): json_blob["args"] = args.args + if "PORTAL_CONFIG" in json_blob["env"]: + validate_portal_config(json_blob) + #print(f"put asks/{args.id}/ runtype:{runtype}") url = apiurl(args, "/asks/{id}/".format(id=args.id)) @@ -1705,22 +2576,38 @@ def create__subaccount(args): print(f"Failed with error {r.status_code}") @parser.command( - argument("--team_name", help="name of the team", type=str), + argument("--team-name", help="name of the team", type=str), + argument("--transfer-credit", help="amount of personal credit to transfer to the new team", type=float, default=0), usage="vastai create-team --team_name TEAM_NAME", help="Create a new team", epilog=deindent(""" - As of right now creating a team account will convert your current account into a team account. - Once you convert your user account into a team account, this change is permanent and cannot be reversed. - The created team account will inherit all aspects of your existing user account, including billing information, cloud services, and any other account settings. - The user who initiates the team creation becomes the team owner. - Carefully evaluate the decision to convert your user account into a team account, as this change is permanent. - For more information see: https://vast.ai/docs/team/introduction + Creates a new team under your account. + + Unlike legacy teams, this command does NOT convert your personal account into a team. + Each team is created as a separate account, and you can be a member of multiple teams. + + When you create a team: + - You become the team owner. + - The team starts as an independent account with its own billing, credits, and resources. + - Default roles (owner, manager, member) are automatically created. + - You can invite others, assign roles, and manage resources within the team. + + Optional: + You can transfer a portion of your existing personal credits to the team by using + the `--transfer_credit` flag. Example: + vastai create-team --team_name myteam --transfer_credit 25 + + Notes: + - You cannot create a team from within another team account. + + For more details, see: + https://vast.ai/docs/teams-quickstart """) ) def create__team(args): url = apiurl(args, "/team/") - r = http_post(args, url, headers=headers, json={"team_name": args.team_name}) + r = http_post(args, url, headers=headers, json={"team_name": args.team_name, "transfer_credit": args.transfer_credit}) r.raise_for_status() print(r.json()) @@ -1836,6 +2723,99 @@ def create__template(args): print("The response is not valid JSON.") +@parser.command( + argument("id", help="id of volume offer", type=int), + argument("-s", "--size", + help="size in GB of volume. Default %(default)s GB.", default=15, type=float), + argument("-n", "--name", help="Optional name of volume.", type=str), + usage="vastai create volume ID [options]", + help="Create a new volume", + epilog=deindent(""" + Creates a volume from an offer ID (which is returned from "search volumes"). Each offer ID can be used to create multiple volumes, + provided the size of all volumes does not exceed the size of the offer. + """) +) +def create__volume(args: argparse.Namespace): + + json_blob ={ + "size": int(args.size), + "id": int(args.id) + } + if args.name: + json_blob["name"] = args.name + + url = apiurl(args, "/volumes/") + + if (args.explain): + print("request json: ") + print(json_blob) + r = http_put(args, url, headers=headers,json=json_blob) + r.raise_for_status() + if args.raw: + return r + else: + print("Created. {}".format(r.json())) + + +@parser.command( + argument("id", help="id of network volume offer", type=int), + argument("-s", "--size", + help="size in GB of network volume. Default %(default)s GB.", default=15, type=float), + argument("-n", "--name", help="Optional name of network volume.", type=str), + usage="vastai create network volume ID [options]", + help="Create a new network volume", + epilog=deindent(""" + Creates a network volume from an offer ID (which is returned from "search network volumes"). Each offer ID can be used to create multiple volumes, + provided the size of all volumes does not exceed the size of the offer. + """) +) +def create__network_volume(args: argparse.Namespace): + + json_blob ={ + "size": int(args.size), + "id": int(args.id) + } + if args.name: + json_blob["name"] = args.name + + url = apiurl(args, "/network_volumes/") + + if (args.explain): + print("request json: ") + print(json_blob) + r = http_put(args, url, headers=headers,json=json_blob) + r.raise_for_status() + if args.raw: + return r + else: + print("Created. {}".format(r.json())) + +@parser.command( + argument("cluster_id", help="ID of cluster to create overlay on top of", type=int), + argument("name", help="overlay network name"), + usage="vastai create overlay CLUSTER_ID OVERLAY_NAME", + help="Creates overlay network on top of a physical cluster", + epilog=deindent(""" + Creates an overlay network to allow local networking between instances on a physical cluster""") +) +def create__overlay(args: argparse.Namespace): + json_blob = { + "cluster_id": args.cluster_id, + "name": args.name + } + + if args.explain: + print("request json:", json_blob) + + req_url = apiurl(args, "/overlay/") + r = http_post(args, req_url, json=json_blob) + r.raise_for_status() + + if args.raw: + return r + + print(r.json()["msg"]) + @parser.command( argument("id", help="id of apikey to remove", type=int), usage="vastai delete api-key ID", @@ -1858,16 +2838,54 @@ def delete__ssh_key(args): r.raise_for_status() print(r.json()) +@parser.command( + argument("id", help="id of scheduled job to remove", type=int), + usage="vastai delete scheduled-job ID", + help="Delete a scheduled job", +) +def delete__scheduled_job(args): + url = apiurl(args, "/commands/schedule_job/{id}/".format(id=args.id)) + r = http_del(args, url, headers=headers) + r.raise_for_status() + print(r.json()) + + + +@parser.command( + argument("cluster_id", help="ID of cluster to delete", type=int), + usage="vastai delete cluster CLUSTER_ID", + help="Delete Cluster", + epilog=deindent(""" + Delete Vast Cluster""") +) +def delete__cluster(args: argparse.Namespace): + json_blob = { + "cluster_id": args.cluster_id + } + + if args.explain: + print("request json:", json_blob) + + req_url = apiurl(args, "/cluster/") + r = http_del(args, req_url, json=json_blob) + r.raise_for_status() + + if args.raw: + return r + + print(r.json()["msg"]) + + @parser.command( argument("id", help="id of group to delete", type=int), - usage="vastai delete autogroup ID ", - help="Delete an autogroup group", + usage="vastai delete workergroup ID ", + help="Delete a workergroup group", epilog=deindent(""" - Note that deleteing an autogroup group doesn't automatically destroy all the instances that are associated with your autogroup group. - Example: vastai delete autogroup 4242 + Note that deleting a workergroup doesn't automatically destroy all the instances that are associated with your workergroup. + Example: vastai delete workergroup 4242 """), ) -def delete__autogroup(args): +def delete__workergroup(args): id = args.id url = apiurl(args, f"/autojobs/{id}/" ) json_blob = {"client_id": "me", "autojob_id": args.id} @@ -1878,7 +2896,7 @@ def delete__autogroup(args): r.raise_for_status() if 'application/json' in r.headers.get('Content-Type', ''): try: - print("autogroup delete {}".format(r.json())) + print("workergroup delete {}".format(r.json())) except requests.exceptions.JSONDecodeError: print("The response is not valid JSON.") print(r) @@ -1892,7 +2910,6 @@ def delete__autogroup(args): usage="vastai delete endpoint ID ", help="Delete an endpoint group", epilog=deindent(""" - Note that deleting an endpoint group doesn't automatically destroy all the instances that are associated with your endpoint group, nor all the autogroups. Example: vastai delete endpoint 4242 """), ) @@ -1916,6 +2933,57 @@ def delete__endpoint(args): print("The response is not JSON. Content-Type:", r.headers.get('Content-Type')) print(r.text) +@parser.command( + argument("id", help="id of deployment to delete", type=int, nargs="?", default=None), + argument("--name", help="name of deployment to delete (deletes all tags unless --tag is specified)", type=str, default=None), + argument("--tag", help="tag to filter by when deleting by name", type=str, default=None), + usage="vastai delete deployment [ID | --name NAME [--tag TAG]]", + help="Delete a deployment by id, or by name and optional tag", + epilog=deindent(""" + Examples: + vastai delete deployment 1234 + vastai delete deployment --name my-deployment + vastai delete deployment --name my-deployment --tag prod + """), +) +def delete__deployment(args): + if args.id is not None and args.name is not None: + print("Error: specify either an id or --name, not both") + return + if args.tag is not None and args.name is None: + print("Error: --tag can only be used with --name") + return + + if args.id is not None: + url = apiurl(args, f"/deployment/{args.id}/") + r = http_del(args, url, headers=headers) + elif args.name is not None: + url = apiurl(args, "/deployments/") + json_blob = {"name": args.name} + if args.tag is not None: + json_blob["tag"] = args.tag + if args.explain: + print("request json: ") + print(json_blob) + r = http_del(args, url, headers=headers, json=json_blob) + else: + print("Error: must specify either an id or --name") + return + + r.raise_for_status() + if 'application/json' in r.headers.get('Content-Type', ''): + rj = r.json() + if rj.get("success"): + if "count" in rj: + print(f"Deleted {rj['count']} deployment(s)") + else: + print("Deployment deleted successfully") + else: + print(rj.get("msg", "Unknown error")) + else: + print("The response is not JSON. Content-Type:", r.headers.get('Content-Type')) + print(r.text) + @parser.command( argument("name", help="Environment variable name to delete", type=str), usage="vastai delete env-var ", @@ -1934,6 +3002,35 @@ def delete__env_var(args): else: print(f"Failed to delete environment variable: {result.get('msg', 'Unknown error')}") +@parser.command( + argument("overlay_identifier", help="ID (int) or name (str) of overlay to delete", nargs="?"), + usage="vastai delete overlay OVERLAY_IDENTIFIER", + help="Deletes overlay and removes all of its associated instances" +) +def delete__overlay(args: argparse.Namespace): + identifier = args.overlay_identifier + try: + overlay_id = int(identifier) + json_blob = { + "overlay_id": overlay_id + } + except (ValueError, TypeError): + json_blob = { + "overlay_name": identifier + } + + if args.explain: + print("request json:", json_blob) + + req_url = apiurl(args, "/overlay/") + r = http_del(args, req_url, json=json_blob) + r.raise_for_status() + + if args.raw: + return r + + print(r.json()["msg"]) + @parser.command( argument("--template-id", help="Template ID of Template to Delete", type=int), argument("--hash-id", help="Hash ID of Template to Delete", type=str), @@ -1975,6 +3072,25 @@ def delete__template(args): print("The response is not JSON. Content-Type:", r.headers.get('Content-Type')) print(r.text) + +@parser.command( + argument("id", help="id of volume contract", type=int), + usage="vastai delete volume ID", + help="Delete a volume", + epilog=deindent(""" + Deletes volume with the given ID. All instances using the volume must be destroyed before the volume can be deleted. + """) +) +def delete__volume(args: argparse.Namespace): + url = apiurl(args, "/volumes/", query_args={"id": args.id}) + r = http_del(args, url, headers=headers) + r.raise_for_status() + if args.raw: + return r + else: + print("Deleted. {}".format(r.json())) + + def destroy_instance(id,args): url = apiurl(args, "/instances/{id}/".format(id=id)) r = http_del(args, url, headers=headers,json={}) @@ -2047,6 +3163,11 @@ def detach__ssh(args): @parser.command( argument("id", help="id of instance to execute on", type=int), argument("COMMAND", help="bash command surrounded by single quotes", type=str), + argument("--schedule", choices=["HOURLY", "DAILY", "WEEKLY"], help="try to schedule a command to run hourly, daily, or monthly. Valid values are HOURLY, DAILY, WEEKLY For ex. --schedule DAILY"), + argument("--start_date", type=str, default=default_start_date(), help="Start date/time in format 'YYYY-MM-DD HH:MM:SS PM' (UTC). Default is now. (optional)"), + argument("--end_date", type=str, default=default_end_date(), help="End date/time in format 'YYYY-MM-DD HH:MM:SS PM' (UTC). Default is 7 days from now. (optional)"), + argument("--day", type=parse_day_cron_style, help="Day of week you want scheduled job to run on (0-6, where 0=Sunday) or \"*\". Default will be 0. For ex. --day 0", default=0), + argument("--hour", type=parse_hour_cron_style, help="Hour of day you want scheduled job to run on (0-23) or \"*\" (UTC). Default will be 0. For ex. --hour 16", default=0), usage="vastai execute id COMMAND", help="Execute a (constrained) remote command on a machine", epilog=deindent(""" @@ -2077,18 +3198,21 @@ def execute(args): r = http_put(args, url, headers=headers,json=json_blob ) r.raise_for_status() + if (args.schedule): + validate_frequency_values(args.day, args.hour, args.schedule) + cli_command = "execute" + api_endpoint = "/api/v0/instances/command/{id}/".format(id=args.id) + json_blob["instance_id"] = args.id + add_scheduled_job(args, json_blob, cli_command, api_endpoint, "PUT", instance_id=args.id) + return + if (r.status_code == 200): rj = r.json() if (rj["success"]): for i in range(0,30): time.sleep(0.3) - url = rj.get("result_url",None) - if (url is None): - api_key_id_h = hashlib.md5( (args.api_key + str(args.id)).encode('utf-8') ).hexdigest() - url = "https://s3.amazonaws.com/vast.ai/instance_logs/" + api_key_id_h + "C.log" - # print(f"trying {url}") - r = requests.get(url) #headers=headers - # print(f"got: {r.status_code}") + url = rj["result_url"] + r = requests.get(url) if (r.status_code == 200): filtered_text = r.text.replace(rj["writeable_path"], ''); print(filtered_text) @@ -2100,9 +3224,11 @@ def execute(args): print("failed with error {r.status_code}".format(**locals())); + @parser.command( - argument("id", help="id of instance to execute on", type=int), + argument("id", help="id of endpoint group to fetch logs from", type=int), argument("--level", help="log detail level (0 to 3)", type=int, default=1), + argument("--tail", help="", type=int, default=None), usage="vastai get endpt-logs ID [--api-key API_KEY]", help="Fetch logs for a specific serverless endpoint group", epilog=deindent(""" @@ -2111,40 +3237,87 @@ def execute(args): ) def get__endpt_logs(args): #url = apiurl(args, "/endptjobs/" ) - url = "https://run.vast.ai/get_endpoint_logs/" + if args.url == server_url_default: + args.url = None + url = (args.url or "https://run.vast.ai") + "/get_endpoint_logs/" json_blob = {"id": args.id, "api_key": args.api_key} + if args.tail: json_blob["tail"] = args.tail if (args.explain): print(f"{url} with request json: ") print(json_blob) - #response = requests.post(f"{server_addr}/route/", headers={"Content-Type": "application/json"}, data=json.dumps(route_payload), timeout=4) - #response.raise_for_status() # Raises HTTPError for bad responses - r = http_post(args, url, headers=headers,json=json_blob) r.raise_for_status() - #print("autogroup list ".format(r.json())) levels = {0 : "info0", 1: "info1", 2: "trace", 3: "debug"} if (r.status_code == 200): - rj = r.json() + rj = None + try: + rj = r.json() + except Exception as e: + print(str(e)) + print(r.text) if args.raw: # sort_keys - return rj + return rj or r.text else: dbg_lvl = levels[args.level] - print(rj[dbg_lvl]) + if rj and dbg_lvl: print(rj[dbg_lvl]) #print(json.dumps(rj, indent=1, sort_keys=True)) - + else: + print(r.text) @parser.command( - argument("--email", help="email of user to be invited", type=str), - argument("--role", help="role of user to be invited", type=str), - usage="vastai invite team-member --email EMAIL --role ROLE", - help="Invite a team member", -) -def invite__team_member(args): - url = apiurl(args, "/team/invite/", query_args={"email": args.email, "role": args.role}) - r = http_post(args, url, headers=headers) + argument("id", help="id of endpoint group to fetch logs from", type=int), + argument("--level", help="log detail level (0 to 3)", type=int, default=1), + argument("--tail", help="", type=int, default=None), + usage="vastai get wrkgrp-logs ID [--api-key API_KEY]", + help="Fetch logs for a specific serverless worker group group", + epilog=deindent(""" + Example: vastai get endpt-logs 382 + """), +) +def get__wrkgrp_logs(args): + #url = apiurl(args, "/endptjobs/" ) + if args.url == server_url_default: + args.url = None + url = (args.url or "https://run.vast.ai") + "/get_autogroup_logs/" + json_blob = {"id": args.id, "api_key": args.api_key} + if args.tail: json_blob["tail"] = args.tail + if (args.explain): + print(f"{url} with request json: ") + print(json_blob) + + r = http_post(args, url, headers=headers,json=json_blob) + r.raise_for_status() + levels = {0 : "info0", 1: "info1", 2: "trace", 3: "debug"} + + if (r.status_code == 200): + rj = None + try: + rj = r.json() + except Exception as e: + print(str(e)) + print(r.text) + if args.raw: + # sort_keys + return rj or r.text + else: + dbg_lvl = levels[args.level] + if rj and dbg_lvl: print(rj[dbg_lvl]) + #print(json.dumps(rj, indent=1, sort_keys=True)) + else: + print(r.text) + +@parser.command( + argument("--email", help="email of user to be invited", type=str), + argument("--role", help="role of user to be invited", type=str), + usage="vastai invite member --email EMAIL --role ROLE", + help="Invite a team member", +) +def invite__member(args): + url = apiurl(args, "/team/invite/", query_args={"email": args.email, "role": args.role}) + r = http_post(args, url, headers=headers) r.raise_for_status() if (r.status_code == 200): print(f"successfully invited {args.email} to your current team") @@ -2153,6 +3326,62 @@ def invite__team_member(args): print(f"failed with error {r.status_code}") +@parser.command( + argument("cluster_id", help="ID of cluster to add machine to", type=int), + argument("machine_ids", help="machine id(s) to join cluster", type=int, nargs="+"), + usage="vastai join cluster CLUSTER_ID MACHINE_IDS", + help="Join Machine to Cluster", + epilog=deindent(""" + Join's Machine to Vast Cluster + """) +) +def join__cluster(args: argparse.Namespace): + json_blob = { + "cluster_id": args.cluster_id, + "machine_ids": args.machine_ids + } + + if args.explain: + print("request json:", json_blob) + + req_url = apiurl(args, "/cluster/") + r = http_put(args, req_url, json=json_blob) + r.raise_for_status() + + if args.raw: + return r + + print(r.json()["msg"]) + + +@parser.command( + argument("name", help="Overlay network name to join instance to.", type=str), + argument("instance_id", help="Instance ID to add to overlay.", type=int), + usage="vastai join overlay OVERLAY_NAME INSTANCE_ID", + help="Adds instance to an overlay network", + epilog=deindent(""" + Adds an instance to a compatible overlay network.""") +) +def join__overlay(args: argparse.Namespace): + json_blob = { + "name": args.name, + "instance_id": args.instance_id + } + + if args.explain: + print("request json:", json_blob) + + req_url = apiurl(args, "/overlay/") + r = http_put(args, req_url, json=json_blob) + r.raise_for_status() + + if args.raw: + return r + + print(r.json()["msg"]) + + + @parser.command( argument("id", help="id of instance to label", type=int), argument("label", help="label to set", type=str), @@ -2215,12 +3444,12 @@ def is_cache_valid() -> bool: REGIONS = { - "North_America": "[US, CA]", - "South_America": "[BR, AR, CL]", - "Europe": "[SE, UA, GB, PL, PT, SI, DE, IT, CH, LT, GR, FI, IS, AT, FR, RO, MD, HU, NO, MK, BG, ES, HR, NL, CZ, EE", - "Asia": "[CN, JP, KR, ID, IN, HK, MY, IL, TH, QA, TR, RU, VN, TW, OM, SG, AE, KZ]", - "Oceania": "[AU, NZ]", - "Africa": "[EG, ZA]", +"North_America": "[AG, BS, BB, BZ, CA, CR, CU, DM, DO, SV, GD, GT, HT, HN, JM, MX, NI, PA, KN, LC, VC, TT, US]", +"South_America": "[AR, BO, BR, CL, CO, EC, FK, GF, GY, PY, PE, SR, UY, VE]", +"Europe": "[AL, AD, AT, BY, BE, BA, BG, HR, CY, CZ, DK, EE, FI, FR, DE, GR, HU, IS, IE, IT, LV, LI, LT, LU, MT, MD, MC, ME, NL, MK, NO, PL, PT, RO, RU, SM, RS, SK, SI, ES, SE, CH, UA, GB, VA, XK]", +"Asia": "[AF, AM, AZ, BH, BD, BT, BN, KH, CN, GE, IN, ID, IR, IQ, IL, JP, JO, KZ, KW, KG, LA, LB, MY, MV, MN, MM, NP, KP, OM, PK, PH, QA, SA, SG, KR, LK, SY, TW, TJ, TH, TL, TR, TM, AE, UZ, VN, YE, HK, MO]", +"Oceania": "[AS, AU, CK, FJ, PF, GU, KI, MH, FM, NR, NC, NZ, NU, MP, PW, PG, PN, WS, SB, TK, TO, TV, VU, WF]", +"Africa": "[DZ, AO, BJ, BW, BF, BI, CV, CM, CF, TD, KM, CG, CD, CI, DJ, EG, GQ, ER, SZ, ET, GA, GM, GH, GN, GW, KE, LS, LR, LY, MG, MW, ML, MR, MU, MA, MZ, NA, NE, NG, RW, ST, SN, SC, SL, SO, ZA, SS, SD, TZ, TG, TN, UG, ZM, ZW]" } def _is_valid_region(region): @@ -2261,7 +3490,6 @@ def _parse_region(region): argument("--extra", help=argparse.SUPPRESS), argument("--env", help="env variables and port mapping options, surround with '' ", type=str), argument("--args", nargs=argparse.REMAINDER, help="list of arguments passed to container ENTRYPOINT. Onstart is recommended for this purpose. (must be last argument)"), - argument("--force", help="Skip sanity checks when creating from an existing instance", action="store_true"), argument("--cancel-unavail", help="Return error if scheduling fails (rather than creating a stopped instance)", action="store_true"), argument("--template_hash", help="template hash which contains all relevant information about an instance. This can be used as a replacement for other parameters describing the instance configuration", type=str), usage="vastai launch instance [--help] [--api-key API_KEY] [geolocation] [disk_space]", @@ -2350,15 +3578,10 @@ def launch__instance(args): args.onstart_cmd = args.entrypoint json_blob = { - "client_id": "me", - "gpu_name": args.gpu_name, - "num_gpus": args.num_gpus, - "region": args.region, - "image": args.image, - "disk": args.disk, + "image": args.image, + "disk": args.disk, "q" : query, "env" : parse_env(args.env), - "disk": args.disk, "label": args.label, "extra": args.extra, "onstart": args.onstart_cmd, @@ -2367,7 +3590,6 @@ def launch__instance(args): "lang_utf8": args.lang_utf8, "use_jupyter_lab": args.jupyter_lab, "jupyter_dir": args.jupyter_dir, - "force": args.force, "cancel_unavail": args.cancel_unavail, "template_hash_id" : args.template_hash } @@ -2433,8 +3655,7 @@ def logs(args): rj = r.json() for i in range(0, 30): time.sleep(0.3) - api_key_id_h = hashlib.md5((args.api_key + str(args.INSTANCE_ID)).encode('utf-8')).hexdigest() - url = "https://s3.amazonaws.com/vast.ai/instance_logs/" + api_key_id_h + ".log" + url = rj["result_url"] print(f"waiting on logs for instance {args.INSTANCE_ID} fetching from {url}") r = requests.get(url) if r.status_code == 200: @@ -2483,6 +3704,11 @@ def prepay__instance(args): @parser.command( argument("id", help="id of instance to reboot", type=int), + argument("--schedule", choices=["HOURLY", "DAILY", "WEEKLY"], help="try to schedule a command to run hourly, daily, or monthly. Valid values are HOURLY, DAILY, WEEKLY For ex. --schedule DAILY"), + argument("--start_date", type=str, default=default_start_date(), help="Start date/time in format 'YYYY-MM-DD HH:MM:SS PM' (UTC). Default is now. (optional)"), + argument("--end_date", type=str, default=default_end_date(), help="End date/time in format 'YYYY-MM-DD HH:MM:SS PM' (UTC). Default is 7 days from now. (optional)"), + argument("--day", type=parse_day_cron_style, help="Day of week you want scheduled job to run on (0-6, where 0=Sunday) or \"*\". Default will be 0. For ex. --day 0", default=0), + argument("--hour", type=parse_hour_cron_style, help="Hour of day you want scheduled job to run on (0-23) or \"*\" (UTC). Default will be 0. For ex. --hour 16", default=0), usage="vastai reboot instance ID [OPTIONS]", help="Reboot (stop/start) an instance", epilog=deindent(""" @@ -2498,6 +3724,14 @@ def reboot__instance(args): r = http_put(args, url, headers=headers,json={}) r.raise_for_status() + if (args.schedule): + validate_frequency_values(args.day, args.hour, args.schedule) + cli_command = "reboot instance" + api_endpoint = "/api/v0/instances/reboot/{id}/".format(id=args.id) + json_blob = {"instance_id": args.id} + add_scheduled_job(args, json_blob, cli_command, api_endpoint, "PUT", instance_id=args.id) + return + if (r.status_code == 200): rj = r.json(); if (rj["success"]): @@ -2510,7 +3744,7 @@ def reboot__instance(args): @parser.command( - argument("id", help="id of instance to reboot", type=int), + argument("id", help="id of instance to recycle", type=int), usage="vastai recycle instance ID [OPTIONS]", help="Recycle (destroy/create) an instance", epilog=deindent(""" @@ -2538,10 +3772,10 @@ def recycle__instance(args): @parser.command( argument("id", help="id of user to remove", type=int), - usage="vastai remove team-member ID", + usage="vastai remove member ID", help="Remove a team member", ) -def remove__team_member(args): +def remove__member(args): url = apiurl(args, "/team/members/{id}/".format(id=args.id)) r = http_del(args, url, headers=headers) r.raise_for_status() @@ -2911,7 +4145,7 @@ def search__benchmarks(args): @parser.command( argument("query", help="Search query in simple query syntax (see below)", nargs="*", default=None), usage="vastai search invoices [--help] [--api-key API_KEY] [--raw] ", - help="Search for benchmark results using custom query", + help="Search for invoices using custom query", epilog=deindent(""" Query syntax: @@ -2994,7 +4228,6 @@ def search__invoices(args): argument("-n", "--no-default", action="store_true", help="Disable default query"), argument("--new", action="store_true", help="New search exp"), argument("--limit", type=int, help=""), - argument("--disable-bundling", action="store_true", help="Deprecated"), argument("--storage", type=float, default=5.0, help="Amount of storage to use for pricing, in GiB. default=5.0GiB"), argument("-o", "--order", type=str, help="Comma-separated list of fields to sort on. postfix field with - to sort desc. ex: -o 'num_gpus,total_flops-'. default='score-'", default='score-'), argument("query", help="Query to search for. default: 'external=false rentable=true verified=true', pass -n to ignore default", nargs="*", default=None), @@ -3134,8 +4367,6 @@ def search__offers(args): # For backwards compatibility, support --type=interruptible option if query["type"] == 'interruptible': query["type"] = 'bid' - if args.disable_bundling: - query["disable_bundling"] = True except ValueError as e: print("Error: ", e) return 1 @@ -3284,7 +4515,7 @@ def search__templates(args): print("Error: ", e) return 1 url = apiurl(args, "/template/", {"select_cols" : ['*'], "select_filters" : query}) - r = requests.get(url, headers=headers) + r = http_get(args, url, headers=headers) if r.status_code != 200: print(r.text) r.raise_for_status() @@ -3298,121 +4529,338 @@ def search__templates(args): print(r.text) print("failed with error {r.status_code}".format(**locals())) - @parser.command( - argument("new_api_key", help="Api key to set as currently logged in user"), - usage="vastai set api-key APIKEY", - help="Set api-key (get your api-key from the console/CLI)", -) -def set__api_key(args): - """Caution: a bad API key will make it impossible to connect to the servers. - :param argparse.Namespace args: should supply all the command-line options - """ - with open(APIKEY_FILE, "w") as writer: - writer.write(args.new_api_key) - print("Your api key has been saved in {}".format(APIKEY_FILE)) + argument("-n", "--no-default", action="store_true", help="Disable default query"), + argument("--limit", type=int, help=""), + argument("--storage", type=float, default=1.0, help="Amount of storage to use for pricing, in GiB. default=1.0GiB"), + argument("-o", "--order", type=str, help="Comma-separated list of fields to sort on. postfix field with - to sort desc. ex: -o 'disk_space,inet_up-'. default='score-'", default='score-'), + argument("query", help="Query to search for. default: 'external=false verified=true disk_space>=1', pass -n to ignore default", nargs="*", default=None), + usage="vastai search volumes [--help] [--api-key API_KEY] [--raw] ", + help="Search for volume offers using custom query", + epilog=deindent(""" + Query syntax: + query = comparison comparison... + comparison = field op value + field = + op = one of: <, <=, ==, !=, >=, >, in, notin + value = | 'any' | [value0, value1, ...] + bool: True, False + note: to pass '>' and '<' on the command line, make sure to use quotes + note: to encode a string query value (ie for gpu_name), replace any spaces ' ' with underscore '_' -@parser.command( - argument("--file", help="file path for params in json format", type=str), - usage="vastai set user --file FILE", - help="Update user data from json file", - epilog=deindent(""" + Examples: - Available fields: + # search for volumes with greater than 50GB of available storage and greater than 500 Mb/s upload and download speed + vastai search volumes "disk_space>50 inet_up>500 inet_down>500" + + Available fields: - Name Type Description + Name Type Description - ssh_key string - paypal_email string - wise_email string - email string - normalized_email string - username string - fullname string - billaddress_line1 string - billaddress_line2 string - billaddress_city string - billaddress_zip string - billaddress_country string - billaddress_taxinfo string - balance_threshold_enabled string - balance_threshold string - autobill_threshold string - phone_number string - tfa_enabled bool + cpu_arch: string host machine cpu architecture (e.g. amd64, arm64) + cuda_vers: float machine max supported cuda version (based on driver version) + datacenter: bool show only datacenter offers + disk_bw: float disk read bandwidth, in MB/s + disk_space: float disk storage space, in GB + driver_version: string machine's nvidia/amd driver version as 3 digit string ex. "535.86.05" + duration: float max rental duration in days + geolocation: string Two letter country code. Works with operators =, !=, in, notin (e.g. geolocation not in ['XV','XZ']) + gpu_arch: string host machine gpu architecture (e.g. nvidia, amd) + gpu_name: string GPU model name (no quotes, replace spaces with underscores, ie: RTX_3090 rather than 'RTX 3090') + has_avx: bool CPU supports AVX instruction set. + id: int volume offer unique ID + inet_down: float internet download speed in Mb/s + inet_up: float internet upload speed in Mb/s + machine_id: int machine id of volume offer + pci_gen: float PCIE generation + pcie_bw: float PCIE bandwidth (CPU to GPU) + reliability: float machine reliability score (see FAQ for explanation) + storage_cost: float storage cost in $/GB/month + static_ip: bool is the IP addr static/stable + total_flops: float total TFLOPs from all GPUs + ubuntu_version: string host machine ubuntu OS version + verified: bool is the machine verified """), ) -def set__user(args): - params = None - with open(args.file, 'r') as file: - params = json.load(file) - url = apiurl(args, "/users/") - r = requests.put(url, headers=headers, json=params) - r.raise_for_status() - print(f"{r.json()}") - +def search__volumes(args: argparse.Namespace): + try: + if args.no_default: + query = {} + else: + query = {"verified": {"eq": True}, "external": {"eq": False}, "disk_space": {"gte": 1}} -@parser.command( - argument("id", help="id of instance", type=int), - usage="vastai ssh-url ID", - help="ssh url helper", -) -def ssh_url(args): - """ + if args.query is not None: + query = parse_query(args.query, query, vol_offers_fields, {}, offers_mult) - :param argparse.Namespace args: should supply all the command-line options - :rtype: - """ - return _ssh_url(args, "ssh://") + order = [] + for name in args.order.split(","): + name = name.strip() + if not name: continue + direction = "asc" + field = name + if name.strip("-") != name: + direction = "desc" + field = name.strip("-") + if name.strip("+") != name: + direction = "asc" + field = name.strip("+") + if field in offers_alias: + field = offers_alias[field]; + order.append([field, direction]) + query["order"] = order + if (args.limit): + query["limit"] = int(args.limit) + query["allocated_storage"] = args.storage + except ValueError as e: + print("Error: ", e) + return 1 -@parser.command( - argument("id", help="id", type=int), - usage="vastai scp-url ID", - help="scp url helper", -) -def scp_url(args): - """ + json_blob = query - :param argparse.Namespace args: should supply all the command-line options - :rtype: - """ - return _ssh_url(args, "scp://") + if (args.explain): + print("request json: ") + print(json_blob) + url = apiurl(args, "/volumes/search/") + r = http_post(args, url, headers=headers, json=json_blob) + r.raise_for_status() + + if (r.headers.get('Content-Type') != 'application/json'): + print(f"invalid return Content-Type: {r.headers.get('Content-Type')}") + return -def _ssh_url(args, protocol): + rows = r.json()["offers"] + + if args.raw: + return rows + else: + display_table(rows, vol_displayable_fields) - json_object = None - # Opening JSON file - try: - with open(f"ssh_{args.id}.json", 'r') as openfile: - json_object = json.load(openfile) - except: - pass - port = None - ipaddr = None +@parser.command( + argument("-n", "--no-default", action="store_true", help="Disable default query"), + argument("--limit", type=int, help=""), + argument("--storage", type=float, default=1.0, help="Amount of storage to use for pricing, in GiB. default=1.0GiB"), + argument("-o", "--order", type=str, help="Comma-separated list of fields to sort on. postfix field with - to sort desc. ex: -o 'disk_space,inet_up-'. default='score-'", default='score-'), + argument("query", help="Query to search for. default: 'external=false verified=true disk_space>=1', pass -n to ignore default", nargs="*", default=None), + usage="vastai search network volumes [--help] [--api-key API_KEY] [--raw] ", + help="Search for network volume offers using custom query", + epilog=deindent(""" + Query syntax: - if json_object is not None: - ipaddr = json_object["ipaddr"] - port = json_object["port"] + query = comparison comparison... + comparison = field op value + field = + op = one of: <, <=, ==, !=, >=, >, in, notin + value = | 'any' | [value0, value1, ...] + bool: True, False + + note: to pass '>' and '<' on the command line, make sure to use quotes + note: to encode a string query value (ie for gpu_name), replace any spaces ' ' with underscore '_' + + Examples: + + # search for volumes with greater than 50GB of available storage and greater than 500 Mb/s upload and download speed + vastai search volumes "disk_space>50 inet_up>500 inet_down>500" + + Available fields: + + Name Type Description + duration: float max rental duration in days + geolocation: string Two letter country code. Works with operators =, !=, in, notin (e.g. geolocation not in ['XV','XZ']) + id: int volume offer unique ID + inet_down: float internet download speed in Mb/s + inet_up: float internet upload speed in Mb/s + reliability: float machine reliability score (see FAQ for explanation) + storage_cost: float storage cost in $/GB/month + verified: bool is the machine verified + """), +) +def search__network_volumes(args: argparse.Namespace): + try: + + if args.no_default: + query = {} + else: + query = {"verified": {"eq": True}, "external": {"eq": False}, "disk_space": {"gte": 1}} + + if args.query is not None: + query = parse_query(args.query, query, vol_offers_fields, {}, offers_mult) + + order = [] + for name in args.order.split(","): + name = name.strip() + if not name: continue + direction = "asc" + field = name + if name.strip("-") != name: + direction = "desc" + field = name.strip("-") + if name.strip("+") != name: + direction = "asc" + field = name.strip("+") + if field in offers_alias: + field = offers_alias[field]; + order.append([field, direction]) + + query["order"] = order + if (args.limit): + query["limit"] = int(args.limit) + query["allocated_storage"] = args.storage + except ValueError as e: + print("Error: ", e) + return 1 + + json_blob = query + + if (args.explain): + print("request json: ") + print(json_blob) + url = apiurl(args, "/network_volumes/search/") + r = http_post(args, url, headers=headers, json=json_blob) + + r.raise_for_status() + + if (r.headers.get('Content-Type') != 'application/json'): + print(f"invalid return Content-Type: {r.headers.get('Content-Type')}") + return + + rows = r.json()["offers"] + + if args.raw: + return rows + else: + display_table(rows, nw_vol_displayable_fields) + + +@parser.command( + argument("new_api_key", help="Api key to set as currently logged in user"), + usage="vastai set api-key APIKEY", + help="Set api-key (get your api-key from the console/CLI)", +) +def set__api_key(args): + """Caution: a bad API key will make it impossible to connect to the servers. + :param argparse.Namespace args: should supply all the command-line options + """ + with open(APIKEY_FILE, "w") as writer: + writer.write(args.new_api_key) + print("Your api key has been saved in {}".format(APIKEY_FILE)) + + APIKEY_FILE_HOME = os.path.expanduser("~/.vast_api_key") # Legacy + if os.path.exists(APIKEY_FILE_HOME): + os.remove(APIKEY_FILE_HOME) + print("Your api key has been removed from {}".format(APIKEY_FILE_HOME)) + + + +@parser.command( + argument("--file", help="file path for params in json format", type=str), + usage="vastai set user --file FILE", + help="Update user data from json file", + epilog=deindent(""" + + Available fields: + + Name Type Description + + ssh_key string + paypal_email string + wise_email string + email string + normalized_email string + username string + fullname string + billaddress_line1 string + billaddress_line2 string + billaddress_city string + billaddress_zip string + billaddress_country string + billaddress_taxinfo string + balance_threshold_enabled string + balance_threshold string + autobill_threshold string + phone_number string + """), +) +def set__user(args): + params = None + with open(args.file, 'r') as file: + params = json.load(file) + url = apiurl(args, "/users/") + r = requests.put(url, headers=headers, json=params) + r.raise_for_status() + print(f"{r.json()}") + + + +@parser.command( + argument("id", help="id of instance", type=int), + usage="vastai ssh-url ID", + help="ssh url helper", +) +def ssh_url(args): + """ + + :param argparse.Namespace args: should supply all the command-line options + :rtype: + """ + return _ssh_url(args, "ssh://") + + +@parser.command( + argument("id", help="id", type=int), + usage="vastai scp-url ID", + help="scp url helper", +) +def scp_url(args): + """ + + :param argparse.Namespace args: should supply all the command-line options + :rtype: + """ + return _ssh_url(args, "scp://") + + +def _ssh_url(args, protocol): + + json_object = None + + # Opening JSON file + try: + with open(f"{DIRS['temp']}/ssh_{args.id}.json", 'r') as openfile: + json_object = json.load(openfile) + except: + pass + + port = None + ipaddr = None + + if json_object is not None: + ipaddr = json_object["ipaddr"] + port = json_object["port"] - if ipaddr is None: - req_url = apiurl(args, "/instances", {"owner": "me"}); - r = http_get(args, req_url); + if ipaddr is None or ipaddr.endswith('.vast.ai'): + req_url = apiurl(args, "/instances", {"owner": "me"}) + r = http_get(args, req_url) r.raise_for_status() rows = r.json()["instances"] + if args.id: - instance, = [r for r in rows if r['id'] == args.id] + matches = [r for r in rows if r['id'] == args.id] + if not matches: + print(f"error: no instance found with id {args.id}") + return 1 + instance = matches[0] elif len(rows) > 1: print("Found multiple running instances") return 1 else: - instance, = rows + instance = rows[0] ports = instance.get("ports",{}) port_22d = ports.get("22/tcp",None) @@ -3435,7 +4883,7 @@ def _ssh_url(args, protocol): # Writing to sample.json try: - with open(f"ssh_{args.id}.json", "w") as outfile: + with open(f"{DIRS['temp']}/ssh_{args.id}.json", "w") as outfile: json.dump({"ipaddr":ipaddr, "port":port}, outfile) except: pass @@ -3485,6 +4933,59 @@ def show__audit_logs(args): else: display_table(rows, audit_log_fields) +def normalize_schedule_fields(job): + """ + Mutates the job dict to replace None values with readable scheduling labels. + """ + if job.get("day_of_the_week") is None: + job["day_of_the_week"] = "Everyday" + else: + days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] + job["day_of_the_week"] = days[int(job["day_of_the_week"])] + + if job.get("hour_of_the_day") is None: + job["hour_of_the_day"] = "Every hour" + else: + hour = int(job["hour_of_the_day"]) + suffix = "AM" if hour < 12 else "PM" + hour_12 = hour % 12 + hour_12 = 12 if hour_12 == 0 else hour_12 + job["hour_of_the_day"] = f"{hour_12}_{suffix}" + + if job.get("min_of_the_hour") is None: + job["min_of_the_hour"] = "Every minute" + else: + job["min_of_the_hour"] = f"{int(job['min_of_the_hour']):02d}" + + return job + +def normalize_jobs(jobs): + """ + Applies normalization to a list of job dicts. + """ + return [normalize_schedule_fields(job) for job in jobs] + + +@parser.command( + usage="vastai show scheduled-jobs [--api-key API_KEY] [--raw]", + help="Display the list of scheduled jobs" +) +def show__scheduled_jobs(args): + """ + Shows the list of scheduled jobs for the account. + + :param argparse.Namespace args: should supply all the command-line options + :rtype: + """ + req_url = apiurl(args, "/commands/schedule_job/") + r = http_get(args, req_url) + r.raise_for_status() + rows = r.json() + if args.raw: + return rows + else: + rows = normalize_jobs(rows) + display_table(rows, scheduled_jobs_fields) @parser.command( usage="vastai show ssh-keys", @@ -3500,13 +5001,13 @@ def show__ssh_keys(args): print(r.json()) @parser.command( - usage="vastai show autogroups [--api-key API_KEY]", - help="Display user's current autogroup groups", + usage="vastai show workergroups [--api-key API_KEY]", + help="Display user's current workergroups", epilog=deindent(""" - Example: vastai show autogroups + Example: vastai show workergroups """), ) -def show__autogroups(args): +def show__workergroups(args): url = apiurl(args, "/autojobs/" ) json_blob = {"client_id": "me", "api_key": args.api_key} if (args.explain): @@ -3514,7 +5015,7 @@ def show__autogroups(args): print(json_blob) r = http_get(args, url, headers=headers,json=json_blob) r.raise_for_status() - #print("autogroup list ".format(r.json())) + #print("workergroup list ".format(r.json())) if (r.status_code == 200): rj = r.json(); @@ -3543,63 +5044,140 @@ def show__endpoints(args): print(json_blob) r = http_get(args, url, headers=headers,json=json_blob) r.raise_for_status() - #print("autogroup list ".format(r.json())) + #print("workergroup list ".format(r.json())) if (r.status_code == 200): rj = r.json(); if (rj["success"]): - rows = rj["results"] + rows = rj["results"] + for row in rows: + row.pop("api_key", None) + row.pop("auto_delete_in_seconds", None) + row.pop("auto_delete_due_24h", None) if args.raw: return rows else: - #print(rows) print(json.dumps(rows, indent=1, sort_keys=True)) else: print(rj["msg"]); @parser.command( - usage="vastai show connections [--api-key API_KEY] [--raw]", - help="Display user's cloud connections" + usage="vastai show deployments [--api-key API_KEY]", + help="Display user's current deployments", + epilog=deindent(""" + Example: vastai show deployments + """), ) -def show__connections(args): - """ - Shows the stats on the machine the user is renting. - - :param argparse.Namespace args: should supply all the command-line options - :rtype: - """ - req_url = apiurl(args, "/users/cloud_integrations/"); - print(req_url) - r = http_get(args, req_url, headers=headers); +def show__deployments(args): + url = apiurl(args, "/deployments/") + r = http_get(args, url, headers=headers) r.raise_for_status() - rows = r.json() - if args.raw: - return rows - else: - display_table(rows, connection_fields) + if r.status_code == 200: + rj = r.json() + if rj["success"]: + rows = rj["deployments"] + if args.raw: + return rows + else: + print(json.dumps(rows, indent=1, sort_keys=True)) + else: + print(rj.get("msg", "Unknown error")) @parser.command( - argument("id", help="id of instance to get info for", type=int), - usage="vastai show deposit ID [options]", - help="Display reserve deposit info for an instance" + argument("id", help="id of deployment to show", type=int), + usage="vastai show deployment ID", + help="Display details of a single deployment", + epilog=deindent(""" + Example: vastai show deployment 1234 + """), ) -def show__deposit(args): - """ - Shows reserve deposit info for an instance. - - :param argparse.Namespace args: should supply all the command-line options - :rtype: - """ - req_url = apiurl(args, "/instances/balance/{id}/".format(id=args.id) , {"owner": "me"} ) - r = http_get(args, req_url) +def show__deployment(args): + url = apiurl(args, f"/deployment/{args.id}/") + r = http_get(args, url, headers=headers) r.raise_for_status() - print(json.dumps(r.json(), indent=1, sort_keys=True)) - -@parser.command( + if r.status_code == 200: + rj = r.json() + if rj["success"]: + row = rj["deployment"] + if args.raw: + return row + else: + print(json.dumps(row, indent=1, sort_keys=True)) + else: + print(rj.get("msg", "Unknown error")) + + +@parser.command( + argument("id", help="id of deployment to show versions for", type=int), + usage="vastai show deployment-versions ID", + help="Display versions for a deployment", + epilog=deindent(""" + Example: vastai show deployment-versions 1234 + """), +) +def show__deployment_versions(args): + url = apiurl(args, f"/deployment/{args.id}/versions/") + r = http_get(args, url, headers=headers) + r.raise_for_status() + + if r.status_code == 200: + rj = r.json() + if rj["success"]: + rows = rj["versions"] + if args.raw: + return rows + else: + print(json.dumps(rows, indent=1, sort_keys=True)) + else: + print(rj.get("msg", "Unknown error")) + + +@parser.command( + usage="vastai show connections [--api-key API_KEY] [--raw]", + help="Display user's cloud connections" +) +def show__connections(args): + """ + Shows the stats on the machine the user is renting. + + :param argparse.Namespace args: should supply all the command-line options + :rtype: + """ + req_url = apiurl(args, "/users/cloud_integrations/"); + print(req_url) + r = http_get(args, req_url, headers=headers); + r.raise_for_status() + rows = r.json() + + if args.raw: + return rows + else: + display_table(rows, connection_fields) + + +@parser.command( + argument("id", help="id of instance to get info for", type=int), + usage="vastai show deposit ID [options]", + help="Display reserve deposit info for an instance" +) +def show__deposit(args): + """ + Shows reserve deposit info for an instance. + + :param argparse.Namespace args: should supply all the command-line options + :rtype: + """ + req_url = apiurl(args, "/instances/balance/{id}/".format(id=args.id) , {"owner": "me"} ) + r = http_get(args, req_url) + r.raise_for_status() + print(json.dumps(r.json(), indent=1, sort_keys=True)) + + +@parser.command( argument("-q", "--quiet", action="store_true", help="only display numeric ids"), argument("-s", "--start_date", help="start date and time for report. Many formats accepted", type=str), argument("-e", "--end_date", help="end date and time for report. Many formats accepted ", type=str), @@ -3615,10 +5193,10 @@ def show__earnings(args): :rtype: """ - Minutes = 60.0; - Hours = 60.0*Minutes; - Days = 24.0*Hours; - Years = 365.0*Days; + Minutes = 60.0 + Hours = 60.0*Minutes + Days = 24.0*Hours + Years = 365.0*Days cday = time.time() / Days sday = cday - 1.0 eday = cday - 1.0 @@ -3634,7 +5212,7 @@ def show__earnings(args): try: end_date = dateutil.parser.parse(str(args.end_date)) end_date_txt = end_date.isoformat() - end_timestamp = time.mktime(end_date.timetuple()) + end_timestamp = end_date.timestamp() eday = end_timestamp / Days except ValueError as e: print(f"Warning: Invalid end date format! Ignoring end date! \n {str(e)}") @@ -3643,22 +5221,22 @@ def show__earnings(args): try: start_date = dateutil.parser.parse(str(args.start_date)) start_date_txt = start_date.isoformat() - start_timestamp = time.mktime(start_date.timetuple()) + start_timestamp = start_date.timestamp() sday = start_timestamp / Days - except ValueError: + except ValueError as e: print(f"Warning: Invalid start date format! Ignoring start date! \n {str(e)}") - - req_url = apiurl(args, "/users/me/machine-earnings", {"owner": "me", "sday": sday, "eday": eday, "machid" :args.machine_id}); r = http_get(args, req_url) r.raise_for_status() rows = r.json() + if args.raw: + return rows print(json.dumps(rows, indent=1, sort_keys=True)) -def sum(X, k): +def inv_sum(X, k): y = 0 for x in X: a = float(x.get(k,0)) @@ -3716,8 +5294,8 @@ def show__env_vars(args): argument("-c", "--only_charges", action="store_true", help="Show only charge items"), argument("-p", "--only_credits", action="store_true", help="Show only credit items"), argument("--instance_label", help="Filter charges on a particular instance label (useful for autoscaler groups)"), - usage="vastai show invoices [OPTIONS]", - help="Get billing history reports", + usage="(DEPRECATED) vastai show invoices [OPTIONS]", + help="(DEPRECATED) Get billing history reports", ) def show__invoices(args): """ @@ -3785,9 +5363,321 @@ def show__invoices(args): else: print(filter_header) display_table(rows, invoice_fields) - print(f"Total: ${sum(rows, 'amount')}") + print(f"Total: ${inv_sum(rows, 'amount')}") print("Current: ", current_charges) +# Helper to convert date string or int to timestamp +def to_timestamp_(val): + if isinstance(val, int): + return val + if isinstance(val, str): + if val.isdigit(): + return int(val) + return int(datetime.strptime(val + "+0000", '%Y-%m-%d%z').timestamp()) + raise ValueError("Invalid date format") + +charge_types = ['instance','volume','serverless', 'i', 'v', 's'] +invoice_types = { + "transfers": "transfer", + "stripe": "stripe_payments", + "bitpay": "bitpay", + "coinbase": "coinbase", + "crypto.com": "crypto.com", + "reserved": "instance_prepay", + "payout_paypal": "paypal_manual", + "payout_wise": "wise_manual" +} + +@parser.command( + argument('-i', '--invoices', mutex_group='grp', action='store_true', required=True, help='Show invoices instead of charges'), + argument('-it', '--invoice-type', choices=invoice_types.keys(), nargs='+', metavar='type', help=f'Filter which types of invoices to show: {{{", ".join(invoice_types.keys())}}}'), + argument('-c', '--charges', mutex_group='grp', action='store_true', required=True, help='Show charges instead of invoices'), + argument('-ct', '--charge-type', choices=charge_types, nargs='+', metavar='type', help='Filter which types of charges to show: {i|instance, v|volume, s|serverless}'), + argument('-s', '--start-date', help='Start date (YYYY-MM-DD or timestamp)'), + argument('-e', '--end-date', help='End date (YYYY-MM-DD or timestamp)'), + argument('-l', '--limit', type=int, default=20, help='Number of results per page (default: 20, max: 100)'), + argument('-t', '--next-token', help='Pagination token for next page'), + argument('-f', '--format', choices=['table', 'tree'], default='table', help='Output format for charges (default: table)'), + argument('-v', '--verbose', action='store_true', help='Include full Instance Charge details and Invoice Metadata (tree view only)'), + argument('--latest-first', action='store_true', help='Sort by latest first'), + usage="vastai show invoices-v1 [OPTIONS]", + help="Get billing (invoices/charges) history reports with advanced filtering and pagination", + epilog=deindent(""" + This command supports colored output and rich formatting if the 'rich' python module is installed! + + Examples: + # Show the first 20 invoices in the last week (note: default window is a 7 day period ending today) + vastai show invoices-v1 --invoices + + # Show the first 50 charges over a 7 day period starting from 2025-11-30 in tree format + vastai show invoices-v1 --charges -s 2025-11-30 -f tree -l 50 + + # Show the first 20 invoices of specific types for the month of November 2025 + vastai show invoices-v1 -i -it stripe bitpay transfers --start-date 2025-11-01 --end-date 2025-11-30 + + # Show the first 20 charges for only volumes and serverless instances between two dates, including all details and metadata + vastai show invoices-v1 -c --charge-type v s -s 2025-11-01 -e 2025-11-05 --format tree --verbose + + # Get the next page of paginated invoices, limit to 50 per page (note: type/date filters MUST match previous request for pagination to work) + vastai show invoices-v1 --invoices --limit 50 --next-token eyJ2YWx1ZXMiOiB7ImlkIjogMjUwNzgyMzR9LCAib3NfcGFnZSI6IDB9 + + # Show the last 10 instance (only) charges over a 7 day period ending in 2025-12-25, sorted by latest charges first + vastai show invoices-v1 --charges -ct instance --end-date 2025-12-25 -l 10 --latest-first + """) +) +def show__invoices_v1(args): + output_lines = [] + try: + from rich.prompt import Confirm + has_rich = True + except ImportError: + output_lines.append("NOTE: To view results in color and table/tree format please install the 'rich' python module with 'pip install rich'\n") + has_rich = False + + # Handle default start and end date values + if not args.start_date and not args.end_date: + args.end_date = int(time.time()) # Set end date to current time if both are missing + if not args.start_date: + args.start_date = args.end_date - 7 * 24*60*60 # Default to 7 days before given end date + elif not args.end_date: + args.end_date = args.start_date + 7 * 24*60*60 # Default to 7 days after given start date + + try: + # Parse dates - handle both YYYY-MM-DD format and timestamps + start_timestamp = to_timestamp_(args.start_date) + end_timestamp = to_timestamp_(args.end_date) + except Exception as e: + print(f"Error parsing dates: {e}") + print("Use format YYYY-MM-DD or UNIX timestamp") + return + + if has_rich and not args.no_color: + print("(use --no-color to disable colored output)\n") + + start_date = convert_timestamp_to_date(start_timestamp) + end_date = convert_timestamp_to_date(end_timestamp) + data_type = "Instance Charges" if args.charges else "Invoices" + output_lines.append(f"Fetching {data_type} from {start_date} to {end_date}...") + + # Build request parameters + date_col = 'day' if args.charges else 'when' + params = { + 'select_filters': {date_col: {'gte': start_timestamp, 'lte': end_timestamp}}, + 'latest_first': args.latest_first, + 'limit': min(args.limit, 100) if args.limit > 0 else 20, # Enforce max limit of 100 + } + if args.charges: + params['format'] = args.format + for ct in args.charge_type or []: + filters = params['select_filters'].setdefault('type', {}).setdefault('in', []) + if ct in {'i','instance'}: filters.append('instance') + elif ct in {'v','volume'}: filters.append('volume') + elif ct in {'s','serverless'}: filters.append('serverless') + + if args.invoices: + for it in args.invoice_type or []: + filters = params['select_filters'].setdefault('service', {}).setdefault('in', []) + filters.append(invoice_types[it]) + + if args.next_token: + params['after_token'] = args.next_token + + endpoint = '/api/v0/charges/' if args.charges else '/api/v1/invoices/' + url = apiurl(args, endpoint, query_args=params) + + found_results, found_count = [], 0 + looping = True + while looping: + response = http_get(args, url) + response.raise_for_status() + response = response.json() + + found_results += response.get('results', []) + found_count += response.get('count', 0) + total = response.get('total', 0) + next_token = response.get('next_token') + + if args.raw or has_rich is False: + output_lines.append("Raw response:\n" + json.dumps(response, indent=2)) + if next_token: + print(f"Next page token: {next_token}\n") + elif not found_results: + output_lines.append("No results found") + else: # Display results + formatted_results = format_invoices_charges_results(args, deepcopy(found_results)) + if args.invoices: + rich_obj = create_rich_table_for_invoices(formatted_results) + elif args.format == 'tree': + rich_obj = create_charges_tree(formatted_results) + else: + rich_obj = create_rich_table_for_charges(args, formatted_results) + + output_lines.append(rich_object_to_string(rich_obj, no_color=args.no_color)) + output_lines.append(f"Showing {found_count} of {total} results") + if next_token: + output_lines.append(f"Next page token: {next_token}\n") + + paging = print_or_page(args, '\n'.join(output_lines)) + + if next_token and not paging: + if has_rich: + ans = Confirm.ask("Fetch next page?", show_default=False, default=False) + else: + ans = input("Fetch next page? (y/N): ").strip().lower() == 'y' + if ans: + params['after_token'] = next_token + url = apiurl(args, endpoint, query_args=params) + output_lines.clear() + args.full = True + else: + looping = False + else: + looping = False + +def format_invoices_charges_results(args, results): + indices_to_remove = [] + for i,item in enumerate(results): + item['start'] = convert_timestamp_to_date(item['start']) if item['start'] else None + item['end'] = convert_timestamp_to_date(item['end']) if item['end'] else None + if item['amount'] == 0: + indices_to_remove.append(i) # Removing items that don't contribute to the total + elif args.invoices: + if item['type'] not in {'transfer', 'payout'}: + item['amount'] *= -1 # present amounts intuitively as related to balance + item['amount_str'] = f"${item['amount']:.2f}" if item['amount'] > 0 else f"-${abs(item['amount']):.2f}" + else: + item['amount'] = f"${item['amount']:.3f}" + + if args.charges: + if item['type'] in {'instance','volume'} and not args.verbose: + item['items'] = [] # Remove instance charge details if verbose is not set + if item['source'] and '-' in item['source']: + item['type'], item['source'] = item['source'].capitalize().split('-') + + item['items'] = format_invoices_charges_results(args, item['items']) + + for i in reversed(indices_to_remove): # Remove in reverse order to avoid index shifting + del results[i] + + return results + + +def rich_object_to_string(rich_obj, no_color=True): + """ Render a Rich object (Table or Tree) to a string. """ + from rich.console import Console + buffer = StringIO() # Use an in-memory stream to suppress visible output + console = Console(record=True, file=buffer) + console.print(rich_obj) + return console.export_text(clear=True, styles=not no_color) + +def create_charges_tree(results, parent=None, title="Charges Breakdown"): + """ Build and return a Rich Tree from nested charge results. """ + from rich.text import Text + from rich.tree import Tree + from rich.panel import Panel + if parent is None: # Create root node if this is the first call + root = Tree(Text(title, style="bold red")) + create_charges_tree(results, root) + return Panel(root, style="white on #000000", expand=False) + + top_level = (parent.label.plain == title) + for item in results: + end_date = f" → {item['end']}" if item['start'] != item['end'] else "" + label = Text.assemble( + (item["type"], "bold cyan"), + (f" {item['source']}" if item.get('source') else "", "gold1"), " → ", + (f"{item['amount']}", 'bold green1' if top_level else 'green1'), + (f" — {item['description']}", "bright_white" if top_level else "dim white"), + (f" ({item['start']}{end_date})", "bold bright_white" if top_level else "white") + ) + node = parent.add(label, guide_style="blue3") + if item.get("items"): + create_charges_tree(item["items"], node) + return parent + +def create_rich_table_for_charges(args, results): + """ Build and return a Rich Table from charge results. """ + from rich.table import Table + from rich.text import Text + from rich import box + from rich.padding import Padding + table = Table(style="white", header_style="bold bright_yellow", box=box.DOUBLE_EDGE, row_styles=["on grey11", "none"]) + table.add_column(Text("Type", justify="center"), style="bold steel_blue1", justify="center") + table.add_column(Text("ID", justify="center"), style="gold1", justify="center") + table.add_column(Text("Amount", justify="center"), style="sea_green2", justify="right") + table.add_column(Text("Start", justify="center"), style="bright_white", justify="center") + table.add_column(Text("End", justify="center"), style="bright_white", justify="center") + if not args.charge_type or 'serverless' in args.charge_type: + table.add_column(Text("Endpoint", justify="center"), style="bright_red", justify="center") + table.add_column(Text("Workergroup", justify="center"), style="orchid", justify="center") + for item in results: + row = [item['type'].capitalize(), item['source'], item['amount'], item['start'], item['end']] + if not args.charge_type or 'serverless' in args.charge_type: + row.append(str(item['metadata'].get('endpoint_id', ''))) + row.append(str(item['metadata'].get('workergroup_id', ''))) + table.add_row(*row) + return Padding(table, (1, 2), style="on #000000", expand=False) # Print with a black background + +def create_rich_table_for_invoices(results): + """ Build and return a Rich Table from invoice results. """ + from rich.table import Table + from rich.text import Text + from rich import box + from rich.padding import Padding + invoice_type_to_color = { + "credit": "green1", + "transfer": "gold1", + "payout": "orchid", + "reserved": "sky_blue1", + "refund": "bright_red", + } + table = Table(style="white", header_style="bold bright_yellow", box=box.DOUBLE_EDGE, row_styles=["on grey11", "none"]) + table.add_column(Text("ID", justify="center"), style="bright_white", justify="center") + table.add_column(Text("Created", justify="center"), style="yellow3", justify="center") + table.add_column(Text("Paid", justify="center"), style="yellow3", justify="center") + table.add_column(Text("Type", justify="center"), justify="center") + table.add_column(Text("Result", justify="center"), justify="right") + table.add_column(Text("Source", justify="center"), style="bright_cyan", justify="center") + table.add_column(Text("Description", justify="center"), style="bright_white", justify="left") + for item in results: + table.add_row( + str(item['metadata']['invoice_id']), + item['start'], + item['end'] if item['end'] else 'N/A', + Text(item['type'].capitalize(), style=invoice_type_to_color.get(item['type'], "white")), + Text(item['amount_str'], style="sea_green2" if item['amount'] > 0 else "bright_red"), + item['source'].capitalize() if item['type'] != 'transfer' else item['source'], + item['description'], + ) + return Padding(table, (1, 2), style="on #000000", expand=False) # Print with a black background + +def create_rich_table_from_rows(rows, headers=None, title='', sort_key=None): + """ (Generic) Creates a Rich table from a list of dict rows. """ + from rich import box + from rich.table import Table + if not isinstance(rows, list): + raise ValueError("Invalid Data Type: rows must be a list") + # Handle list of dictionaries + if isinstance(rows[0], dict): + headers = headers or list(rows[0].keys()) + rows = [[row_dict.get(h, "") for h in headers] for row_dict in rows] + elif headers is None: + raise ValueError("Headers must be provided if rows are not dictionaries") + # Sort rows if requested + if sort_key: + rows = sorted(rows, key=sort_key) + # Create the Rich table + table = Table(title=title, style="white", header_style="bold bright_yellow", box=box.DOUBLE_EDGE) + # Add columns + for header in headers: + # You can customize alignment and style here per column + table.add_column(header, justify="left", style="bright_white", no_wrap=True) + # Add rows + for row in rows: + # Convert everything to string to avoid type issues + table.add_row(*[str(cell) for cell in row]) + return table + @parser.command( argument("id", help="id of instance to get", type=int), @@ -3809,6 +5699,13 @@ def show__instance(args): r = http_get(args, req_url) r.raise_for_status() row = r.json()["instances"] + if row is None: + if getattr(args, "internal", False): + return None + if args.raw: + return {"instances": None} + print(f"Instance {args.id} not found or no longer exists.", file=sys.stderr) + return 1 row['duration'] = time.time() - row['start_date'] row['extra_env'] = {env_var[0]: env_var[1] for env_var in row['extra_env']} if args.raw: @@ -3833,7 +5730,7 @@ def show__instances(args = {}, extra = {}): #r = http_get(req_url) r = http_get(args, req_url) r.raise_for_status() - rows = r.json()["instances"] + rows = r.json()["instances"] or [] for row in rows: row = {k: strip_strings(v) for k, v in row.items()} row['duration'] = time.time() - row['start_date'] @@ -3851,6 +5748,571 @@ def show__instances(args = {}, extra = {}): display_table(rows, instance_fields) +_DEFAULT_INSTANCE_SELECT_COLS = [ + "id", "actual_status", "label", + "num_gpus", "gpu_name", "gpu_util", + "disk_space", "disk_usage", "disk_util", + "volume_info", + "dph_total", "image_uuid", + "start_date", "verification", +] + +_VERBOSE_INSTANCE_SELECT_COLS = _DEFAULT_INSTANCE_SELECT_COLS + [ + "machine_id", "template_id", "template_name", + "geolocation", "inet_up", "inet_down", + "ssh_host", "ssh_port", "status_msg", +] + +def _fmt_age(start_date): + """Format seconds elapsed since start_date as e.g. '2d 3h' or '4h 15m'.""" + if not start_date: + return "—" + secs = max(0, time.time() - start_date) + d, rem = divmod(int(secs), 86400) + h, rem = divmod(rem, 3600) + m, _ = divmod(rem, 60) + if d: return f"{d}d {h}h" + if h: return f"{h}h {m}m" + return f"{m}m" + +def _fmt_disk(disk_usage, disk_space, disk_util): + """Format disk as 'used/total GB (X%)' or '?/total GB'.""" + total = f"{disk_space:.0f}" if disk_space is not None else "?" + if disk_usage is None or disk_usage < 0: + return f"?/{total} GB" + used = f"{disk_usage:.1f}" + if disk_util is not None and disk_util >= 0: + pct = disk_util * 100 + return f"{used}/{total} GB ({pct:.0f}%)" + return f"{used}/{total} GB" + +def _fmt_volumes(volume_info): + """Format volume_info list as a compact string showing IDs and usage.""" + if not volume_info: + return "—" + if len(volume_info) == 1: + v = volume_info[0] + vid = v.get("id", "?") + avail = v.get("avail_space") + total = v.get("total_space") + if avail is not None and total is not None: + used = total - avail + return f"#{vid} {used:.0f}/{total:.0f} GB" + return f"#{vid}" + # Multiple volumes: list all IDs + return ", ".join(f"#{v.get('id', '?')}" for v in volume_info) + +def _fmt_gpu(num_gpus, gpu_name, gpu_util): + """Format as '4x RTX 3090' or '4x RTX 3090 (72%)'.""" + base = f"{int(num_gpus)}x {gpu_name}" if num_gpus and gpu_name else (gpu_name or "—") + if gpu_util is not None and gpu_util >= 0: + return f"{base} ({gpu_util:.0f}%)" + return base + +_STATUS_COLORS = {"running": "bold green", "loading": "bold yellow", "exited": "bright_red", "created": "bright_white"} +_VERIF_COLORS = {"verified": "sea_green2", "unverified": "gold1", "deverified": "bright_red"} + +def _status_style(status): + return _STATUS_COLORS.get(status, "white") + +def _verif_style(v): + return _VERIF_COLORS.get(v, "white") + + +# max_width caps Rich column expansion; also used by _estimate_table_width so estimate >= actual +_INSTANCE_COL_MAX_WIDTHS = { + "gpu": 20, # "8x NVIDIA GTX 1080 Ti" = 21 chars; cap at 20 to keep estimate accurate + "image": 30, # matches min_width so estimate == actual rendering width + "age": 8, # "XXXd XXh" = 8 chars + "volumes": 17, # "used/total GB (label)" capped so 10-col table fits at 150 cols + "location": 22, # "California, USA!-55-84-51-63" = 28 chars; cap to keep table in bounds + "net": 11, # "↑1000 ↓1000" = 11 chars + "ssh": 22, # "ssh2281.vast.ai:13912" style + "template": 32, + "msg": 30, +} + +# min_width: minimum content width (chars) used for fit estimation and as Rich min_width +# drop_order 0 = never drop; higher = drop sooner when terminal is narrow +# Priority (drop first → last): ssh > volumes > disk > verified > age > image > $/hr > never + +# Column spec: (name, header, style, justify, min_width, drop_order, verbose_only) +_INSTANCE_COL_SPECS = [ + ("id", "ID", "bright_white", "right", 4, 0, False), + ("status", "Status", None, "center", 7, 0, False), + ("label", "Label", "bright_white", "left", 7, 0, False), + ("gpu", "GPU", "steel_blue1", "left", 13, 0, False), + ("disk", "Disk", "bright_white", "right", 8, 5, False), + ("volumes", "Volumes", "bright_white", "left", 10, 6, False), # gated by show_volumes + ("dph", "$/hr", "sea_green2", "right", 7, 1, False), + ("image", "Image", "orchid", "left", 30, 2, False), + ("age", "Age", "bright_white", "left", 8, 3, False), + ("verified", "Verified", None, "center", 10, 0, False), + # verbose-only columns (drop order continues from 8+) + ("machine", "Machine", "gold1", "center", 5, 8, True), + ("net", "Net Mbps", "bright_white", "left", 9, 9, True), + ("location", "Location", "bright_white", "center", 10, 10, True), + ("template", "Template", "bright_white", "center", 20, 11, True), + ("ssh", "SSH", "cyan", "left", 21, 7, True), + ("msg", "Msg", "dim white", "left", 15, 12, True), +] + +_INSTANCE_COL_SPEC_BY_NAME = {s[0]: s for s in _INSTANCE_COL_SPECS} +try: + from rich.text import Text as _RichText +except ImportError: + _RichText = None # type: ignore + +def _render_instance_col(name, inst): + """Render a single cell value for the given column name.""" + if name == "id": + return str(inst.get("id", "—")) + if name == "status": + s = inst.get("actual_status") or "—" + return _RichText(s, style=_status_style(s)) + if name == "label": + return inst.get("label") or "—" + if name == "gpu": + return _fmt_gpu(inst.get("num_gpus"), inst.get("gpu_name"), inst.get("gpu_util")) + if name == "disk": + return _fmt_disk(inst.get("disk_usage"), inst.get("disk_space"), inst.get("disk_util")) + if name == "volumes": + return _fmt_volumes(inst.get("volume_info") or []) + if name == "dph": + dph = inst.get("dph_total") + return f"${dph:.4f}" if dph is not None else "—" + if name == "image": + return (inst.get("image_uuid") or "—")[:50] + if name == "age": + return _fmt_age(inst.get("start_date")) + if name == "verified": + v = inst.get("verification") or "—" + return _RichText(v, style=_verif_style(v)) + if name == "ssh": + return f"{inst.get('ssh_host')}:{inst.get('ssh_port', '')}" if inst.get("ssh_host") else "—" + if name == "machine": + return str(inst.get("machine_id", "—")) + if name == "net": + up, down = inst.get("inet_up"), inst.get("inet_down") + return f"↑{up:.0f} ↓{down:.0f}" if (up is not None and down is not None) else "—" + if name == "location": + return inst.get("geolocation") or "—" + if name == "template": + tid, tname = inst.get("template_id"), inst.get("template_name") or "" + return (f"{tname[:28]} ({tid})" if tid else tname[:30] or "—") + if name == "msg": + return (inst.get("status_msg") or "—")[:40] + return "—" + +def _estimate_table_width(specs): + """Estimate rendered table width for a list of col specs. + Uses _INSTANCE_COL_MAX_WIDTHS when available so estimate >= actual rendered width. + Formula: Padding(2) + outer borders(2) + per-col cell padding(2) + separators(n-1) + content + """ + n = len(specs) + content = sum( + _INSTANCE_COL_MAX_WIDTHS.get(s[0]) or max(len(s[1]), s[4]) + for s in specs + ) + return 4 + 2 * n + (n - 1) + content + +def _build_instances_table(instances, verbose=False, cols=None): + """Build the Rich table for instances. + + cols: optional list of column name strings (overrides auto-selection). + Returns (Padding, hidden_headers) where hidden_headers lists auto-dropped column headers. + """ + import shutil + from rich.table import Table + from rich import box + + show_volumes = any(inst.get("volume_info") for inst in instances) + term_width = shutil.get_terminal_size((120, 24)).columns + + if cols is not None: + # User-specified columns: look up specs by name, preserve requested order + active = [_INSTANCE_COL_SPEC_BY_NAME[c] for c in cols if c in _INSTANCE_COL_SPEC_BY_NAME] + hidden = [] + else: + # Auto-selection: start with all applicable columns + candidate = [ + s for s in _INSTANCE_COL_SPECS + if (not s[6] or verbose) and not (s[0] == "volumes" and not show_volumes) + ] + # Drop lowest-priority columns (highest drop_order, skip drop_order==0) until it fits + droppable = sorted((s for s in candidate if s[5] > 0), key=lambda s: s[5], reverse=True) + active = list(candidate) + for drop_spec in droppable: + if _estimate_table_width(active) <= term_width: + break + active.remove(drop_spec) + hidden = [s[1] for s in candidate if s not in active] # headers of dropped cols + + tbl = Table( + style="white", + header_style="bold bright_yellow", + box=box.DOUBLE_EDGE, + row_styles=["on grey11", "none"], + ) + for name, header, style, justify, min_width, *_ in active: + kwargs = dict(justify=justify, no_wrap=True, min_width=min_width) + if name in _INSTANCE_COL_MAX_WIDTHS: + kwargs["max_width"] = _INSTANCE_COL_MAX_WIDTHS[name] + if style: + kwargs["style"] = style + tbl.add_column(_RichText(header, justify="center"), **kwargs) + + for inst in instances: + tbl.add_row(*[_render_instance_col(name, inst) for name, *_ in active]) + + return tbl, hidden + +def _build_summary_panel(total, label_counts, active_filters=None, order_by=None): + """Build a Rich Panel summarising the instance query. + + active_filters: dict of {key: [values]} for display + order_by: list of {"col": str, "dir": "asc"|"desc"} dicts + """ + from rich.panel import Panel + from rich.table import Table + from rich.text import Text + + lines = [] + + # Total + lines.append(Text.assemble(("Total: ", "bold bright_yellow"), (f"{total} instances", "bold bright_white"))) + + # Label breakdown from label_counts + if label_counts: + parts = [] + for lbl, cnt in sorted(label_counts.items(), key=lambda x: -x[1]): + display = lbl if lbl else "(unlabeled)" + parts.append(f"{display}: {cnt}") + lines.append(Text.assemble(("Labels: ", "bold bright_yellow"), (" · ".join(parts), "bright_white"))) + + # Active filter line + if active_filters: + filter_line = Text.assemble(("Filters: ", "bold bright_yellow")) + for i, (k, vals) in enumerate(active_filters.items()): + if i: filter_line.append(" ", style="dim") + filter_line.append(f"{k}=", style="bold bright_white") + filter_line.append_text(_render_filter_values(vals, _FILTER_VALUE_COLORS.get(k), bold=True, line_sep=True)) + lines.append(filter_line) + + # Active order-by line + if order_by: + order_line = Text.assemble(("Order by: ", "bold bright_yellow")) + for i, key in enumerate(order_by): + if i: order_line.append(" > ", style="bright_white") + order_line.append(key["col"], style="bold bright_white") + order_line.append(f" ({key['dir']})", style="bright_white") + lines.append(order_line) + + grid = Table.grid(padding=(0, 0)) + grid.add_column() + for line in lines: + grid.add_row(line) + + return Panel(grid, title="[bold bright_yellow]Results Summary[/bold bright_yellow]", style="on #000000", border_style="bright_yellow", expand=False) + + +def _render_filter_values(values, colors=None, bold=False, line_sep=False): + """Render a sequence of filter values as a Rich Text, dot-separated, with optional per-value colors.""" + t = _RichText() + for i, v in enumerate(values): + if i: t.append("|" if line_sep else " · ", style="bright_white") + style = (colors or {}).get(v, "bright_white") + t.append(v, style=("bold " + style) if bold else style) + return t + + +# Maps active_display_filters keys to their per-value color dicts (absent = bright_white) +_FILTER_VALUE_COLORS = { + "status": _STATUS_COLORS, + "verification": _VERIF_COLORS, +} + + +def _build_filters_panel(filters): + """Build a Rich Panel showing the distinct filterable values from /instances/filters/.""" + from rich.panel import Panel + from rich.table import Table + from rich.text import Text + + statuses = sorted({f["actual_status"] for f in filters if f.get("actual_status")}) + verifs = sorted({f["verification"] for f in filters if f.get("verification")}) + gpus = sorted({f["gpu_name"] for f in filters if f.get("gpu_name")}) + + lines = [ + Text.assemble(("--status: ", "bold bright_yellow"), _render_filter_values(statuses, _STATUS_COLORS)), + Text.assemble(("--verification: ", "bold bright_yellow"), _render_filter_values(verifs, _VERIF_COLORS)), + Text.assemble(("--gpu-name: ", "bold bright_yellow"), _render_filter_values(gpus)), + ] + + grid = Table.grid(padding=(0, 0)) + grid.add_column() + for line in lines: + grid.add_row(line) + + return Panel(grid, title="[bright_white]Filterable Values[/bright_white]", style="on #000000", border_style="bright_white", expand=False) + + +@parser.command( + argument("-q", "--quiet", action="store_true", help="only print instance IDs, one per line"), + argument("-v", "--verbose", action="store_true", help="show additional columns (SSH, location, template, etc.)"), + argument("-a", "--all", action="store_true", help="fetch all pages automatically and send to pager; useful for scripting"), + argument("-s", "--status", metavar="STATUS", nargs="+", help="filter by container status: running loading exited (space-separated for multiple)"), + argument("--label", metavar="LABEL", nargs="+", help="filter by instance label; pass empty string '' to match unlabeled instances"), + argument("--gpu-name", metavar="GPU", nargs="+", dest="gpu_name", help="filter by GPU model name, e.g. 'RTX A5000' 'GTX 1070' (space-separated for multiple)"), + argument("--verification", metavar="VERIF", nargs="+", choices=["verified", "unverified", "deverified"], help="filter by machine verification status: verified unverified deverified"), + argument("-l", "--limit", type=int, default=25, help="max instances per page (1–25, default 25)"), + argument("-t", "--next-token", dest="next_token", help="resume from a pagination token printed at the end of a previous page"), + argument("--order-by", dest="order_by", metavar="COL [asc|desc]", action="append", help="sort by column with optional direction (default asc); repeat for multiple keys, e.g. --order-by start_date desc --order-by id"), + argument("--cols", metavar="COLS", help=f"override displayed columns with a comma-separated list (available: {','.join(s[0] for s in _INSTANCE_COL_SPECS)})"), + usage="vastai show instances-v1 [OPTIONS] [--api-key API_KEY] [--raw]", + help="List your running instances with filtering, sorting, and pagination", + epilog=deindent(""" + Displays your instances in a table with auto-sizing columns. Narrow terminals + drop lower-priority columns automatically; use --cols to override. Sorted by + id asc by default. Paginated at 25 results; follow the next-page prompt or + pass --next-token to continue. + + A 'Filterable Values' panel is always shown on the first page, listing the exact values + accepted by --status, --verification, and --gpu-name for your instances. + + Examples: + vastai show instances-v1 + vastai show instances-v1 -v + vastai show instances-v1 --status running loading + vastai show instances-v1 --gpu-name 'RTX A5000' 'GTX 1070' + vastai show instances-v1 --label training --order-by start_date desc + vastai show instances-v1 --verification verified --status running --limit 10 + vastai show instances-v1 --order-by start_date desc --order-by label + vastai show instances-v1 --next-token eyJ2YWx1ZXMiOiB7ImlkIjogMjUwNzgyMzR9... + vastai show instances-v1 --cols id,status,gpu,dph + """), +) +def show__instances_v1(args): + try: + from rich.prompt import Confirm + from rich.text import Text + from rich.padding import Padding + has_rich = True + except ImportError: + has_rich = False + + # ── build select_filters ────────────────────────────────────────────── + select_filters = {} + active_display_filters = {} + + if args.status: + invalid_statuses = [s for s in args.status if s not in _STATUS_COLORS] + if invalid_statuses: + valid = ", ".join(sorted(_STATUS_COLORS)) + print(f"Warning: unknown status value(s): {', '.join(invalid_statuses)}. Valid: {valid}", file=sys.stderr) + select_filters["actual_status"] = {"in": args.status} + active_display_filters["status"] = args.status + + if args.label is not None: + vals = [None if l == "" else l for l in args.label] + select_filters["label"] = {"in": vals} + active_display_filters["label"] = [l or "(unlabeled)" for l in vals] + + if args.gpu_name: + select_filters["gpu_name"] = {"in": args.gpu_name} + active_display_filters["gpu_name"] = args.gpu_name + + if args.verification: + select_filters["verification"] = {"in": args.verification} + active_display_filters["verification"] = args.verification + + # ── order_by ───────────────────────────────────────────────────────── + order_by = [{"col": "id", "dir": "asc"}] + if args.order_by: + order_by = [] + seen_cols = set() + for entry in args.order_by: + parts = entry.split() + col = parts[0] + dirn = parts[1].lower() if len(parts) > 1 and parts[1].lower() in ("asc", "desc") else "asc" + order_by.append({"col": col, "dir": dirn}) + seen_cols.add(col) + if "id" not in seen_cols: + order_by.append({"col": "id", "dir": "asc"}) + + limit = max(1, min(args.limit, 25)) + + params = { + "select_filters": select_filters, + "order_by": order_by, + "limit": limit, + } + # Only restrict columns when not in raw mode — raw users want the full response + if not args.raw: + params["select_cols"] = _VERBOSE_INSTANCE_SELECT_COLS if args.verbose else _DEFAULT_INSTANCE_SELECT_COLS + if not has_rich: + params["select_cols"] = [s[0] for s in instance_fields] # fall back to old set of fields for non-rich display to avoid missing data + if args.next_token: + params["after_token"] = args.next_token + + endpoint = "/api/v1/instances/" + + # ── fetch filter breakdown for filter summary ──────────────────────── + filter_combos = None + if not args.quiet and not args.raw: + try: + fr = http_get(args, apiurl(args, "/instances/filters/")) + fr.raise_for_status() + filter_combos = fr.json().get("filters", []) + except Exception: + pass # non-fatal; summary just won't show breakdown + + # ── pagination loop ─────────────────────────────────────────────────── + user_cols = [c.strip() for c in args.cols.split(",")] if args.cols and has_rich else None + + page = 0 + offset = 0 + looping = True + all_instances = [] # accumulates instances across pages in --all mode + while looping: + if args.all and page > 0: + time.sleep(1) + url = apiurl(args, endpoint, query_args=params) + r = http_get(args, url) + r.raise_for_status() + data = r.json() + + instances = data.get("instances", []) + next_token = data.get("next_token") + total = data.get("total_instances", 0) + label_cnts = data.get("label_counts", {}) + page += 1 + + # ── --raw: return the full response dict ────────────────────────── + if args.raw: + print_or_page(args, json.dumps(data, indent=1)) + return + + # ── --quiet: only IDs ───────────────────────────────────────────── + if args.quiet: + for inst in instances: + instance_id = inst.get("id") + if instance_id is not None: + print(instance_id) + break + + # ── --all: collect instances, build single display at end ───────── + if args.all: + all_instances.extend(instances) + if next_token: + sys.stderr.write(f"\rLoading more... (page {page + 1})") + sys.stderr.flush() + params["after_token"] = next_token + continue + # All pages fetched — clear indicator and fall through to display + sys.stderr.write("\r\033[K") + sys.stderr.flush() + instances = all_instances # render everything as one + + # ── rich display ────────────────────────────────────────────────── + output_parts = [] + + if has_rich: + if page == 1 or args.all: + if filter_combos: + output_parts.append(rich_object_to_string(_build_filters_panel(filter_combos), no_color=args.no_color)) + output_parts.append(rich_object_to_string(_build_summary_panel( + total, label_cnts, + active_filters=active_display_filters, + order_by=order_by if args.order_by else None, + ), no_color=args.no_color).rstrip("\n")) + else: + output_parts.append('') # spacing between pages + + if not instances: + empty_msg = "No instances matched your filters." if active_display_filters else "No instances found." + output_parts.append(rich_object_to_string(Text(empty_msg, style="bright_white"), no_color=args.no_color).rstrip()) + else: + tbl, hidden = _build_instances_table(instances, verbose=args.verbose, cols=user_cols) + + caption = Text() + if not args.all: + if not args.next_token: + caption.append(f"[Page {page}]", style="bright_white") + caption.append(" · ", style="bright_white") + caption.append("Fetched Results: ", style="bright_white") + caption.append(f"{offset + 1} – {offset + len(instances)}", style="bright_white") + caption.append(f" of {total}", style="bright_white") + if hidden and (page == 1 or args.all): + caption.append( + f"\nColumns hidden to fit terminal width: {', '.join(hidden)}" + f" · use --cols to customize (see --help)", + style="dim", + ) + if caption: + tbl.caption = caption + tbl.caption_justify = "left" + + padded = Padding(tbl, (0, 1, 0, 1), style="on #000000", expand=False) + output_parts.append(rich_object_to_string(padded, no_color=args.no_color)) + if next_token: + output_parts.append(f"Next page token: {next_token}\n") + else: # Plain Text Reslt Display (no Rich) + if page == 1 or args.all: + if filter_combos: + statuses = sorted({f["actual_status"] for f in filter_combos if f.get("actual_status")}) + verifs = sorted({f["verification"] for f in filter_combos if f.get("verification")}) + gpus = sorted({f["gpu_name"] for f in filter_combos if f.get("gpu_name")}) + print("Filterable Values:") + print(f" --status: {' | '.join(statuses) if statuses else '(none)'}") + print(f" --verification: {' | '.join(verifs) if verifs else '(none)'}") + print(f" --gpu-name: {' | '.join(gpus) if gpus else '(none)'}") + print() + summary_lines = [f"Total: {total} instances"] + if label_cnts: + lbl_parts = [f"{(lbl or '(unlabeled)')}: {cnt}" for lbl, cnt in sorted(label_cnts.items(), key=lambda x: -x[1])] + summary_lines.append(f"Labels: {' · '.join(lbl_parts)}") + if active_display_filters: + filter_parts = [f"{k}={' | '.join(str(v) for v in vals)}" for k, vals in active_display_filters.items()] + summary_lines.append(f"Filters: {' '.join(filter_parts)}") + if args.order_by: + order_parts = [f"{o['col']} ({o['dir']})" for o in order_by] + summary_lines.append(f"Order by: {' > '.join(order_parts)}") + print("Results Summary:") + for line in summary_lines: + print(f" {line}") + print() + + if not instances: + print("No instances matched your filters." if active_display_filters else "No instances found.") + else: + display_table(instances, instance_fields) + if not args.all: + print(f"[Page {page}] Fetched Results: {offset + 1} – {offset + len(instances)} of {total}") + if next_token: + print(f"Next page token: {next_token}") + if page == 1: + print("\nNOTE: install the 'rich' module for colored output (pip install rich)") + + if not args.all: + offset += len(instances) + + if args.all or not next_token: + print_or_page(args, "\n".join(output_parts)) + looping = False + else: + print("\n".join(output_parts)) + try: + if has_rich: + ans = Confirm.ask(f"Fetch next page? (page {page + 1})", default=False) + else: + ans = input(f"Fetch next page? (page {page + 1}) (y/N): ").strip().lower() == "y" + except (EOFError, KeyboardInterrupt): + ans = False + if ans: + params["after_token"] = next_token + else: + looping = False @parser.command( @@ -3875,32 +6337,71 @@ def show__ipaddrs(args): display_table(rows, ipaddr_fields) - @parser.command( - argument("-q", "--quiet", action="store_true", help="display information about user"), - usage="vastai show user [OPTIONS]", - help="Get current user data", + usage="vastai show clusters", + help="Show clusters associated with your account.", epilog=deindent(""" - Shows stats for logged-in user. These include user balance, email, and ssh key. Does not show API key. + Show clusters associated with your account. """) ) -def show__user(args): - """ - Shows stats for logged-in user. Does not show API key. - - :param argparse.Namespace args: should supply all the command-line options - :rtype: - """ - req_url = apiurl(args, "/users/current", {"owner": "me"}); - r = http_get(args, req_url); +def show__clusters(args: argparse.Namespace): + req_url = apiurl(args, "/clusters/") + r = http_get(args, req_url) r.raise_for_status() - user_blob = r.json() - user_blob.pop("api_key") + response_data = r.json() if args.raw: - return user_blob - else: - display_table([user_blob], user_fields) + return response_data + + rows = [] + for cluster_id, cluster_data in response_data['clusters'].items(): + machine_ids = [ node["machine_id"] for node in cluster_data["nodes"]] + + manager_node = next(node for node in cluster_data['nodes'] if node['is_cluster_manager']) + + row_data = { + 'id': cluster_id, + 'subnet': cluster_data['subnet'], + 'node_count': len(cluster_data['nodes']), + 'machine_ids': str(machine_ids), + 'manager_id': str(manager_node['machine_id']), + 'manager_ip': manager_node['local_ip'], + } + + rows.append(row_data) + + display_table(rows, cluster_fields, replace_spaces=False) + + +@parser.command( + usage="vastai show overlays", + help="Show overlays associated with your account.", + epilog=deindent(""" + Show overlays associated with your account. + """) +) +def show__overlays(args: argparse.Namespace): + req_url = apiurl(args, "/overlay/") + r = http_get(args, req_url) + r.raise_for_status() + response_data = r.json() + if args.raw: + return response_data + rows = [] + for overlay in response_data: + row_data = { + 'overlay_id': overlay['overlay_id'], + 'name': overlay['name'], + 'subnet': overlay['internal_subnet'] if overlay['internal_subnet'] else 'N/A', + 'cluster_id': overlay['cluster_id'], + 'instance_count': len(overlay['instances']), + 'instances': str(overlay['instances']), + } + rows.append(row_data) + display_table(rows, overlay_fields, replace_spaces=False) + + + @parser.command( argument("-q", "--quiet", action="store_true", help="display subaccounts from current user"), @@ -3924,43 +6425,973 @@ def show__subaccounts(args): display_table(rows, user_fields) @parser.command( - usage="vastai show team-members", + usage="vastai show members", help="Show your team members", ) -def show__team_members(args): - url = apiurl(args, "/team/members/") - r = http_get(args, url, headers=headers) +def show__members(args): + url = apiurl(args, "/team/members/") + r = http_get(args, url, headers=headers) + r.raise_for_status() + + if args.raw: + return r + else: + print(r.json()) + +@parser.command( + argument("NAME", help="name of the role", type=str), + usage="vastai show team-role NAME", + help="Show your team role", +) +def show__team_role(args): + url = apiurl(args, "/team/roles/{id}/".format(id=args.NAME)) + r = http_get(args, url, headers=headers) + r.raise_for_status() + print(json.dumps(r.json(), indent=1, sort_keys=True)) + +@parser.command( + usage="vastai show team-roles", + help="Show roles for a team" +) +def show__team_roles(args): + url = apiurl(args, "/team/roles-full/") + r = http_get(args, url, headers=headers) + r.raise_for_status() + + if args.raw: + return r + else: + print(r.json()) + +@parser.command( + argument("-q", "--quiet", action="store_true", help="display information about user"), + usage="vastai show user [OPTIONS]", + help="Get current user data", + epilog=deindent(""" + Shows stats for logged-in user. These include user balance, email, and ssh key. Does not show API key. + """) +) +def show__user(args): + """ + Shows stats for logged-in user. Does not show API key. + + :param argparse.Namespace args: should supply all the command-line options + :rtype: + """ + req_url = apiurl(args, "/users/current"); + r = http_get(args, req_url); + r.raise_for_status() + user_blob = r.json() + user_blob.pop("api_key") + + if args.raw: + return user_blob + else: + display_table([user_blob], user_fields) + +@parser.command( + argument("-t", "--type", help="volume type to display. Default to all. Possible values are \"local\", \"all\", \"network\"", type=str, default="all"), + usage="vastai show volumes [OPTIONS]", + help="Show stats on owned volumes.", + epilog=deindent(""" + Show stats on owned volumes + """) +) +def show__volumes(args: argparse.Namespace): + types = { + "local": "local_volume", + "network": "network_volume", + "all": "all_volume" + } + type = types.get(args.type, "all") + req_url = apiurl(args, "/volumes", {"owner": "me", "type" : type}); + r = http_get(args, req_url) + r.raise_for_status() + rows = r.json()["volumes"] + processed = [] + for row in rows: + row = {k: strip_strings(v) for k, v in row.items()} + row['duration'] = time.time() - row['start_date'] + processed.append(row) + if args.raw: + return processed + else: + display_table(processed, volume_fields, replace_spaces=False) + + +@parser.command( + argument("cluster_id", help="ID of cluster you want to remove machine from.", type=int), + argument("machine_id", help="ID of machine to remove from cluster.", type=int), + argument("new_manager_id", help="ID of machine to promote to manager. Must already be in cluster", type=int, nargs="?"), + usage="vastai remove-machine-from-cluster CLUSTER_ID MACHINE_ID NEW_MANAGER_ID", + help="Removes machine from cluster", + epilog=deindent("""Removes machine from cluster and also reassigns manager ID, + if we're removing the manager node""") +) +def remove_machine_from_cluster(args: argparse.Namespace): + json_blob = { + "cluster_id": args.cluster_id, + "machine_id": args.machine_id, + } + + if args.new_manager_id: + json_blob["new_manager_id"] = args.new_manager_id + if args.explain: + print("request json:", json_blob) + + req_url = apiurl(args, "/cluster/remove_machine/") + r = http_del(args, req_url, json=json_blob) + r.raise_for_status() + + if args.raw: + return r + + print(r.json()["msg"]) + + + +def handle_failed_tfa_verification(args, e): + error_data = e.response.json() + error_msg = error_data.get("msg", str(e)) + error_code = error_data.get("error", "") + + if args.raw: + print(json.dumps(error_data, indent=2)) + + print(f"\n{FAIL} Error: {error_msg}") + + # Provide helpful context for common errors + if error_code in {"tfa_locked", "2fa_verification_failed"}: + fail_count = error_data.get("fail_count", 0) + locked_until = error_data.get("locked_until") + + if fail_count > 0: + print(f" Failed attempts: {fail_count}") + if locked_until: + lock_time_sec = (datetime.fromtimestamp(locked_until) - datetime.now()).seconds + minutes, seconds = divmod(lock_time_sec, 60) + print(f" Time Remaining for 2FA Lock: {minutes} minutes and {seconds} seconds...") + + elif error_code == "2fa_expired": + # Note: Only SMS & email use tfa challenges that expire when verifying + print(f"\n The {args.method_type} 2FA code and secret have expired. Please start over:") + print(f" vastai tfa send-{args.method_type}") + + +def format_backup_codes(backup_codes): + """Format backup codes for display or file output.""" + output_lines = [ + "=" * 60, " VAST.AI 2FA BACKUP CODES", "=" * 60, + f"\nGenerated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", + f"\n{WARN} WARNING: All previous backup codes are now invalid!", + "\nYour New Backup Codes (one-time use only):", + "-" * 40, + ] + + for i, code in enumerate(backup_codes, 1): + output_lines.append(f" {i:2d}. {code}") + + output_lines.extend([ + "-" * 40, + "\nIMPORTANT:", + " • Each code can only be used once", + " • Store them in a secure location", + " • Use these codes to log in if you lose access to your 2FA device", + "\n" + "=" * 60, + ]) + return "\n".join(output_lines) + + +def confirm_destructive_action(prompt="Are you sure? (y/n): "): + """Prompt user for confirmation of destructive actions""" + try: + response = input(f" {prompt}").strip().lower() + return 'y' in response + except (EOFError, KeyboardInterrupt): + print("\nOperation cancelled.") + raise + +def save_to_file(content, filepath): + """Save content to file, creating parent directories if needed.""" + try: + filepath = os.path.abspath(os.path.expanduser(filepath)) + + # If directory provided, this should be handled by caller + parent_dir = os.path.dirname(filepath) + if parent_dir: + os.makedirs(parent_dir, exist_ok=True) + + with open(filepath, "w") as f: + f.write(content) + return True + except (IOError, OSError) as e: + print(f"\n{FAIL} Error saving file: {e}") + return False + + +def get_backup_codes_filename(): + """Generate a timestamped filename for backup codes.""" + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + return f"vastai_backup_codes_{timestamp}.txt" + +def save_backup_codes(backup_codes): + """Save or display 2FA backup codes based on user choice.""" + print(f"\nBackup codes regenerated successfully! {SUCCESS}") + print(f"\n{WARN} WARNING: All previous backup codes are now invalid!") + + formatted_content = format_backup_codes(backup_codes) + filename = get_backup_codes_filename() + + while True: + print("\nHow would you like to save your new backup codes?") + print(f" 1. Save to default location (~/Downloads/{filename})") + print(f" 2. Save to a custom path") + print(f" 3. Print to screen ({WARN} potentially unsafe - visible to onlookers)") + + try: + choice = input("\nEnter choice (1-3): ").strip() + + if choice in {'1', '2'}: + # Determine filepath + if choice == '1': + downloads_dir = os.path.expanduser("~/Downloads") + filepath = os.path.join(downloads_dir, filename) + else: # choice == '2' + custom_path = input("\nEnter full path for backup codes file: ").strip() + if not custom_path: + print("Error: Path cannot be empty") + continue + + filepath = os.path.abspath(os.path.expanduser(custom_path)) + + # If directory provided, add filename + if os.path.isdir(filepath): + filepath = os.path.join(filepath, filename) + + # Try to save + if save_to_file(formatted_content, filepath): + print(f"\n{SUCCESS} Backup codes saved to: {filepath}") + print(f"\nIMPORTANT:") + print(f" • The file contains {len(backup_codes)} one-time use backup codes") + if choice == '1': + print(f" • Move this file to a secure location") + return + else: + print("Please try again with a different path.") + continue + + elif choice == '3': + print(f"\n{WARN} WARNING: Printing sensitive codes to screen!") + confirm = input("\nAre you sure? Anyone nearby can see these codes. (yes/no): ").strip().lower() + + if confirm in {'yes', 'y'}: + print("\n" + formatted_content + "\n") + return + else: + print("Cancelled. Please choose another option.") + continue + + else: + print("Invalid choice. Please enter 1, 2, or 3.") + + except (EOFError, KeyboardInterrupt): + print("\n\nOperation cancelled. Your backup codes were generated but not saved.") + print("You will need to regenerate them to get new codes.") + raise + + +def build_tfa_verification_payload(args, **kwargs): + """Build common payload for TFA verification requests.""" + payload = { + "tfa_method_id": getattr(args, 'method_id', None), + "tfa_method": getattr(args, 'method_type', None), + "code": getattr(args, 'code', None), + "backup_code": getattr(args, 'backup_code', None), + "secret": getattr(args, 'secret', None), + } + for key, value in kwargs.items(): + payload[key] = value + + return {k:v for k,v in payload.items() if v} + +@parser.command( + argument("code", help="6-digit verification code from SMS or Authenticator app", type=str), + argument("-t", "--method-type", choices=["sms", "totp"], help="New 2FA Method type to activate", type=str, default=None), + argument("--secret", help="Secret token from setup process (required)", type=str, required=True), + argument("--phone-number", help="Phone number for SMS method (E.164 format)", type=str, default=None), + argument("-l", "--label", help="Label for the new 2FA method", type=str, default=None), + usage="vastai tfa activate CODE --secret SECRET [--method-type METHOD_TYPE] [--phone-number PHONE_NUMBER] [--label LABEL]", + help="Activate a new 2FA method by verifying the code", + epilog=deindent(f""" + Complete the 2FA setup process by verifying your code. + + {'*'*120} + NOTE: Prior to running this command, you must authorize your attempt to create a new 2FA method by following the instructions in the `vastai tfa auth-new` command. + This is required to ensure that only you can add new 2FA methods to your account. + {'*'*120} + + For TOTP (Authenticator app): + 1. Run 'vastai tfa totp-setup' to get the manual key/QR code and secret + 2. Enter the manual key or scan the QR code with your Authenticator app + 3. Run this command with the 6-digit code from your app and the secret token from step 1 + + For SMS: + 1. Run 'vastai tfa send-sms --phone-number ' to receive SMS and get secret token + 2. Run this command with the code you received via SMS and the phone number it was sent to + + If this is your first 2FA method, backup codes will be generated and displayed. + Save these backup codes in a secure location! + + Examples: + vastai tfa activate --method-type totp --secret abc123def456 123456 + vastai tfa activate --method-type sms --secret abc123def456 --phone-number +12345678901 123456 + vastai tfa activate --method-type sms --secret abc123def456 --phone-number +12345678901 --label "Work Phone" 123456 + """), +) +def tfa__activate(args): + """Activate a new 2FA method by confirming the verification code.""" + url = apiurl(args, "/api/v0/tfa/confirm-new/") + + # Build the request payload + payload = build_tfa_verification_payload(args, phone_number=args.phone_number, label=args.label) + + r = http_post(args, url, headers=apiheaders(args), json=payload) + + if not r.ok: + if r.status_code == 403 and r.json().get("error") == "authorization_required": + print(f"\n{FAIL} Error: Authorization required to add a new 2FA method") + print("Please run `vastai tfa auth-new` to authorize this action and try again.") + return 1 + r.raise_for_status() + + response_data = r.json() + + # Display success message + method_name = "SMS" if args.phone_number or args.method_type == "sms" else "TOTP (Authenticator App)" + print(f"\n{SUCCESS} {method_name} 2FA method activated successfully!") + + # Display backup codes if this is the first 2FA method + if "backup_codes" in response_data: + save_backup_codes(response_data["backup_codes"]) + + +def print_next_steps_after_new_method_auth(): + print(f"\nNext Steps:" + "\n To add a new SMS 2FA method:" + "\n 1. Run `vastai tfa send-sms --phone-number ` to receive SMS and get secret token" + "\n 2. Run `vastai tfa activate --method-type sms --secret --phone-number CODE` to activate the new method with the code you received via SMS\n" + "\n To add a new TOTP (Authenticator app) 2FA method:" + "\n 1. Run `vastai tfa totp-setup` to get the manual key/QR code and secret" + "\n 2. Enter the manual key or scan the QR code with your Authenticator app" + "\n 3. Run `vastai tfa activate --method-type totp --secret CODE` to activate the new method with the 6-digit code from your app") + +@parser.command( + argument("-c", "--code", help="2FA code from Authenticator app, SMS, or Email", type=str), + argument("-s", "--secret", help="Secret token from previous auth step", type=str, default=None), + argument("-t", "--method-type", mutex_group="type_grp", choices=["email", "sms", "totp"], help="2FA Method type. Only use when you only have one method of this type", type=str, default="email"), + argument("-bc", "--backup-code", mutex_group='type_grp', help="One-time backup code (alternative to regular 2FA code)", type=str, default=None), + argument("-id", "--method-id", mutex_group="type_grp", help="2FA Method ID if you have more than one of the same type ('id' from `tfa status`)", type=str, default=None), + usage="vastai tfa auth-new {[--method-type METHOD_TYPE | --method-id ID | --backup-code BACKUP_CODE] | [--secret SECRET --code CODE]}", + help="Authorize your account to add a new 2FA method", + epilog=deindent(""" + Authorize your account to add a new 2FA method by verifying via email or an existing method. + + This is a required step to ensure that only you can add new 2FA methods to your account. + + Step 1. Run command with your chosen verification method: + - If you have an existing 2FA method set up, you can use '--backup-code BACKUP_CODE' to immediately authorize (skip Step 2) + - Use --method-type {sms|totp} or --method-id ID to specify which existing method to use for verification (see `vastai tfa status` for available methods and their IDs) + - Use --method-type email if you have a verified email address and/or no other 2FA methods set up to receive a code via email + + Step 2. When prompted, enter the 2FA code from your 2FA method of choice to confirm authorization. + + Note: + If you exit the command before being able to enter the code, you can run this command again + with --secret SECRET and --code CODE to complete the authorization step as long as the code has not expired. + + Examples: + # Initiating New Method Authorization + vastai tfa auth-new (method type is email by default) + vastai tfa auth-new --method-type totp + vastai tfa auth-new -t sms + vastai tfa auth-new --method-id 456 + vastai tfa auth-new --backup-code ABCD-EFGH-IJKL + + # Completing Authorization with code and secret if not completed in previous run + vastai tfa auth-new --secret abc123def456 --code 123456 + """), +) +def tfa__auth_new(args): + """Authorize the user to add a new 2FA method by verifying with an existing method.""" + url = apiurl(args, "/api/v0/tfa/authorize-new-method/") + + secret, code = args.secret, args.code + if not secret and not code: + payload = {} + if args.backup_code: + payload["backup_code"] = args.backup_code + elif args.method_id: + payload["tfa_method_id"] = args.method_id + elif args.method_type: + payload["tfa_method"] = args.method_type + + r = http_post(args, url, headers=apiheaders(args), json=payload) + r.raise_for_status() + response_data = r.json() + + if args.backup_code and response_data.get("msg") == "Authorization successful.": + print(f"\n{SUCCESS} Successfully authorized account for adding new 2FA method using backup code") + print_next_steps_after_new_method_auth() + return 0 + + secret = response_data.get("secret") + if not secret: + print(f"\n{FAIL} Error: No secret token received for authorization. Please try again.") + return 1 + + print(f"\n{SUCCESS} Authorization initiated successfully.") + print(f"2FA Secret: {secret}") + try: + code = input("Enter 2FA code to complete authorization: ").strip() + except (EOFError, KeyboardInterrupt): + print("\nOperation cancelled.") + print("You can still complete this authorization later by running:" + f"\n vastai tfa auth-new --secret {secret} --code ") + return 1 + + # Attempt to complete authorization with provided code + payload = {"secret": secret, "code": code} + try: + if args.explain: # Adding some space if printing http request details + print("\n") + + r = http_put(args, url, headers=apiheaders(args), json=payload) + r.raise_for_status() + print(f"\n{SUCCESS} Successfully authorized account for adding new 2FA method!") + print_next_steps_after_new_method_auth() + + except requests.exceptions.HTTPError as e: + handle_failed_tfa_verification(args, e) + print(f"\n{FAIL} Authorization failed. Please try again.") + return 1 + + +@parser.command( + argument("-id", "--id-to-delete", help="ID of the 2FA method to delete (see `vastai tfa status`)", type=int, default=None), + argument("-c", "--code", mutex_group='code_grp', required=True, help="2FA code from your Authenticator app, SMS, or Email to authorize deletion", type=str), + argument("-t", "--method-type", mutex_group="type_grp", choices=["email", "sms", "totp"], help="2FA Method type. Only use when you only have one method of this type", type=str, default=None), + argument("-s", "--secret", help="Secret token (required for SMS or Email 2FA)", type=str, default=None), + argument("-bc", "--backup-code", mutex_group='code_grp', required=True, help="One-time backup code (alternative to regular 2FA code)", type=str, default=None), + argument("--method-id", mutex_group="type_grp", help="2FA Method ID to use if you have more than one of the same type ('id' from `tfa status`)", type=str, default=None), + usage="vastai tfa delete [--id-to-delete ID] [--code CODE] [--method-type METHOD_TYPE] [--secret SECRET] [--backup-code BACKUP_CODE] [--method-id ID]", + help="Remove a 2FA method from your account", + epilog=deindent(f""" + Remove a 2FA method from your account. + + This action requires 2FA verification to prevent unauthorized removals. + + {'*'*120} + NOTE: If you do not specify --id-to-delete, the system will attempt to delete the method you are using to authenticate. + However please be advised, it is much safer to specify the ID to avoid confusion if you have multiple methods. + {'*'*120} + + Use `vastai tfa status` to see your active methods and their IDs. + + Examples: + # Delete method #123, authorize with email code and secret from `tfa send-email` + vastai tfa delete --id-to-delete 123 --method-type email --secret abc123def456 -c 456789 + + # Delete method #123, authorize with TOTP/Authenticator code + vastai tfa delete --id-to-delete 123 --method-type totp --code 456789 + + # Delete method #123, authorize with SMS and secret from `tfa send-sms` + vastai tfa delete -id 123 --method-type sms --secret abc123def456 -c 456789 + + # Delete method #123, authorize with backup code + vastai tfa delete --id-to-delete 123 --backup-code ABCD-EFGH-IJKL + + # Delete method #123, specify which TOTP method to use if you have multiple + vastai tfa delete -id 123 --method-id 456 -c 456789 + + # Delete the TOTP method you are using to authenticate (use with caution) + vastai tfa delete -c 456789 + """), +) +def tfa__delete(args): + """Remove a 2FA method from the user's account after verifying authorization.""" + url = apiurl(args, "/api/v0/tfa/") + + if args.method_type in {"sms", "email"} and not args.secret: + print(f"\n{FAIL} Error: --secret is required for deletion authorization when using the {args.method_type} tfa method.") + print(f"\nPlease use: `vastai tfa send-{args.method_type}` to get the missing secret and try again.") + return 1 + + # Confirm action since this invalidates existing codes + prompt = "\nAre you sure you want to delete this 2FA method? (y|n): " + if confirm_destructive_action(prompt) == False: + print("Operation cancelled.") + return + + # Build the request payload + payload = build_tfa_verification_payload(args, target_id=args.id_to_delete) + try: + r = http_del(args, url, headers=apiheaders(args), json=payload) + r.raise_for_status() + + response_data = r.json() + + print(f"\n{SUCCESS} 2FA method deleted successfully.") + + if "remaining_methods" in response_data: + remaining = response_data["remaining_methods"] + print(f"\nYou have {remaining} 2FA method{'s' if remaining != 1 else ''} remaining.") + else: + print(f"\n{WARN} WARNING: You have removed all 2FA methods from your account.") + print("Your backup codes have been invalidated and 2FA is now fully disabled.") + + except requests.exceptions.HTTPError as e: + handle_failed_tfa_verification(args, e) + return 1 + + +@parser.command( + argument("-c", "--code", mutex_group='code_grp', required=True, help="2FA code from Authenticator app, SMS, or Email", type=str), + argument("-t", "--method-type", mutex_group="type_grp", choices=["email", "sms", "totp"], help="2FA Method type. Only use when you only have one method of this type", type=str, default=None), + argument("-s", "--secret", help="Secret token from previous login step (required for SMS or Email 2FA)", type=str, default=None), + argument("-bc", "--backup-code", mutex_group='code_grp', required=True, help="One-time backup code (alternative to regular 2FA code)", type=str, default=None), + argument("-id", "--method-id", mutex_group="type_grp", help="2FA Method ID if you have more than one of the same type ('id' from `tfa status`)", type=str, default=None), + + usage="vastai tfa login [--code CODE] [--method-type METHOD_TYPE] [--secret SECRET] [--backup-code BACKUP_CODE]", + help="Complete 2FA login by verifying code", + epilog=deindent(""" + Complete Two-Factor Authentication login by providing the 2FA code. + + For Email: Include the --method-type email flag and provide -s/--secret from the `tfa send-email` command response + For TOTP: Include the --method-type totp and provide the 6-digit code from your Authenticator app + For SMS: Include the --method-type sms flag and provide -s/--secret from the `tfa send-sms` command response + For backup code: Use --backup-code instead of code (codes may only be used once) + + Examples: + vastai tfa login --method-type totp -c 123456 + vastai tfa login --method-type sms --code 123456 --secret abc123def456 + vastai tfa login -t email -c 123456 -s abc123def456 + vastai tfa login --backup-code ABCD-EFGH-IJKL + """), +) +def tfa__login(args): + """Complete 2FA login and store the session key.""" + url = apiurl(args, "/api/v0/tfa/") + + # Build the request payload + payload = build_tfa_verification_payload(args) + + try: + r = http_post(args, url, headers=apiheaders(args), json=payload) + r.raise_for_status() + + response_data = r.json() + + # Check for session_key in response and save it + if "session_key" in response_data: + session_key = response_data["session_key"] + if session_key != args.api_key: + # Write the session key to the TFA key file + with open(TFAKEY_FILE, "w") as f: + f.write(session_key) + print(f"{SUCCESS} 2FA login successful! Session key saved to {TFAKEY_FILE}") + else: + print(f"{SUCCESS} 2FA login successful! Your session key has been refreshed.") + + # Display remaining backup codes if present + if "backup_codes_remaining" in response_data: + remaining = response_data["backup_codes_remaining"] + if remaining == 0: + print(f"{WARN} Warning: You have no backup codes remaining! Please generate new backup codes immediately to avoid being locked out of your account if you lose access to your 2FA device.") + elif remaining <= 3: + print(f"{WARN} Warning: You only have {remaining} backup codes remaining. Consider regenerating them.") + else: + print(f"Backup codes remaining: {remaining}") + else: + print("2FA login successful but a session key was not returned. Please check that you have an API Key that's properly set up") + + except requests.exceptions.HTTPError as e: + handle_failed_tfa_verification(args, e) + return 1 + + +@parser.command( + argument("-p", "--phone-number", help="Phone number to receive SMS code (E.164 format, e.g., +1234567890)", type=str, default=None), + argument("-s", "--secret", help="Secret token from the original 2FA login attempt", type=str, required=True), + usage="vastai tfa resend-sms --secret SECRET [--phone-number PHONE_NUMBER]", + help="Resend SMS 2FA code", + epilog=deindent(""" + Resend the SMS verification code to your phone. + + This is useful if: + • You didn't receive the original SMS + • The code expired before you could use it + • You accidentally deleted the message + + You must provide the same secret token from the original request. + + Example: + vastai tfa resend-sms --secret abc123def456 + """), +) +def tfa__resend_sms(args): + """Resend SMS 2FA code to the user's phone.""" + url = apiurl(args, "/api/v0/tfa/sms/resend/") + payload = build_tfa_verification_payload(args, phone_number=args.phone_number) + + r = http_post(args, url, headers=apiheaders(args), json=payload) + r.raise_for_status() + + response_data = r.json() + + print(f"{SUCCESS} SMS code resent successfully!") + print(f"\n{response_data['msg']}") + print(f"\nOnce you receive the SMS code, complete your 2FA login with:") + print(f" vastai tfa login --method-type sms --secret {args.secret} -c ") + + +@parser.command( + argument("-c", "--code", mutex_group='code_grp', required=True, help="2FA code from Authenticator app, SMS, or Email", type=str), + argument("-t", "--method-type", mutex_group="type_grp", choices=["email", "sms", "totp"], help="2FA Method type. Only use when you only have one method of this type", type=str, default=None), + argument("-s", "--secret", help="Secret token from previous login step (required for SMS or Email 2FA)", type=str, default=None), + argument("-bc", "--backup-code", mutex_group='code_grp', required=True, help="One-time backup code (alternative to regular 2FA code)", type=str, default=None), + argument("-id", "--method-id", mutex_group="type_grp", help="2FA Method ID if you have more than one of the same type ('id' from `tfa status`)", type=str, default=None), + usage="vastai tfa regen-codes [--code CODE] [--method-type METHOD_TYPE] [--secret SECRET] [--backup-code BACKUP_CODE] [--method-id ID]", + help="Regenerate backup codes for 2FA", + epilog=deindent(""" + Generate a new set of backup codes for your account. + + This action requires 2FA verification to prevent unauthorized regeneration. + + WARNING: This will invalidate all existing backup codes! + Any previously generated codes will no longer work. + + Backup codes are one-time use codes that allow you to log in + if you lose access to your primary 2FA method (lost phone, etc). + + You should regenerate your backup codes if: + • You've used several codes and are running low + • You think your codes may have been compromised + • You lost your saved codes and need new ones + + Important: Save the new codes in a secure location immediately! + They will not be shown again. + + Examples: + vastai tfa regen-codes --code --method-type totp 123456 + vastai tfa regen-codes -c 123456 -t sms --secret abc123def456 + vastai tfa regen-codes --backup-code ABCD-EFGH-IJKL + """), +) +def tfa__regen_codes(args): + """Regenerate backup codes for 2FA recovery.""" + url = apiurl(args, "/api/v0/tfa/regen-backup-codes/") + + # Confirm action since this invalidates existing codes + prompt = "\nThis will invalidate all existing backup codes. Continue? (y|n): " + if confirm_destructive_action(prompt) == False: + print("Operation cancelled.") + return + + # Build the request payload with verification + payload = build_tfa_verification_payload(args) + try: + r = http_put(args, url, headers=apiheaders(args), json=payload) + r.raise_for_status() + + response_data = r.json() + + # Display the new backup codes + if "backup_codes" in response_data: + save_backup_codes(response_data["backup_codes"]) + else: + print(f"\n{SUCCESS} Backup codes regenerated successfully!") + print("(No codes returned in response - this may be an error)") + + except requests.exceptions.HTTPError as e: + handle_failed_tfa_verification(args, e) + return 1 + + +@parser.command( + usage="vastai tfa send-email", + help="Request a 2FA Email verification code", + epilog=deindent(""" + Request a two-factor authentication code to be sent via Email. + + The secret token will be returned and must be used with 'vastai tfa activate'. + + Examples: + vastai tfa send-email + """), +) +def tfa__send_email(args): + """Request a 2FA Email code to be sent to the user's email.""" + url = apiurl(args, "/api/v0/tfa/email/") + + # Build the request payload + payload = {} + + r = http_post(args, url, headers=apiheaders(args), json=payload) + r.raise_for_status() + + response_data = r.json() + + # Extract and display the secret token + secret = response_data["secret"] + print(f"{SUCCESS} Email code sent successfully!") + print(f" Secret token: {secret}") + print(f"\nOnce you receive the Email code:") + print(f"\n You can complete your 2FA log in with:") + print(f" vastai tfa login --method-type email --secret {secret} -c \n") + + +@parser.command( + argument("-p", "--phone-number", help="Phone number to receive SMS code (E.164 format, e.g., +1234567890)", type=str, default=None), + usage="vastai tfa send-sms [--phone-number PHONE_NUMBER]", + help="Request a 2FA SMS verification code", + epilog=deindent(""" + Request a two-factor authentication code to be sent via SMS. + + If --phone-number is not provided, uses the phone number on your account. + The secret token will be returned and must be used with 'vastai tfa activate'. + + Examples: + vastai tfa send-sms + vastai tfa send-sms --phone-number +12345678901 + """), +) +def tfa__send_sms(args): + """Request a 2FA SMS code to be sent to the user's phone.""" + url = apiurl(args, "/api/v0/tfa/sms/") + + # Build the request payload + payload = {} + + # Add phone number if provided + if args.phone_number: + payload["phone_number"] = args.phone_number + + r = http_post(args, url, headers=apiheaders(args), json=payload) + r.raise_for_status() + + response_data = r.json() + + # Extract and display the secret token + secret = response_data["secret"] + print(f"{SUCCESS} SMS code sent successfully!") + print(f" Secret token: {secret}") + print(f"\nOnce you receive the SMS code:") + print(f"\n If you are setting up SMS 2FA for the first time, run:") + phone_num = f"--phone-number {args.phone_number}" if args.phone_number else "[--phone-number ]" + print(f" vastai tfa activate --method-type sms --secret {secret} {phone_num} [--label