From 184054c6c1d90768b97a1e56789718928f9535bc Mon Sep 17 00:00:00 2001 From: HWANG BYUNGHA Date: Thu, 22 Jan 2026 01:08:17 +0900 Subject: [PATCH] Initial commit: Vultr API v2 Python wrapper with FastAPI server - vultr_api/: Python library wrapping Vultr API v2 - 17 resource modules (instances, dns, firewall, vpc, etc.) - Pagination support, error handling - server/: FastAPI REST server - All API endpoints exposed via HTTP - X-API-Key header authentication - Swagger docs at /docs - Podman quadlet config for systemd deployment Co-Authored-By: Claude Opus 4.5 --- .gitignore | 17 + CLAUDE.md | 68 +++ README.md | 256 ++++++++ examples/acl_management.py | 119 ++++ examples/basic_usage.py | 105 ++++ pyproject.toml | 44 ++ server/Dockerfile | 18 + server/deps.py | 20 + server/main.py | 58 ++ server/routers/__init__.py | 38 ++ server/routers/account.py | 38 ++ server/routers/backups.py | 34 ++ server/routers/bare_metal.py | 118 ++++ server/routers/block_storage.py | 78 +++ server/routers/dns.py | 155 +++++ server/routers/firewall.py | 124 ++++ server/routers/instances.py | 151 +++++ server/routers/load_balancers.py | 108 ++++ server/routers/os_api.py | 24 + server/routers/plans.py | 44 ++ server/routers/regions.py | 34 ++ server/routers/reserved_ips.py | 86 +++ server/routers/snapshots.py | 59 ++ server/routers/ssh_keys.py | 59 ++ server/routers/startup_scripts.py | 61 ++ server/routers/vpc.py | 136 +++++ uv.lock | 814 +++++++++++++++++++++++++ vultr-api-server.container | 16 + vultr_api/__init__.py | 8 + vultr_api/client.py | 214 +++++++ vultr_api/resources/__init__.py | 41 ++ vultr_api/resources/account.py | 115 ++++ vultr_api/resources/backups.py | 65 ++ vultr_api/resources/bare_metal.py | 291 +++++++++ vultr_api/resources/base.py | 15 + vultr_api/resources/block_storage.py | 135 ++++ vultr_api/resources/dns.py | 348 +++++++++++ vultr_api/resources/firewall.py | 306 ++++++++++ vultr_api/resources/instances.py | 536 ++++++++++++++++ vultr_api/resources/load_balancers.py | 266 ++++++++ vultr_api/resources/os.py | 34 ++ vultr_api/resources/plans.py | 75 +++ vultr_api/resources/regions.py | 53 ++ vultr_api/resources/reserved_ips.py | 135 ++++ vultr_api/resources/snapshots.py | 108 ++++ vultr_api/resources/ssh_keys.py | 99 +++ vultr_api/resources/startup_scripts.py | 112 ++++ vultr_api/resources/vpc.py | 220 +++++++ 48 files changed, 6058 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 examples/acl_management.py create mode 100644 examples/basic_usage.py create mode 100644 pyproject.toml create mode 100644 server/Dockerfile create mode 100644 server/deps.py create mode 100644 server/main.py create mode 100644 server/routers/__init__.py create mode 100644 server/routers/account.py create mode 100644 server/routers/backups.py create mode 100644 server/routers/bare_metal.py create mode 100644 server/routers/block_storage.py create mode 100644 server/routers/dns.py create mode 100644 server/routers/firewall.py create mode 100644 server/routers/instances.py create mode 100644 server/routers/load_balancers.py create mode 100644 server/routers/os_api.py create mode 100644 server/routers/plans.py create mode 100644 server/routers/regions.py create mode 100644 server/routers/reserved_ips.py create mode 100644 server/routers/snapshots.py create mode 100644 server/routers/ssh_keys.py create mode 100644 server/routers/startup_scripts.py create mode 100644 server/routers/vpc.py create mode 100644 uv.lock create mode 100644 vultr-api-server.container create mode 100644 vultr_api/__init__.py create mode 100644 vultr_api/client.py create mode 100644 vultr_api/resources/__init__.py create mode 100644 vultr_api/resources/account.py create mode 100644 vultr_api/resources/backups.py create mode 100644 vultr_api/resources/bare_metal.py create mode 100644 vultr_api/resources/base.py create mode 100644 vultr_api/resources/block_storage.py create mode 100644 vultr_api/resources/dns.py create mode 100644 vultr_api/resources/firewall.py create mode 100644 vultr_api/resources/instances.py create mode 100644 vultr_api/resources/load_balancers.py create mode 100644 vultr_api/resources/os.py create mode 100644 vultr_api/resources/plans.py create mode 100644 vultr_api/resources/regions.py create mode 100644 vultr_api/resources/reserved_ips.py create mode 100644 vultr_api/resources/snapshots.py create mode 100644 vultr_api/resources/ssh_keys.py create mode 100644 vultr_api/resources/startup_scripts.py create mode 100644 vultr_api/resources/vpc.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a45e28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Environment variables +.env + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +dist/ +build/ + +# IDE +.idea/ +.vscode/ +*.swp +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6805ca0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,68 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a Python wrapper library for the Vultr API v2. It provides a typed, object-oriented interface for managing Vultr cloud infrastructure including compute instances, DNS, firewalls, VPCs, and more. + +## Development Setup + +```bash +# Install in development mode +cd ~/vultr-api +pip install -e . + +# Or add to PYTHONPATH if requests is already installed +export PYTHONPATH=~/vultr-api:$PYTHONPATH + +# Install dev dependencies for testing +pip install -e ".[dev]" +``` + +## Running Tests + +```bash +# Run all tests +pytest + +# Run with coverage +pytest --cov=vultr_api + +# Run a single test file +pytest tests/test_instances.py +``` + +## Architecture + +### Client Pattern +- `VultrClient` (client.py) is the main entry point that holds the API key and HTTP session +- Resources are initialized as attributes on the client (e.g., `client.instances`, `client.dns`) +- All HTTP requests flow through `VultrClient._request()` which handles auth, errors, and JSON parsing + +### Resource Pattern +- Each API domain has a resource class in `vultr_api/resources/` +- All resources inherit from `BaseResource` which provides access to the parent client +- Resources call `self.client.get()`, `self.client.post()`, etc. for API operations +- Pagination is handled by `VultrClient.paginate()` - resources expose this via `list_all()` methods + +### Key Resources +| Resource | File | Description | +|----------|------|-------------| +| `account` | account.py | Account info, ACL (IP access control) | +| `instances` | instances.py | Cloud compute VMs, IPv4/IPv6, VPC attachment | +| `dns` | dns.py | DNS domains and records | +| `firewall` | firewall.py | Firewall groups and rules | +| `vpc` | vpc.py | VPC and VPC 2.0 networks | + +### Error Handling +- `VultrAPIError` exception contains `message`, `status_code`, and raw `response` +- Import from `vultr_api.client import VultrAPIError` + +## API Authentication + +The library expects a Vultr API key passed to `VultrClient(api_key="...")`. For examples, use the `VULTR_API_KEY` environment variable. + +## API Reference + +Official Vultr API v2 documentation: https://www.vultr.com/api/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..34bece8 --- /dev/null +++ b/README.md @@ -0,0 +1,256 @@ +# Vultr API v2 Python Wrapper + +Vultr API v2를 위한 Python 클라이언트 라이브러리입니다. + +## 설치 + +```bash +cd ~/vultr-api +pip install -e . +``` + +또는 requests가 설치되어 있다면 직접 import 가능: + +```bash +export PYTHONPATH=~/vultr-api:$PYTHONPATH +``` + +## 사용법 + +### 기본 설정 + +```python +from vultr_api import VultrClient + +# API 키로 클라이언트 생성 +client = VultrClient(api_key="your-api-key") +``` + +### 계정 정보 + +```python +# 계정 정보 조회 +account = client.account.get() +print(f"잔액: ${account['balance']}") +print(f"이메일: {account['email']}") +``` + +### ACL (IP 접근 제어) - 중요! + +```python +# 현재 ACL 조회 +acl = client.account.get_acl() +print(f"ACL 활성화: {acl.get('acl_enabled')}") +print(f"허용된 IP: {acl.get('acls', [])}") + +# IP 추가 +client.account.add_acl("203.0.113.50/32") + +# IP 목록으로 설정 +client.account.set_acl([ + "203.0.113.50/32", + "10.0.0.0/8", + "192.168.1.0/24" +]) + +# IP 제거 +client.account.remove_acl("10.0.0.0/8") + +# ACL 비활성화 (모든 IP 허용) +client.account.clear_acl() +``` + +### 인스턴스 관리 + +```python +# 인스턴스 목록 조회 +instances = client.instances.list() +for instance in instances.get("instances", []): + print(f"{instance['label']}: {instance['main_ip']}") + +# 인스턴스 생성 +new_instance = client.instances.create( + region="nrt", # 도쿄 + plan="vc2-1c-1gb", # 1 vCPU, 1GB RAM + os_id=1743, # Ubuntu 22.04 + label="my-server", + enable_ipv6=True, + sshkey_id=["ssh-key-id"], + tags=["web", "production"] +) +print(f"인스턴스 생성됨: {new_instance['id']}") + +# 인스턴스 상세 조회 +instance = client.instances.get("instance-id") + +# 인스턴스 제어 +client.instances.start("instance-id") +client.instances.stop("instance-id") +client.instances.reboot("instance-id") + +# 인스턴스 삭제 +client.instances.delete("instance-id") +``` + +### DNS 관리 + +```python +# 도메인 목록 +domains = client.dns.list_domains() + +# 도메인 생성 +domain = client.dns.create_domain("example.com", ip="203.0.113.50") + +# 레코드 추가 +client.dns.create_a_record("example.com", "www", "203.0.113.50") +client.dns.create_aaaa_record("example.com", "www", "2001:db8::1") +client.dns.create_cname_record("example.com", "blog", "www.example.com") +client.dns.create_mx_record("example.com", "@", "mail.example.com", priority=10) +client.dns.create_txt_record("example.com", "@", "v=spf1 include:_spf.example.com ~all") + +# 레코드 목록 +records = client.dns.list_records("example.com") + +# 레코드 삭제 +client.dns.delete_record("example.com", "record-id") +``` + +### 방화벽 관리 + +```python +# 방화벽 그룹 생성 +group = client.firewall.create_group(description="Web Servers") +group_id = group["id"] + +# 규칙 추가 +client.firewall.allow_ssh(group_id) # SSH (22) +client.firewall.allow_http(group_id) # HTTP (80) +client.firewall.allow_https(group_id) # HTTPS (443) +client.firewall.allow_ping(group_id) # ICMP + +# 커스텀 규칙 +client.firewall.create_rule( + firewall_group_id=group_id, + ip_type="v4", + protocol="tcp", + port="3000:3999", # 포트 범위 + subnet="10.0.0.0", + subnet_size=8, + notes="Internal services" +) + +# 인스턴스에 방화벽 적용 +client.instances.update("instance-id", firewall_group_id=group_id) +``` + +### SSH 키 관리 + +```python +# SSH 키 목록 +keys = client.ssh_keys.list() + +# SSH 키 추가 +key = client.ssh_keys.create( + name="my-laptop", + ssh_key="ssh-rsa AAAAB3NzaC1yc2EAAAA..." +) + +# SSH 키 삭제 +client.ssh_keys.delete("key-id") +``` + +### VPC 관리 + +```python +# VPC 생성 +vpc = client.vpc.create( + region="nrt", + description="Private Network", + v4_subnet="10.10.0.0", + v4_subnet_mask=24 +) + +# VPC 2.0 생성 +vpc2 = client.vpc.create_vpc2( + region="nrt", + description="VPC 2.0 Network", + ip_block="10.20.0.0", + prefix_length=24 +) + +# 인스턴스에 VPC 연결 +client.instances.attach_vpc("instance-id", "vpc-id") +``` + +### 스냅샷 관리 + +```python +# 스냅샷 생성 +snapshot = client.snapshots.create( + instance_id="instance-id", + description="Before upgrade" +) + +# 스냅샷 목록 +snapshots = client.snapshots.list() + +# 스냅샷으로 복원 +client.instances.restore_snapshot("instance-id", "snapshot-id") +``` + +### 플랜 및 리전 조회 + +```python +# 사용 가능한 플랜 +plans = client.plans.list() +for plan in plans.get("plans", []): + print(f"{plan['id']}: {plan['vcpu_count']}vCPU, {plan['ram']}MB, ${plan['monthly_cost']}/월") + +# 리전 목록 +regions = client.regions.list() +for region in regions.get("regions", []): + print(f"{region['id']}: {region['city']}, {region['country']}") + +# OS 목록 +os_list = client.os.list() +for os_info in os_list.get("os", []): + print(f"{os_info['id']}: {os_info['name']}") +``` + +## 지원하는 리소스 + +| 리소스 | 설명 | +|--------|------| +| `account` | 계정 정보, ACL 관리 | +| `instances` | 클라우드 인스턴스 | +| `bare_metal` | 베어메탈 서버 | +| `dns` | DNS 도메인/레코드 | +| `firewall` | 방화벽 그룹/규칙 | +| `ssh_keys` | SSH 키 | +| `startup_scripts` | 시작 스크립트 | +| `snapshots` | 스냅샷 | +| `backups` | 백업 | +| `block_storage` | 블록 스토리지 | +| `reserved_ips` | 예약 IP | +| `vpc` | VPC, VPC 2.0 | +| `load_balancers` | 로드 밸런서 | +| `plans` | 플랜 목록 | +| `regions` | 리전 목록 | +| `os` | OS 목록 | + +## 에러 처리 + +```python +from vultr_api.client import VultrAPIError + +try: + client.instances.get("invalid-id") +except VultrAPIError as e: + print(f"API 오류: {e.message}") + print(f"상태 코드: {e.status_code}") +``` + +## API 참고 + +- [Vultr API v2 문서](https://www.vultr.com/api/) +- [Vultr API Postman Collection](https://www.postman.com/vultr-api/vultr-api-v2/documentation/soddyfe/vultr-api-v2) diff --git a/examples/acl_management.py b/examples/acl_management.py new file mode 100644 index 0000000..fc3e40b --- /dev/null +++ b/examples/acl_management.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +Vultr ACL (IP 접근 제어) 관리 예제 + +IP를 등록해야 API에 접근할 수 있을 때 사용하는 예제입니다. +""" + +import os +import sys +import argparse + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from vultr_api import VultrClient +from vultr_api.client import VultrAPIError + + +def get_client(): + api_key = os.environ.get("VULTR_API_KEY") + if not api_key: + print("VULTR_API_KEY 환경 변수를 설정해주세요") + sys.exit(1) + return VultrClient(api_key=api_key) + + +def list_acl(client): + """현재 ACL 목록 조회""" + acl = client.account.get_acl() + print(f"ACL 활성화: {acl.get('acl_enabled', False)}") + acls = acl.get("acls", []) + if acls: + print("허용된 IP/CIDR:") + for ip in acls: + print(f" - {ip}") + else: + print("허용된 IP 없음 (모든 IP 허용)") + + +def add_ip(client, ip): + """IP 추가""" + print(f"IP 추가 중: {ip}") + client.account.add_acl(ip) + print("완료!") + list_acl(client) + + +def remove_ip(client, ip): + """IP 제거""" + print(f"IP 제거 중: {ip}") + client.account.remove_acl(ip) + print("완료!") + list_acl(client) + + +def set_ips(client, ips): + """IP 목록 설정""" + print(f"IP 목록 설정 중: {ips}") + client.account.set_acl(ips) + print("완료!") + list_acl(client) + + +def clear_acl(client): + """ACL 초기화""" + print("ACL 초기화 중 (모든 IP 허용)...") + client.account.clear_acl() + print("완료!") + list_acl(client) + + +def main(): + parser = argparse.ArgumentParser(description="Vultr ACL 관리") + subparsers = parser.add_subparsers(dest="command", help="명령") + + # list 명령 + subparsers.add_parser("list", help="ACL 목록 조회") + + # add 명령 + add_parser = subparsers.add_parser("add", help="IP 추가") + add_parser.add_argument("ip", help="추가할 IP/CIDR (예: 192.168.1.1/32)") + + # remove 명령 + remove_parser = subparsers.add_parser("remove", help="IP 제거") + remove_parser.add_argument("ip", help="제거할 IP/CIDR") + + # set 명령 + set_parser = subparsers.add_parser("set", help="IP 목록 설정") + set_parser.add_argument("ips", nargs="+", help="설정할 IP/CIDR 목록") + + # clear 명령 + subparsers.add_parser("clear", help="ACL 초기화 (모든 IP 허용)") + + args = parser.parse_args() + + if not args.command: + parser.print_help() + sys.exit(1) + + try: + client = get_client() + + if args.command == "list": + list_acl(client) + elif args.command == "add": + add_ip(client, args.ip) + elif args.command == "remove": + remove_ip(client, args.ip) + elif args.command == "set": + set_ips(client, args.ips) + elif args.command == "clear": + clear_acl(client) + + except VultrAPIError as e: + print(f"API 오류: {e.message}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/basic_usage.py b/examples/basic_usage.py new file mode 100644 index 0000000..bac961c --- /dev/null +++ b/examples/basic_usage.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +""" +Vultr API 기본 사용 예제 +""" + +import os +import sys + +# vultr_api 패키지 경로 추가 (개발용) +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from vultr_api import VultrClient +from vultr_api.client import VultrAPIError + + +def main(): + # API 키 설정 (환경 변수에서 가져오기) + api_key = os.environ.get("VULTR_API_KEY") + if not api_key: + print("VULTR_API_KEY 환경 변수를 설정해주세요") + print("export VULTR_API_KEY='your-api-key'") + sys.exit(1) + + # 클라이언트 생성 + client = VultrClient(api_key=api_key) + + try: + # 1. 계정 정보 조회 + print("=" * 50) + print("계정 정보") + print("=" * 50) + account = client.account.get() + print(f"이메일: {account.get('email')}") + print(f"이름: {account.get('name')}") + print(f"잔액: ${account.get('balance')}") + print(f"보류 요금: ${account.get('pending_charges')}") + print() + + # 2. ACL 정보 조회 + print("=" * 50) + print("ACL (IP 접근 제어)") + print("=" * 50) + acl = client.account.get_acl() + print(f"ACL 활성화: {acl.get('acl_enabled', False)}") + acls = acl.get("acls", []) + if acls: + print("허용된 IP:") + for ip in acls: + print(f" - {ip}") + else: + print("허용된 IP: 없음 (모든 IP 허용)") + print() + + # 3. 인스턴스 목록 + print("=" * 50) + print("인스턴스 목록") + print("=" * 50) + instances_resp = client.instances.list() + instances = instances_resp.get("instances", []) + if instances: + for inst in instances: + status = inst.get("status", "unknown") + power = inst.get("power_status", "unknown") + print(f"- {inst.get('label', 'No Label')}") + print(f" ID: {inst.get('id')}") + print(f" IP: {inst.get('main_ip')}") + print(f" 리전: {inst.get('region')}") + print(f" 상태: {status} ({power})") + print(f" 플랜: {inst.get('plan')}") + print() + else: + print("인스턴스가 없습니다") + print() + + # 4. 리전 목록 (일부만) + print("=" * 50) + print("사용 가능한 리전 (일부)") + print("=" * 50) + regions_resp = client.regions.list(per_page=10) + regions = regions_resp.get("regions", []) + for region in regions[:5]: + print(f"- {region.get('id')}: {region.get('city')}, {region.get('country')}") + print() + + # 5. 플랜 목록 (일부만) + print("=" * 50) + print("사용 가능한 플랜 (일부)") + print("=" * 50) + plans_resp = client.plans.list(per_page=10) + plans = plans_resp.get("plans", []) + for plan in plans[:5]: + print(f"- {plan.get('id')}") + print(f" CPU: {plan.get('vcpu_count')}vCPU, RAM: {plan.get('ram')}MB") + print(f" 가격: ${plan.get('monthly_cost')}/월") + print() + + except VultrAPIError as e: + print(f"API 오류 발생: {e.message}") + if e.status_code: + print(f"상태 코드: {e.status_code}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..33a7ac5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "vultr-api" +version = "1.0.0" +description = "Vultr API v2 Python Wrapper" +readme = "README.md" +requires-python = ">=3.8" +license = {text = "MIT"} +authors = [ + {name = "Admin"} +] +keywords = ["vultr", "api", "cloud", "vps"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "requests>=2.25.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", +] + +[project.urls] +"Homepage" = "https://github.com/admin/vultr-api" +"Bug Tracker" = "https://github.com/admin/vultr-api/issues" + +[tool.setuptools.packages.find] +where = ["."] +include = ["vultr_api*"] diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..c437d17 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install dependencies +RUN pip install --no-cache-dir fastapi uvicorn requests pydantic + +# Copy vultr_api library +COPY vultr_api/ /app/vultr_api/ + +# Copy server code +COPY server/ /app/ + +# Expose port +EXPOSE 8000 + +# Run server +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/server/deps.py b/server/deps.py new file mode 100644 index 0000000..cded675 --- /dev/null +++ b/server/deps.py @@ -0,0 +1,20 @@ +"""Dependencies for FastAPI""" +import os +from fastapi import HTTPException, Security +from fastapi.security import APIKeyHeader + +from vultr_api import VultrClient + +API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False) +VULTR_API_KEY = os.environ.get("VULTR_API_KEY") + + +def get_client(api_key: str = Security(API_KEY_HEADER)) -> VultrClient: + """Get VultrClient instance with API key from header or environment""" + key = api_key or VULTR_API_KEY + if not key: + raise HTTPException( + status_code=401, + detail="API key required. Set X-API-Key header or VULTR_API_KEY env var" + ) + return VultrClient(api_key=key) diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..9dba3f3 --- /dev/null +++ b/server/main.py @@ -0,0 +1,58 @@ +""" +Vultr API REST Server +FastAPI wrapper for vultr-api library +""" +from fastapi import FastAPI +from contextlib import asynccontextmanager + +from routers import ( + account, instances, dns, firewall, ssh_keys, + startup_scripts, snapshots, block_storage, reserved_ips, + vpc, load_balancers, bare_metal, backups, plans, regions, os_api +) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan handler""" + print("Vultr API Server starting...") + yield + print("Vultr API Server shutting down...") + + +app = FastAPI( + title="Vultr API Server", + description="REST API server wrapping Vultr API v2", + version="1.0.0", + lifespan=lifespan, +) + +# Include routers +app.include_router(account.router, prefix="/account", tags=["Account"]) +app.include_router(instances.router, prefix="/instances", tags=["Instances"]) +app.include_router(dns.router, prefix="/dns", tags=["DNS"]) +app.include_router(firewall.router, prefix="/firewall", tags=["Firewall"]) +app.include_router(ssh_keys.router, prefix="/ssh-keys", tags=["SSH Keys"]) +app.include_router(startup_scripts.router, prefix="/startup-scripts", tags=["Startup Scripts"]) +app.include_router(snapshots.router, prefix="/snapshots", tags=["Snapshots"]) +app.include_router(block_storage.router, prefix="/block-storage", tags=["Block Storage"]) +app.include_router(reserved_ips.router, prefix="/reserved-ips", tags=["Reserved IPs"]) +app.include_router(vpc.router, prefix="/vpc", tags=["VPC"]) +app.include_router(load_balancers.router, prefix="/load-balancers", tags=["Load Balancers"]) +app.include_router(bare_metal.router, prefix="/bare-metal", tags=["Bare Metal"]) +app.include_router(backups.router, prefix="/backups", tags=["Backups"]) +app.include_router(plans.router, prefix="/plans", tags=["Plans"]) +app.include_router(regions.router, prefix="/regions", tags=["Regions"]) +app.include_router(os_api.router, prefix="/os", tags=["Operating Systems"]) + + +@app.get("/", tags=["Health"]) +async def root(): + """Health check endpoint""" + return {"status": "ok", "service": "vultr-api-server"} + + +@app.get("/health", tags=["Health"]) +async def health(): + """Health check endpoint""" + return {"status": "healthy"} diff --git a/server/routers/__init__.py b/server/routers/__init__.py new file mode 100644 index 0000000..a8328f9 --- /dev/null +++ b/server/routers/__init__.py @@ -0,0 +1,38 @@ +"""API Routers""" +from . import ( + account, + instances, + dns, + firewall, + ssh_keys, + startup_scripts, + snapshots, + block_storage, + reserved_ips, + vpc, + load_balancers, + bare_metal, + backups, + plans, + regions, + os_api, +) + +__all__ = [ + "account", + "instances", + "dns", + "firewall", + "ssh_keys", + "startup_scripts", + "snapshots", + "block_storage", + "reserved_ips", + "vpc", + "load_balancers", + "bare_metal", + "backups", + "plans", + "regions", + "os_api", +] diff --git a/server/routers/account.py b/server/routers/account.py new file mode 100644 index 0000000..3e15f5c --- /dev/null +++ b/server/routers/account.py @@ -0,0 +1,38 @@ +"""Account router""" +from fastapi import APIRouter, Depends +from typing import List + +from vultr_api import VultrClient +from deps import get_client + +router = APIRouter() + + +@router.get("") +async def get_account(client: VultrClient = Depends(get_client)): + """Get account information""" + return client.account.get() + + +@router.get("/acl") +async def get_acl(client: VultrClient = Depends(get_client)): + """Get account ACL (IP access control list)""" + return client.account.get_acl() + + +@router.put("/acl") +async def set_acl(acls: List[str], client: VultrClient = Depends(get_client)): + """Set account ACL""" + return client.account.set_acl(acls) + + +@router.post("/acl") +async def add_acl(ip: str, client: VultrClient = Depends(get_client)): + """Add IP to ACL""" + return client.account.add_acl(ip) + + +@router.delete("/acl/{ip}") +async def remove_acl(ip: str, client: VultrClient = Depends(get_client)): + """Remove IP from ACL""" + return client.account.remove_acl(ip) diff --git a/server/routers/backups.py b/server/routers/backups.py new file mode 100644 index 0000000..11ba15e --- /dev/null +++ b/server/routers/backups.py @@ -0,0 +1,34 @@ +"""Backups router""" +from fastapi import APIRouter, Depends, Query +from typing import Optional + +from vultr_api import VultrClient +from deps import get_client + +router = APIRouter() + + +@router.get("") +async def list_backups( + instance_id: Optional[str] = None, + per_page: int = Query(25, le=500), + cursor: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List all backups""" + return client.backups.list(instance_id=instance_id, per_page=per_page, cursor=cursor) + + +@router.get("/all") +async def list_all_backups( + instance_id: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List all backups (auto-paginated)""" + return {"backups": client.backups.list_all(instance_id=instance_id)} + + +@router.get("/{backup_id}") +async def get_backup(backup_id: str, client: VultrClient = Depends(get_client)): + """Get backup details""" + return client.backups.get(backup_id) diff --git a/server/routers/bare_metal.py b/server/routers/bare_metal.py new file mode 100644 index 0000000..cd94780 --- /dev/null +++ b/server/routers/bare_metal.py @@ -0,0 +1,118 @@ +"""Bare Metal router""" +from fastapi import APIRouter, Depends, Query +from typing import Optional, List +from pydantic import BaseModel + +from vultr_api import VultrClient +from deps import get_client + +router = APIRouter() + + +class CreateBareMetalRequest(BaseModel): + region: str + plan: str + os_id: Optional[int] = None + snapshot_id: Optional[str] = None + app_id: Optional[int] = None + image_id: Optional[str] = None + script_id: Optional[str] = None + ssh_key_ids: Optional[List[str]] = None + enable_ipv6: Optional[bool] = None + hostname: Optional[str] = None + label: Optional[str] = None + tags: Optional[List[str]] = None + + +class UpdateBareMetalRequest(BaseModel): + label: Optional[str] = None + tags: Optional[List[str]] = None + enable_ipv6: Optional[bool] = None + + +@router.get("") +async def list_bare_metal( + per_page: int = Query(25, le=500), + cursor: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List all bare metal servers""" + return client.bare_metal.list(per_page=per_page, cursor=cursor) + + +@router.get("/all") +async def list_all_bare_metal(client: VultrClient = Depends(get_client)): + """List all bare metal servers (auto-paginated)""" + return {"bare_metals": client.bare_metal.list_all()} + + +@router.get("/{baremetal_id}") +async def get_bare_metal(baremetal_id: str, client: VultrClient = Depends(get_client)): + """Get bare metal server details""" + return client.bare_metal.get(baremetal_id) + + +@router.post("") +async def create_bare_metal(req: CreateBareMetalRequest, client: VultrClient = Depends(get_client)): + """Create a bare metal server""" + return client.bare_metal.create(**req.model_dump(exclude_none=True)) + + +@router.patch("/{baremetal_id}") +async def update_bare_metal(baremetal_id: str, req: UpdateBareMetalRequest, client: VultrClient = Depends(get_client)): + """Update a bare metal server""" + return client.bare_metal.update(baremetal_id, **req.model_dump(exclude_none=True)) + + +@router.delete("/{baremetal_id}") +async def delete_bare_metal(baremetal_id: str, client: VultrClient = Depends(get_client)): + """Delete a bare metal server""" + return client.bare_metal.delete(baremetal_id) + + +@router.post("/{baremetal_id}/start") +async def start_bare_metal(baremetal_id: str, client: VultrClient = Depends(get_client)): + """Start a bare metal server""" + return client.bare_metal.start(baremetal_id) + + +@router.post("/{baremetal_id}/halt") +async def halt_bare_metal(baremetal_id: str, client: VultrClient = Depends(get_client)): + """Halt a bare metal server""" + return client.bare_metal.halt(baremetal_id) + + +@router.post("/{baremetal_id}/reboot") +async def reboot_bare_metal(baremetal_id: str, client: VultrClient = Depends(get_client)): + """Reboot a bare metal server""" + return client.bare_metal.reboot(baremetal_id) + + +@router.post("/{baremetal_id}/reinstall") +async def reinstall_bare_metal(baremetal_id: str, hostname: Optional[str] = None, client: VultrClient = Depends(get_client)): + """Reinstall a bare metal server""" + return client.bare_metal.reinstall(baremetal_id, hostname=hostname) + + +@router.get("/{baremetal_id}/ipv4") +async def list_ipv4(baremetal_id: str, client: VultrClient = Depends(get_client)): + """List IPv4 addresses for a bare metal server""" + return client.bare_metal.list_ipv4(baremetal_id) + + +@router.get("/{baremetal_id}/ipv6") +async def list_ipv6(baremetal_id: str, client: VultrClient = Depends(get_client)): + """List IPv6 addresses for a bare metal server""" + return client.bare_metal.list_ipv6(baremetal_id) + + +@router.get("/{baremetal_id}/bandwidth") +async def get_bandwidth(baremetal_id: str, client: VultrClient = Depends(get_client)): + """Get bandwidth usage for a bare metal server""" + return client.bare_metal.bandwidth(baremetal_id) + + +@router.get("/{baremetal_id}/user-data") +async def get_user_data(baremetal_id: str, client: VultrClient = Depends(get_client)): + """Get user data for a bare metal server""" + return client.bare_metal.get_user_data(baremetal_id) diff --git a/server/routers/block_storage.py b/server/routers/block_storage.py new file mode 100644 index 0000000..ad1ce5d --- /dev/null +++ b/server/routers/block_storage.py @@ -0,0 +1,78 @@ +"""Block Storage router""" +from fastapi import APIRouter, Depends, Query +from typing import Optional +from pydantic import BaseModel + +from vultr_api import VultrClient +from deps import get_client + +router = APIRouter() + + +class CreateBlockStorageRequest(BaseModel): + region: str + size_gb: int + label: Optional[str] = None + block_type: Optional[str] = None + + +class UpdateBlockStorageRequest(BaseModel): + size_gb: Optional[int] = None + label: Optional[str] = None + + +class AttachBlockStorageRequest(BaseModel): + instance_id: str + live: Optional[bool] = True + + +@router.get("") +async def list_block_storage( + per_page: int = Query(25, le=500), + cursor: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List all block storage volumes""" + return client.block_storage.list(per_page=per_page, cursor=cursor) + + +@router.get("/all") +async def list_all_block_storage(client: VultrClient = Depends(get_client)): + """List all block storage volumes (auto-paginated)""" + return {"blocks": client.block_storage.list_all()} + + +@router.get("/{block_id}") +async def get_block_storage(block_id: str, client: VultrClient = Depends(get_client)): + """Get block storage details""" + return client.block_storage.get(block_id) + + +@router.post("") +async def create_block_storage(req: CreateBlockStorageRequest, client: VultrClient = Depends(get_client)): + """Create a block storage volume""" + return client.block_storage.create(**req.model_dump(exclude_none=True)) + + +@router.patch("/{block_id}") +async def update_block_storage(block_id: str, req: UpdateBlockStorageRequest, client: VultrClient = Depends(get_client)): + """Update a block storage volume""" + return client.block_storage.update(block_id, **req.model_dump(exclude_none=True)) + + +@router.delete("/{block_id}") +async def delete_block_storage(block_id: str, client: VultrClient = Depends(get_client)): + """Delete a block storage volume""" + return client.block_storage.delete(block_id) + + +@router.post("/{block_id}/attach") +async def attach_block_storage(block_id: str, req: AttachBlockStorageRequest, client: VultrClient = Depends(get_client)): + """Attach block storage to an instance""" + return client.block_storage.attach(block_id, instance_id=req.instance_id, live=req.live) + + +@router.post("/{block_id}/detach") +async def detach_block_storage(block_id: str, live: bool = True, client: VultrClient = Depends(get_client)): + """Detach block storage from an instance""" + return client.block_storage.detach(block_id, live=live) diff --git a/server/routers/dns.py b/server/routers/dns.py new file mode 100644 index 0000000..36ff148 --- /dev/null +++ b/server/routers/dns.py @@ -0,0 +1,155 @@ +"""DNS router""" +from fastapi import APIRouter, Depends, Query +from typing import Optional +from pydantic import BaseModel + +from vultr_api import VultrClient +from deps import get_client + +router = APIRouter() + + +class CreateDomainRequest(BaseModel): + domain: str + ip: Optional[str] = None + + +class CreateRecordRequest(BaseModel): + type: str + name: str + data: str + ttl: Optional[int] = 300 + priority: Optional[int] = None + + +class UpdateRecordRequest(BaseModel): + name: Optional[str] = None + data: Optional[str] = None + ttl: Optional[int] = None + priority: Optional[int] = None + + +@router.get("/domains") +async def list_domains( + per_page: int = Query(25, le=500), + cursor: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List all DNS domains""" + return client.dns.list_domains(per_page=per_page, cursor=cursor) + + +@router.get("/domains/all") +async def list_all_domains(client: VultrClient = Depends(get_client)): + """List all DNS domains (auto-paginated)""" + return {"domains": client.dns.list_all_domains()} + + +@router.get("/domains/{domain}") +async def get_domain(domain: str, client: VultrClient = Depends(get_client)): + """Get DNS domain details""" + return client.dns.get_domain(domain) + + +@router.post("/domains") +async def create_domain(req: CreateDomainRequest, client: VultrClient = Depends(get_client)): + """Create a DNS domain""" + return client.dns.create_domain(req.domain, ip=req.ip) + + +@router.delete("/domains/{domain}") +async def delete_domain(domain: str, client: VultrClient = Depends(get_client)): + """Delete a DNS domain""" + return client.dns.delete_domain(domain) + + +@router.get("/domains/{domain}/soa") +async def get_soa(domain: str, client: VultrClient = Depends(get_client)): + """Get SOA record for a domain""" + return client.dns.get_soa(domain) + + +@router.get("/domains/{domain}/dnssec") +async def get_dnssec(domain: str, client: VultrClient = Depends(get_client)): + """Get DNSSEC info for a domain""" + return client.dns.get_dnssec(domain) + + +# Records +@router.get("/domains/{domain}/records") +async def list_records( + domain: str, + per_page: int = Query(25, le=500), + cursor: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List DNS records for a domain""" + return client.dns.list_records(domain, per_page=per_page, cursor=cursor) + + +@router.get("/domains/{domain}/records/all") +async def list_all_records(domain: str, client: VultrClient = Depends(get_client)): + """List all DNS records (auto-paginated)""" + return {"records": client.dns.list_all_records(domain)} + + +@router.get("/domains/{domain}/records/{record_id}") +async def get_record(domain: str, record_id: str, client: VultrClient = Depends(get_client)): + """Get a DNS record""" + return client.dns.get_record(domain, record_id) + + +@router.post("/domains/{domain}/records") +async def create_record(domain: str, req: CreateRecordRequest, client: VultrClient = Depends(get_client)): + """Create a DNS record""" + return client.dns.create_record( + domain, + type=req.type, + name=req.name, + data=req.data, + ttl=req.ttl, + priority=req.priority + ) + + +@router.patch("/domains/{domain}/records/{record_id}") +async def update_record(domain: str, record_id: str, req: UpdateRecordRequest, client: VultrClient = Depends(get_client)): + """Update a DNS record""" + return client.dns.update_record(domain, record_id, **req.model_dump(exclude_none=True)) + + +@router.delete("/domains/{domain}/records/{record_id}") +async def delete_record(domain: str, record_id: str, client: VultrClient = Depends(get_client)): + """Delete a DNS record""" + return client.dns.delete_record(domain, record_id) + + +# Convenience endpoints +@router.post("/domains/{domain}/records/a") +async def create_a_record(domain: str, name: str, ip: str, ttl: int = 300, client: VultrClient = Depends(get_client)): + """Create an A record""" + return client.dns.create_a_record(domain, name, ip, ttl) + + +@router.post("/domains/{domain}/records/aaaa") +async def create_aaaa_record(domain: str, name: str, ip: str, ttl: int = 300, client: VultrClient = Depends(get_client)): + """Create an AAAA record""" + return client.dns.create_aaaa_record(domain, name, ip, ttl) + + +@router.post("/domains/{domain}/records/cname") +async def create_cname_record(domain: str, name: str, target: str, ttl: int = 300, client: VultrClient = Depends(get_client)): + """Create a CNAME record""" + return client.dns.create_cname_record(domain, name, target, ttl) + + +@router.post("/domains/{domain}/records/txt") +async def create_txt_record(domain: str, name: str, data: str, ttl: int = 300, client: VultrClient = Depends(get_client)): + """Create a TXT record""" + return client.dns.create_txt_record(domain, name, data, ttl) + + +@router.post("/domains/{domain}/records/mx") +async def create_mx_record(domain: str, name: str, data: str, priority: int = 10, ttl: int = 300, client: VultrClient = Depends(get_client)): + """Create an MX record""" + return client.dns.create_mx_record(domain, name, data, priority, ttl) diff --git a/server/routers/firewall.py b/server/routers/firewall.py new file mode 100644 index 0000000..736418d --- /dev/null +++ b/server/routers/firewall.py @@ -0,0 +1,124 @@ +"""Firewall router""" +from fastapi import APIRouter, Depends, Query +from typing import Optional +from pydantic import BaseModel + +from vultr_api import VultrClient +from deps import get_client + +router = APIRouter() + + +class CreateGroupRequest(BaseModel): + description: Optional[str] = None + + +class CreateRuleRequest(BaseModel): + ip_type: str # v4 or v6 + protocol: str # tcp, udp, icmp, gre, esp, ah + subnet: str + subnet_size: int + port: Optional[str] = None + source: Optional[str] = None + notes: Optional[str] = None + + +@router.get("/groups") +async def list_groups( + per_page: int = Query(25, le=500), + cursor: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List all firewall groups""" + return client.firewall.list_groups(per_page=per_page, cursor=cursor) + + +@router.get("/groups/all") +async def list_all_groups(client: VultrClient = Depends(get_client)): + """List all firewall groups (auto-paginated)""" + return {"firewall_groups": client.firewall.list_all_groups()} + + +@router.get("/groups/{group_id}") +async def get_group(group_id: str, client: VultrClient = Depends(get_client)): + """Get firewall group details""" + return client.firewall.get_group(group_id) + + +@router.post("/groups") +async def create_group(req: CreateGroupRequest, client: VultrClient = Depends(get_client)): + """Create a firewall group""" + return client.firewall.create_group(description=req.description) + + +@router.patch("/groups/{group_id}") +async def update_group(group_id: str, description: str, client: VultrClient = Depends(get_client)): + """Update a firewall group""" + return client.firewall.update_group(group_id, description=description) + + +@router.delete("/groups/{group_id}") +async def delete_group(group_id: str, client: VultrClient = Depends(get_client)): + """Delete a firewall group""" + return client.firewall.delete_group(group_id) + + +# Rules +@router.get("/groups/{group_id}/rules") +async def list_rules( + group_id: str, + per_page: int = Query(25, le=500), + cursor: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List firewall rules for a group""" + return client.firewall.list_rules(group_id, per_page=per_page, cursor=cursor) + + +@router.get("/groups/{group_id}/rules/all") +async def list_all_rules(group_id: str, client: VultrClient = Depends(get_client)): + """List all firewall rules (auto-paginated)""" + return {"firewall_rules": client.firewall.list_all_rules(group_id)} + + +@router.get("/groups/{group_id}/rules/{rule_id}") +async def get_rule(group_id: str, rule_id: int, client: VultrClient = Depends(get_client)): + """Get a firewall rule""" + return client.firewall.get_rule(group_id, rule_id) + + +@router.post("/groups/{group_id}/rules") +async def create_rule(group_id: str, req: CreateRuleRequest, client: VultrClient = Depends(get_client)): + """Create a firewall rule""" + return client.firewall.create_rule(group_id, **req.model_dump(exclude_none=True)) + + +@router.delete("/groups/{group_id}/rules/{rule_id}") +async def delete_rule(group_id: str, rule_id: int, client: VultrClient = Depends(get_client)): + """Delete a firewall rule""" + return client.firewall.delete_rule(group_id, rule_id) + + +# Convenience endpoints +@router.post("/groups/{group_id}/rules/allow-ssh") +async def allow_ssh(group_id: str, source: str = "0.0.0.0/0", client: VultrClient = Depends(get_client)): + """Allow SSH (port 22) from source""" + return client.firewall.allow_ssh(group_id, source=source) + + +@router.post("/groups/{group_id}/rules/allow-http") +async def allow_http(group_id: str, source: str = "0.0.0.0/0", client: VultrClient = Depends(get_client)): + """Allow HTTP (port 80) from source""" + return client.firewall.allow_http(group_id, source=source) + + +@router.post("/groups/{group_id}/rules/allow-https") +async def allow_https(group_id: str, source: str = "0.0.0.0/0", client: VultrClient = Depends(get_client)): + """Allow HTTPS (port 443) from source""" + return client.firewall.allow_https(group_id, source=source) + + +@router.post("/groups/{group_id}/rules/allow-ping") +async def allow_ping(group_id: str, source: str = "0.0.0.0/0", client: VultrClient = Depends(get_client)): + """Allow ICMP ping from source""" + return client.firewall.allow_ping(group_id, source=source) diff --git a/server/routers/instances.py b/server/routers/instances.py new file mode 100644 index 0000000..4941c9b --- /dev/null +++ b/server/routers/instances.py @@ -0,0 +1,151 @@ +"""Instances router""" +from fastapi import APIRouter, Depends, Query +from typing import Optional, List +from pydantic import BaseModel + +from vultr_api import VultrClient +from deps import get_client + +router = APIRouter() + + +class CreateInstanceRequest(BaseModel): + region: str + plan: str + os_id: Optional[int] = None + iso_id: Optional[str] = None + snapshot_id: Optional[str] = None + app_id: Optional[int] = None + image_id: Optional[str] = None + script_id: Optional[str] = None + ssh_key_ids: Optional[List[str]] = None + backups: Optional[str] = None + enable_ipv6: Optional[bool] = None + hostname: Optional[str] = None + label: Optional[str] = None + tags: Optional[List[str]] = None + firewall_group_id: Optional[str] = None + vpc_id: Optional[str] = None + + +class UpdateInstanceRequest(BaseModel): + label: Optional[str] = None + tags: Optional[List[str]] = None + firewall_group_id: Optional[str] = None + enable_ipv6: Optional[bool] = None + backups: Optional[str] = None + + +@router.get("") +async def list_instances( + per_page: int = Query(25, le=500), + cursor: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List all instances""" + return client.instances.list(per_page=per_page, cursor=cursor) + + +@router.get("/all") +async def list_all_instances(client: VultrClient = Depends(get_client)): + """List all instances (auto-paginated)""" + return {"instances": client.instances.list_all()} + + +@router.get("/{instance_id}") +async def get_instance(instance_id: str, client: VultrClient = Depends(get_client)): + """Get instance details""" + return client.instances.get(instance_id) + + +@router.post("") +async def create_instance(req: CreateInstanceRequest, client: VultrClient = Depends(get_client)): + """Create a new instance""" + return client.instances.create(**req.model_dump(exclude_none=True)) + + +@router.patch("/{instance_id}") +async def update_instance(instance_id: str, req: UpdateInstanceRequest, client: VultrClient = Depends(get_client)): + """Update an instance""" + return client.instances.update(instance_id, **req.model_dump(exclude_none=True)) + + +@router.delete("/{instance_id}") +async def delete_instance(instance_id: str, client: VultrClient = Depends(get_client)): + """Delete an instance""" + return client.instances.delete(instance_id) + + +@router.post("/{instance_id}/start") +async def start_instance(instance_id: str, client: VultrClient = Depends(get_client)): + """Start an instance""" + return client.instances.start(instance_id) + + +@router.post("/{instance_id}/stop") +async def stop_instance(instance_id: str, client: VultrClient = Depends(get_client)): + """Stop an instance (halt)""" + return client.instances.halt(instance_id) + + +@router.post("/{instance_id}/reboot") +async def reboot_instance(instance_id: str, client: VultrClient = Depends(get_client)): + """Reboot an instance""" + return client.instances.reboot(instance_id) + + +@router.post("/{instance_id}/reinstall") +async def reinstall_instance(instance_id: str, hostname: Optional[str] = None, client: VultrClient = Depends(get_client)): + """Reinstall an instance""" + return client.instances.reinstall(instance_id, hostname=hostname) + + +@router.get("/{instance_id}/ipv4") +async def list_ipv4(instance_id: str, client: VultrClient = Depends(get_client)): + """List IPv4 addresses for an instance""" + return client.instances.list_ipv4(instance_id) + + +@router.get("/{instance_id}/ipv6") +async def list_ipv6(instance_id: str, client: VultrClient = Depends(get_client)): + """List IPv6 addresses for an instance""" + return client.instances.list_ipv6(instance_id) + + +@router.get("/{instance_id}/bandwidth") +async def get_bandwidth(instance_id: str, client: VultrClient = Depends(get_client)): + """Get bandwidth usage for an instance""" + return client.instances.bandwidth(instance_id) + + +@router.get("/{instance_id}/neighbors") +async def get_neighbors(instance_id: str, client: VultrClient = Depends(get_client)): + """Get instance neighbors""" + return client.instances.neighbors(instance_id) + + +@router.get("/{instance_id}/user-data") +async def get_user_data(instance_id: str, client: VultrClient = Depends(get_client)): + """Get instance user data""" + return client.instances.get_user_data(instance_id) + + +@router.post("/{instance_id}/backup") +async def create_backup(instance_id: str, client: VultrClient = Depends(get_client)): + """Create a backup of an instance""" + return client.instances.create_backup(instance_id) + + +@router.post("/{instance_id}/restore") +async def restore_instance( + instance_id: str, + backup_id: Optional[str] = None, + snapshot_id: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """Restore an instance from backup or snapshot""" + if backup_id: + return client.instances.restore_backup(instance_id, backup_id) + elif snapshot_id: + return client.instances.restore_snapshot(instance_id, snapshot_id) + return {"error": "backup_id or snapshot_id required"} diff --git a/server/routers/load_balancers.py b/server/routers/load_balancers.py new file mode 100644 index 0000000..b0d5d54 --- /dev/null +++ b/server/routers/load_balancers.py @@ -0,0 +1,108 @@ +"""Load Balancers router""" +from fastapi import APIRouter, Depends, Query +from typing import Optional, List +from pydantic import BaseModel + +from vultr_api import VultrClient +from deps import get_client + +router = APIRouter() + + +class ForwardingRule(BaseModel): + frontend_protocol: str + frontend_port: int + backend_protocol: str + backend_port: int + + +class HealthCheck(BaseModel): + protocol: str + port: int + path: Optional[str] = None + check_interval: Optional[int] = None + response_timeout: Optional[int] = None + unhealthy_threshold: Optional[int] = None + healthy_threshold: Optional[int] = None + + +class CreateLoadBalancerRequest(BaseModel): + region: str + label: Optional[str] = None + balancing_algorithm: Optional[str] = None # roundrobin or leastconn + forwarding_rules: Optional[List[ForwardingRule]] = None + health_check: Optional[HealthCheck] = None + instances: Optional[List[str]] = None + ssl_redirect: Optional[bool] = None + proxy_protocol: Optional[bool] = None + + +class UpdateLoadBalancerRequest(BaseModel): + label: Optional[str] = None + balancing_algorithm: Optional[str] = None + ssl_redirect: Optional[bool] = None + proxy_protocol: Optional[bool] = None + + +@router.get("") +async def list_load_balancers( + per_page: int = Query(25, le=500), + cursor: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List all load balancers""" + return client.load_balancers.list(per_page=per_page, cursor=cursor) + + +@router.get("/all") +async def list_all_load_balancers(client: VultrClient = Depends(get_client)): + """List all load balancers (auto-paginated)""" + return {"load_balancers": client.load_balancers.list_all()} + + +@router.get("/{lb_id}") +async def get_load_balancer(lb_id: str, client: VultrClient = Depends(get_client)): + """Get load balancer details""" + return client.load_balancers.get(lb_id) + + +@router.post("") +async def create_load_balancer(req: CreateLoadBalancerRequest, client: VultrClient = Depends(get_client)): + """Create a load balancer""" + data = req.model_dump(exclude_none=True) + if 'forwarding_rules' in data: + data['forwarding_rules'] = [r for r in data['forwarding_rules']] + if 'health_check' in data: + data['health_check'] = dict(data['health_check']) + return client.load_balancers.create(**data) + + +@router.patch("/{lb_id}") +async def update_load_balancer(lb_id: str, req: UpdateLoadBalancerRequest, client: VultrClient = Depends(get_client)): + """Update a load balancer""" + return client.load_balancers.update(lb_id, **req.model_dump(exclude_none=True)) + + +@router.delete("/{lb_id}") +async def delete_load_balancer(lb_id: str, client: VultrClient = Depends(get_client)): + """Delete a load balancer""" + return client.load_balancers.delete(lb_id) + + +# Forwarding rules +@router.get("/{lb_id}/forwarding-rules") +async def list_forwarding_rules(lb_id: str, client: VultrClient = Depends(get_client)): + """List forwarding rules for a load balancer""" + return client.load_balancers.list_forwarding_rules(lb_id) + + +@router.post("/{lb_id}/forwarding-rules") +async def create_forwarding_rule(lb_id: str, req: ForwardingRule, client: VultrClient = Depends(get_client)): + """Create a forwarding rule""" + return client.load_balancers.create_forwarding_rule(lb_id, **req.model_dump()) + + +@router.delete("/{lb_id}/forwarding-rules/{rule_id}") +async def delete_forwarding_rule(lb_id: str, rule_id: str, client: VultrClient = Depends(get_client)): + """Delete a forwarding rule""" + return client.load_balancers.delete_forwarding_rule(lb_id, rule_id) diff --git a/server/routers/os_api.py b/server/routers/os_api.py new file mode 100644 index 0000000..9b98ce6 --- /dev/null +++ b/server/routers/os_api.py @@ -0,0 +1,24 @@ +"""Operating Systems router""" +from fastapi import APIRouter, Depends, Query +from typing import Optional + +from vultr_api import VultrClient +from deps import get_client + +router = APIRouter() + + +@router.get("") +async def list_os( + per_page: int = Query(25, le=500), + cursor: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List all operating systems""" + return client.os.list(per_page=per_page, cursor=cursor) + + +@router.get("/all") +async def list_all_os(client: VultrClient = Depends(get_client)): + """List all operating systems (auto-paginated)""" + return {"os": client.os.list_all()} diff --git a/server/routers/plans.py b/server/routers/plans.py new file mode 100644 index 0000000..3f547a0 --- /dev/null +++ b/server/routers/plans.py @@ -0,0 +1,44 @@ +"""Plans router""" +from fastapi import APIRouter, Depends, Query +from typing import Optional + +from vultr_api import VultrClient +from deps import get_client + +router = APIRouter() + + +@router.get("") +async def list_plans( + plan_type: Optional[str] = None, # vc2, vhf, vdc, etc. + per_page: int = Query(25, le=500), + cursor: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List all cloud compute plans""" + return client.plans.list(plan_type=plan_type, per_page=per_page, cursor=cursor) + + +@router.get("/all") +async def list_all_plans( + plan_type: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List all cloud compute plans (auto-paginated)""" + return {"plans": client.plans.list_all(plan_type=plan_type)} + + +@router.get("/bare-metal") +async def list_bare_metal_plans( + per_page: int = Query(25, le=500), + cursor: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List all bare metal plans""" + return client.plans.list_bare_metal(per_page=per_page, cursor=cursor) + + +@router.get("/bare-metal/all") +async def list_all_bare_metal_plans(client: VultrClient = Depends(get_client)): + """List all bare metal plans (auto-paginated)""" + return {"plans_metal": client.plans.list_all_bare_metal()} diff --git a/server/routers/regions.py b/server/routers/regions.py new file mode 100644 index 0000000..f12d9a1 --- /dev/null +++ b/server/routers/regions.py @@ -0,0 +1,34 @@ +"""Regions router""" +from fastapi import APIRouter, Depends, Query +from typing import Optional + +from vultr_api import VultrClient +from deps import get_client + +router = APIRouter() + + +@router.get("") +async def list_regions( + per_page: int = Query(25, le=500), + cursor: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List all regions""" + return client.regions.list(per_page=per_page, cursor=cursor) + + +@router.get("/all") +async def list_all_regions(client: VultrClient = Depends(get_client)): + """List all regions (auto-paginated)""" + return {"regions": client.regions.list_all()} + + +@router.get("/{region_id}/availability") +async def list_region_availability( + region_id: str, + plan_type: Optional[str] = None, # vc2, vhf, vdc, etc. + client: VultrClient = Depends(get_client) +): + """List available plans in a region""" + return client.regions.list_availability(region_id, plan_type=plan_type) diff --git a/server/routers/reserved_ips.py b/server/routers/reserved_ips.py new file mode 100644 index 0000000..11af854 --- /dev/null +++ b/server/routers/reserved_ips.py @@ -0,0 +1,86 @@ +"""Reserved IPs router""" +from fastapi import APIRouter, Depends, Query +from typing import Optional +from pydantic import BaseModel + +from vultr_api import VultrClient +from deps import get_client + +router = APIRouter() + + +class CreateReservedIPRequest(BaseModel): + region: str + ip_type: str = "v4" # v4 or v6 + label: Optional[str] = None + + +class UpdateReservedIPRequest(BaseModel): + label: str + + +class AttachReservedIPRequest(BaseModel): + instance_id: str + + +class ConvertReservedIPRequest(BaseModel): + ip_address: str + label: Optional[str] = None + + +@router.get("") +async def list_reserved_ips( + per_page: int = Query(25, le=500), + cursor: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List all reserved IPs""" + return client.reserved_ips.list(per_page=per_page, cursor=cursor) + + +@router.get("/all") +async def list_all_reserved_ips(client: VultrClient = Depends(get_client)): + """List all reserved IPs (auto-paginated)""" + return {"reserved_ips": client.reserved_ips.list_all()} + + +@router.get("/{reserved_ip}") +async def get_reserved_ip(reserved_ip: str, client: VultrClient = Depends(get_client)): + """Get reserved IP details""" + return client.reserved_ips.get(reserved_ip) + + +@router.post("") +async def create_reserved_ip(req: CreateReservedIPRequest, client: VultrClient = Depends(get_client)): + """Create a reserved IP""" + return client.reserved_ips.create(**req.model_dump(exclude_none=True)) + + +@router.patch("/{reserved_ip}") +async def update_reserved_ip(reserved_ip: str, req: UpdateReservedIPRequest, client: VultrClient = Depends(get_client)): + """Update a reserved IP""" + return client.reserved_ips.update(reserved_ip, label=req.label) + + +@router.delete("/{reserved_ip}") +async def delete_reserved_ip(reserved_ip: str, client: VultrClient = Depends(get_client)): + """Delete a reserved IP""" + return client.reserved_ips.delete(reserved_ip) + + +@router.post("/{reserved_ip}/attach") +async def attach_reserved_ip(reserved_ip: str, req: AttachReservedIPRequest, client: VultrClient = Depends(get_client)): + """Attach reserved IP to an instance""" + return client.reserved_ips.attach(reserved_ip, instance_id=req.instance_id) + + +@router.post("/{reserved_ip}/detach") +async def detach_reserved_ip(reserved_ip: str, client: VultrClient = Depends(get_client)): + """Detach reserved IP from an instance""" + return client.reserved_ips.detach(reserved_ip) + + +@router.post("/convert") +async def convert_to_reserved_ip(req: ConvertReservedIPRequest, client: VultrClient = Depends(get_client)): + """Convert an instance IP to a reserved IP""" + return client.reserved_ips.convert(ip_address=req.ip_address, label=req.label) diff --git a/server/routers/snapshots.py b/server/routers/snapshots.py new file mode 100644 index 0000000..de2c063 --- /dev/null +++ b/server/routers/snapshots.py @@ -0,0 +1,59 @@ +"""Snapshots router""" +from fastapi import APIRouter, Depends, Query +from typing import Optional +from pydantic import BaseModel + +from vultr_api import VultrClient +from deps import get_client + +router = APIRouter() + + +class CreateSnapshotRequest(BaseModel): + instance_id: str + description: Optional[str] = None + + +class CreateSnapshotFromURLRequest(BaseModel): + url: str + description: Optional[str] = None + + +@router.get("") +async def list_snapshots( + per_page: int = Query(25, le=500), + cursor: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List all snapshots""" + return client.snapshots.list(per_page=per_page, cursor=cursor) + + +@router.get("/all") +async def list_all_snapshots(client: VultrClient = Depends(get_client)): + """List all snapshots (auto-paginated)""" + return {"snapshots": client.snapshots.list_all()} + + +@router.get("/{snapshot_id}") +async def get_snapshot(snapshot_id: str, client: VultrClient = Depends(get_client)): + """Get snapshot details""" + return client.snapshots.get(snapshot_id) + + +@router.post("") +async def create_snapshot(req: CreateSnapshotRequest, client: VultrClient = Depends(get_client)): + """Create a snapshot from an instance""" + return client.snapshots.create(instance_id=req.instance_id, description=req.description) + + +@router.post("/from-url") +async def create_snapshot_from_url(req: CreateSnapshotFromURLRequest, client: VultrClient = Depends(get_client)): + """Create a snapshot from a URL""" + return client.snapshots.create_from_url(url=req.url, description=req.description) + + +@router.delete("/{snapshot_id}") +async def delete_snapshot(snapshot_id: str, client: VultrClient = Depends(get_client)): + """Delete a snapshot""" + return client.snapshots.delete(snapshot_id) diff --git a/server/routers/ssh_keys.py b/server/routers/ssh_keys.py new file mode 100644 index 0000000..775e3ce --- /dev/null +++ b/server/routers/ssh_keys.py @@ -0,0 +1,59 @@ +"""SSH Keys router""" +from fastapi import APIRouter, Depends, Query +from typing import Optional +from pydantic import BaseModel + +from vultr_api import VultrClient +from deps import get_client + +router = APIRouter() + + +class CreateSSHKeyRequest(BaseModel): + name: str + ssh_key: str + + +class UpdateSSHKeyRequest(BaseModel): + name: Optional[str] = None + ssh_key: Optional[str] = None + + +@router.get("") +async def list_ssh_keys( + per_page: int = Query(25, le=500), + cursor: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List all SSH keys""" + return client.ssh_keys.list(per_page=per_page, cursor=cursor) + + +@router.get("/all") +async def list_all_ssh_keys(client: VultrClient = Depends(get_client)): + """List all SSH keys (auto-paginated)""" + return {"ssh_keys": client.ssh_keys.list_all()} + + +@router.get("/{ssh_key_id}") +async def get_ssh_key(ssh_key_id: str, client: VultrClient = Depends(get_client)): + """Get SSH key details""" + return client.ssh_keys.get(ssh_key_id) + + +@router.post("") +async def create_ssh_key(req: CreateSSHKeyRequest, client: VultrClient = Depends(get_client)): + """Create an SSH key""" + return client.ssh_keys.create(name=req.name, ssh_key=req.ssh_key) + + +@router.patch("/{ssh_key_id}") +async def update_ssh_key(ssh_key_id: str, req: UpdateSSHKeyRequest, client: VultrClient = Depends(get_client)): + """Update an SSH key""" + return client.ssh_keys.update(ssh_key_id, **req.model_dump(exclude_none=True)) + + +@router.delete("/{ssh_key_id}") +async def delete_ssh_key(ssh_key_id: str, client: VultrClient = Depends(get_client)): + """Delete an SSH key""" + return client.ssh_keys.delete(ssh_key_id) diff --git a/server/routers/startup_scripts.py b/server/routers/startup_scripts.py new file mode 100644 index 0000000..408df2c --- /dev/null +++ b/server/routers/startup_scripts.py @@ -0,0 +1,61 @@ +"""Startup Scripts router""" +from fastapi import APIRouter, Depends, Query +from typing import Optional +from pydantic import BaseModel + +from vultr_api import VultrClient +from deps import get_client + +router = APIRouter() + + +class CreateScriptRequest(BaseModel): + name: str + script: str + type: Optional[str] = "boot" # boot or pxe + + +class UpdateScriptRequest(BaseModel): + name: Optional[str] = None + script: Optional[str] = None + type: Optional[str] = None + + +@router.get("") +async def list_startup_scripts( + per_page: int = Query(25, le=500), + cursor: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List all startup scripts""" + return client.startup_scripts.list(per_page=per_page, cursor=cursor) + + +@router.get("/all") +async def list_all_startup_scripts(client: VultrClient = Depends(get_client)): + """List all startup scripts (auto-paginated)""" + return {"startup_scripts": client.startup_scripts.list_all()} + + +@router.get("/{script_id}") +async def get_startup_script(script_id: str, client: VultrClient = Depends(get_client)): + """Get startup script details""" + return client.startup_scripts.get(script_id) + + +@router.post("") +async def create_startup_script(req: CreateScriptRequest, client: VultrClient = Depends(get_client)): + """Create a startup script""" + return client.startup_scripts.create(name=req.name, script=req.script, type=req.type) + + +@router.patch("/{script_id}") +async def update_startup_script(script_id: str, req: UpdateScriptRequest, client: VultrClient = Depends(get_client)): + """Update a startup script""" + return client.startup_scripts.update(script_id, **req.model_dump(exclude_none=True)) + + +@router.delete("/{script_id}") +async def delete_startup_script(script_id: str, client: VultrClient = Depends(get_client)): + """Delete a startup script""" + return client.startup_scripts.delete(script_id) diff --git a/server/routers/vpc.py b/server/routers/vpc.py new file mode 100644 index 0000000..3601e0a --- /dev/null +++ b/server/routers/vpc.py @@ -0,0 +1,136 @@ +"""VPC router""" +from fastapi import APIRouter, Depends, Query +from typing import Optional, List +from pydantic import BaseModel + +from vultr_api import VultrClient +from deps import get_client + +router = APIRouter() + + +class CreateVPCRequest(BaseModel): + region: str + description: Optional[str] = None + v4_subnet: Optional[str] = None + v4_subnet_mask: Optional[int] = None + + +class UpdateVPCRequest(BaseModel): + description: str + + +class CreateVPC2Request(BaseModel): + region: str + description: Optional[str] = None + ip_block: Optional[str] = None + prefix_length: Optional[int] = None + + +class AttachVPC2Request(BaseModel): + nodes: List[dict] # [{"id": "instance_id", "ip_address": "10.0.0.1"}, ...] + + +# VPC 1.0 +@router.get("") +async def list_vpcs( + per_page: int = Query(25, le=500), + cursor: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List all VPCs""" + return client.vpc.list(per_page=per_page, cursor=cursor) + + +@router.get("/all") +async def list_all_vpcs(client: VultrClient = Depends(get_client)): + """List all VPCs (auto-paginated)""" + return {"vpcs": client.vpc.list_all()} + + +@router.get("/{vpc_id}") +async def get_vpc(vpc_id: str, client: VultrClient = Depends(get_client)): + """Get VPC details""" + return client.vpc.get(vpc_id) + + +@router.post("") +async def create_vpc(req: CreateVPCRequest, client: VultrClient = Depends(get_client)): + """Create a VPC""" + return client.vpc.create(**req.model_dump(exclude_none=True)) + + +@router.patch("/{vpc_id}") +async def update_vpc(vpc_id: str, req: UpdateVPCRequest, client: VultrClient = Depends(get_client)): + """Update a VPC""" + return client.vpc.update(vpc_id, description=req.description) + + +@router.delete("/{vpc_id}") +async def delete_vpc(vpc_id: str, client: VultrClient = Depends(get_client)): + """Delete a VPC""" + return client.vpc.delete(vpc_id) + + +# VPC 2.0 +@router.get("/v2/list") +async def list_vpc2s( + per_page: int = Query(25, le=500), + cursor: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List all VPC 2.0 networks""" + return client.vpc.list_vpc2(per_page=per_page, cursor=cursor) + + +@router.get("/v2/all") +async def list_all_vpc2s(client: VultrClient = Depends(get_client)): + """List all VPC 2.0 networks (auto-paginated)""" + return {"vpcs": client.vpc.list_all_vpc2()} + + +@router.get("/v2/{vpc_id}") +async def get_vpc2(vpc_id: str, client: VultrClient = Depends(get_client)): + """Get VPC 2.0 details""" + return client.vpc.get_vpc2(vpc_id) + + +@router.post("/v2") +async def create_vpc2(req: CreateVPC2Request, client: VultrClient = Depends(get_client)): + """Create a VPC 2.0 network""" + return client.vpc.create_vpc2(**req.model_dump(exclude_none=True)) + + +@router.patch("/v2/{vpc_id}") +async def update_vpc2(vpc_id: str, description: str, client: VultrClient = Depends(get_client)): + """Update a VPC 2.0 network""" + return client.vpc.update_vpc2(vpc_id, description=description) + + +@router.delete("/v2/{vpc_id}") +async def delete_vpc2(vpc_id: str, client: VultrClient = Depends(get_client)): + """Delete a VPC 2.0 network""" + return client.vpc.delete_vpc2(vpc_id) + + +@router.get("/v2/{vpc_id}/nodes") +async def list_vpc2_nodes( + vpc_id: str, + per_page: int = Query(25, le=500), + cursor: Optional[str] = None, + client: VultrClient = Depends(get_client) +): + """List nodes attached to a VPC 2.0""" + return client.vpc.list_vpc2_nodes(vpc_id, per_page=per_page, cursor=cursor) + + +@router.post("/v2/{vpc_id}/nodes/attach") +async def attach_vpc2_nodes(vpc_id: str, req: AttachVPC2Request, client: VultrClient = Depends(get_client)): + """Attach nodes to a VPC 2.0""" + return client.vpc.attach_vpc2_nodes(vpc_id, nodes=req.nodes) + + +@router.post("/v2/{vpc_id}/nodes/detach") +async def detach_vpc2_nodes(vpc_id: str, req: AttachVPC2Request, client: VultrClient = Depends(get_client)): + """Detach nodes from a VPC 2.0""" + return client.vpc.detach_vpc2_nodes(vpc_id, nodes=req.nodes) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..1e6da9a --- /dev/null +++ b/uv.lock @@ -0,0 +1,814 @@ +version = 1 +revision = 3 +requires-python = ">=3.8" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", + "python_full_version < '3.9'", +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4e/3926a1c11f0433791985727965263f788af00db3482d89a7545ca5ecc921/charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84", size = 198599, upload-time = "2025-10-14T04:41:53.213Z" }, + { url = "https://files.pythonhosted.org/packages/ec/7c/b92d1d1dcffc34592e71ea19c882b6709e43d20fa498042dea8b815638d7/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3", size = 143090, upload-time = "2025-10-14T04:41:54.385Z" }, + { url = "https://files.pythonhosted.org/packages/84/ce/61a28d3bb77281eb24107b937a497f3c43089326d27832a63dcedaab0478/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac", size = 139490, upload-time = "2025-10-14T04:41:55.551Z" }, + { url = "https://files.pythonhosted.org/packages/c0/bd/c9e59a91b2061c6f8bb98a150670cb16d4cd7c4ba7d11ad0cdf789155f41/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af", size = 155334, upload-time = "2025-10-14T04:41:56.724Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/f17ae176a80f22ff823456af91ba3bc59df308154ff53aef0d39eb3d3419/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2", size = 152823, upload-time = "2025-10-14T04:41:58.236Z" }, + { url = "https://files.pythonhosted.org/packages/bf/fa/cf5bb2409a385f78750e78c8d2e24780964976acdaaed65dbd6083ae5b40/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d", size = 147618, upload-time = "2025-10-14T04:41:59.409Z" }, + { url = "https://files.pythonhosted.org/packages/9b/63/579784a65bc7de2d4518d40bb8f1870900163e86f17f21fd1384318c459d/charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3", size = 145516, upload-time = "2025-10-14T04:42:00.579Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a9/94ec6266cd394e8f93a4d69cca651d61bf6ac58d2a0422163b30c698f2c7/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63", size = 145266, upload-time = "2025-10-14T04:42:01.684Z" }, + { url = "https://files.pythonhosted.org/packages/09/14/d6626eb97764b58c2779fa7928fa7d1a49adb8ce687c2dbba4db003c1939/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7", size = 139559, upload-time = "2025-10-14T04:42:02.902Z" }, + { url = "https://files.pythonhosted.org/packages/09/01/ddbe6b01313ba191dbb0a43c7563bc770f2448c18127f9ea4b119c44dff0/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4", size = 156653, upload-time = "2025-10-14T04:42:04.005Z" }, + { url = "https://files.pythonhosted.org/packages/95/c8/d05543378bea89296e9af4510b44c704626e191da447235c8fdedfc5b7b2/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf", size = 145644, upload-time = "2025-10-14T04:42:05.211Z" }, + { url = "https://files.pythonhosted.org/packages/72/01/2866c4377998ef8a1f6802f6431e774a4c8ebe75b0a6e569ceec55c9cbfb/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074", size = 153964, upload-time = "2025-10-14T04:42:06.341Z" }, + { url = "https://files.pythonhosted.org/packages/4a/66/66c72468a737b4cbd7851ba2c522fe35c600575fbeac944460b4fd4a06fe/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a", size = 148777, upload-time = "2025-10-14T04:42:07.535Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/d0d56677fdddbffa8ca00ec411f67bb8c947f9876374ddc9d160d4f2c4b3/charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa", size = 98687, upload-time = "2025-10-14T04:42:08.678Z" }, + { url = "https://files.pythonhosted.org/packages/00/64/c3bc303d1b586480b1c8e6e1e2191a6d6dd40255244e5cf16763dcec52e6/charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576", size = 106115, upload-time = "2025-10-14T04:42:09.793Z" }, + { url = "https://files.pythonhosted.org/packages/46/7c/0c4760bccf082737ca7ab84a4c2034fcc06b1f21cf3032ea98bd6feb1725/charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", size = 209609, upload-time = "2025-10-14T04:42:10.922Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a4/69719daef2f3d7f1819de60c9a6be981b8eeead7542d5ec4440f3c80e111/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", size = 149029, upload-time = "2025-10-14T04:42:12.38Z" }, + { url = "https://files.pythonhosted.org/packages/e6/21/8d4e1d6c1e6070d3672908b8e4533a71b5b53e71d16828cc24d0efec564c/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608", size = 144580, upload-time = "2025-10-14T04:42:13.549Z" }, + { url = "https://files.pythonhosted.org/packages/a7/0a/a616d001b3f25647a9068e0b9199f697ce507ec898cacb06a0d5a1617c99/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", size = 162340, upload-time = "2025-10-14T04:42:14.892Z" }, + { url = "https://files.pythonhosted.org/packages/85/93/060b52deb249a5450460e0585c88a904a83aec474ab8e7aba787f45e79f2/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", size = 159619, upload-time = "2025-10-14T04:42:16.676Z" }, + { url = "https://files.pythonhosted.org/packages/dd/21/0274deb1cc0632cd587a9a0ec6b4674d9108e461cb4cd40d457adaeb0564/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", size = 153980, upload-time = "2025-10-14T04:42:17.917Z" }, + { url = "https://files.pythonhosted.org/packages/28/2b/e3d7d982858dccc11b31906976323d790dded2017a0572f093ff982d692f/charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", size = 152174, upload-time = "2025-10-14T04:42:19.018Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ff/4a269f8e35f1e58b2df52c131a1fa019acb7ef3f8697b7d464b07e9b492d/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", size = 151666, upload-time = "2025-10-14T04:42:20.171Z" }, + { url = "https://files.pythonhosted.org/packages/da/c9/ec39870f0b330d58486001dd8e532c6b9a905f5765f58a6f8204926b4a93/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", size = 145550, upload-time = "2025-10-14T04:42:21.324Z" }, + { url = "https://files.pythonhosted.org/packages/75/8f/d186ab99e40e0ed9f82f033d6e49001701c81244d01905dd4a6924191a30/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", size = 163721, upload-time = "2025-10-14T04:42:22.46Z" }, + { url = "https://files.pythonhosted.org/packages/96/b1/6047663b9744df26a7e479ac1e77af7134b1fcf9026243bb48ee2d18810f/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", size = 152127, upload-time = "2025-10-14T04:42:23.712Z" }, + { url = "https://files.pythonhosted.org/packages/59/78/e5a6eac9179f24f704d1be67d08704c3c6ab9f00963963524be27c18ed87/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", size = 161175, upload-time = "2025-10-14T04:42:24.87Z" }, + { url = "https://files.pythonhosted.org/packages/e5/43/0e626e42d54dd2f8dd6fc5e1c5ff00f05fbca17cb699bedead2cae69c62f/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", size = 155375, upload-time = "2025-10-14T04:42:27.246Z" }, + { url = "https://files.pythonhosted.org/packages/e9/91/d9615bf2e06f35e4997616ff31248c3657ed649c5ab9d35ea12fce54e380/charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", size = 99692, upload-time = "2025-10-14T04:42:28.425Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a9/6c040053909d9d1ef4fcab45fddec083aedc9052c10078339b47c8573ea8/charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", size = 107192, upload-time = "2025-10-14T04:42:29.482Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c6/4fa536b2c0cd3edfb7ccf8469fa0f363ea67b7213a842b90909ca33dd851/charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", size = 100220, upload-time = "2025-10-14T04:42:30.632Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/08/7e37f82e4d1aead42a7443ff06a1e406aabf7302c4f00a546e4b320b994c/coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", size = 798791, upload-time = "2024-08-04T19:45:30.9Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/61/eb7ce5ed62bacf21beca4937a90fe32545c91a3c8a42a30c6616d48fc70d/coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16", size = 206690, upload-time = "2024-08-04T19:43:07.695Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/041928e434442bd3afde5584bdc3f932fb4562b1597629f537387cec6f3d/coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36", size = 207127, upload-time = "2024-08-04T19:43:10.15Z" }, + { url = "https://files.pythonhosted.org/packages/c7/c8/6ca52b5147828e45ad0242388477fdb90df2c6cbb9a441701a12b3c71bc8/coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02", size = 235654, upload-time = "2024-08-04T19:43:12.405Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/9ac2b62557f4340270942011d6efeab9833648380109e897d48ab7c1035d/coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc", size = 233598, upload-time = "2024-08-04T19:43:14.078Z" }, + { url = "https://files.pythonhosted.org/packages/53/23/9e2c114d0178abc42b6d8d5281f651a8e6519abfa0ef460a00a91f80879d/coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23", size = 234732, upload-time = "2024-08-04T19:43:16.632Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7e/a0230756fb133343a52716e8b855045f13342b70e48e8ad41d8a0d60ab98/coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34", size = 233816, upload-time = "2024-08-04T19:43:19.049Z" }, + { url = "https://files.pythonhosted.org/packages/28/7c/3753c8b40d232b1e5eeaed798c875537cf3cb183fb5041017c1fdb7ec14e/coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c", size = 232325, upload-time = "2024-08-04T19:43:21.246Z" }, + { url = "https://files.pythonhosted.org/packages/57/e3/818a2b2af5b7573b4b82cf3e9f137ab158c90ea750a8f053716a32f20f06/coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959", size = 233418, upload-time = "2024-08-04T19:43:22.945Z" }, + { url = "https://files.pythonhosted.org/packages/c8/fb/4532b0b0cefb3f06d201648715e03b0feb822907edab3935112b61b885e2/coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232", size = 209343, upload-time = "2024-08-04T19:43:25.121Z" }, + { url = "https://files.pythonhosted.org/packages/5a/25/af337cc7421eca1c187cc9c315f0a755d48e755d2853715bfe8c418a45fa/coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0", size = 210136, upload-time = "2024-08-04T19:43:26.851Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5f/67af7d60d7e8ce61a4e2ddcd1bd5fb787180c8d0ae0fbd073f903b3dd95d/coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93", size = 206796, upload-time = "2024-08-04T19:43:29.115Z" }, + { url = "https://files.pythonhosted.org/packages/e1/0e/e52332389e057daa2e03be1fbfef25bb4d626b37d12ed42ae6281d0a274c/coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3", size = 207244, upload-time = "2024-08-04T19:43:31.285Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cd/766b45fb6e090f20f8927d9c7cb34237d41c73a939358bc881883fd3a40d/coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", size = 239279, upload-time = "2024-08-04T19:43:33.581Z" }, + { url = "https://files.pythonhosted.org/packages/70/6c/a9ccd6fe50ddaf13442a1e2dd519ca805cbe0f1fcd377fba6d8339b98ccb/coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", size = 236859, upload-time = "2024-08-04T19:43:35.301Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/8351b465febb4dbc1ca9929505202db909c5a635c6fdf33e089bbc3d7d85/coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", size = 238549, upload-time = "2024-08-04T19:43:37.578Z" }, + { url = "https://files.pythonhosted.org/packages/68/3c/289b81fa18ad72138e6d78c4c11a82b5378a312c0e467e2f6b495c260907/coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", size = 237477, upload-time = "2024-08-04T19:43:39.92Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1c/aa1efa6459d822bd72c4abc0b9418cf268de3f60eeccd65dc4988553bd8d/coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", size = 236134, upload-time = "2024-08-04T19:43:41.453Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/521c698f2d2796565fe9c789c2ee1ccdae610b3aa20b9b2ef980cc253640/coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", size = 236910, upload-time = "2024-08-04T19:43:43.037Z" }, + { url = "https://files.pythonhosted.org/packages/7d/30/033e663399ff17dca90d793ee8a2ea2890e7fdf085da58d82468b4220bf7/coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c", size = 209348, upload-time = "2024-08-04T19:43:44.787Z" }, + { url = "https://files.pythonhosted.org/packages/20/05/0d1ccbb52727ccdadaa3ff37e4d2dc1cd4d47f0c3df9eb58d9ec8508ca88/coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6", size = 210230, upload-time = "2024-08-04T19:43:46.707Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d4/300fc921dff243cd518c7db3a4c614b7e4b2431b0d1145c1e274fd99bd70/coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", size = 206983, upload-time = "2024-08-04T19:43:49.082Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ab/6bf00de5327ecb8db205f9ae596885417a31535eeda6e7b99463108782e1/coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", size = 207221, upload-time = "2024-08-04T19:43:52.15Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/2ead05e735022d1a7f3a0a683ac7f737de14850395a826192f0288703472/coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", size = 240342, upload-time = "2024-08-04T19:43:53.746Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ef/94043e478201ffa85b8ae2d2c79b4081e5a1b73438aafafccf3e9bafb6b5/coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", size = 237371, upload-time = "2024-08-04T19:43:55.993Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0f/c890339dd605f3ebc269543247bdd43b703cce6825b5ed42ff5f2d6122c7/coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", size = 239455, upload-time = "2024-08-04T19:43:57.618Z" }, + { url = "https://files.pythonhosted.org/packages/d1/04/7fd7b39ec7372a04efb0f70c70e35857a99b6a9188b5205efb4c77d6a57a/coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", size = 238924, upload-time = "2024-08-04T19:44:00.012Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bf/73ce346a9d32a09cf369f14d2a06651329c984e106f5992c89579d25b27e/coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", size = 237252, upload-time = "2024-08-04T19:44:01.713Z" }, + { url = "https://files.pythonhosted.org/packages/86/74/1dc7a20969725e917b1e07fe71a955eb34bc606b938316bcc799f228374b/coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", size = 238897, upload-time = "2024-08-04T19:44:03.898Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e9/d9cc3deceb361c491b81005c668578b0dfa51eed02cd081620e9a62f24ec/coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", size = 209606, upload-time = "2024-08-04T19:44:05.532Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/5a2e41922ea6740f77d555c4d47544acd7dc3f251fe14199c09c0f5958d3/coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", size = 210373, upload-time = "2024-08-04T19:44:07.079Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f9/9aa4dfb751cb01c949c990d136a0f92027fbcc5781c6e921df1cb1563f20/coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", size = 207007, upload-time = "2024-08-04T19:44:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/b9/67/e1413d5a8591622a46dd04ff80873b04c849268831ed5c304c16433e7e30/coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", size = 207269, upload-time = "2024-08-04T19:44:11.045Z" }, + { url = "https://files.pythonhosted.org/packages/14/5b/9dec847b305e44a5634d0fb8498d135ab1d88330482b74065fcec0622224/coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", size = 239886, upload-time = "2024-08-04T19:44:12.83Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b7/35760a67c168e29f454928f51f970342d23cf75a2bb0323e0f07334c85f3/coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", size = 237037, upload-time = "2024-08-04T19:44:15.393Z" }, + { url = "https://files.pythonhosted.org/packages/f7/95/d2fd31f1d638df806cae59d7daea5abf2b15b5234016a5ebb502c2f3f7ee/coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", size = 239038, upload-time = "2024-08-04T19:44:17.466Z" }, + { url = "https://files.pythonhosted.org/packages/6e/bd/110689ff5752b67924efd5e2aedf5190cbbe245fc81b8dec1abaffba619d/coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", size = 238690, upload-time = "2024-08-04T19:44:19.336Z" }, + { url = "https://files.pythonhosted.org/packages/d3/a8/08d7b38e6ff8df52331c83130d0ab92d9c9a8b5462f9e99c9f051a4ae206/coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", size = 236765, upload-time = "2024-08-04T19:44:20.994Z" }, + { url = "https://files.pythonhosted.org/packages/d6/6a/9cf96839d3147d55ae713eb2d877f4d777e7dc5ba2bce227167d0118dfe8/coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", size = 238611, upload-time = "2024-08-04T19:44:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/74/e4/7ff20d6a0b59eeaab40b3140a71e38cf52547ba21dbcf1d79c5a32bba61b/coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", size = 209671, upload-time = "2024-08-04T19:44:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/35/59/1812f08a85b57c9fdb6d0b383d779e47b6f643bc278ed682859512517e83/coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", size = 210368, upload-time = "2024-08-04T19:44:26.276Z" }, + { url = "https://files.pythonhosted.org/packages/9c/15/08913be1c59d7562a3e39fce20661a98c0a3f59d5754312899acc6cb8a2d/coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", size = 207758, upload-time = "2024-08-04T19:44:29.028Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ae/b5d58dff26cade02ada6ca612a76447acd69dccdbb3a478e9e088eb3d4b9/coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", size = 208035, upload-time = "2024-08-04T19:44:30.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/62095e355ec0613b08dfb19206ce3033a0eedb6f4a67af5ed267a8800642/coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", size = 250839, upload-time = "2024-08-04T19:44:32.412Z" }, + { url = "https://files.pythonhosted.org/packages/7c/1e/c2967cb7991b112ba3766df0d9c21de46b476d103e32bb401b1b2adf3380/coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", size = 246569, upload-time = "2024-08-04T19:44:34.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/61/a7a6a55dd266007ed3b1df7a3386a0d760d014542d72f7c2c6938483b7bd/coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", size = 248927, upload-time = "2024-08-04T19:44:36.313Z" }, + { url = "https://files.pythonhosted.org/packages/c8/fa/13a6f56d72b429f56ef612eb3bc5ce1b75b7ee12864b3bd12526ab794847/coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", size = 248401, upload-time = "2024-08-04T19:44:38.155Z" }, + { url = "https://files.pythonhosted.org/packages/75/06/0429c652aa0fb761fc60e8c6b291338c9173c6aa0f4e40e1902345b42830/coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", size = 246301, upload-time = "2024-08-04T19:44:39.883Z" }, + { url = "https://files.pythonhosted.org/packages/52/76/1766bb8b803a88f93c3a2d07e30ffa359467810e5cbc68e375ebe6906efb/coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", size = 247598, upload-time = "2024-08-04T19:44:41.59Z" }, + { url = "https://files.pythonhosted.org/packages/66/8b/f54f8db2ae17188be9566e8166ac6df105c1c611e25da755738025708d54/coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", size = 210307, upload-time = "2024-08-04T19:44:43.301Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b0/e0dca6da9170aefc07515cce067b97178cefafb512d00a87a1c717d2efd5/coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", size = 211453, upload-time = "2024-08-04T19:44:45.677Z" }, + { url = "https://files.pythonhosted.org/packages/81/d0/d9e3d554e38beea5a2e22178ddb16587dbcbe9a1ef3211f55733924bf7fa/coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0", size = 206674, upload-time = "2024-08-04T19:44:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/ea/cab2dc248d9f45b2b7f9f1f596a4d75a435cb364437c61b51d2eb33ceb0e/coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a", size = 207101, upload-time = "2024-08-04T19:44:49.32Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6f/f82f9a500c7c5722368978a5390c418d2a4d083ef955309a8748ecaa8920/coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b", size = 236554, upload-time = "2024-08-04T19:44:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/a6/94/d3055aa33d4e7e733d8fa309d9adf147b4b06a82c1346366fc15a2b1d5fa/coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3", size = 234440, upload-time = "2024-08-04T19:44:53.464Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/885bcd787d9dd674de4a7d8ec83faf729534c63d05d51d45d4fa168f7102/coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de", size = 235889, upload-time = "2024-08-04T19:44:55.165Z" }, + { url = "https://files.pythonhosted.org/packages/f4/63/df50120a7744492710854860783d6819ff23e482dee15462c9a833cc428a/coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6", size = 235142, upload-time = "2024-08-04T19:44:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5d/9d0acfcded2b3e9ce1c7923ca52ccc00c78a74e112fc2aee661125b7843b/coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569", size = 233805, upload-time = "2024-08-04T19:44:59.033Z" }, + { url = "https://files.pythonhosted.org/packages/c4/56/50abf070cb3cd9b1dd32f2c88f083aab561ecbffbcd783275cb51c17f11d/coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989", size = 234655, upload-time = "2024-08-04T19:45:01.398Z" }, + { url = "https://files.pythonhosted.org/packages/25/ee/b4c246048b8485f85a2426ef4abab88e48c6e80c74e964bea5cd4cd4b115/coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7", size = 209296, upload-time = "2024-08-04T19:45:03.819Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1c/96cf86b70b69ea2b12924cdf7cabb8ad10e6130eab8d767a1099fbd2a44f/coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8", size = 210137, upload-time = "2024-08-04T19:45:06.25Z" }, + { url = "https://files.pythonhosted.org/packages/19/d3/d54c5aa83268779d54c86deb39c1c4566e5d45c155369ca152765f8db413/coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255", size = 206688, upload-time = "2024-08-04T19:45:08.358Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fe/137d5dca72e4a258b1bc17bb04f2e0196898fe495843402ce826a7419fe3/coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8", size = 207120, upload-time = "2024-08-04T19:45:11.526Z" }, + { url = "https://files.pythonhosted.org/packages/78/5b/a0a796983f3201ff5485323b225d7c8b74ce30c11f456017e23d8e8d1945/coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2", size = 235249, upload-time = "2024-08-04T19:45:13.202Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/76089d6a5ef9d68f018f65411fcdaaeb0141b504587b901d74e8587606ad/coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a", size = 233237, upload-time = "2024-08-04T19:45:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/eef79b779a540326fee9520e5542a8b428cc3bfa8b7c8f1022c1ee4fc66c/coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc", size = 234311, upload-time = "2024-08-04T19:45:16.924Z" }, + { url = "https://files.pythonhosted.org/packages/75/e1/656d65fb126c29a494ef964005702b012f3498db1a30dd562958e85a4049/coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004", size = 233453, upload-time = "2024-08-04T19:45:18.672Z" }, + { url = "https://files.pythonhosted.org/packages/68/6a/45f108f137941a4a1238c85f28fd9d048cc46b5466d6b8dda3aba1bb9d4f/coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb", size = 231958, upload-time = "2024-08-04T19:45:20.63Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e7/47b809099168b8b8c72ae311efc3e88c8d8a1162b3ba4b8da3cfcdb85743/coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36", size = 232938, upload-time = "2024-08-04T19:45:23.062Z" }, + { url = "https://files.pythonhosted.org/packages/52/80/052222ba7058071f905435bad0ba392cc12006380731c37afaf3fe749b88/coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c", size = 209352, upload-time = "2024-08-04T19:45:25.042Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d8/1b92e0b3adcf384e98770a00ca095da1b5f7b483e6563ae4eb5e935d24a1/coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca", size = 210153, upload-time = "2024-08-04T19:45:27.079Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2b/0354ed096bca64dc8e32a7cbcae28b34cb5ad0b1fe2125d6d99583313ac0/coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df", size = 198926, upload-time = "2024-08-04T19:45:28.875Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version < '3.9'" }, +] + +[[package]] +name = "coverage" +version = "7.10.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, + { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, + { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, + { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, + { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, + { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, + { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, + { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, + { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, + { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, + { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, + { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, + { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, + { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, + { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version == '3.9.*'" }, +] + +[[package]] +name = "coverage" +version = "7.13.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/9a/3742e58fd04b233df95c012ee9f3dfe04708a5e1d32613bd2d47d4e1be0d/coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147", size = 218633, upload-time = "2025-12-28T15:40:10.165Z" }, + { url = "https://files.pythonhosted.org/packages/7e/45/7e6bdc94d89cd7c8017ce735cf50478ddfe765d4fbf0c24d71d30ea33d7a/coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d", size = 219147, upload-time = "2025-12-28T15:40:12.069Z" }, + { url = "https://files.pythonhosted.org/packages/f7/38/0d6a258625fd7f10773fe94097dc16937a5f0e3e0cdf3adef67d3ac6baef/coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0", size = 245894, upload-time = "2025-12-28T15:40:13.556Z" }, + { url = "https://files.pythonhosted.org/packages/27/58/409d15ea487986994cbd4d06376e9860e9b157cfbfd402b1236770ab8dd2/coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90", size = 247721, upload-time = "2025-12-28T15:40:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/da/bf/6e8056a83fd7a96c93341f1ffe10df636dd89f26d5e7b9ca511ce3bcf0df/coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d", size = 249585, upload-time = "2025-12-28T15:40:17.226Z" }, + { url = "https://files.pythonhosted.org/packages/f4/15/e1daff723f9f5959acb63cbe35b11203a9df77ee4b95b45fffd38b318390/coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b", size = 246597, upload-time = "2025-12-28T15:40:19.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/a6/1efd31c5433743a6ddbc9d37ac30c196bb07c7eab3d74fbb99b924c93174/coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6", size = 247626, upload-time = "2025-12-28T15:40:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9f/1609267dd3e749f57fdd66ca6752567d1c13b58a20a809dc409b263d0b5f/coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e", size = 245629, upload-time = "2025-12-28T15:40:22.397Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f6/6815a220d5ec2466383d7cc36131b9fa6ecbe95c50ec52a631ba733f306a/coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae", size = 245901, upload-time = "2025-12-28T15:40:23.836Z" }, + { url = "https://files.pythonhosted.org/packages/ac/58/40576554cd12e0872faf6d2c0eb3bc85f71d78427946ddd19ad65201e2c0/coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29", size = 246505, upload-time = "2025-12-28T15:40:25.421Z" }, + { url = "https://files.pythonhosted.org/packages/3b/77/9233a90253fba576b0eee81707b5781d0e21d97478e5377b226c5b096c0f/coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f", size = 221257, upload-time = "2025-12-28T15:40:27.217Z" }, + { url = "https://files.pythonhosted.org/packages/e0/43/e842ff30c1a0a623ec80db89befb84a3a7aad7bfe44a6ea77d5a3e61fedd/coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1", size = 222191, upload-time = "2025-12-28T15:40:28.916Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" }, + { url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" }, + { url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" }, + { url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" }, + { url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" }, + { url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" }, + { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" }, + { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" }, + { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, + { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, + { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, + { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, + { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, + { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, + { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.9' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.9'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "packaging", marker = "python_full_version < '3.9'" }, + { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "tomli", marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version == '3.9.*' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.9.*'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "packaging", marker = "python_full_version == '3.9.*'" }, + { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pygments", marker = "python_full_version == '3.9.*'" }, + { name = "tomli", marker = "python_full_version == '3.9.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "coverage", version = "7.6.1", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.9'" }, + { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042, upload-time = "2024-03-24T20:16:34.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990, upload-time = "2024-03-24T20:16:32.444Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version == '3.9.*'" }, + { name = "coverage", version = "7.13.1", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "requests" +version = "2.32.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version < '3.9'" }, + { name = "charset-normalizer", marker = "python_full_version < '3.9'" }, + { name = "idna", marker = "python_full_version < '3.9'" }, + { name = "urllib3", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version >= '3.9'" }, + { name = "charset-normalizer", marker = "python_full_version >= '3.9'" }, + { name = "idna", marker = "python_full_version >= '3.9'" }, + { name = "urllib3", version = "2.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677, upload-time = "2024-09-12T10:52:18.401Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338, upload-time = "2024-09-12T10:52:16.589Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "vultr-api" +version = "1.0.0" +source = { editable = "." } +dependencies = [ + { name = "requests", version = "2.32.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-cov", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pytest-cov", version = "7.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, + { name = "requests", specifier = ">=2.25.0" }, +] +provides-extras = ["dev"] diff --git a/vultr-api-server.container b/vultr-api-server.container new file mode 100644 index 0000000..e8fc438 --- /dev/null +++ b/vultr-api-server.container @@ -0,0 +1,16 @@ +[Unit] +Description=Vultr API Server +After=network-online.target + +[Container] +Image=localhost/vultr-api-server:latest +ContainerName=vultr-api-server +PublishPort=8001:8000 +AutoUpdate=local + +[Service] +Restart=always +TimeoutStartSec=300 + +[Install] +WantedBy=default.target diff --git a/vultr_api/__init__.py b/vultr_api/__init__.py new file mode 100644 index 0000000..0c4e9e3 --- /dev/null +++ b/vultr_api/__init__.py @@ -0,0 +1,8 @@ +""" +Vultr API v2 Python Wrapper +""" + +from .client import VultrClient + +__version__ = "1.0.0" +__all__ = ["VultrClient"] diff --git a/vultr_api/client.py b/vultr_api/client.py new file mode 100644 index 0000000..1187e43 --- /dev/null +++ b/vultr_api/client.py @@ -0,0 +1,214 @@ +""" +Vultr API v2 Client +""" + +import requests +from typing import Optional, Dict, Any, List +from urllib.parse import urljoin + +from .resources.account import AccountResource +from .resources.instances import InstancesResource +from .resources.dns import DNSResource +from .resources.firewall import FirewallResource +from .resources.ssh_keys import SSHKeysResource +from .resources.startup_scripts import StartupScriptsResource +from .resources.snapshots import SnapshotsResource +from .resources.block_storage import BlockStorageResource +from .resources.reserved_ips import ReservedIPsResource +from .resources.vpc import VPCResource +from .resources.load_balancers import LoadBalancersResource +from .resources.bare_metal import BareMetalResource +from .resources.plans import PlansResource +from .resources.regions import RegionsResource +from .resources.os import OSResource +from .resources.backups import BackupsResource + + +class VultrAPIError(Exception): + """Vultr API Error""" + def __init__(self, message: str, status_code: int = None, response: dict = None): + self.message = message + self.status_code = status_code + self.response = response + super().__init__(self.message) + + +class VultrClient: + """ + Vultr API v2 Client + + Usage: + client = VultrClient(api_key="your-api-key") + + # Get account info + account = client.account.get() + + # List instances + instances = client.instances.list() + + # Create instance + instance = client.instances.create( + region="ewr", + plan="vc2-1c-1gb", + os_id=215 + ) + """ + + BASE_URL = "https://api.vultr.com/v2/" + + def __init__(self, api_key: str, timeout: int = 30): + """ + Initialize Vultr API client + + Args: + api_key: Vultr API key (get from https://my.vultr.com/settings/#settingsapi) + timeout: Request timeout in seconds + """ + self.api_key = api_key + self.timeout = timeout + self.session = requests.Session() + self.session.headers.update({ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }) + + # Initialize resources + self.account = AccountResource(self) + self.instances = InstancesResource(self) + self.dns = DNSResource(self) + self.firewall = FirewallResource(self) + self.ssh_keys = SSHKeysResource(self) + self.startup_scripts = StartupScriptsResource(self) + self.snapshots = SnapshotsResource(self) + self.block_storage = BlockStorageResource(self) + self.reserved_ips = ReservedIPsResource(self) + self.vpc = VPCResource(self) + self.load_balancers = LoadBalancersResource(self) + self.bare_metal = BareMetalResource(self) + self.plans = PlansResource(self) + self.regions = RegionsResource(self) + self.os = OSResource(self) + self.backups = BackupsResource(self) + + def _request( + self, + method: str, + endpoint: str, + params: Optional[Dict] = None, + data: Optional[Dict] = None, + ) -> Optional[Dict[str, Any]]: + """ + Make API request + + Args: + method: HTTP method (GET, POST, PATCH, DELETE) + endpoint: API endpoint (e.g., "instances") + params: Query parameters + data: Request body data + + Returns: + Response JSON or None for 204 responses + """ + url = urljoin(self.BASE_URL, endpoint) + + try: + response = self.session.request( + method=method, + url=url, + params=params, + json=data, + timeout=self.timeout, + ) + + if response.status_code == 204: + return None + + if response.status_code >= 400: + error_data = None + try: + error_data = response.json() + except: + pass + + error_msg = f"API Error: {response.status_code}" + if error_data and "error" in error_data: + error_msg = error_data["error"] + + raise VultrAPIError( + message=error_msg, + status_code=response.status_code, + response=error_data, + ) + + if response.text: + return response.json() + return None + + except requests.RequestException as e: + raise VultrAPIError(f"Request failed: {str(e)}") + + def get(self, endpoint: str, params: Optional[Dict] = None) -> Optional[Dict]: + """GET request""" + return self._request("GET", endpoint, params=params) + + def post(self, endpoint: str, data: Optional[Dict] = None) -> Optional[Dict]: + """POST request""" + return self._request("POST", endpoint, data=data) + + def patch(self, endpoint: str, data: Optional[Dict] = None) -> Optional[Dict]: + """PATCH request""" + return self._request("PATCH", endpoint, data=data) + + def put(self, endpoint: str, data: Optional[Dict] = None) -> Optional[Dict]: + """PUT request""" + return self._request("PUT", endpoint, data=data) + + def delete(self, endpoint: str) -> None: + """DELETE request""" + self._request("DELETE", endpoint) + + def paginate( + self, + endpoint: str, + key: str, + params: Optional[Dict] = None, + per_page: int = 100, + ) -> List[Dict]: + """ + Paginate through all results + + Args: + endpoint: API endpoint + key: Response key containing items (e.g., "instances") + params: Additional query parameters + per_page: Items per page (max 500) + + Returns: + List of all items + """ + params = params or {} + params["per_page"] = per_page + + all_items = [] + cursor = None + + while True: + if cursor: + params["cursor"] = cursor + + response = self.get(endpoint, params=params) + + if not response: + break + + items = response.get(key, []) + all_items.extend(items) + + meta = response.get("meta", {}) + links = meta.get("links", {}) + cursor = links.get("next", "") + + if not cursor: + break + + return all_items diff --git a/vultr_api/resources/__init__.py b/vultr_api/resources/__init__.py new file mode 100644 index 0000000..27d7ee2 --- /dev/null +++ b/vultr_api/resources/__init__.py @@ -0,0 +1,41 @@ +""" +Vultr API Resources +""" + +from .base import BaseResource +from .account import AccountResource +from .instances import InstancesResource +from .dns import DNSResource +from .firewall import FirewallResource +from .ssh_keys import SSHKeysResource +from .startup_scripts import StartupScriptsResource +from .snapshots import SnapshotsResource +from .block_storage import BlockStorageResource +from .reserved_ips import ReservedIPsResource +from .vpc import VPCResource +from .load_balancers import LoadBalancersResource +from .bare_metal import BareMetalResource +from .plans import PlansResource +from .regions import RegionsResource +from .os import OSResource +from .backups import BackupsResource + +__all__ = [ + "BaseResource", + "AccountResource", + "InstancesResource", + "DNSResource", + "FirewallResource", + "SSHKeysResource", + "StartupScriptsResource", + "SnapshotsResource", + "BlockStorageResource", + "ReservedIPsResource", + "VPCResource", + "LoadBalancersResource", + "BareMetalResource", + "PlansResource", + "RegionsResource", + "OSResource", + "BackupsResource", +] diff --git a/vultr_api/resources/account.py b/vultr_api/resources/account.py new file mode 100644 index 0000000..f84feea --- /dev/null +++ b/vultr_api/resources/account.py @@ -0,0 +1,115 @@ +""" +Account Resource + +Includes account info and ACL (Access Control List) management +""" + +from typing import Dict, List, Optional +from .base import BaseResource + + +class AccountResource(BaseResource): + """ + Account and ACL management + + Usage: + # Get account info + account = client.account.get() + + # Get bandwidth + bandwidth = client.account.get_bandwidth() + + # ACL (IP access control) + acl = client.account.get_acl() + client.account.set_acl(acls=["192.168.1.1/32", "10.0.0.0/8"]) + """ + + def get(self) -> Dict: + """ + Get account information + + Returns: + Account info including name, email, balance, etc. + """ + response = self.client.get("account") + return response.get("account", {}) + + def get_bandwidth(self) -> Dict: + """ + Get account bandwidth usage + + Returns: + Bandwidth usage info + """ + return self.client.get("account/bandwidth") + + # ACL (Access Control List) methods + def get_acl(self) -> Dict: + """ + Get API access control list + + Returns: + ACL info including enabled status and list of allowed IPs/CIDRs + """ + return self.client.get("account/acl") + + def set_acl(self, acls: List[str]) -> Dict: + """ + Update API access control list + + Args: + acls: List of IP addresses or CIDR ranges to allow. + Pass empty list to disable ACL. + + Returns: + Updated ACL info + + Example: + # Allow specific IPs + client.account.set_acl(["192.168.1.100/32", "10.0.0.0/8"]) + + # Disable ACL (allow all) + client.account.set_acl([]) + """ + return self.client.post("account/acl", {"acls": acls}) + + def add_acl(self, ip: str) -> Dict: + """ + Add an IP/CIDR to the ACL + + Args: + ip: IP address or CIDR to add (e.g., "192.168.1.1/32") + + Returns: + Updated ACL info + """ + current = self.get_acl() + acls = current.get("acls", []) + if ip not in acls: + acls.append(ip) + return self.set_acl(acls) + + def remove_acl(self, ip: str) -> Dict: + """ + Remove an IP/CIDR from the ACL + + Args: + ip: IP address or CIDR to remove + + Returns: + Updated ACL info + """ + current = self.get_acl() + acls = current.get("acls", []) + if ip in acls: + acls.remove(ip) + return self.set_acl(acls) + + def clear_acl(self) -> Dict: + """ + Clear all ACL entries (disable ACL) + + Returns: + Updated ACL info + """ + return self.set_acl([]) diff --git a/vultr_api/resources/backups.py b/vultr_api/resources/backups.py new file mode 100644 index 0000000..445388c --- /dev/null +++ b/vultr_api/resources/backups.py @@ -0,0 +1,65 @@ +""" +Backups Resource + +Backup management +""" + +from typing import Dict, List +from .base import BaseResource + + +class BackupsResource(BaseResource): + """ + Backup management + + Usage: + # List backups + backups = client.backups.list() + + # Get backup details + backup = client.backups.get("backup-id") + """ + + def list( + self, + instance_id: str = None, + per_page: int = 100, + cursor: str = None + ) -> Dict: + """ + List backups + + Args: + instance_id: Filter by instance ID + per_page: Items per page + cursor: Pagination cursor + + Returns: + Dict with 'backups' list and 'meta' pagination + """ + params = {"per_page": per_page} + if instance_id: + params["instance_id"] = instance_id + if cursor: + params["cursor"] = cursor + return self.client.get("backups", params=params) + + def list_all(self, instance_id: str = None) -> List[Dict]: + """List all backups (auto-paginate)""" + params = {} + if instance_id: + params["instance_id"] = instance_id + return self.client.paginate("backups", "backups", params=params) + + def get(self, backup_id: str) -> Dict: + """ + Get backup details + + Args: + backup_id: Backup ID + + Returns: + Backup details + """ + response = self.client.get(f"backups/{backup_id}") + return response.get("backup", {}) diff --git a/vultr_api/resources/bare_metal.py b/vultr_api/resources/bare_metal.py new file mode 100644 index 0000000..8d3dfd5 --- /dev/null +++ b/vultr_api/resources/bare_metal.py @@ -0,0 +1,291 @@ +""" +Bare Metal Resource + +Bare metal server management +""" + +from typing import Dict, List +from .base import BaseResource + + +class BareMetalResource(BaseResource): + """ + Bare metal server management + + Usage: + # List bare metal servers + servers = client.bare_metal.list() + + # Create server + server = client.bare_metal.create( + region="ewr", + plan="vbm-4c-32gb", + os_id=215 + ) + """ + + def list( + self, + per_page: int = 100, + cursor: str = None, + tag: str = None, + label: str = None, + main_ip: str = None, + region: str = None, + ) -> Dict: + """ + List bare metal servers + + Returns: + Dict with 'bare_metals' list and 'meta' pagination + """ + params = {"per_page": per_page} + if cursor: + params["cursor"] = cursor + if tag: + params["tag"] = tag + if label: + params["label"] = label + if main_ip: + params["main_ip"] = main_ip + if region: + params["region"] = region + + return self.client.get("bare-metals", params=params) + + def list_all(self, **kwargs) -> List[Dict]: + """List all bare metal servers (auto-paginate)""" + return self.client.paginate("bare-metals", "bare_metals", params=kwargs) + + def get(self, baremetal_id: str) -> Dict: + """ + Get bare metal server details + + Args: + baremetal_id: Bare metal ID + + Returns: + Server details + """ + response = self.client.get(f"bare-metals/{baremetal_id}") + return response.get("bare_metal", {}) + + def create( + self, + region: str, + plan: str, + os_id: int = None, + script_id: str = None, + snapshot_id: str = None, + enable_ipv6: bool = False, + label: str = None, + sshkey_id: List[str] = None, + app_id: int = None, + image_id: str = None, + user_data: str = None, + activation_email: bool = False, + hostname: str = None, + tag: str = None, + tags: List[str] = None, + reserved_ipv4: str = None, + persistent_pxe: bool = False, + attach_vpc2: List[str] = None, + ) -> Dict: + """ + Create a bare metal server + + Args: + region: Region ID + plan: Plan ID + os_id: Operating System ID + script_id: Startup script ID + snapshot_id: Snapshot ID + enable_ipv6: Enable IPv6 + label: Server label + sshkey_id: SSH key IDs + app_id: Application ID + image_id: Custom image ID + user_data: Cloud-init user data + activation_email: Send activation email + hostname: Server hostname + tag: Deprecated, use tags + tags: Tags + reserved_ipv4: Reserved IPv4 to assign + persistent_pxe: Enable persistent PXE + attach_vpc2: VPC 2.0 IDs to attach + + Returns: + Created server details + """ + data = {"region": region, "plan": plan} + + if os_id is not None: + data["os_id"] = os_id + if script_id: + data["script_id"] = script_id + if snapshot_id: + data["snapshot_id"] = snapshot_id + if enable_ipv6: + data["enable_ipv6"] = enable_ipv6 + if label: + data["label"] = label + if sshkey_id: + data["sshkey_id"] = sshkey_id + if app_id is not None: + data["app_id"] = app_id + if image_id: + data["image_id"] = image_id + if user_data: + data["user_data"] = user_data + if activation_email: + data["activation_email"] = activation_email + if hostname: + data["hostname"] = hostname + if tag: + data["tag"] = tag + if tags: + data["tags"] = tags + if reserved_ipv4: + data["reserved_ipv4"] = reserved_ipv4 + if persistent_pxe: + data["persistent_pxe"] = persistent_pxe + if attach_vpc2: + data["attach_vpc2"] = attach_vpc2 + + response = self.client.post("bare-metals", data) + return response.get("bare_metal", {}) + + def update( + self, + baremetal_id: str, + user_data: str = None, + label: str = None, + tag: str = None, + tags: List[str] = None, + os_id: int = None, + app_id: int = None, + image_id: str = None, + enable_ipv6: bool = None, + ) -> Dict: + """ + Update a bare metal server + + Args: + baremetal_id: Bare metal ID + (other args same as create) + + Returns: + Updated server details + """ + data = {} + + if user_data is not None: + data["user_data"] = user_data + if label is not None: + data["label"] = label + if tag is not None: + data["tag"] = tag + if tags is not None: + data["tags"] = tags + if os_id is not None: + data["os_id"] = os_id + if app_id is not None: + data["app_id"] = app_id + if image_id is not None: + data["image_id"] = image_id + if enable_ipv6 is not None: + data["enable_ipv6"] = enable_ipv6 + + response = self.client.patch(f"bare-metals/{baremetal_id}", data) + return response.get("bare_metal", {}) + + def delete(self, baremetal_id: str) -> None: + """ + Delete a bare metal server + + Args: + baremetal_id: Bare metal ID to delete + """ + self.client.delete(f"bare-metals/{baremetal_id}") + + def start(self, baremetal_id: str) -> None: + """Start a bare metal server""" + self.client.post(f"bare-metals/{baremetal_id}/start") + + def halt(self, baremetal_id: str) -> None: + """Halt a bare metal server""" + self.client.post(f"bare-metals/{baremetal_id}/halt") + + def reboot(self, baremetal_id: str) -> None: + """Reboot a bare metal server""" + self.client.post(f"bare-metals/{baremetal_id}/reboot") + + def reinstall(self, baremetal_id: str, hostname: str = None) -> Dict: + """ + Reinstall a bare metal server + + Args: + baremetal_id: Bare metal ID + hostname: New hostname (optional) + + Returns: + Server details + """ + data = {} + if hostname: + data["hostname"] = hostname + + response = self.client.post(f"bare-metals/{baremetal_id}/reinstall", data) + return response.get("bare_metal", {}) + + def get_bandwidth(self, baremetal_id: str) -> Dict: + """Get bandwidth usage""" + return self.client.get(f"bare-metals/{baremetal_id}/bandwidth") + + def get_user_data(self, baremetal_id: str) -> Dict: + """Get user data""" + return self.client.get(f"bare-metals/{baremetal_id}/user-data") + + def get_upgrades(self, baremetal_id: str, upgrade_type: str = None) -> Dict: + """Get available upgrades""" + params = {} + if upgrade_type: + params["type"] = upgrade_type + return self.client.get(f"bare-metals/{baremetal_id}/upgrades", params=params) + + def get_vnc(self, baremetal_id: str) -> Dict: + """Get VNC URL""" + return self.client.get(f"bare-metals/{baremetal_id}/vnc") + + # IPv4 management + def list_ipv4(self, baremetal_id: str) -> List[Dict]: + """List IPv4 addresses""" + response = self.client.get(f"bare-metals/{baremetal_id}/ipv4") + return response.get("ipv4s", []) + + def list_ipv6(self, baremetal_id: str) -> List[Dict]: + """List IPv6 addresses""" + response = self.client.get(f"bare-metals/{baremetal_id}/ipv6") + return response.get("ipv6s", []) + + # VPC 2.0 + def list_vpc2(self, baremetal_id: str) -> List[Dict]: + """List attached VPC 2.0 networks""" + response = self.client.get(f"bare-metals/{baremetal_id}/vpc2") + return response.get("vpcs", []) + + def attach_vpc2( + self, + baremetal_id: str, + vpc_id: str, + ip_address: str = None + ) -> None: + """Attach VPC 2.0 to bare metal""" + data = {"vpc_id": vpc_id} + if ip_address: + data["ip_address"] = ip_address + self.client.post(f"bare-metals/{baremetal_id}/vpc2/attach", data) + + def detach_vpc2(self, baremetal_id: str, vpc_id: str) -> None: + """Detach VPC 2.0 from bare metal""" + self.client.post(f"bare-metals/{baremetal_id}/vpc2/detach", {"vpc_id": vpc_id}) diff --git a/vultr_api/resources/base.py b/vultr_api/resources/base.py new file mode 100644 index 0000000..7c6e3b9 --- /dev/null +++ b/vultr_api/resources/base.py @@ -0,0 +1,15 @@ +""" +Base Resource class +""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..client import VultrClient + + +class BaseResource: + """Base class for API resources""" + + def __init__(self, client: "VultrClient"): + self.client = client diff --git a/vultr_api/resources/block_storage.py b/vultr_api/resources/block_storage.py new file mode 100644 index 0000000..4a999f7 --- /dev/null +++ b/vultr_api/resources/block_storage.py @@ -0,0 +1,135 @@ +""" +Block Storage Resource + +Block storage management +""" + +from typing import Dict, List +from .base import BaseResource + + +class BlockStorageResource(BaseResource): + """ + Block storage management + + Usage: + # List block storage + blocks = client.block_storage.list() + + # Create block storage + block = client.block_storage.create( + region="ewr", + size_gb=50, + label="my-storage" + ) + + # Attach to instance + client.block_storage.attach(block_id="block-id", instance_id="instance-id") + """ + + def list(self, per_page: int = 100, cursor: str = None) -> Dict: + """ + List block storage volumes + + Returns: + Dict with 'blocks' list and 'meta' pagination + """ + params = {"per_page": per_page} + if cursor: + params["cursor"] = cursor + return self.client.get("blocks", params=params) + + def list_all(self) -> List[Dict]: + """List all block storage volumes (auto-paginate)""" + return self.client.paginate("blocks", "blocks") + + def get(self, block_id: str) -> Dict: + """ + Get block storage details + + Args: + block_id: Block storage ID + + Returns: + Block storage details + """ + response = self.client.get(f"blocks/{block_id}") + return response.get("block", {}) + + def create( + self, + region: str, + size_gb: int, + label: str = None, + block_type: str = None + ) -> Dict: + """ + Create block storage + + Args: + region: Region ID + size_gb: Size in GB (10-40000) + label: Label + block_type: Storage type ("high_perf" or "storage_opt") + + Returns: + Created block storage details + """ + data = {"region": region, "size_gb": size_gb} + if label: + data["label"] = label + if block_type: + data["block_type"] = block_type + + response = self.client.post("blocks", data) + return response.get("block", {}) + + def update(self, block_id: str, label: str = None, size_gb: int = None) -> None: + """ + Update block storage + + Args: + block_id: Block ID + label: New label + size_gb: New size (can only increase) + """ + data = {} + if label: + data["label"] = label + if size_gb: + data["size_gb"] = size_gb + + self.client.patch(f"blocks/{block_id}", data) + + def delete(self, block_id: str) -> None: + """ + Delete block storage + + Args: + block_id: Block ID to delete + """ + self.client.delete(f"blocks/{block_id}") + + def attach(self, block_id: str, instance_id: str, live: bool = False) -> None: + """ + Attach block storage to instance + + Args: + block_id: Block storage ID + instance_id: Instance ID to attach to + live: Live attach (no reboot) + """ + self.client.post(f"blocks/{block_id}/attach", { + "instance_id": instance_id, + "live": live + }) + + def detach(self, block_id: str, live: bool = False) -> None: + """ + Detach block storage from instance + + Args: + block_id: Block storage ID + live: Live detach (no reboot) + """ + self.client.post(f"blocks/{block_id}/detach", {"live": live}) diff --git a/vultr_api/resources/dns.py b/vultr_api/resources/dns.py new file mode 100644 index 0000000..542fd5e --- /dev/null +++ b/vultr_api/resources/dns.py @@ -0,0 +1,348 @@ +""" +DNS Resource + +DNS domain and record management +""" + +from typing import Dict, List, Optional +from .base import BaseResource + + +class DNSResource(BaseResource): + """ + DNS domain and record management + + Usage: + # List domains + domains = client.dns.list_domains() + + # Create domain + domain = client.dns.create_domain("example.com") + + # List records + records = client.dns.list_records("example.com") + + # Create A record + record = client.dns.create_record( + domain="example.com", + name="www", + record_type="A", + data="192.168.1.1", + ttl=300 + ) + """ + + # Domain management + def list_domains(self, per_page: int = 100, cursor: str = None) -> Dict: + """ + List all DNS domains + + Args: + per_page: Number of items per page + cursor: Pagination cursor + + Returns: + Dict with 'domains' list and 'meta' pagination + """ + params = {"per_page": per_page} + if cursor: + params["cursor"] = cursor + return self.client.get("domains", params=params) + + def list_all_domains(self) -> List[Dict]: + """List all domains (auto-paginate)""" + return self.client.paginate("domains", "domains") + + def get_domain(self, domain: str) -> Dict: + """ + Get domain details + + Args: + domain: Domain name (e.g., "example.com") + + Returns: + Domain details + """ + response = self.client.get(f"domains/{domain}") + return response.get("domain", {}) + + def create_domain( + self, + domain: str, + ip: str = None, + dns_sec: str = "disabled" + ) -> Dict: + """ + Create a DNS domain + + Args: + domain: Domain name + ip: Default IP address for A record (optional) + dns_sec: DNSSEC status ("enabled" or "disabled") + + Returns: + Created domain details + """ + data = {"domain": domain, "dns_sec": dns_sec} + if ip: + data["ip"] = ip + + response = self.client.post("domains", data) + return response.get("domain", {}) + + def update_domain(self, domain: str, dns_sec: str) -> None: + """ + Update domain DNSSEC setting + + Args: + domain: Domain name + dns_sec: DNSSEC status ("enabled" or "disabled") + """ + self.client.put(f"domains/{domain}", {"dns_sec": dns_sec}) + + def delete_domain(self, domain: str) -> None: + """ + Delete a DNS domain + + Args: + domain: Domain name to delete + """ + self.client.delete(f"domains/{domain}") + + def get_soa(self, domain: str) -> Dict: + """ + Get SOA record info + + Args: + domain: Domain name + + Returns: + SOA record details + """ + return self.client.get(f"domains/{domain}/soa") + + def update_soa( + self, + domain: str, + nsprimary: str = None, + email: str = None + ) -> None: + """ + Update SOA record + + Args: + domain: Domain name + nsprimary: Primary nameserver + email: Admin email + """ + data = {} + if nsprimary: + data["nsprimary"] = nsprimary + if email: + data["email"] = email + + self.client.patch(f"domains/{domain}/soa", data) + + def get_dnssec(self, domain: str) -> Dict: + """ + Get DNSSEC info + + Args: + domain: Domain name + + Returns: + DNSSEC details + """ + return self.client.get(f"domains/{domain}/dnssec") + + # Record management + def list_records( + self, + domain: str, + per_page: int = 100, + cursor: str = None + ) -> Dict: + """ + List DNS records for a domain + + Args: + domain: Domain name + per_page: Number of items per page + cursor: Pagination cursor + + Returns: + Dict with 'records' list and 'meta' pagination + """ + params = {"per_page": per_page} + if cursor: + params["cursor"] = cursor + return self.client.get(f"domains/{domain}/records", params=params) + + def list_all_records(self, domain: str) -> List[Dict]: + """List all records for domain (auto-paginate)""" + return self.client.paginate(f"domains/{domain}/records", "records") + + def get_record(self, domain: str, record_id: str) -> Dict: + """ + Get a specific DNS record + + Args: + domain: Domain name + record_id: Record ID + + Returns: + Record details + """ + response = self.client.get(f"domains/{domain}/records/{record_id}") + return response.get("record", {}) + + def create_record( + self, + domain: str, + name: str, + record_type: str, + data: str, + ttl: int = 300, + priority: int = None + ) -> Dict: + """ + Create a DNS record + + Args: + domain: Domain name + name: Record name (subdomain or "@" for root) + record_type: Record type (A, AAAA, CNAME, MX, TXT, NS, SRV, CAA, SSHFP) + data: Record data/value + ttl: Time to live in seconds (default 300) + priority: Priority (required for MX and SRV) + + Returns: + Created record details + + Example: + # A record + client.dns.create_record("example.com", "www", "A", "192.168.1.1") + + # MX record + client.dns.create_record("example.com", "@", "MX", "mail.example.com", priority=10) + + # TXT record (SPF) + client.dns.create_record("example.com", "@", "TXT", "v=spf1 include:_spf.example.com ~all") + """ + record_data = { + "name": name, + "type": record_type, + "data": data, + "ttl": ttl + } + + if priority is not None: + record_data["priority"] = priority + + response = self.client.post(f"domains/{domain}/records", record_data) + return response.get("record", {}) + + def update_record( + self, + domain: str, + record_id: str, + name: str = None, + data: str = None, + ttl: int = None, + priority: int = None + ) -> None: + """ + Update a DNS record + + Args: + domain: Domain name + record_id: Record ID to update + name: New record name + data: New record data + ttl: New TTL + priority: New priority + """ + record_data = {} + + if name is not None: + record_data["name"] = name + if data is not None: + record_data["data"] = data + if ttl is not None: + record_data["ttl"] = ttl + if priority is not None: + record_data["priority"] = priority + + self.client.patch(f"domains/{domain}/records/{record_id}", record_data) + + def delete_record(self, domain: str, record_id: str) -> None: + """ + Delete a DNS record + + Args: + domain: Domain name + record_id: Record ID to delete + """ + self.client.delete(f"domains/{domain}/records/{record_id}") + + # Convenience methods + def create_a_record( + self, + domain: str, + name: str, + ip: str, + ttl: int = 300 + ) -> Dict: + """Create an A record""" + return self.create_record(domain, name, "A", ip, ttl) + + def create_aaaa_record( + self, + domain: str, + name: str, + ip: str, + ttl: int = 300 + ) -> Dict: + """Create an AAAA record""" + return self.create_record(domain, name, "AAAA", ip, ttl) + + def create_cname_record( + self, + domain: str, + name: str, + target: str, + ttl: int = 300 + ) -> Dict: + """Create a CNAME record""" + return self.create_record(domain, name, "CNAME", target, ttl) + + def create_mx_record( + self, + domain: str, + name: str, + mail_server: str, + priority: int = 10, + ttl: int = 300 + ) -> Dict: + """Create an MX record""" + return self.create_record(domain, name, "MX", mail_server, ttl, priority) + + def create_txt_record( + self, + domain: str, + name: str, + text: str, + ttl: int = 300 + ) -> Dict: + """Create a TXT record""" + return self.create_record(domain, name, "TXT", text, ttl) + + def create_ns_record( + self, + domain: str, + name: str, + nameserver: str, + ttl: int = 300 + ) -> Dict: + """Create an NS record""" + return self.create_record(domain, name, "NS", nameserver, ttl) diff --git a/vultr_api/resources/firewall.py b/vultr_api/resources/firewall.py new file mode 100644 index 0000000..a1b1aa0 --- /dev/null +++ b/vultr_api/resources/firewall.py @@ -0,0 +1,306 @@ +""" +Firewall Resource + +Firewall group and rule management +""" + +from typing import Dict, List, Optional +from .base import BaseResource + + +class FirewallResource(BaseResource): + """ + Firewall group and rule management + + Usage: + # List firewall groups + groups = client.firewall.list_groups() + + # Create firewall group + group = client.firewall.create_group(description="Web servers") + + # Add rule + rule = client.firewall.create_rule( + firewall_group_id="group-id", + ip_type="v4", + protocol="tcp", + port="80", + subnet="0.0.0.0", + subnet_size=0 + ) + """ + + # Firewall groups + def list_groups(self, per_page: int = 100, cursor: str = None) -> Dict: + """ + List firewall groups + + Returns: + Dict with 'firewall_groups' list and 'meta' pagination + """ + params = {"per_page": per_page} + if cursor: + params["cursor"] = cursor + return self.client.get("firewalls", params=params) + + def list_all_groups(self) -> List[Dict]: + """List all firewall groups (auto-paginate)""" + return self.client.paginate("firewalls", "firewall_groups") + + def get_group(self, firewall_group_id: str) -> Dict: + """ + Get firewall group details + + Args: + firewall_group_id: Firewall group ID + + Returns: + Firewall group details + """ + response = self.client.get(f"firewalls/{firewall_group_id}") + return response.get("firewall_group", {}) + + def create_group(self, description: str = None) -> Dict: + """ + Create a firewall group + + Args: + description: Group description + + Returns: + Created firewall group + """ + data = {} + if description: + data["description"] = description + + response = self.client.post("firewalls", data) + return response.get("firewall_group", {}) + + def update_group(self, firewall_group_id: str, description: str) -> None: + """ + Update firewall group description + + Args: + firewall_group_id: Group ID + description: New description + """ + self.client.put(f"firewalls/{firewall_group_id}", {"description": description}) + + def delete_group(self, firewall_group_id: str) -> None: + """ + Delete a firewall group + + Args: + firewall_group_id: Group ID to delete + """ + self.client.delete(f"firewalls/{firewall_group_id}") + + # Firewall rules + def list_rules( + self, + firewall_group_id: str, + per_page: int = 100, + cursor: str = None + ) -> Dict: + """ + List rules in a firewall group + + Args: + firewall_group_id: Group ID + + Returns: + Dict with 'firewall_rules' list and 'meta' pagination + """ + params = {"per_page": per_page} + if cursor: + params["cursor"] = cursor + return self.client.get( + f"firewalls/{firewall_group_id}/rules", + params=params + ) + + def list_all_rules(self, firewall_group_id: str) -> List[Dict]: + """List all rules in a firewall group (auto-paginate)""" + return self.client.paginate( + f"firewalls/{firewall_group_id}/rules", + "firewall_rules" + ) + + def get_rule(self, firewall_group_id: str, rule_id: str) -> Dict: + """ + Get a firewall rule + + Args: + firewall_group_id: Group ID + rule_id: Rule ID + + Returns: + Rule details + """ + response = self.client.get( + f"firewalls/{firewall_group_id}/rules/{rule_id}" + ) + return response.get("firewall_rule", {}) + + def create_rule( + self, + firewall_group_id: str, + ip_type: str, + protocol: str, + port: str, + subnet: str, + subnet_size: int, + source: str = None, + notes: str = None + ) -> Dict: + """ + Create a firewall rule + + Args: + firewall_group_id: Group ID + ip_type: IP type ("v4" or "v6") + protocol: Protocol ("icmp", "tcp", "udp", "gre", "esp", "ah") + port: Port number, range (e.g., "8000:9000"), or empty for all + subnet: IP address or subnet + subnet_size: CIDR size (0-32 for v4, 0-128 for v6) + source: Source type ("", "cloudflare") - empty for custom subnet + notes: Rule notes + + Returns: + Created rule details + + Example: + # Allow SSH from anywhere + client.firewall.create_rule( + firewall_group_id="group-id", + ip_type="v4", + protocol="tcp", + port="22", + subnet="0.0.0.0", + subnet_size=0, + notes="SSH" + ) + + # Allow HTTP from Cloudflare + client.firewall.create_rule( + firewall_group_id="group-id", + ip_type="v4", + protocol="tcp", + port="80", + subnet="", + subnet_size=0, + source="cloudflare", + notes="HTTP via Cloudflare" + ) + """ + data = { + "ip_type": ip_type, + "protocol": protocol, + "port": port, + "subnet": subnet, + "subnet_size": subnet_size, + } + + if source: + data["source"] = source + if notes: + data["notes"] = notes + + response = self.client.post( + f"firewalls/{firewall_group_id}/rules", + data + ) + return response.get("firewall_rule", {}) + + def delete_rule(self, firewall_group_id: str, rule_id: str) -> None: + """ + Delete a firewall rule + + Args: + firewall_group_id: Group ID + rule_id: Rule ID to delete + """ + self.client.delete(f"firewalls/{firewall_group_id}/rules/{rule_id}") + + # Convenience methods + def allow_ssh( + self, + firewall_group_id: str, + ip_type: str = "v4", + subnet: str = "0.0.0.0", + subnet_size: int = 0 + ) -> Dict: + """ + Create SSH allow rule + + Args: + firewall_group_id: Group ID + ip_type: "v4" or "v6" + subnet: Source subnet (default: anywhere) + subnet_size: CIDR size (default: 0 for anywhere) + + Returns: + Created rule + """ + return self.create_rule( + firewall_group_id=firewall_group_id, + ip_type=ip_type, + protocol="tcp", + port="22", + subnet=subnet, + subnet_size=subnet_size, + notes="SSH" + ) + + def allow_http( + self, + firewall_group_id: str, + ip_type: str = "v4", + subnet: str = "0.0.0.0", + subnet_size: int = 0 + ) -> Dict: + """Create HTTP allow rule""" + return self.create_rule( + firewall_group_id=firewall_group_id, + ip_type=ip_type, + protocol="tcp", + port="80", + subnet=subnet, + subnet_size=subnet_size, + notes="HTTP" + ) + + def allow_https( + self, + firewall_group_id: str, + ip_type: str = "v4", + subnet: str = "0.0.0.0", + subnet_size: int = 0 + ) -> Dict: + """Create HTTPS allow rule""" + return self.create_rule( + firewall_group_id=firewall_group_id, + ip_type=ip_type, + protocol="tcp", + port="443", + subnet=subnet, + subnet_size=subnet_size, + notes="HTTPS" + ) + + def allow_ping( + self, + firewall_group_id: str, + ip_type: str = "v4" + ) -> Dict: + """Create ICMP (ping) allow rule""" + return self.create_rule( + firewall_group_id=firewall_group_id, + ip_type=ip_type, + protocol="icmp", + port="", + subnet="0.0.0.0" if ip_type == "v4" else "::", + subnet_size=0, + notes="ICMP/Ping" + ) diff --git a/vultr_api/resources/instances.py b/vultr_api/resources/instances.py new file mode 100644 index 0000000..a2b959d --- /dev/null +++ b/vultr_api/resources/instances.py @@ -0,0 +1,536 @@ +""" +Instances Resource + +Cloud compute instance management +""" + +from typing import Dict, List, Optional +from .base import BaseResource + + +class InstancesResource(BaseResource): + """ + Cloud compute instances management + + Usage: + # List all instances + instances = client.instances.list() + + # Get specific instance + instance = client.instances.get("instance-id") + + # Create instance + instance = client.instances.create( + region="ewr", + plan="vc2-1c-1gb", + os_id=215, + label="my-server" + ) + + # Delete instance + client.instances.delete("instance-id") + """ + + def list( + self, + per_page: int = 100, + cursor: str = None, + tag: str = None, + label: str = None, + main_ip: str = None, + region: str = None, + ) -> Dict: + """ + List all instances + + Args: + per_page: Number of items per page (max 500) + cursor: Pagination cursor + tag: Filter by tag + label: Filter by label + main_ip: Filter by main IP + region: Filter by region + + Returns: + Dict with 'instances' list and 'meta' pagination info + """ + params = {"per_page": per_page} + if cursor: + params["cursor"] = cursor + if tag: + params["tag"] = tag + if label: + params["label"] = label + if main_ip: + params["main_ip"] = main_ip + if region: + params["region"] = region + + return self.client.get("instances", params=params) + + def list_all(self, **kwargs) -> List[Dict]: + """ + List all instances (auto-paginate) + + Returns: + List of all instances + """ + return self.client.paginate("instances", "instances", params=kwargs) + + def get(self, instance_id: str) -> Dict: + """ + Get instance details + + Args: + instance_id: Instance ID + + Returns: + Instance details + """ + response = self.client.get(f"instances/{instance_id}") + return response.get("instance", {}) + + def create( + self, + region: str, + plan: str, + os_id: int = None, + iso_id: str = None, + snapshot_id: str = None, + app_id: int = None, + image_id: str = None, + ipxe_chain_url: str = None, + script_id: str = None, + enable_ipv6: bool = False, + disable_public_ipv4: bool = False, + attach_private_network: List[str] = None, + attach_vpc: List[str] = None, + attach_vpc2: List[str] = None, + label: str = None, + sshkey_id: List[str] = None, + backups: str = None, + ddos_protection: bool = False, + activation_email: bool = False, + hostname: str = None, + tag: str = None, + tags: List[str] = None, + firewall_group_id: str = None, + reserved_ipv4: str = None, + user_data: str = None, + ) -> Dict: + """ + Create a new instance + + Args: + region: Region ID (e.g., "ewr", "lax", "nrt") + plan: Plan ID (e.g., "vc2-1c-1gb") + os_id: Operating System ID + iso_id: ISO ID for custom install + snapshot_id: Snapshot ID to restore from + app_id: Application ID + image_id: Custom image ID + ipxe_chain_url: iPXE chain URL + script_id: Startup script ID + enable_ipv6: Enable IPv6 + disable_public_ipv4: Disable public IPv4 + attach_private_network: Private network IDs to attach + attach_vpc: VPC IDs to attach + attach_vpc2: VPC 2.0 IDs to attach + label: Instance label + sshkey_id: SSH key IDs to add + backups: Backup schedule ("enabled" or "disabled") + ddos_protection: Enable DDoS protection + activation_email: Send activation email + hostname: Server hostname + tag: Deprecated, use tags + tags: Tags for the instance + firewall_group_id: Firewall group ID + reserved_ipv4: Reserved IPv4 to assign + user_data: Cloud-init user data (base64) + + Returns: + Created instance details + """ + data = {"region": region, "plan": plan} + + # Add optional parameters + if os_id is not None: + data["os_id"] = os_id + if iso_id: + data["iso_id"] = iso_id + if snapshot_id: + data["snapshot_id"] = snapshot_id + if app_id is not None: + data["app_id"] = app_id + if image_id: + data["image_id"] = image_id + if ipxe_chain_url: + data["ipxe_chain_url"] = ipxe_chain_url + if script_id: + data["script_id"] = script_id + if enable_ipv6: + data["enable_ipv6"] = enable_ipv6 + if disable_public_ipv4: + data["disable_public_ipv4"] = disable_public_ipv4 + if attach_private_network: + data["attach_private_network"] = attach_private_network + if attach_vpc: + data["attach_vpc"] = attach_vpc + if attach_vpc2: + data["attach_vpc2"] = attach_vpc2 + if label: + data["label"] = label + if sshkey_id: + data["sshkey_id"] = sshkey_id + if backups: + data["backups"] = backups + if ddos_protection: + data["ddos_protection"] = ddos_protection + if activation_email: + data["activation_email"] = activation_email + if hostname: + data["hostname"] = hostname + if tag: + data["tag"] = tag + if tags: + data["tags"] = tags + if firewall_group_id: + data["firewall_group_id"] = firewall_group_id + if reserved_ipv4: + data["reserved_ipv4"] = reserved_ipv4 + if user_data: + data["user_data"] = user_data + + response = self.client.post("instances", data) + return response.get("instance", {}) + + def update( + self, + instance_id: str, + app_id: int = None, + image_id: str = None, + backups: str = None, + firewall_group_id: str = None, + enable_ipv6: bool = None, + os_id: int = None, + user_data: str = None, + tag: str = None, + tags: List[str] = None, + label: str = None, + ddos_protection: bool = None, + ) -> Dict: + """ + Update an instance + + Args: + instance_id: Instance ID to update + (other args same as create) + + Returns: + Updated instance details + """ + data = {} + + if app_id is not None: + data["app_id"] = app_id + if image_id is not None: + data["image_id"] = image_id + if backups is not None: + data["backups"] = backups + if firewall_group_id is not None: + data["firewall_group_id"] = firewall_group_id + if enable_ipv6 is not None: + data["enable_ipv6"] = enable_ipv6 + if os_id is not None: + data["os_id"] = os_id + if user_data is not None: + data["user_data"] = user_data + if tag is not None: + data["tag"] = tag + if tags is not None: + data["tags"] = tags + if label is not None: + data["label"] = label + if ddos_protection is not None: + data["ddos_protection"] = ddos_protection + + response = self.client.patch(f"instances/{instance_id}", data) + return response.get("instance", {}) + + def delete(self, instance_id: str) -> None: + """ + Delete an instance + + Args: + instance_id: Instance ID to delete + """ + self.client.delete(f"instances/{instance_id}") + + def start(self, instance_id: str) -> None: + """Start an instance""" + self.client.post(f"instances/{instance_id}/start") + + def stop(self, instance_id: str) -> None: + """Stop an instance (alias for halt)""" + self.halt(instance_id) + + def halt(self, instance_id: str) -> None: + """Halt an instance""" + self.client.post(f"instances/{instance_id}/halt") + + def reboot(self, instance_id: str) -> None: + """Reboot an instance""" + self.client.post(f"instances/{instance_id}/reboot") + + def reinstall(self, instance_id: str, hostname: str = None) -> Dict: + """ + Reinstall an instance + + Args: + instance_id: Instance ID + hostname: New hostname (optional) + + Returns: + Instance details + """ + data = {} + if hostname: + data["hostname"] = hostname + + response = self.client.post(f"instances/{instance_id}/reinstall", data) + return response.get("instance", {}) + + def get_bandwidth(self, instance_id: str) -> Dict: + """Get instance bandwidth usage""" + return self.client.get(f"instances/{instance_id}/bandwidth") + + def get_neighbors(self, instance_id: str) -> Dict: + """Get instance neighbors (shared host)""" + return self.client.get(f"instances/{instance_id}/neighbors") + + def get_user_data(self, instance_id: str) -> Dict: + """Get instance user data""" + return self.client.get(f"instances/{instance_id}/user-data") + + def get_upgrades(self, instance_id: str, upgrade_type: str = None) -> Dict: + """ + Get available upgrades + + Args: + instance_id: Instance ID + upgrade_type: Filter by type ("all", "applications", "os", "plans") + + Returns: + Available upgrades + """ + params = {} + if upgrade_type: + params["type"] = upgrade_type + return self.client.get(f"instances/{instance_id}/upgrades", params=params) + + # IPv4 management + def list_ipv4(self, instance_id: str) -> List[Dict]: + """List IPv4 addresses""" + response = self.client.get(f"instances/{instance_id}/ipv4") + return response.get("ipv4s", []) + + def create_ipv4(self, instance_id: str, reboot: bool = True) -> Dict: + """ + Create additional IPv4 + + Args: + instance_id: Instance ID + reboot: Reboot instance to apply (default True) + + Returns: + New IPv4 info + """ + response = self.client.post( + f"instances/{instance_id}/ipv4", + {"reboot": reboot} + ) + return response.get("ipv4", {}) + + def delete_ipv4(self, instance_id: str, ipv4: str) -> None: + """Delete an IPv4 address""" + self.client.delete(f"instances/{instance_id}/ipv4/{ipv4}") + + def create_ipv4_reverse( + self, + instance_id: str, + ip: str, + reverse: str + ) -> None: + """Set IPv4 reverse DNS""" + self.client.post( + f"instances/{instance_id}/ipv4/reverse", + {"ip": ip, "reverse": reverse} + ) + + def set_default_ipv4_reverse(self, instance_id: str, ip: str) -> None: + """Set default reverse DNS for IPv4""" + self.client.post( + f"instances/{instance_id}/ipv4/reverse/default", + {"ip": ip} + ) + + # IPv6 management + def list_ipv6(self, instance_id: str) -> List[Dict]: + """List IPv6 addresses""" + response = self.client.get(f"instances/{instance_id}/ipv6") + return response.get("ipv6s", []) + + def create_ipv6_reverse( + self, + instance_id: str, + ip: str, + reverse: str + ) -> None: + """Set IPv6 reverse DNS""" + self.client.post( + f"instances/{instance_id}/ipv6/reverse", + {"ip": ip, "reverse": reverse} + ) + + def list_ipv6_reverse(self, instance_id: str) -> List[Dict]: + """List IPv6 reverse DNS entries""" + response = self.client.get(f"instances/{instance_id}/ipv6/reverse") + return response.get("reverse_ipv6s", []) + + def delete_ipv6_reverse(self, instance_id: str, ipv6: str) -> None: + """Delete IPv6 reverse DNS""" + self.client.delete(f"instances/{instance_id}/ipv6/reverse/{ipv6}") + + # Backup management + def get_backup_schedule(self, instance_id: str) -> Dict: + """Get backup schedule""" + return self.client.get(f"instances/{instance_id}/backup-schedule") + + def set_backup_schedule( + self, + instance_id: str, + backup_type: str, + hour: int = None, + dow: int = None, + dom: int = None, + ) -> None: + """ + Set backup schedule + + Args: + instance_id: Instance ID + backup_type: Schedule type ("daily", "weekly", "monthly", "daily_alt_even", "daily_alt_odd") + hour: Hour of day (0-23) + dow: Day of week (1-7, for weekly) + dom: Day of month (1-28, for monthly) + """ + data = {"type": backup_type} + if hour is not None: + data["hour"] = hour + if dow is not None: + data["dow"] = dow + if dom is not None: + data["dom"] = dom + + self.client.post(f"instances/{instance_id}/backup-schedule", data) + + def restore_backup(self, instance_id: str, backup_id: str) -> Dict: + """ + Restore instance from backup + + Args: + instance_id: Instance ID + backup_id: Backup ID to restore + + Returns: + Restore status + """ + return self.client.post( + f"instances/{instance_id}/restore", + {"backup_id": backup_id} + ) + + def restore_snapshot(self, instance_id: str, snapshot_id: str) -> Dict: + """ + Restore instance from snapshot + + Args: + instance_id: Instance ID + snapshot_id: Snapshot ID to restore + + Returns: + Restore status + """ + return self.client.post( + f"instances/{instance_id}/restore", + {"snapshot_id": snapshot_id} + ) + + # VPC management + def list_vpcs(self, instance_id: str) -> List[Dict]: + """List VPCs attached to instance""" + response = self.client.get(f"instances/{instance_id}/vpcs") + return response.get("vpcs", []) + + def attach_vpc(self, instance_id: str, vpc_id: str) -> None: + """Attach VPC to instance""" + self.client.post( + f"instances/{instance_id}/vpcs/attach", + {"vpc_id": vpc_id} + ) + + def detach_vpc(self, instance_id: str, vpc_id: str) -> None: + """Detach VPC from instance""" + self.client.post( + f"instances/{instance_id}/vpcs/detach", + {"vpc_id": vpc_id} + ) + + # VPC 2.0 management + def list_vpc2(self, instance_id: str) -> List[Dict]: + """List VPC 2.0 networks attached to instance""" + response = self.client.get(f"instances/{instance_id}/vpc2") + return response.get("vpcs", []) + + def attach_vpc2( + self, + instance_id: str, + vpc_id: str, + ip_address: str = None + ) -> None: + """ + Attach VPC 2.0 to instance + + Args: + instance_id: Instance ID + vpc_id: VPC 2.0 ID + ip_address: Specific IP to assign (optional) + """ + data = {"vpc_id": vpc_id} + if ip_address: + data["ip_address"] = ip_address + + self.client.post(f"instances/{instance_id}/vpc2/attach", data) + + def detach_vpc2(self, instance_id: str, vpc_id: str) -> None: + """Detach VPC 2.0 from instance""" + self.client.post( + f"instances/{instance_id}/vpc2/detach", + {"vpc_id": vpc_id} + ) + + # ISO management + def attach_iso(self, instance_id: str, iso_id: str) -> Dict: + """Attach ISO to instance""" + return self.client.post( + f"instances/{instance_id}/iso/attach", + {"iso_id": iso_id} + ) + + def detach_iso(self, instance_id: str) -> Dict: + """Detach ISO from instance""" + return self.client.post(f"instances/{instance_id}/iso/detach") + + def get_iso_status(self, instance_id: str) -> Dict: + """Get ISO attach status""" + return self.client.get(f"instances/{instance_id}/iso") diff --git a/vultr_api/resources/load_balancers.py b/vultr_api/resources/load_balancers.py new file mode 100644 index 0000000..cbec975 --- /dev/null +++ b/vultr_api/resources/load_balancers.py @@ -0,0 +1,266 @@ +""" +Load Balancers Resource + +Load balancer management +""" + +from typing import Dict, List +from .base import BaseResource + + +class LoadBalancersResource(BaseResource): + """ + Load balancer management + + Usage: + # List load balancers + lbs = client.load_balancers.list() + + # Create load balancer + lb = client.load_balancers.create( + region="ewr", + label="my-lb", + forwarding_rules=[{ + "frontend_protocol": "http", + "frontend_port": 80, + "backend_protocol": "http", + "backend_port": 80 + }] + ) + """ + + def list(self, per_page: int = 100, cursor: str = None) -> Dict: + """ + List load balancers + + Returns: + Dict with 'load_balancers' list and 'meta' pagination + """ + params = {"per_page": per_page} + if cursor: + params["cursor"] = cursor + return self.client.get("load-balancers", params=params) + + def list_all(self) -> List[Dict]: + """List all load balancers (auto-paginate)""" + return self.client.paginate("load-balancers", "load_balancers") + + def get(self, load_balancer_id: str) -> Dict: + """ + Get load balancer details + + Args: + load_balancer_id: Load balancer ID + + Returns: + Load balancer details + """ + response = self.client.get(f"load-balancers/{load_balancer_id}") + return response.get("load_balancer", {}) + + def create( + self, + region: str, + forwarding_rules: List[Dict], + label: str = None, + balancing_algorithm: str = "roundrobin", + ssl_redirect: bool = False, + proxy_protocol: bool = False, + health_check: Dict = None, + sticky_session: Dict = None, + ssl: Dict = None, + instances: List[str] = None, + firewall_rules: List[Dict] = None, + vpc: str = None, + ) -> Dict: + """ + Create a load balancer + + Args: + region: Region ID + forwarding_rules: List of forwarding rules + label: Load balancer label + balancing_algorithm: "roundrobin" or "leastconn" + ssl_redirect: Redirect HTTP to HTTPS + proxy_protocol: Enable proxy protocol + health_check: Health check config + sticky_session: Sticky session config + ssl: SSL config + instances: List of instance IDs to attach + firewall_rules: List of firewall rules + vpc: VPC ID + + Returns: + Created load balancer details + + Example forwarding_rules: + [{ + "frontend_protocol": "http", + "frontend_port": 80, + "backend_protocol": "http", + "backend_port": 80 + }] + + Example health_check: + { + "protocol": "http", + "port": 80, + "path": "/health", + "check_interval": 15, + "response_timeout": 5, + "unhealthy_threshold": 5, + "healthy_threshold": 5 + } + """ + data = { + "region": region, + "forwarding_rules": forwarding_rules, + "balancing_algorithm": balancing_algorithm, + "ssl_redirect": ssl_redirect, + "proxy_protocol": proxy_protocol, + } + + if label: + data["label"] = label + if health_check: + data["health_check"] = health_check + if sticky_session: + data["sticky_session"] = sticky_session + if ssl: + data["ssl"] = ssl + if instances: + data["instances"] = instances + if firewall_rules: + data["firewall_rules"] = firewall_rules + if vpc: + data["vpc"] = vpc + + response = self.client.post("load-balancers", data) + return response.get("load_balancer", {}) + + def update( + self, + load_balancer_id: str, + label: str = None, + balancing_algorithm: str = None, + ssl_redirect: bool = None, + proxy_protocol: bool = None, + health_check: Dict = None, + forwarding_rules: List[Dict] = None, + sticky_session: Dict = None, + ssl: Dict = None, + instances: List[str] = None, + firewall_rules: List[Dict] = None, + vpc: str = None, + ) -> None: + """ + Update a load balancer + + Args: + load_balancer_id: Load balancer ID + (other args same as create) + """ + data = {} + + if label is not None: + data["label"] = label + if balancing_algorithm is not None: + data["balancing_algorithm"] = balancing_algorithm + if ssl_redirect is not None: + data["ssl_redirect"] = ssl_redirect + if proxy_protocol is not None: + data["proxy_protocol"] = proxy_protocol + if health_check is not None: + data["health_check"] = health_check + if forwarding_rules is not None: + data["forwarding_rules"] = forwarding_rules + if sticky_session is not None: + data["sticky_session"] = sticky_session + if ssl is not None: + data["ssl"] = ssl + if instances is not None: + data["instances"] = instances + if firewall_rules is not None: + data["firewall_rules"] = firewall_rules + if vpc is not None: + data["vpc"] = vpc + + self.client.patch(f"load-balancers/{load_balancer_id}", data) + + def delete(self, load_balancer_id: str) -> None: + """ + Delete a load balancer + + Args: + load_balancer_id: Load balancer ID to delete + """ + self.client.delete(f"load-balancers/{load_balancer_id}") + + # Forwarding rules + def list_forwarding_rules(self, load_balancer_id: str) -> List[Dict]: + """List forwarding rules""" + response = self.client.get( + f"load-balancers/{load_balancer_id}/forwarding-rules" + ) + return response.get("forwarding_rules", []) + + def create_forwarding_rule( + self, + load_balancer_id: str, + frontend_protocol: str, + frontend_port: int, + backend_protocol: str, + backend_port: int + ) -> Dict: + """Create a forwarding rule""" + data = { + "frontend_protocol": frontend_protocol, + "frontend_port": frontend_port, + "backend_protocol": backend_protocol, + "backend_port": backend_port, + } + response = self.client.post( + f"load-balancers/{load_balancer_id}/forwarding-rules", + data + ) + return response.get("forwarding_rule", {}) + + def get_forwarding_rule( + self, + load_balancer_id: str, + rule_id: str + ) -> Dict: + """Get a forwarding rule""" + response = self.client.get( + f"load-balancers/{load_balancer_id}/forwarding-rules/{rule_id}" + ) + return response.get("forwarding_rule", {}) + + def delete_forwarding_rule( + self, + load_balancer_id: str, + rule_id: str + ) -> None: + """Delete a forwarding rule""" + self.client.delete( + f"load-balancers/{load_balancer_id}/forwarding-rules/{rule_id}" + ) + + # Firewall rules + def list_firewall_rules(self, load_balancer_id: str) -> List[Dict]: + """List firewall rules""" + response = self.client.get( + f"load-balancers/{load_balancer_id}/firewall-rules" + ) + return response.get("firewall_rules", []) + + def get_firewall_rule( + self, + load_balancer_id: str, + rule_id: str + ) -> Dict: + """Get a firewall rule""" + response = self.client.get( + f"load-balancers/{load_balancer_id}/firewall-rules/{rule_id}" + ) + return response.get("firewall_rule", {}) diff --git a/vultr_api/resources/os.py b/vultr_api/resources/os.py new file mode 100644 index 0000000..5121d5d --- /dev/null +++ b/vultr_api/resources/os.py @@ -0,0 +1,34 @@ +""" +OS Resource + +Operating system listings +""" + +from typing import Dict, List +from .base import BaseResource + + +class OSResource(BaseResource): + """ + Operating system listings + + Usage: + # List all OS options + os_list = client.os.list() + """ + + def list(self, per_page: int = 100, cursor: str = None) -> Dict: + """ + List available operating systems + + Returns: + Dict with 'os' list and 'meta' pagination + """ + params = {"per_page": per_page} + if cursor: + params["cursor"] = cursor + return self.client.get("os", params=params) + + def list_all(self) -> List[Dict]: + """List all operating systems (auto-paginate)""" + return self.client.paginate("os", "os") diff --git a/vultr_api/resources/plans.py b/vultr_api/resources/plans.py new file mode 100644 index 0000000..a1f466e --- /dev/null +++ b/vultr_api/resources/plans.py @@ -0,0 +1,75 @@ +""" +Plans Resource + +Available plan listings +""" + +from typing import Dict, List +from .base import BaseResource + + +class PlansResource(BaseResource): + """ + Plan listings + + Usage: + # List cloud compute plans + plans = client.plans.list() + + # List bare metal plans + bm_plans = client.plans.list_bare_metal() + """ + + def list( + self, + plan_type: str = None, + per_page: int = 100, + cursor: str = None, + os: str = None + ) -> Dict: + """ + List cloud compute plans + + Args: + plan_type: Filter by type ("all", "vc2", "vhf", "vdc", "voc", "voc-g", "voc-c", "voc-m", "voc-s", "vcg") + per_page: Items per page + cursor: Pagination cursor + os: Filter by supported OS ("windows") + + Returns: + Dict with 'plans' list and 'meta' pagination + """ + params = {"per_page": per_page} + if plan_type: + params["type"] = plan_type + if cursor: + params["cursor"] = cursor + if os: + params["os"] = os + + return self.client.get("plans", params=params) + + def list_all(self, plan_type: str = None, os: str = None) -> List[Dict]: + """List all plans (auto-paginate)""" + params = {} + if plan_type: + params["type"] = plan_type + if os: + params["os"] = os + return self.client.paginate("plans", "plans", params=params) + + def list_bare_metal(self, per_page: int = 100, cursor: str = None) -> Dict: + """ + List bare metal plans + + Returns: + Dict with 'plans' list and 'meta' pagination + """ + params = {"per_page": per_page} + if cursor: + params["cursor"] = cursor + return self.client.get("plans-metal", params=params) + + def list_all_bare_metal(self) -> List[Dict]: + """List all bare metal plans (auto-paginate)""" + return self.client.paginate("plans-metal", "plans") diff --git a/vultr_api/resources/regions.py b/vultr_api/resources/regions.py new file mode 100644 index 0000000..5e390ee --- /dev/null +++ b/vultr_api/resources/regions.py @@ -0,0 +1,53 @@ +""" +Regions Resource + +Available region listings +""" + +from typing import Dict, List +from .base import BaseResource + + +class RegionsResource(BaseResource): + """ + Region listings + + Usage: + # List all regions + regions = client.regions.list() + + # List plans available in a region + plans = client.regions.list_availability("ewr") + """ + + def list(self, per_page: int = 100, cursor: str = None) -> Dict: + """ + List all regions + + Returns: + Dict with 'regions' list and 'meta' pagination + """ + params = {"per_page": per_page} + if cursor: + params["cursor"] = cursor + return self.client.get("regions", params=params) + + def list_all(self) -> List[Dict]: + """List all regions (auto-paginate)""" + return self.client.paginate("regions", "regions") + + def list_availability(self, region_id: str, plan_type: str = None) -> Dict: + """ + List available plans in a region + + Args: + region_id: Region ID (e.g., "ewr", "lax", "nrt") + plan_type: Filter by type ("vc2", "vhf", "vdc") + + Returns: + Dict with 'available_plans' list + """ + params = {} + if plan_type: + params["type"] = plan_type + return self.client.get(f"regions/{region_id}/availability", params=params) diff --git a/vultr_api/resources/reserved_ips.py b/vultr_api/resources/reserved_ips.py new file mode 100644 index 0000000..6b0809b --- /dev/null +++ b/vultr_api/resources/reserved_ips.py @@ -0,0 +1,135 @@ +""" +Reserved IPs Resource + +Reserved IP address management +""" + +from typing import Dict, List +from .base import BaseResource + + +class ReservedIPsResource(BaseResource): + """ + Reserved IP address management + + Usage: + # List reserved IPs + ips = client.reserved_ips.list() + + # Create reserved IP + ip = client.reserved_ips.create(region="ewr", ip_type="v4") + + # Attach to instance + client.reserved_ips.attach(reserved_ip="id", instance_id="instance-id") + """ + + def list(self, per_page: int = 100, cursor: str = None) -> Dict: + """ + List reserved IPs + + Returns: + Dict with 'reserved_ips' list and 'meta' pagination + """ + params = {"per_page": per_page} + if cursor: + params["cursor"] = cursor + return self.client.get("reserved-ips", params=params) + + def list_all(self) -> List[Dict]: + """List all reserved IPs (auto-paginate)""" + return self.client.paginate("reserved-ips", "reserved_ips") + + def get(self, reserved_ip: str) -> Dict: + """ + Get reserved IP details + + Args: + reserved_ip: Reserved IP ID + + Returns: + Reserved IP details + """ + response = self.client.get(f"reserved-ips/{reserved_ip}") + return response.get("reserved_ip", {}) + + def create( + self, + region: str, + ip_type: str, + label: str = None + ) -> Dict: + """ + Create a reserved IP + + Args: + region: Region ID + ip_type: IP type ("v4" or "v6") + label: Label + + Returns: + Created reserved IP details + """ + data = {"region": region, "ip_type": ip_type} + if label: + data["label"] = label + + response = self.client.post("reserved-ips", data) + return response.get("reserved_ip", {}) + + def update(self, reserved_ip: str, label: str) -> None: + """ + Update reserved IP label + + Args: + reserved_ip: Reserved IP ID + label: New label + """ + self.client.patch(f"reserved-ips/{reserved_ip}", {"label": label}) + + def delete(self, reserved_ip: str) -> None: + """ + Delete a reserved IP + + Args: + reserved_ip: Reserved IP ID to delete + """ + self.client.delete(f"reserved-ips/{reserved_ip}") + + def attach(self, reserved_ip: str, instance_id: str) -> None: + """ + Attach reserved IP to instance + + Args: + reserved_ip: Reserved IP ID + instance_id: Instance ID to attach to + """ + self.client.post(f"reserved-ips/{reserved_ip}/attach", { + "instance_id": instance_id + }) + + def detach(self, reserved_ip: str) -> None: + """ + Detach reserved IP from instance + + Args: + reserved_ip: Reserved IP ID + """ + self.client.post(f"reserved-ips/{reserved_ip}/detach") + + def convert(self, ip_address: str, label: str = None) -> Dict: + """ + Convert instance IP to reserved IP + + Args: + ip_address: IP address to convert + label: Label for reserved IP + + Returns: + Created reserved IP details + """ + data = {"ip_address": ip_address} + if label: + data["label"] = label + + response = self.client.post("reserved-ips/convert", data) + return response.get("reserved_ip", {}) diff --git a/vultr_api/resources/snapshots.py b/vultr_api/resources/snapshots.py new file mode 100644 index 0000000..ab41964 --- /dev/null +++ b/vultr_api/resources/snapshots.py @@ -0,0 +1,108 @@ +""" +Snapshots Resource + +Snapshot management +""" + +from typing import Dict, List +from .base import BaseResource + + +class SnapshotsResource(BaseResource): + """ + Snapshot management + + Usage: + # List snapshots + snapshots = client.snapshots.list() + + # Create snapshot from instance + snapshot = client.snapshots.create(instance_id="instance-id", description="My snapshot") + + # Create from URL + snapshot = client.snapshots.create_from_url(url="https://example.com/image.raw") + """ + + def list(self, per_page: int = 100, cursor: str = None) -> Dict: + """ + List snapshots + + Returns: + Dict with 'snapshots' list and 'meta' pagination + """ + params = {"per_page": per_page} + if cursor: + params["cursor"] = cursor + return self.client.get("snapshots", params=params) + + def list_all(self) -> List[Dict]: + """List all snapshots (auto-paginate)""" + return self.client.paginate("snapshots", "snapshots") + + def get(self, snapshot_id: str) -> Dict: + """ + Get snapshot details + + Args: + snapshot_id: Snapshot ID + + Returns: + Snapshot details + """ + response = self.client.get(f"snapshots/{snapshot_id}") + return response.get("snapshot", {}) + + def create(self, instance_id: str, description: str = None) -> Dict: + """ + Create a snapshot from an instance + + Args: + instance_id: Instance ID to snapshot + description: Snapshot description + + Returns: + Created snapshot details + """ + data = {"instance_id": instance_id} + if description: + data["description"] = description + + response = self.client.post("snapshots", data) + return response.get("snapshot", {}) + + def create_from_url(self, url: str, description: str = None) -> Dict: + """ + Create a snapshot from a URL + + Args: + url: URL to raw disk image + description: Snapshot description + + Returns: + Created snapshot details + """ + data = {"url": url} + if description: + data["description"] = description + + response = self.client.post("snapshots/create-from-url", data) + return response.get("snapshot", {}) + + def update(self, snapshot_id: str, description: str) -> None: + """ + Update snapshot description + + Args: + snapshot_id: Snapshot ID + description: New description + """ + self.client.put(f"snapshots/{snapshot_id}", {"description": description}) + + def delete(self, snapshot_id: str) -> None: + """ + Delete a snapshot + + Args: + snapshot_id: Snapshot ID to delete + """ + self.client.delete(f"snapshots/{snapshot_id}") diff --git a/vultr_api/resources/ssh_keys.py b/vultr_api/resources/ssh_keys.py new file mode 100644 index 0000000..4204756 --- /dev/null +++ b/vultr_api/resources/ssh_keys.py @@ -0,0 +1,99 @@ +""" +SSH Keys Resource + +SSH key management +""" + +from typing import Dict, List +from .base import BaseResource + + +class SSHKeysResource(BaseResource): + """ + SSH key management + + Usage: + # List keys + keys = client.ssh_keys.list() + + # Create key + key = client.ssh_keys.create( + name="my-key", + ssh_key="ssh-rsa AAAAB3..." + ) + + # Delete key + client.ssh_keys.delete("key-id") + """ + + def list(self, per_page: int = 100, cursor: str = None) -> Dict: + """ + List SSH keys + + Returns: + Dict with 'ssh_keys' list and 'meta' pagination + """ + params = {"per_page": per_page} + if cursor: + params["cursor"] = cursor + return self.client.get("ssh-keys", params=params) + + def list_all(self) -> List[Dict]: + """List all SSH keys (auto-paginate)""" + return self.client.paginate("ssh-keys", "ssh_keys") + + def get(self, ssh_key_id: str) -> Dict: + """ + Get SSH key details + + Args: + ssh_key_id: SSH key ID + + Returns: + SSH key details + """ + response = self.client.get(f"ssh-keys/{ssh_key_id}") + return response.get("ssh_key", {}) + + def create(self, name: str, ssh_key: str) -> Dict: + """ + Create an SSH key + + Args: + name: Key name + ssh_key: Public key content + + Returns: + Created SSH key details + """ + response = self.client.post("ssh-keys", { + "name": name, + "ssh_key": ssh_key + }) + return response.get("ssh_key", {}) + + def update(self, ssh_key_id: str, name: str = None, ssh_key: str = None) -> None: + """ + Update an SSH key + + Args: + ssh_key_id: Key ID + name: New name + ssh_key: New public key content + """ + data = {} + if name: + data["name"] = name + if ssh_key: + data["ssh_key"] = ssh_key + + self.client.patch(f"ssh-keys/{ssh_key_id}", data) + + def delete(self, ssh_key_id: str) -> None: + """ + Delete an SSH key + + Args: + ssh_key_id: Key ID to delete + """ + self.client.delete(f"ssh-keys/{ssh_key_id}") diff --git a/vultr_api/resources/startup_scripts.py b/vultr_api/resources/startup_scripts.py new file mode 100644 index 0000000..5c90680 --- /dev/null +++ b/vultr_api/resources/startup_scripts.py @@ -0,0 +1,112 @@ +""" +Startup Scripts Resource + +Startup script management +""" + +from typing import Dict, List +from .base import BaseResource + + +class StartupScriptsResource(BaseResource): + """ + Startup script management + + Usage: + # List scripts + scripts = client.startup_scripts.list() + + # Create script + script = client.startup_scripts.create( + name="my-script", + script="#!/bin/bash\\necho 'Hello'", + script_type="boot" + ) + + # Delete script + client.startup_scripts.delete("script-id") + """ + + def list(self, per_page: int = 100, cursor: str = None) -> Dict: + """ + List startup scripts + + Returns: + Dict with 'startup_scripts' list and 'meta' pagination + """ + params = {"per_page": per_page} + if cursor: + params["cursor"] = cursor + return self.client.get("startup-scripts", params=params) + + def list_all(self) -> List[Dict]: + """List all startup scripts (auto-paginate)""" + return self.client.paginate("startup-scripts", "startup_scripts") + + def get(self, startup_script_id: str) -> Dict: + """ + Get startup script details + + Args: + startup_script_id: Script ID + + Returns: + Script details + """ + response = self.client.get(f"startup-scripts/{startup_script_id}") + return response.get("startup_script", {}) + + def create( + self, + name: str, + script: str, + script_type: str = "boot" + ) -> Dict: + """ + Create a startup script + + Args: + name: Script name + script: Script content (base64 or plain text) + script_type: Type ("boot" or "pxe") + + Returns: + Created script details + """ + response = self.client.post("startup-scripts", { + "name": name, + "script": script, + "type": script_type + }) + return response.get("startup_script", {}) + + def update( + self, + startup_script_id: str, + name: str = None, + script: str = None + ) -> None: + """ + Update a startup script + + Args: + startup_script_id: Script ID + name: New name + script: New script content + """ + data = {} + if name: + data["name"] = name + if script: + data["script"] = script + + self.client.patch(f"startup-scripts/{startup_script_id}", data) + + def delete(self, startup_script_id: str) -> None: + """ + Delete a startup script + + Args: + startup_script_id: Script ID to delete + """ + self.client.delete(f"startup-scripts/{startup_script_id}") diff --git a/vultr_api/resources/vpc.py b/vultr_api/resources/vpc.py new file mode 100644 index 0000000..fd16473 --- /dev/null +++ b/vultr_api/resources/vpc.py @@ -0,0 +1,220 @@ +""" +VPC Resource + +VPC (Virtual Private Cloud) management - both v1 and v2 +""" + +from typing import Dict, List +from .base import BaseResource + + +class VPCResource(BaseResource): + """ + VPC management (v1 and v2) + + Usage: + # List VPCs + vpcs = client.vpc.list() + + # Create VPC + vpc = client.vpc.create( + region="ewr", + description="My VPC", + v4_subnet="10.0.0.0", + v4_subnet_mask=24 + ) + + # VPC 2.0 + vpc2 = client.vpc.create_vpc2(region="ewr", description="My VPC 2.0") + """ + + # VPC v1 + def list(self, per_page: int = 100, cursor: str = None) -> Dict: + """ + List VPCs + + Returns: + Dict with 'vpcs' list and 'meta' pagination + """ + params = {"per_page": per_page} + if cursor: + params["cursor"] = cursor + return self.client.get("vpcs", params=params) + + def list_all(self) -> List[Dict]: + """List all VPCs (auto-paginate)""" + return self.client.paginate("vpcs", "vpcs") + + def get(self, vpc_id: str) -> Dict: + """ + Get VPC details + + Args: + vpc_id: VPC ID + + Returns: + VPC details + """ + response = self.client.get(f"vpcs/{vpc_id}") + return response.get("vpc", {}) + + def create( + self, + region: str, + description: str = None, + v4_subnet: str = None, + v4_subnet_mask: int = None + ) -> Dict: + """ + Create a VPC + + Args: + region: Region ID + description: VPC description + v4_subnet: IPv4 subnet (e.g., "10.0.0.0") + v4_subnet_mask: Subnet mask (e.g., 24) + + Returns: + Created VPC details + """ + data = {"region": region} + if description: + data["description"] = description + if v4_subnet: + data["v4_subnet"] = v4_subnet + if v4_subnet_mask: + data["v4_subnet_mask"] = v4_subnet_mask + + response = self.client.post("vpcs", data) + return response.get("vpc", {}) + + def update(self, vpc_id: str, description: str) -> None: + """ + Update VPC description + + Args: + vpc_id: VPC ID + description: New description + """ + self.client.put(f"vpcs/{vpc_id}", {"description": description}) + + def delete(self, vpc_id: str) -> None: + """ + Delete a VPC + + Args: + vpc_id: VPC ID to delete + """ + self.client.delete(f"vpcs/{vpc_id}") + + # VPC 2.0 + def list_vpc2(self, per_page: int = 100, cursor: str = None) -> Dict: + """ + List VPC 2.0 networks + + Returns: + Dict with 'vpcs' list and 'meta' pagination + """ + params = {"per_page": per_page} + if cursor: + params["cursor"] = cursor + return self.client.get("vpc2", params=params) + + def list_all_vpc2(self) -> List[Dict]: + """List all VPC 2.0 networks (auto-paginate)""" + return self.client.paginate("vpc2", "vpcs") + + def get_vpc2(self, vpc_id: str) -> Dict: + """ + Get VPC 2.0 details + + Args: + vpc_id: VPC 2.0 ID + + Returns: + VPC 2.0 details + """ + response = self.client.get(f"vpc2/{vpc_id}") + return response.get("vpc", {}) + + def create_vpc2( + self, + region: str, + description: str = None, + ip_block: str = None, + prefix_length: int = None + ) -> Dict: + """ + Create a VPC 2.0 network + + Args: + region: Region ID + description: VPC description + ip_block: IP block (e.g., "10.0.0.0") + prefix_length: Prefix length (e.g., 24) + + Returns: + Created VPC 2.0 details + """ + data = {"region": region} + if description: + data["description"] = description + if ip_block: + data["ip_block"] = ip_block + if prefix_length: + data["prefix_length"] = prefix_length + + response = self.client.post("vpc2", data) + return response.get("vpc", {}) + + def update_vpc2(self, vpc_id: str, description: str) -> None: + """ + Update VPC 2.0 description + + Args: + vpc_id: VPC 2.0 ID + description: New description + """ + self.client.put(f"vpc2/{vpc_id}", {"description": description}) + + def delete_vpc2(self, vpc_id: str) -> None: + """ + Delete a VPC 2.0 network + + Args: + vpc_id: VPC 2.0 ID to delete + """ + self.client.delete(f"vpc2/{vpc_id}") + + def list_vpc2_nodes(self, vpc_id: str) -> List[Dict]: + """ + List nodes attached to VPC 2.0 + + Args: + vpc_id: VPC 2.0 ID + + Returns: + List of attached nodes + """ + response = self.client.get(f"vpc2/{vpc_id}/nodes") + return response.get("nodes", []) + + def attach_vpc2_nodes(self, vpc_id: str, nodes: List[Dict]) -> None: + """ + Attach nodes to VPC 2.0 + + Args: + vpc_id: VPC 2.0 ID + nodes: List of nodes [{"id": "instance-id", "ip_address": "10.0.0.5"}, ...] + """ + self.client.post(f"vpc2/{vpc_id}/nodes/attach", {"nodes": nodes}) + + def detach_vpc2_nodes(self, vpc_id: str, nodes: List[str]) -> None: + """ + Detach nodes from VPC 2.0 + + Args: + vpc_id: VPC 2.0 ID + nodes: List of node IDs + """ + self.client.post(f"vpc2/{vpc_id}/nodes/detach", {"nodes": nodes})