diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 607e7e1..e7c451e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,4 +1,4 @@ -# Set update schedule for GitHub Actions +# Set update schedule for GitHub Actions and Python dependencies --- version: 2 updates: @@ -6,3 +6,8 @@ updates: directory: "/" schedule: interval: "weekly" + + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml deleted file mode 100644 index 0542341..0000000 --- a/.github/workflows/flake8.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Flake8 - -on: [push] - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: GitHub Action for Flake8 - uses: cclauss/GitHub-Action-for-Flake8@v0.5.0 diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index 04d5eb3..7404fe9 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -9,19 +9,14 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.x" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -U setuptools wheel twine - - name: Build and publish + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d + + - name: Build + run: uv build + + - name: Publish env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - python setup.py sdist bdist_wheel - twine upload dist/* + UV_PUBLISH_USERNAME: ${{ secrets.PYPI_USERNAME }} + UV_PUBLISH_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: uv publish diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 95f59f0..7eb96b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,41 +1,45 @@ -name: Python package +name: Test -on: [push] +on: [push, pull_request] jobs: - build: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d + with: + enable-cache: true + - run: uv run ruff check . + - run: uv run ruff format --check . + + test: runs-on: ubuntu-latest strategy: - max-parallel: 4 + fail-fast: false matrix: python-version: - - "3.6" - - "3.7" - - "3.8" - - "3.9" - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d with: + enable-cache: true python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install tox tox-gh-actions - - name: Test with tox - run: tox - - name: GitHub Action for Flake8 - uses: cclauss/GitHub-Action-for-Flake8@v0.5.0 + - name: Run tests + run: uv run --frozen pytest --cov=cloudscale --cov-report=xml - name: Upload coverage to Codecov + if: matrix.python-version == '3.13' uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - file: ./coverage.xml + files: ./coverage.xml flags: unittests name: codecov-umbrella - yml: ./codecov.yml diff --git a/.gitignore b/.gitignore index 239d44a..c2cff0d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ dist __pycache__/ *.pyc .coverage +.pytest_cache +.ruff_cache .tox .venv coverage.xml diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index b4c5700..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,5 +0,0 @@ -include *.txt -include *.yml -include tox.ini -graft tests -global-exclude *.py[cod] diff --git a/Makefile b/Makefile index 15404f0..bc1d246 100644 --- a/Makefile +++ b/Makefile @@ -1,17 +1,30 @@ -clean: - rm -rf *.egg-info - rm -rf *.dist-info - rm -rf dist - rm -rf build +.PHONY: install lint format test coverage build clean release test-release + +install: + uv sync + +lint: + uv run ruff check . + uv run ruff format --check . + +format: + uv run ruff check --fix . + uv run ruff format . + +test: + uv run pytest + +coverage: + uv run pytest --cov=cloudscale build: clean - python3 setup.py sdist bdist_wheel + uv build -test-release: - twine upload --repository testpypi dist/* +clean: + rm -rf dist build *.egg-info -release: - twine upload dist/* +test-release: build + uv publish --publish-url https://test.pypi.org/legacy/ -docs: - mkdocs gh-deploy +release: build + uv publish diff --git a/README.md b/README.md index 3d86a9b..62af466 100644 --- a/README.md +++ b/README.md @@ -7,18 +7,74 @@ # Python SDK for cloudscale.ch -A Python3 library for [cloudscale.ch](https://www.cloudscale.ch). +A Python library for [cloudscale.ch](https://www.cloudscale.ch), requires Python 3.10 or newer. ## Install / Update ~~~shell -pip3 install cloudscale-sdk +pip install -U cloudscale-sdk ~~~ +## Usage + +~~~python +from cloudscale import Cloudscale + +with Cloudscale(api_token="...") as cloudscale: + server = cloudscale.server.create( + name="db-master", flavor="flex-4", image="debian-12" + ) + cloudscale.server.stop(uuid=server["uuid"]) + for volume in cloudscale.volume.get_all(filter_tag="project=apollo"): + print(volume["name"]) +~~~ + +Resources available as attributes: `custom_image`, `flavor`, `floating_ip`, `image`, +`network`, `objects_user`, `region`, `server`, `server_group`, `subnet`, `volume`. +Depending on the resource they offer `get_all()`, `get_by_uuid()`, `create()`, +`update()` and `delete()`. + +Errors are raised as `CloudscaleException` (configuration problems) and +`CloudscaleApiException` (API errors, carrying `status_code` and `response`). + +### Authentication + +The API token is taken from the `api_token` argument, or from a `cloudscale.ini` +looked up in this order, later files winning: + +1. `$XDG_CONFIG_HOME/cloudscale/cloudscale.ini` (`~/.config/...` by default) +2. `~/.cloudscale.ini` +3. `./cloudscale.ini` + +~~~ini +[default] +api_token = ... + +[production] +api_token = ... +timeout = 30 +~~~ + +Select a section with `Cloudscale(profile="production")` or `$CLOUDSCALE_PROFILE`. + +### Environment variables + +| Variable | Description | +| --- | --- | +| `CLOUDSCALE_API_URL` | API endpoint, defaults to `https://api.cloudscale.ch/v1` | +| `CLOUDSCALE_CONFIG` | Config file name, defaults to `cloudscale.ini` | +| `CLOUDSCALE_PROFILE` | Config section to use, defaults to `default` | +| `CLOUDSCALE_LOG_LEVEL` | Log level, defaults to `ERROR` | + ## Development -### Run tests with coverage +This project uses [uv](https://docs.astral.sh/uv/) and +[ruff](https://docs.astral.sh/ruff/). ~~~shell -tox -e coverage +make install # create the virtualenv +make test # run the tests +make coverage # run the tests with a coverage report +make lint # lint and format check +make format # apply lint fixes and formatting ~~~ diff --git a/cloudscale/__init__.py b/cloudscale/__init__.py index dc2efa6..fdfa1cd 100644 --- a/cloudscale/__init__.py +++ b/cloudscale/__init__.py @@ -1,118 +1,15 @@ -import configparser -import logging -import os +"""A Python SDK for the cloudscale.ch API.""" -from xdg import XDG_CONFIG_HOME - -from .error import CloudscaleApiException, CloudscaleException # noqa F401 -from .lib.custom_image import CustomImage -from .lib.flavor import Flavor -from .lib.floating_ip import FloatingIp -from .lib.image import Image -from .lib.network import Network -from .lib.objects_user import ObjectsUser -from .lib.region import Region -from .lib.server import Server -from .lib.server_group import ServerGroup -from .lib.subnet import Subnet -from .lib.volume import Volume -from .log import logger +from .client import CLOUDSCALE_API_URL, Cloudscale +from .config import CLOUDSCALE_CONFIG +from .error import CloudscaleApiException, CloudscaleException from .version import __version__ -CLOUDSCALE_API_URL = os.getenv("CLOUDSCALE_API_URL", "https://api.cloudscale.ch/v1") -CLOUDSCALE_CONFIG = "cloudscale.ini" - - -class Cloudscale: - api_url = CLOUDSCALE_API_URL - version = __version__ - - def __init__(self, api_token: str = "", profile: str = "", debug: bool = False): - """Cloudscale - - Args: - api_token (str, optional): API token. Defaults to None. - profile (str, optional): Section to use in cloudscale.ini. Defaults to None. - debug (bool, optional): Enable debug logging. Defaults to False. - - Raises: - CloudscaleException: Exception related to API token handling. - """ - if debug: - logger.setLevel(logging.INFO) - - logger.info(f"Started, version: {self.version}") - - if api_token and profile: - raise CloudscaleException("API token and profile are mutually exclusive") - - # Read ini configs - self.config = self._read_from_configfile(profile=profile) - - if api_token: - self.api_token = api_token - else: - self.api_token = self.config.get("api_token") - - if not self.api_token: - raise CloudscaleException("Missing API key") - - logger.info(f"API Token used: {self.api_token[:4]}...") - - # Configure requests timeout - self.timeout: int = int(self.config.get("timeout", 60)) - logger.debug(f"Timeout is: {self.timeout}") - - # Resource attributes - self.custom_image = CustomImage(self) - self.flavor = Flavor(self) - self.floating_ip = FloatingIp(self) - self.image = Image(self) - self.network = Network(self) - self.objects_user = ObjectsUser(self) - self.region = Region(self) - self.server = Server(self) - self.server_group = ServerGroup(self) - self.subnet = Subnet(self) - self.volume = Volume(self) - - def _read_from_configfile(self, profile: str = "") -> dict: - """Reads from config ini file. - - Args: - profile (str, optional): Section to read. Defaults to "". - - Raises: - CloudscaleException: Profile not found. - - Returns: - dict: Read configs. - """ - - config_file = os.getenv("CLOUDSCALE_CONFIG", CLOUDSCALE_CONFIG) - - paths = ( - os.path.join(XDG_CONFIG_HOME, "cloudscale", config_file), - os.path.join(os.path.expanduser("~"), ".{}".format(config_file)), - os.path.join(os.getcwd(), config_file), - ) - - conf = configparser.ConfigParser() - conf.read(paths) - - if profile: - if not conf.has_section(profile): - raise CloudscaleException( - "Profile '{}' not found in config files: ({})".format( - profile, ", ".join(paths) - ) - ) - else: - profile = os.getenv("CLOUDSCALE_PROFILE", "default") - - logger.info(f"Using profile {profile}") - - if not conf.has_section(profile): - return dict() - - return dict(conf[profile]) +__all__ = [ + "CLOUDSCALE_API_URL", + "CLOUDSCALE_CONFIG", + "Cloudscale", + "CloudscaleApiException", + "CloudscaleException", + "__version__", +] diff --git a/cloudscale/client.py b/cloudscale/client.py new file mode 100644 index 0000000..cf24690 --- /dev/null +++ b/cloudscale/client.py @@ -0,0 +1,107 @@ +"""The :class:`Cloudscale` client, entry point of the SDK.""" + +import logging +import os +from types import TracebackType + +from .config import read_config +from .error import CloudscaleException +from .http import RestAPIClient +from .lib.custom_image import CustomImage +from .lib.flavor import Flavor +from .lib.floating_ip import FloatingIp +from .lib.image import Image +from .lib.network import Network +from .lib.objects_user import ObjectsUser +from .lib.region import Region +from .lib.server import Server +from .lib.server_group import ServerGroup +from .lib.subnet import Subnet +from .lib.volume import Volume +from .log import logger +from .version import __version__ + +CLOUDSCALE_API_URL = os.getenv("CLOUDSCALE_API_URL", "https://api.cloudscale.ch/v1") + +DEFAULT_TIMEOUT = 60 + + +class Cloudscale: + """Client for the cloudscale.ch API. + + Each API resource is available as an attribute, e.g. ``cloudscale.server``. + """ + + api_url = CLOUDSCALE_API_URL + version = __version__ + + def __init__(self, api_token: str = "", profile: str = "", debug: bool = False): + """Cloudscale + + Args: + api_token (str, optional): API token. Defaults to None. + profile (str, optional): Section to use in cloudscale.ini. Defaults to None. + debug (bool, optional): Enable debug logging. Defaults to False. + + Raises: + CloudscaleException: Exception related to API token handling. + """ + if debug: + logger.setLevel(logging.DEBUG) + + logger.info("Started, version: %s", self.version) + + if api_token and profile: + raise CloudscaleException("API token and profile are mutually exclusive") + + # Read ini configs + self.config = read_config(profile=profile) + + self.api_token: str = api_token or self.config.get("api_token", "") + if not self.api_token: + raise CloudscaleException("Missing API key") + + logger.info("API Token used: %s...", self.api_token[:4]) + + # Configure requests timeout + self.timeout: int = int(self.config.get("timeout", DEFAULT_TIMEOUT)) + logger.debug("Timeout is: %s", self.timeout) + + self._client = RestAPIClient( + api_token=self.api_token, + api_url=self.api_url, + user_agent=f"cloudscale-sdk {self.version}", + timeout=self.timeout, + ) + + # Resource attributes + self.custom_image = CustomImage(self) + self.flavor = Flavor(self) + self.floating_ip = FloatingIp(self) + self.image = Image(self) + self.network = Network(self) + self.objects_user = ObjectsUser(self) + self.region = Region(self) + self.server = Server(self) + self.server_group = ServerGroup(self) + self.subnet = Subnet(self) + self.volume = Volume(self) + + def _read_from_configfile(self, profile: str = "") -> dict: + """Deprecated, use :func:`cloudscale.config.read_config` instead.""" + return read_config(profile=profile) + + def close(self) -> None: + """Closes the underlying HTTP session.""" + self._client.close() + + def __enter__(self) -> "Cloudscale": + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close() diff --git a/cloudscale/config.py b/cloudscale/config.py new file mode 100644 index 0000000..2029244 --- /dev/null +++ b/cloudscale/config.py @@ -0,0 +1,59 @@ +"""Discovery and parsing of the ``cloudscale.ini`` config file.""" + +import configparser +import os +from pathlib import Path + +from .error import CloudscaleException +from .log import logger + +CLOUDSCALE_CONFIG = "cloudscale.ini" + + +def _xdg_config_home() -> Path: + """Returns ``$XDG_CONFIG_HOME`` with the freedesktop.org default applied.""" + return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") + + +def config_file_paths() -> tuple[str, ...]: + """Returns the config file locations, in ascending order of precedence.""" + config_file = os.getenv("CLOUDSCALE_CONFIG", CLOUDSCALE_CONFIG) + return ( + str(_xdg_config_home() / "cloudscale" / config_file), + str(Path.home() / f".{config_file}"), + str(Path.cwd() / config_file), + ) + + +def read_config(profile: str = "") -> dict[str, str]: + """Reads a profile from the config files. + + Args: + profile (str, optional): Section to read. Defaults to the section named + by ``$CLOUDSCALE_PROFILE``, or ``default``. + + Raises: + CloudscaleException: An explicitly requested profile was not found. + + Returns: + dict: The key/value pairs of the profile, empty if it does not exist. + """ + paths = config_file_paths() + + conf = configparser.ConfigParser() + conf.read(paths) + + if profile: + if not conf.has_section(profile): + raise CloudscaleException( + f"Profile '{profile}' not found in config files: ({', '.join(paths)})" + ) + else: + profile = os.getenv("CLOUDSCALE_PROFILE", "default") + + logger.info("Using profile %s", profile) + + if not conf.has_section(profile): + return {} + + return dict(conf[profile]) diff --git a/cloudscale/error.py b/cloudscale/error.py index 254cc96..fdff5d4 100644 --- a/cloudscale/error.py +++ b/cloudscale/error.py @@ -1,8 +1,16 @@ class CloudscaleException(Exception): - pass + """Raised for SDK level errors, e.g. configuration problems.""" class CloudscaleApiException(Exception): - def __init__(self, *args, **kwargs): - self.response = kwargs.pop("response") - self.status_code = kwargs.pop("status_code") + """Raised when the API answered with an unexpected HTTP status code. + + Attributes: + response (dict): The parsed response, with ``status_code`` and ``data``. + status_code (int): The HTTP status code returned by the API. + """ + + def __init__(self, *args, response: dict, status_code: int): + super().__init__(*args) + self.response = response + self.status_code = status_code diff --git a/cloudscale/http.py b/cloudscale/http.py index d870321..c5a3ddb 100644 --- a/cloudscale/http.py +++ b/cloudscale/http.py @@ -1,10 +1,16 @@ -from typing import Optional +"""Thin HTTP layer translating resource calls into API requests.""" + +import contextlib +from types import TracebackType from urllib.parse import urlencode import requests from .log import logger +#: HTTP status codes the API uses to signal success. +SUCCESS_STATUS_CODES = frozenset({200, 201, 204}) + class RestAPIClient: def __init__( @@ -18,7 +24,6 @@ def __init__( user_agent (str): HTTP user agent timeout (int, optional): HTTP timeout. Defaults to 60. """ - self.api_url = api_url self.timeout = timeout self.headers = { @@ -26,6 +31,43 @@ def __init__( "Content-type": "application/json", "User-Agent": user_agent, } + self._session = requests.Session() + + def close(self) -> None: + """Closes the underlying HTTP session.""" + self._session.close() + + def __enter__(self) -> "RestAPIClient": + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close() + + def _build_url(self, resource: str, *parts: str | None) -> str: + """Joins the API URL, the resource and any additional path segments.""" + segments = [self.api_url, resource, *(p for p in parts if p)] + return "/".join(segments) + + @staticmethod + def _build_query(payload: dict) -> str: + """Encodes a payload as query string, keys with a ``None`` value stay bare.""" + return "&".join( + key if value is None else urlencode({key: value}) + for key, value in payload.items() + ) + + def _request(self, method: str, url: str, json: dict | None = None) -> dict: + """Sends a request and returns the parsed result.""" + logger.info("HTTP %s to %s", method, url) + response = self._session.request( + method, url, json=json, headers=self.headers, timeout=self.timeout + ) + return self._return_result(response) def _return_result(self, r: requests.Response) -> dict: """Parses results from Requests @@ -36,84 +78,50 @@ def _return_result(self, r: requests.Response) -> dict: Returns: dict: Parsed results """ - - logger.info(f"HTTP status code {r.status_code}") - result = { + logger.info("HTTP status code %s", r.status_code) + result: dict = { "status_code": r.status_code, - "data": dict(), + "data": {}, } - try: + with contextlib.suppress(ValueError): result["data"] = r.json() - except ValueError: - pass return result def get_resources( self, resource: str, - payload: Optional[dict] = None, - resource_id: Optional[str] = None, + payload: dict | None = None, + resource_id: str | None = None, ) -> dict: - query_url = f"{self.api_url}/{resource}" - if resource_id: - query_url = f"{query_url}/{resource_id}" + """Requests one resource by id, or the list of all resources.""" + query_url = self._build_url(resource, resource_id) if payload: - data = "" - for k, v in payload.items(): - if v is not None: - data = urlencode({k: v}) - else: - data = k - break + query_url = f"{query_url}?{self._build_query(payload)}" - query_url = f"{query_url}?{data}" - - logger.info(f"HTTP GET to {query_url}") - - r = requests.get(query_url, headers=self.headers, timeout=self.timeout) - return self._return_result(r) + return self._request("GET", query_url) def post_patch_resource( self, resource: str, - payload: Optional[dict] = None, - resource_id: Optional[str] = None, - action: Optional[str] = None, + payload: dict | None = None, + resource_id: str | None = None, + action: str | None = None, ) -> dict: - if payload: - data = {k: v for k, v in payload.items() if v is not None} - else: - data = dict() - - query_url = f"{self.api_url}/{resource}" - - if not resource_id: - logger.info(f"HTTP POST to {query_url}") - r = requests.post( - query_url, json=data, headers=self.headers, timeout=self.timeout - ) - return self._return_result(r) - - query_url += f"/{resource_id}" - if action: - query_url += f"/{action}" - logger.info(f"HTTP POST to {query_url}") - r = requests.post( - query_url, json=data, headers=self.headers, timeout=self.timeout - ) - return self._return_result(r) - else: - logger.info(f"HTTP PATCH to {query_url}") - r = requests.patch( - query_url, json=data, headers=self.headers, timeout=self.timeout - ) - return self._return_result(r) + """Creates (POST), updates (PATCH) or acts (POST) on a resource. + + Without a ``resource_id`` the resource is created, with an ``action`` the + action is triggered on it, otherwise the resource is updated. + """ + data = {k: v for k, v in (payload or {}).items() if v is not None} + + query_url = self._build_url(resource, resource_id, action) + method = "PATCH" if resource_id and not action else "POST" + + return self._request(method, query_url, json=data) def delete_resource(self, resource: str, resource_id: str) -> dict: - query_url = f"{self.api_url}/{resource}/{resource_id}" - logger.info(f"HTTP DELETE to {query_url}") - r = requests.delete(query_url, headers=self.headers, timeout=self.timeout) - return self._return_result(r) + """Deletes a resource by id.""" + return self._request("DELETE", self._build_url(resource, resource_id)) diff --git a/cloudscale/lib/__init__.py b/cloudscale/lib/__init__.py index 0915a37..35c47f7 100644 --- a/cloudscale/lib/__init__.py +++ b/cloudscale/lib/__init__.py @@ -1,122 +1,10 @@ -from typing import Optional - -from cloudscale.http import RestAPIClient - -from ..error import CloudscaleApiException, CloudscaleException # noqa F401 - - -class CloudscaleBase: - - resource: str = "" - - def __init__(self, config): - """ - :param config: Cloudscale - :return self - """ - self._client = RestAPIClient( - api_token=config.api_token, - api_url=config.api_url, - user_agent=f"cloudscale-sdk {config.version}", - timeout=config.timeout, - ) - - def _process_response(self, response: dict): - status_code: int = int(response.get("status_code")) - if status_code not in (200, 201, 204): - data: dict = response.get("data", dict()) - raise CloudscaleApiException( - f"API Response Error ({status_code}): {data.get('detail', data)}", - response=response, - status_code=status_code, - ) - return response.get("data") - - def get_all(self, path: str = "", filter_tag: Optional[str] = None) -> list: - """Lists all API resources. - - Args: - path (str, optional): Path the resource is located under. Default to "". - filter_tag (str, optional): Filter by tag in format = or . Defaults to None. - - Returns: - list: API data response. - """ - if filter_tag is not None: - if "=" in filter_tag: - tag_key, tag_value = filter_tag.split("=") - else: - tag_key = filter_tag - tag_value = None - - if not tag_key.startswith("tag:"): - tag_key = f"tag:{tag_key}" - - payload = {tag_key: tag_value} - else: - payload = None - - result = self._client.get_resources(self.resource + path, payload=payload) - - return self._process_response(result) or list() - - -class CloudscaleBaseExt(CloudscaleBase): - def get_by_uuid(self, uuid: str, path: str = "") -> dict: - """Queries an API resource by UUID. - - Args: - uuid (str): UUID of the resource. - path (str, optional): Path the resource is located under. Default to "". - - Returns: - dict: API data response. - """ - response = self._client.get_resources(self.resource + path, resource_id=uuid) - return self._process_response(response) or dict() - - -class CloudscaleMutable(CloudscaleBaseExt): - def delete(self, uuid: str, path: str = ""): - """Deletes an API resource by UUID. - - Args: - uuid (str): UUID of the resource. - path (str, optional): Path the resource is located under. Default to "". - - Returns: - dict: API data response. - """ - response = self._client.delete_resource(self.resource + path, resource_id=uuid) - return self._process_response(response) - - def update(self, uuid: str, payload: dict, path: str = "") -> dict: - """Updates an API resource by UUID. - - Args: - uuid (str): UUID of the resource. - payload (dict): API arguments. - path (str, optional): Path the resource is located under. Default to "". - - Returns: - dict: API data response. - """ - response = self._client.post_patch_resource( - self.resource + path, resource_id=uuid, payload=payload - ) - return self._process_response(response) or dict() - - def create(self, payload: dict, path: str = "") -> dict: - """Creates an API resource. - - Args: - payload (dict): API arguments. - path (str, optional): Path the resource is located under. Default to "". - - Returns: - dict: API data response. - """ - response = self._client.post_patch_resource( - self.resource + path, payload=payload - ) - return self._process_response(response) or dict() +from ..error import CloudscaleApiException, CloudscaleException +from .base import CloudscaleBase, CloudscaleBaseExt, CloudscaleMutable + +__all__ = [ + "CloudscaleApiException", + "CloudscaleBase", + "CloudscaleBaseExt", + "CloudscaleException", + "CloudscaleMutable", +] diff --git a/cloudscale/lib/base.py b/cloudscale/lib/base.py new file mode 100644 index 0000000..750d92d --- /dev/null +++ b/cloudscale/lib/base.py @@ -0,0 +1,142 @@ +"""Base classes shared by all API resources.""" + +from ..error import CloudscaleApiException +from ..http import SUCCESS_STATUS_CODES, RestAPIClient + + +class CloudscaleBase: + """A read-only API resource, listable via :meth:`get_all`.""" + + resource: str = "" + + def __init__(self, config): + """ + Args: + config: The :class:`~cloudscale.Cloudscale` instance owning this + resource. Any object exposing ``api_token``, ``api_url``, + ``version`` and ``timeout`` is accepted as well, in which case a + private HTTP client is created for it. + """ + client = getattr(config, "_client", None) + if client is None: + client = RestAPIClient( + api_token=config.api_token, + api_url=config.api_url, + user_agent=f"cloudscale-sdk {config.version}", + timeout=config.timeout, + ) + self._client: RestAPIClient = client + + def _process_response(self, response: dict): + """Unwraps the API data, raising on unexpected HTTP status codes. + + Args: + response (dict): The result of a :class:`~cloudscale.http.RestAPIClient` call. + + Raises: + CloudscaleApiException: The API returned an error status code. + + Returns: + The ``data`` part of the response. + """ + status_code = int(response.get("status_code") or 0) + data = response.get("data") or {} + + if status_code not in SUCCESS_STATUS_CODES: + detail = data.get("detail", data) if isinstance(data, dict) else data + raise CloudscaleApiException( + f"API Response Error ({status_code}): {detail}", + response=response, + status_code=status_code, + ) + + return response.get("data") + + def get_all(self, path: str = "", filter_tag: str | None = None) -> list: + """Lists all API resources. + + Args: + path (str, optional): Path the resource is located under. Default to "". + filter_tag (str, optional): Filter by tag in format = or . Defaults to None. + + Returns: + list: API data response. + """ + payload = _tag_filter_payload(filter_tag) if filter_tag is not None else None + response = self._client.get_resources(self.resource + path, payload=payload) + return self._process_response(response) or [] + + +class CloudscaleBaseExt(CloudscaleBase): + """A read-only API resource that can also be queried by UUID.""" + + def get_by_uuid(self, uuid: str, path: str = "") -> dict: + """Queries an API resource by UUID. + + Args: + uuid (str): UUID of the resource. + path (str, optional): Path the resource is located under. Default to "". + + Returns: + dict: API data response. + """ + response = self._client.get_resources(self.resource + path, resource_id=uuid) + return self._process_response(response) or {} + + +class CloudscaleMutable(CloudscaleBaseExt): + """An API resource that can be created, updated and deleted.""" + + def delete(self, uuid: str, path: str = ""): + """Deletes an API resource by UUID. + + Args: + uuid (str): UUID of the resource. + path (str, optional): Path the resource is located under. Default to "". + + Returns: + dict: API data response. + """ + response = self._client.delete_resource(self.resource + path, resource_id=uuid) + return self._process_response(response) + + def update(self, uuid: str, payload: dict, path: str = "") -> dict: + """Updates an API resource by UUID. + + Args: + uuid (str): UUID of the resource. + payload (dict): API arguments. + path (str, optional): Path the resource is located under. Default to "". + + Returns: + dict: API data response. + """ + response = self._client.post_patch_resource( + self.resource + path, resource_id=uuid, payload=payload + ) + return self._process_response(response) or {} + + def create(self, payload: dict, path: str = "") -> dict: + """Creates an API resource. + + Args: + payload (dict): API arguments. + path (str, optional): Path the resource is located under. Default to "". + + Returns: + dict: API data response. + """ + response = self._client.post_patch_resource( + self.resource + path, payload=payload + ) + return self._process_response(response) or {} + + +def _tag_filter_payload(filter_tag: str) -> dict[str, str | None]: + """Turns a ``=`` or ```` filter into a query payload.""" + tag_key, _, tag_value = filter_tag.partition("=") + + if not tag_key.startswith("tag:"): + tag_key = f"tag:{tag_key}" + + return {tag_key: tag_value if "=" in filter_tag else None} diff --git a/cloudscale/lib/custom_image.py b/cloudscale/lib/custom_image.py index 57c16cb..0955a60 100644 --- a/cloudscale/lib/custom_image.py +++ b/cloudscale/lib/custom_image.py @@ -1,6 +1,4 @@ -from typing import Optional - -from . import CloudscaleMutable +from .base import CloudscaleMutable class CustomImage(CloudscaleMutable): @@ -14,7 +12,7 @@ def import_by_url( zones: list, user_data_handling: str, source_format: str, - tags: Optional[dict] = None, + tags: dict | None = None, ) -> dict: """Imports a custom image. @@ -51,16 +49,21 @@ def get_import_by_uuid(self, uuid: str) -> dict: """ return super().get_by_uuid(uuid=uuid, path="/import") - def create(self, **kwargs): + def create(self, *args, **kwargs): + """Not available, use :meth:`import_by_url` instead. + + Raises: + NotImplementedError: Always, custom images cannot be created directly. + """ raise NotImplementedError def update( self, uuid: str, - name: Optional[str] = None, - slug: Optional[str] = None, - user_data_handling: Optional[str] = None, - tags: Optional[dict] = None, + name: str | None = None, + slug: str | None = None, + user_data_handling: str | None = None, + tags: dict | None = None, ) -> dict: """Updates a custom image. diff --git a/cloudscale/lib/flavor.py b/cloudscale/lib/flavor.py index a0a74da..74be090 100644 --- a/cloudscale/lib/flavor.py +++ b/cloudscale/lib/flavor.py @@ -1,4 +1,4 @@ -from . import CloudscaleBase +from .base import CloudscaleBase class Flavor(CloudscaleBase): diff --git a/cloudscale/lib/floating_ip.py b/cloudscale/lib/floating_ip.py index 9dd24ba..40366c0 100644 --- a/cloudscale/lib/floating_ip.py +++ b/cloudscale/lib/floating_ip.py @@ -1,6 +1,4 @@ -from typing import Optional - -from . import CloudscaleMutable +from .base import CloudscaleMutable class FloatingIp(CloudscaleMutable): @@ -9,12 +7,12 @@ class FloatingIp(CloudscaleMutable): def create( self, ip_version: int, - prefix_length: Optional[int] = None, - reverse_ptr: Optional[str] = None, - server_uuid: Optional[str] = None, - scope: Optional[str] = None, - region: Optional[str] = None, - tags: Optional[dict] = None, + prefix_length: int | None = None, + reverse_ptr: str | None = None, + server_uuid: str | None = None, + scope: str | None = None, + region: str | None = None, + tags: dict | None = None, ) -> dict: """Creates a floating IP. @@ -43,9 +41,9 @@ def create( def update( self, uuid: str, - reverse_ptr: Optional[str] = None, - server_uuid: Optional[str] = None, - tags: Optional[dict] = None, + reverse_ptr: str | None = None, + server_uuid: str | None = None, + tags: dict | None = None, ) -> dict: """Updates a floating IP. diff --git a/cloudscale/lib/image.py b/cloudscale/lib/image.py index c151eee..cd14b90 100644 --- a/cloudscale/lib/image.py +++ b/cloudscale/lib/image.py @@ -1,4 +1,4 @@ -from . import CloudscaleBase +from .base import CloudscaleBase class Image(CloudscaleBase): diff --git a/cloudscale/lib/network.py b/cloudscale/lib/network.py index 21cff4d..21c398d 100644 --- a/cloudscale/lib/network.py +++ b/cloudscale/lib/network.py @@ -1,6 +1,4 @@ -from typing import Optional - -from . import CloudscaleMutable +from .base import CloudscaleMutable class Network(CloudscaleMutable): @@ -9,10 +7,10 @@ class Network(CloudscaleMutable): def create( self, name: str, - zone: Optional[str] = None, - mtu: Optional[int] = None, - auto_create_ipv4_subnet: Optional[bool] = None, - tags: Optional[dict] = None, + zone: str | None = None, + mtu: int | None = None, + auto_create_ipv4_subnet: bool | None = None, + tags: dict | None = None, ) -> dict: """Creates a network. @@ -38,9 +36,9 @@ def create( def update( self, uuid: str, - name: Optional[str] = None, - mtu: Optional[int] = None, - tags: Optional[dict] = None, + name: str | None = None, + mtu: int | None = None, + tags: dict | None = None, ) -> dict: """Updates a network. diff --git a/cloudscale/lib/objects_user.py b/cloudscale/lib/objects_user.py index fa51b86..570a636 100644 --- a/cloudscale/lib/objects_user.py +++ b/cloudscale/lib/objects_user.py @@ -1,12 +1,10 @@ -from typing import Optional - -from . import CloudscaleMutable +from .base import CloudscaleMutable class ObjectsUser(CloudscaleMutable): resource = "objects-users" - def create(self, display_name: str, tags: Optional[dict] = None) -> dict: + def create(self, display_name: str, tags: dict | None = None) -> dict: """Creates an objects user. Args: @@ -23,7 +21,7 @@ def create(self, display_name: str, tags: Optional[dict] = None) -> dict: return super().create(payload=payload) def update( - self, uuid: str, display_name: Optional[str] = None, tags: Optional[dict] = None + self, uuid: str, display_name: str | None = None, tags: dict | None = None ) -> dict: """Updates an objects user. diff --git a/cloudscale/lib/region.py b/cloudscale/lib/region.py index e6f4067..9d75687 100644 --- a/cloudscale/lib/region.py +++ b/cloudscale/lib/region.py @@ -1,6 +1,5 @@ -from . import CloudscaleBase +from .base import CloudscaleBase class Region(CloudscaleBase): - resource = "regions" diff --git a/cloudscale/lib/server.py b/cloudscale/lib/server.py index cc32800..d5b9cde 100644 --- a/cloudscale/lib/server.py +++ b/cloudscale/lib/server.py @@ -1,6 +1,6 @@ -from typing import Optional +import warnings -from . import CloudscaleMutable +from .base import CloudscaleMutable class Server(CloudscaleMutable): @@ -11,18 +11,19 @@ def create( name: str, flavor: str, image: str, - zone: Optional[str] = None, - volume_size: Optional[int] = None, - volumes: Optional[list] = None, - interfaces: Optional[list] = None, - ssh_keys: Optional[list] = None, - password: Optional[str] = None, - use_public_network: Optional[bool] = None, - use_private_network: Optional[bool] = None, - use_ipv6: Optional[bool] = None, - server_groups: Optional[list] = None, - user_data: Optional[str] = None, - tags: Optional[dict] = None, + zone: str | None = None, + volume_size_gb: int | None = None, + volumes: list | None = None, + interfaces: list | None = None, + ssh_keys: list | None = None, + password: str | None = None, + use_public_network: bool | None = None, + use_private_network: bool | None = None, + use_ipv6: bool | None = None, + server_groups: list | None = None, + user_data: str | None = None, + tags: dict | None = None, + volume_size: int | None = None, ) -> dict: """Creates a server. @@ -31,7 +32,7 @@ def create( flavor (str): The flavor of the server. image (str): The image of the server. zone (str, optional): The zone the server is created in. Defaults to None. - volume_size (int, optional): The volume size of the server. Defaults to None. + volume_size_gb (int, optional): The size of the root volume in GiB (1024^3). Defaults to None. volumes (list, optional): A list of volumes to be created and attached to the server. Defaults to None. interfaces (list, optional): A list of interfacs to be attached to the server. Defaults to None. ssh_keys (list, optional): A list of SSH public keys to be placed on the server. Defaults to None. @@ -42,15 +43,26 @@ def create( server_groups (list, optional): A list of server groups UUIDs the server should be placed in. Defaults to None. user_data (str, optional): The user data startup config. Defaults to None. tags (dict, optional): The tags assigned to the server. Defaults to None. + volume_size (int, optional): Deprecated alias of volume_size_gb. Defaults to None. Returns: dict: API data response. """ + if volume_size is not None: + warnings.warn( + "volume_size is deprecated, use volume_size_gb instead", + DeprecationWarning, + stacklevel=2, + ) + if volume_size_gb is None: + volume_size_gb = volume_size + payload = { "name": name, "flavor": flavor, "image": image, "zone": zone, + "volume_size_gb": volume_size_gb, "volumes": volumes, "interfaces": interfaces, "ssh_keys": ssh_keys, @@ -67,10 +79,10 @@ def create( def update( self, uuid: str, - name: Optional[str] = None, - flavor: Optional[str] = None, - interfaces: Optional[dict] = None, - tags: Optional[dict] = None, + name: str | None = None, + flavor: str | None = None, + interfaces: dict | None = None, + tags: dict | None = None, ) -> dict: """Updates a server. @@ -104,7 +116,7 @@ def start(self, uuid: str) -> dict: result = self._client.post_patch_resource( self.resource, resource_id=uuid, action="start" ) - return self._process_response(result) or dict() + return self._process_response(result) or {} def stop(self, uuid: str) -> dict: """Stops a server. @@ -118,7 +130,7 @@ def stop(self, uuid: str) -> dict: result = self._client.post_patch_resource( self.resource, resource_id=uuid, action="stop" ) - return self._process_response(result) or dict() + return self._process_response(result) or {} def reboot(self, uuid: str) -> dict: """Reboots a server. @@ -132,4 +144,4 @@ def reboot(self, uuid: str) -> dict: result = self._client.post_patch_resource( self.resource, resource_id=uuid, action="reboot" ) - return self._process_response(result) or dict() + return self._process_response(result) or {} diff --git a/cloudscale/lib/server_group.py b/cloudscale/lib/server_group.py index 4583f46..59b9f57 100644 --- a/cloudscale/lib/server_group.py +++ b/cloudscale/lib/server_group.py @@ -1,6 +1,4 @@ -from typing import Optional - -from . import CloudscaleMutable +from .base import CloudscaleMutable class ServerGroup(CloudscaleMutable): @@ -9,8 +7,8 @@ class ServerGroup(CloudscaleMutable): def create( self, name: str, - group_type: Optional[str] = None, - tags: Optional[dict] = None, + group_type: str | None = None, + tags: dict | None = None, ) -> dict: """Creates a server group. @@ -32,8 +30,8 @@ def create( def update( self, uuid: str, - name: Optional[str] = None, - tags: Optional[dict] = None, + name: str | None = None, + tags: dict | None = None, ) -> dict: """Updates a server group. diff --git a/cloudscale/lib/subnet.py b/cloudscale/lib/subnet.py index 5780773..510a09e 100644 --- a/cloudscale/lib/subnet.py +++ b/cloudscale/lib/subnet.py @@ -1,6 +1,4 @@ -from typing import Optional - -from . import CloudscaleMutable +from .base import CloudscaleMutable class Subnet(CloudscaleMutable): @@ -10,9 +8,9 @@ def create( self, cidr: str, network_uuid: str, - gateway_address: Optional[str] = None, - dns_servers: Optional[list] = None, - tags: Optional[dict] = None, + gateway_address: str | None = None, + dns_servers: list | None = None, + tags: dict | None = None, ) -> dict: """Creates a subnet. @@ -38,9 +36,9 @@ def create( def update( self, uuid: str, - gateway_address: Optional[str] = None, - dns_servers: Optional[list] = None, - tags: Optional[dict] = None, + gateway_address: str | None = None, + dns_servers: list | None = None, + tags: dict | None = None, ) -> dict: """Updates a subnet. diff --git a/cloudscale/lib/volume.py b/cloudscale/lib/volume.py index 9edf560..36b9796 100644 --- a/cloudscale/lib/volume.py +++ b/cloudscale/lib/volume.py @@ -1,6 +1,4 @@ -from typing import Optional - -from . import CloudscaleMutable +from .base import CloudscaleMutable class Volume(CloudscaleMutable): @@ -11,9 +9,9 @@ def create( name: str, server_uuids: list, size_gb: int, - volume_type: Optional[str] = None, - zone: Optional[str] = None, - tags: Optional[dict] = None, + volume_type: str | None = None, + zone: str | None = None, + tags: dict | None = None, ) -> dict: """Creates a Volume. @@ -41,10 +39,10 @@ def create( def update( self, uuid: str, - name: Optional[str] = None, - server_uuids: Optional[list] = None, - size_gb: Optional[int] = None, - tags: Optional[dict] = None, + name: str | None = None, + server_uuids: list | None = None, + size_gb: int | None = None, + tags: dict | None = None, ) -> dict: """Updates a volume. diff --git a/cloudscale/log.py b/cloudscale/log.py index 917f90c..ead4372 100644 --- a/cloudscale/log.py +++ b/cloudscale/log.py @@ -1,9 +1,10 @@ -import os import logging +import os logging.basicConfig( - level=os.environ.get('CLOUDSCALE_LOG_LEVEL', 'ERROR').upper(), - format='%(asctime)s - %(name)s:%(levelname)s:%(message)s') + level=os.environ.get("CLOUDSCALE_LOG_LEVEL", "INFO").upper(), + format="%(asctime)s - %(name)s:%(levelname)s:%(message)s", +) logger = logging.getLogger(__name__) -logger.debug('Init') +logger.debug("Init") diff --git a/cloudscale/py.typed b/cloudscale/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6df489a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,84 @@ +[project] +name = "cloudscale-sdk" +description = "A library and SDK for cloudscale.ch" +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.10" +authors = [{ name = "René Moser", email = "mail@renemoser.net" }] +classifiers = [ + "Intended Audience :: Developers", + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = ["requests>=2.25"] +dynamic = ["version"] + +[project.urls] +Homepage = "https://github.com/cloudscale-ch/cloudscale-python-sdk" +Issues = "https://github.com/cloudscale-ch/cloudscale-python-sdk/issues" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "cloudscale/version.py" + +[tool.hatch.build.targets.sdist] +include = ["/cloudscale", "/tests", "/README.md", "/LICENSE"] + +[tool.hatch.build.targets.wheel] +packages = ["cloudscale"] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "responses>=0.25", + "ruff>=0.14", +] + +[tool.ruff] +target-version = "py310" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "SIM", # flake8-simplify + "RUF", # ruff specific +] +ignore = [ + "E501", # line length is handled by the formatter +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101"] + +[tool.pytest.ini_options] +minversion = "8.0" +testpaths = ["tests"] +addopts = "-ra --strict-markers --strict-config" + +[tool.coverage.run] +source = ["cloudscale"] +branch = true + +[tool.coverage.report] +show_missing = true +fail_under = 100 +exclude_also = ["if TYPE_CHECKING:", "raise NotImplementedError"] diff --git a/requirements.dev.txt b/requirements.dev.txt deleted file mode 100644 index 1047910..0000000 --- a/requirements.dev.txt +++ /dev/null @@ -1,4 +0,0 @@ -pytest>=5.0.0 -pytest-cache -pytest-cov -responses diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 3a3b8de..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -requests -xdg diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 1258498..0000000 --- a/setup.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[flake8] -ignore = E261,E301,E302,E402,E731 -max-line-length = 160 - -[metadata] -license_file = LICENSE diff --git a/setup.py b/setup.py deleted file mode 100644 index 921bd4f..0000000 --- a/setup.py +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -from setuptools import find_packages, setup - -with open("README.md", "r") as fh: - long_description = fh.read() - -install_requires = [] -with open("requirements.txt", "r", encoding="utf-8") as f: - install_requires = list(i.rstrip() for i in f.readlines()) - -extras_require = { - 'highlight': ['pygments'], -} - -tests_require = [] -with open("requirements.dev.txt", "r", encoding="utf-8") as f: - tests_require = list(i.rstrip() for i in f.readlines()) - -version = {} -with open("cloudscale/version.py") as fp: - exec(fp.read(), version) - -setup( - name="cloudscale-sdk", - version=version['__version__'], - author="René Moser", - author_email="mail@renemoser.net", - license="MIT", - description="A library and SDK for cloudscale.ch", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/cloudscale-ch/cloudscale-python-sdk", - packages=find_packages(exclude=["test.*", "tests"]), - classifiers=[ - "Intended Audience :: Developers", - "Development Status :: 4 - Beta", - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Environment :: Console", - ], - install_requires=install_requires, - extras_require=extras_require, - tests_require=tests_require, - python_requires='>=3.6', -) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..97d1531 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,39 @@ +"""Shared fixtures. + +Every test runs against a throw-away ``$HOME``/CWD so that a real +``cloudscale.ini`` on the developer's machine can never influence the outcome. +""" + +import pytest +import responses as responses_module + +from cloudscale import CLOUDSCALE_API_URL, Cloudscale + +API_URL = CLOUDSCALE_API_URL + + +@pytest.fixture(autouse=True) +def isolated_env(tmp_path, monkeypatch): + """Isolates config file discovery from the machine running the tests.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.delenv("CLOUDSCALE_CONFIG", raising=False) + monkeypatch.delenv("CLOUDSCALE_PROFILE", raising=False) + monkeypatch.chdir(tmp_path) + return home + + +@pytest.fixture +def responses(): + """Mocks out requests, asserting that every registered response is used.""" + with responses_module.RequestsMock() as mock: + yield mock + + +@pytest.fixture +def cloudscale(): + """A client authenticated with a dummy token.""" + with Cloudscale(api_token="token") as client: + yield client diff --git a/tests/test_backwards_compat.py b/tests/test_backwards_compat.py new file mode 100644 index 0000000..06d2185 --- /dev/null +++ b/tests/test_backwards_compat.py @@ -0,0 +1,79 @@ +"""Locks down the public API surface of releases before 0.8.0.""" + +import cloudscale +import cloudscale.error +import cloudscale.http +import cloudscale.lib +import cloudscale.log +import cloudscale.version + + +def test_top_level_names(): + from cloudscale import ( # noqa: F401 + CLOUDSCALE_API_URL, + CLOUDSCALE_CONFIG, + Cloudscale, + CloudscaleApiException, + CloudscaleException, + ) + + assert CLOUDSCALE_CONFIG == "cloudscale.ini" + assert CLOUDSCALE_API_URL.startswith("http") + + +def test_base_classes_importable_from_lib(): + from cloudscale.lib import ( # noqa: F401 + CloudscaleApiException, + CloudscaleBase, + CloudscaleBaseExt, + CloudscaleException, + CloudscaleMutable, + ) + + +def test_resource_classes_keep_their_modules(): + from cloudscale.lib.custom_image import CustomImage + from cloudscale.lib.flavor import Flavor + from cloudscale.lib.floating_ip import FloatingIp + from cloudscale.lib.image import Image + from cloudscale.lib.network import Network + from cloudscale.lib.objects_user import ObjectsUser + from cloudscale.lib.region import Region + from cloudscale.lib.server import Server + from cloudscale.lib.server_group import ServerGroup + from cloudscale.lib.subnet import Subnet + from cloudscale.lib.volume import Volume + + assert CustomImage.resource == "custom-images" + assert Flavor.resource == "flavors" + assert FloatingIp.resource == "floating-ips" + assert Image.resource == "images" + assert Network.resource == "networks" + assert ObjectsUser.resource == "objects-users" + assert Region.resource == "regions" + assert Server.resource == "servers" + assert ServerGroup.resource == "server-groups" + assert Subnet.resource == "subnets" + assert Volume.resource == "volumes" + + +def test_rest_api_client_and_logger(): + from cloudscale.http import RestAPIClient + + assert RestAPIClient + assert cloudscale.log.logger.name == "cloudscale.log" + + +def test_version_module(): + assert cloudscale.version.__version__ == cloudscale.__version__ + + +def test_api_exception_signature(): + error = cloudscale.CloudscaleApiException( + "boom", response={"status_code": 500, "data": {}}, status_code=500 + ) + assert str(error) == "boom" + assert error.status_code == 500 + assert error.response == {"status_code": 500, "data": {}} + assert isinstance(error, Exception) + assert not isinstance(error, cloudscale.CloudscaleException) diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 0000000..b907e68 --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,119 @@ +"""Tests for the behaviour shared by all resources.""" + +import pytest +import responses as responses_module + +from cloudscale import CloudscaleApiException +from cloudscale.lib import CloudscaleBase, CloudscaleBaseExt, CloudscaleMutable +from cloudscale.lib.base import _tag_filter_payload + +from .conftest import API_URL + + +def test_inheritance_chain(): + assert issubclass(CloudscaleMutable, CloudscaleBaseExt) + assert issubclass(CloudscaleBaseExt, CloudscaleBase) + + +@pytest.mark.parametrize( + ("filter_tag", "expected"), + [ + ("project=gemini", {"tag:project": "gemini"}), + ("project", {"tag:project": None}), + ("tag:project=gemini", {"tag:project": "gemini"}), + ("tag:project", {"tag:project": None}), + ("project=", {"tag:project": ""}), + ], +) +def test_tag_filter_payload(filter_tag, expected): + assert _tag_filter_payload(filter_tag) == expected + + +@pytest.mark.parametrize( + ("filter_tag", "query_string"), + [ + ("project=gemini", "tag:project=gemini"), + ("project", "tag:project"), + ], +) +def test_get_all_sends_the_tag_filter(responses, cloudscale, filter_tag, query_string): + responses.get( + f"{API_URL}/servers", + json=[], + status=200, + match=[responses_module.matchers.query_string_matcher(query_string)], + ) + + assert cloudscale.server.get_all(filter_tag=filter_tag) == [] + + +def test_get_all_returns_empty_list_without_data(responses, cloudscale): + responses.get(f"{API_URL}/servers", body="", status=200) + + assert cloudscale.server.get_all() == [] + + +def test_get_by_uuid_returns_empty_dict_without_data(responses, cloudscale): + responses.get(f"{API_URL}/servers/1", body="", status=200) + + assert cloudscale.server.get_by_uuid(uuid="1") == {} + + +def test_error_message_uses_the_detail_field(responses, cloudscale): + responses.get(f"{API_URL}/servers/1", json={"detail": "Not found."}, status=404) + + with pytest.raises(CloudscaleApiException) as exc_info: + cloudscale.server.get_by_uuid(uuid="1") + + error = exc_info.value + assert str(error) == "API Response Error (404): Not found." + assert error.status_code == 404 + assert error.response == {"status_code": 404, "data": {"detail": "Not found."}} + + +def test_error_message_falls_back_to_the_payload(responses, cloudscale): + responses.get(f"{API_URL}/servers/1", json={}, status=500) + + with pytest.raises( + CloudscaleApiException, match=r"^API Response Error \(500\): \{\}$" + ): + cloudscale.server.get_by_uuid(uuid="1") + + +def test_error_message_handles_a_list_payload(responses, cloudscale): + responses.get(f"{API_URL}/servers/1", json=["boom"], status=400) + + with pytest.raises( + CloudscaleApiException, match=r"^API Response Error \(400\): \['boom'\]$" + ): + cloudscale.server.get_by_uuid(uuid="1") + + +def test_error_message_without_a_body(responses, cloudscale): + responses.get(f"{API_URL}/servers/1", body="", status=502) + + with pytest.raises( + CloudscaleApiException, match=r"^API Response Error \(502\): \{\}$" + ): + cloudscale.server.get_by_uuid(uuid="1") + + +class DummyConfig: + """A duck-typed stand-in for Cloudscale, as accepted before v0.8.0.""" + + api_token = "token" + api_url = API_URL + version = "0.0.0" + timeout = 12 + + +def test_resource_accepts_a_duck_typed_config(responses): + servers = CloudscaleBaseExt(DummyConfig()) + servers.resource = "servers" + + responses.get(f"{API_URL}/servers/1", json={"uuid": "1"}, status=200) + + assert servers.get_by_uuid(uuid="1") == {"uuid": "1"} + assert servers._client.timeout == 12 + assert servers._client.headers["User-Agent"] == "cloudscale-sdk 0.0.0" + servers._client.close() diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..5457cea --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,123 @@ +import logging +from unittest import mock + +import pytest + +from cloudscale import Cloudscale, CloudscaleException +from cloudscale.client import CLOUDSCALE_API_URL +from cloudscale.log import logger + + +def write_config(path, content): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + +def test_api_token_argument(): + cloudscale = Cloudscale(api_token="token") + assert cloudscale.api_token == "token" + assert cloudscale.api_url == CLOUDSCALE_API_URL + assert cloudscale.timeout == 60 + assert cloudscale.config == {} + + +def test_missing_api_token(): + with pytest.raises(CloudscaleException, match=r"^Missing API key$"): + Cloudscale() + + +def test_api_token_and_profile_are_mutually_exclusive(): + with pytest.raises( + CloudscaleException, match=r"^API token and profile are mutually exclusive$" + ): + Cloudscale(api_token="token", profile="test") + + +def test_api_token_from_default_profile(isolated_env): + write_config( + isolated_env / ".cloudscale.ini", "[default]\napi_token = default_token\n" + ) + + cloudscale = Cloudscale() + assert cloudscale.api_token == "default_token" + + +def test_api_token_from_named_profile(isolated_env): + write_config(isolated_env / ".cloudscale.ini", "[test]\napi_token = test_token\n") + + cloudscale = Cloudscale(profile="test") + assert cloudscale.api_token == "test_token" + + +def test_timeout_from_profile(isolated_env): + write_config( + isolated_env / ".cloudscale.ini", + "[default]\napi_token = default_token\ntimeout = 5\n", + ) + + cloudscale = Cloudscale() + assert cloudscale.timeout == 5 + assert cloudscale._client.timeout == 5 + + +def test_unknown_profile_raises(isolated_env): + with pytest.raises(CloudscaleException, match="Profile 'does-not-exist' not found"): + Cloudscale(profile="does-not-exist") + + +def test_debug_enables_debug_logging(): + original_level = logger.level + try: + Cloudscale(api_token="token", debug=True) + assert logger.level == logging.DEBUG + finally: + logger.setLevel(original_level) + + +def test_deprecated_read_from_configfile(isolated_env): + write_config( + isolated_env / ".cloudscale.ini", "[default]\napi_token = default_token\n" + ) + + cloudscale = Cloudscale(api_token="token") + assert cloudscale._read_from_configfile() == {"api_token": "default_token"} + + +def test_all_resources_share_one_http_client(cloudscale): + assert cloudscale.custom_image._client is cloudscale._client + assert cloudscale.volume._client is cloudscale._client + + +@pytest.mark.parametrize( + "name", + [ + "custom_image", + "flavor", + "floating_ip", + "image", + "network", + "objects_user", + "region", + "server", + "server_group", + "subnet", + "volume", + ], +) +def test_resource_attributes(cloudscale, name): + assert getattr(cloudscale, name).resource + + +def test_close_is_idempotent(): + cloudscale = Cloudscale(api_token="token") + cloudscale.close() + cloudscale.close() + + +def test_context_manager_closes_session(): + cloudscale = Cloudscale(api_token="token") + + with mock.patch.object(cloudscale._client, "close") as close, cloudscale: + pass + + close.assert_called_once_with() diff --git a/tests/test_common.py b/tests/test_common.py deleted file mode 100644 index 63ca259..0000000 --- a/tests/test_common.py +++ /dev/null @@ -1,98 +0,0 @@ -import os -from configparser import ConfigParser - -import responses -from cloudscale import ( - CLOUDSCALE_API_URL, - Cloudscale, - CloudscaleApiException, - CloudscaleException, -) - - -class TestClass: - - CLOUDSCALE_CONFIG = os.getenv("CLOUDSCALE_CONFIG") - - def setup_class(self): - config = ConfigParser() - config.update( - { - "test": { - "api_token": "test_token", - }, - "default": { - "api_token": "default_token", - }, - } - ) - - # TODO: better solution? - if self.CLOUDSCALE_CONFIG and self.CLOUDSCALE_CONFIG.startswith("/tmp/"): - with open(self.CLOUDSCALE_CONFIG, "w") as configfile: # save - config.write(configfile) - - def test_config_ini(self): - if self.CLOUDSCALE_CONFIG: - - cloudscale = Cloudscale(profile="test") - assert cloudscale.api_token == "test_token" - - cloudscale = Cloudscale() - assert cloudscale.api_token == "default_token" - - try: - cloudscale = Cloudscale(profile="does-not-exist") - except CloudscaleException as e: - assert str(e).startswith( - "Profile 'does-not-exist' not found in config files:" - ) - - def teardown_class(self): - if os.path.exists(self.CLOUDSCALE_CONFIG): - os.remove(self.CLOUDSCALE_CONFIG) - - -def test_missing_api_key(): - try: - cloudscale = Cloudscale(api_token="") - except CloudscaleException as e: - assert str(e) == "Missing API key" - - -def test_mutually_exclusvie_api_key_and_profile(): - try: - cloudscale = Cloudscale(api_token="token", profile="test") - except CloudscaleException as e: - assert str(e) == "API token and profile are mutually exclusive" - - -@responses.activate -def test_exception_returns(): - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/objects-users/unknown", - json={"detail": "Not found."}, - status=404, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/objects-users/unknown", - json={}, - status=500, - ) - - try: - cloudscale = Cloudscale(api_token="token") - cloudscale.objects_user.get_by_uuid(uuid="unknown") - except CloudscaleApiException as e: - assert e.status_code == 404 - assert str(e) == "API Response Error (404): Not found." - assert e.response == {"data": {"detail": "Not found."}, "status_code": 404} - try: - cloudscale = Cloudscale(api_token="token") - cloudscale.objects_user.get_by_uuid(uuid="unknown") - except CloudscaleApiException as e: - assert e.status_code == 500 - assert e.response == {"data": {}, "status_code": 500} - assert str(e) == "API Response Error (500): {}" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..9c52ee4 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,68 @@ +import pytest + +from cloudscale import CLOUDSCALE_CONFIG, CloudscaleException +from cloudscale.config import config_file_paths, read_config + + +def test_config_file_paths_order(isolated_env, tmp_path): + paths = config_file_paths() + assert paths == ( + str(isolated_env / ".config" / "cloudscale" / CLOUDSCALE_CONFIG), + str(isolated_env / f".{CLOUDSCALE_CONFIG}"), + str(tmp_path / CLOUDSCALE_CONFIG), + ) + + +def test_config_file_paths_without_xdg_config_home(isolated_env, monkeypatch): + monkeypatch.delenv("XDG_CONFIG_HOME") + assert config_file_paths()[0] == str( + isolated_env / ".config" / "cloudscale" / CLOUDSCALE_CONFIG + ) + + +def test_config_file_name_from_env(isolated_env, monkeypatch, tmp_path): + monkeypatch.setenv("CLOUDSCALE_CONFIG", "other.ini") + assert config_file_paths()[2] == str(tmp_path / "other.ini") + + +def test_read_config_without_any_config_file(): + assert read_config() == {} + + +def test_read_config_cwd_takes_precedence(isolated_env, tmp_path): + (isolated_env / f".{CLOUDSCALE_CONFIG}").write_text("[default]\napi_token = home\n") + (tmp_path / CLOUDSCALE_CONFIG).write_text("[default]\napi_token = cwd\n") + + assert read_config() == {"api_token": "cwd"} + + +def test_read_config_xdg_location(isolated_env): + path = isolated_env / ".config" / "cloudscale" / CLOUDSCALE_CONFIG + path.parent.mkdir(parents=True) + path.write_text("[default]\napi_token = xdg\n") + + assert read_config() == {"api_token": "xdg"} + + +def test_read_config_profile_from_env(isolated_env, monkeypatch, tmp_path): + monkeypatch.setenv("CLOUDSCALE_PROFILE", "staging") + (tmp_path / CLOUDSCALE_CONFIG).write_text("[staging]\napi_token = staging_token\n") + + assert read_config() == {"api_token": "staging_token"} + + +def test_read_config_missing_profile_raises(tmp_path): + (tmp_path / CLOUDSCALE_CONFIG).write_text("[default]\napi_token = token\n") + + with pytest.raises(CloudscaleException) as exc_info: + read_config(profile="nope") + + message = str(exc_info.value) + assert message.startswith("Profile 'nope' not found in config files: (") + assert str(tmp_path / CLOUDSCALE_CONFIG) in message + + +def test_read_config_default_profile_may_be_absent(tmp_path): + (tmp_path / CLOUDSCALE_CONFIG).write_text("[other]\napi_token = token\n") + + assert read_config() == {} diff --git a/tests/test_custom_image.py b/tests/test_custom_image.py index ed92844..03401f4 100644 --- a/tests/test_custom_image.py +++ b/tests/test_custom_image.py @@ -1,17 +1,16 @@ -import unittest +import pytest +import responses as responses_module -import responses -from cloudscale import ( - CLOUDSCALE_API_URL, - Cloudscale, - CloudscaleApiException, - CloudscaleException, -) +from cloudscale import CloudscaleApiException + +from .conftest import API_URL + +UUID = "11111111-1864-4608-853a-0771b6885a3a" CUSTOM_IMAGE_RESP = { - "href": "https://api.cloudscale.ch/v1/custom-images/11111111-1864-4608-853a-0771b6885a3a", + "href": f"https://api.cloudscale.ch/v1/custom-images/{UUID}", "created_at": "2020-05-29T13:18:42.511407Z", - "uuid": "11111111-1864-4608-853a-0771b6885a3a", + "uuid": UUID, "name": "my-image", "slug": "my-image-slug", "checksums": { @@ -24,11 +23,11 @@ } CUSTOM_IMAGE_IMPORT_RESP = { - "href": "https://api.cloudscale.ch/v1/custom-images/import/11111111-1864-4608-853a-0771b6885a3a", - "uuid": "11111111-1864-4608-853a-0771b6885a3a", + "href": f"https://api.cloudscale.ch/v1/custom-images/import/{UUID}", + "uuid": UUID, "custom_image": { - "href": "https://api.cloudscale.ch/v1/custom-images/11111111-1864-4608-853a-0771b6885a3a", - "uuid": "11111111-1864-4608-853a-0771b6885a3a", + "href": f"https://api.cloudscale.ch/v1/custom-images/{UUID}", + "uuid": UUID, "name": "my-image", }, "url": "https://example.com/foo.raw", @@ -36,133 +35,111 @@ } -@responses.activate -def test_custom_images_get_all(): - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/custom-images", - json=[CUSTOM_IMAGE_RESP], - status=200, - ) +def test_custom_image_get_all(responses, cloudscale): + responses.get(f"{API_URL}/custom-images", json=[CUSTOM_IMAGE_RESP], status=200) - cloudscale = Cloudscale(api_token="token") custom_images = cloudscale.custom_image.get_all() assert custom_images[0]["name"] == "my-image" - assert custom_images[0]["uuid"] == "11111111-1864-4608-853a-0771b6885a3a" + assert custom_images[0]["uuid"] == UUID -@responses.activate -def test_custom_images_get_by_uuid(): - uuid = "11111111-1864-4608-853a-0771b6885a3a" - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/custom-images/" + uuid, - json=CUSTOM_IMAGE_RESP, - status=200, - ) +def test_custom_image_get_by_uuid(responses, cloudscale): + responses.get(f"{API_URL}/custom-images/{UUID}", json=CUSTOM_IMAGE_RESP, status=200) - cloudscale = Cloudscale(api_token="token") - custom_image = cloudscale.custom_image.get_by_uuid(uuid=uuid) + custom_image = cloudscale.custom_image.get_by_uuid(uuid=UUID) assert custom_image["name"] == "my-image" - assert custom_image["uuid"] == uuid + assert custom_image["uuid"] == UUID -@responses.activate -def test_custom_images_delete(): - uuid = "11111111-1864-4608-853a-0771b6885a3a" - responses.add( - responses.DELETE, CLOUDSCALE_API_URL + "/custom-images/" + uuid, status=204 - ) - responses.add( - responses.DELETE, - CLOUDSCALE_API_URL + "/custom-images/unknown", - json={"detail": "Not found."}, - status=404, - ) +def test_custom_image_delete(responses, cloudscale): + responses.delete(f"{API_URL}/custom-images/{UUID}", status=204) + + assert not cloudscale.custom_image.delete(uuid=UUID) - cloudscale = Cloudscale(api_token="token") - custom_image = cloudscale.custom_image.delete(uuid=uuid) - assert not custom_image - try: - cloudscale = Cloudscale(api_token="token") +def test_custom_image_delete_not_found(responses, cloudscale): + responses.delete( + f"{API_URL}/custom-images/unknown", json={"detail": "Not found."}, status=404 + ) + + with pytest.raises(CloudscaleApiException) as exc_info: cloudscale.custom_image.delete(uuid="unknown") - except CloudscaleApiException as e: - assert e.status_code == 404 + + assert exc_info.value.status_code == 404 -@responses.activate -def test_custom_images_import(): - name = "my-image" - responses.add( - responses.POST, - CLOUDSCALE_API_URL + "/custom-images/import", +def test_custom_image_import_by_url(responses, cloudscale): + responses.post( + f"{API_URL}/custom-images/import", json=CUSTOM_IMAGE_IMPORT_RESP, status=201, + match=[ + responses_module.matchers.json_params_matcher( + { + "url": "https://example.com/foo.raw", + "name": "my-image", + "slug": "my-image-slug", + "zones": ["lpg1"], + "user_data_handling": "pass-through", + "source_format": "raw", + "tags": {"project": "apollo"}, + } + ) + ], ) - cloudscale = Cloudscale(api_token="token") - cloudscale.custom_image.import_by_url( + custom_image = cloudscale.custom_image.import_by_url( url="https://example.com/foo.raw", - name=name, + name="my-image", slug="my-image-slug", user_data_handling="pass-through", source_format="raw", zones=["lpg1"], + tags={"project": "apollo"}, ) + assert custom_image["status"] == "in_progress" -def test_custom_images_create_raise_error(): - try: - cloudscale = Cloudscale(api_token="token") - cloudscale.custom_image.create( - url="https://example.com/foo.raw", - name="my-image", - slug="my-image-slug", - user_data_handling="pass-through", - source_format="raw", - zones=["lpg1"], - ) - except NotImplementedError: - pass - else: - assert False - - -@responses.activate -def test_custom_images_update(): - uuid = "11111111-1864-4608-853a-0771b6885a3a" - name = "my-image" - responses.add( - responses.PATCH, - CLOUDSCALE_API_URL + "/custom-images/" + uuid, - json=CUSTOM_IMAGE_RESP, - status=204, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/custom-images/" + uuid, +def test_custom_image_create_is_not_implemented(cloudscale): + with pytest.raises(NotImplementedError): + cloudscale.custom_image.create(name="my-image") + + +def test_custom_image_update(responses, cloudscale): + responses.patch( + f"{API_URL}/custom-images/{UUID}", json=CUSTOM_IMAGE_RESP, status=200, + match=[ + responses_module.matchers.json_params_matcher( + { + "name": "my-image", + "slug": "my-image-slug", + "user_data_handling": "pass-through", + "tags": {}, + } + ) + ], + ) + + custom_image = cloudscale.custom_image.update( + uuid=UUID, + name="my-image", + slug="my-image-slug", + user_data_handling="pass-through", + tags={}, ) - cloudscale = Cloudscale(api_token="token") - custom_image = cloudscale.custom_image.update(uuid=uuid, name=name) - assert custom_image["name"] == name - assert custom_image["uuid"] == uuid - - -@responses.activate -def test_custom_image_import_status(): - uuid = "11111111-1864-4608-853a-0771b6885a3a" - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/custom-images/import/" + uuid, + assert custom_image["name"] == "my-image" + + +def test_custom_image_get_import_by_uuid(responses, cloudscale): + responses.get( + f"{API_URL}/custom-images/import/{UUID}", json=CUSTOM_IMAGE_IMPORT_RESP, status=200, ) - cloudscale = Cloudscale(api_token="token") - custom_image = cloudscale.custom_image.get_import_by_uuid(uuid=uuid) + custom_image = cloudscale.custom_image.get_import_by_uuid(uuid=UUID) assert custom_image["custom_image"]["name"] == "my-image" - assert custom_image["uuid"] == uuid + assert custom_image["uuid"] == UUID assert custom_image["status"] == "in_progress" diff --git a/tests/test_flavor.py b/tests/test_flavor.py index 44682de..540045e 100644 --- a/tests/test_flavor.py +++ b/tests/test_flavor.py @@ -1,10 +1,4 @@ -import responses -from cloudscale import ( - CLOUDSCALE_API_URL, - Cloudscale, - CloudscaleApiException, - CloudscaleException, -) +from .conftest import API_URL FLAVOR_RESP = { "slug": "flex-2", @@ -15,17 +9,9 @@ } -@responses.activate -def test_flavor_get_all(): - responses.add( - responses.GET, CLOUDSCALE_API_URL + "/flavors", json=[FLAVOR_RESP], status=200 - ) - responses.add( - responses.GET, CLOUDSCALE_API_URL + "/flavors", json=[FLAVOR_RESP], status=200 - ) - responses.add(responses.GET, CLOUDSCALE_API_URL + "/flavors", json={}, status=500) +def test_flavor_get_all(responses, cloudscale): + responses.get(f"{API_URL}/flavors", json=[FLAVOR_RESP], status=200) - cloudscale = Cloudscale(api_token="token") flavors = cloudscale.flavor.get_all() assert flavors[0]["slug"] == "flex-2" assert flavors[0]["name"] == "Flex-2" diff --git a/tests/test_floating_ip.py b/tests/test_floating_ip.py index a36598a..f3ccb79 100644 --- a/tests/test_floating_ip.py +++ b/tests/test_floating_ip.py @@ -1,15 +1,16 @@ -import responses -from cloudscale import ( - CLOUDSCALE_API_URL, - Cloudscale, - CloudscaleApiException, - CloudscaleException, -) +import pytest +import responses as responses_module + +from cloudscale import CloudscaleApiException + +from .conftest import API_URL + +NETWORK_ID = "192.0.2.123" FLOATING_IP_RESP = { - "href": "https://api.cloudscale.ch/v1/floating-ips/192.0.2.123", + "href": f"https://api.cloudscale.ch/v1/floating-ips/{NETWORK_ID}", "created_at": "2019-05-29T13:18:42.505197Z", - "network": "192.0.2.123/32", + "network": f"{NETWORK_ID}/32", "ip_version": 4, "server": { "href": "https://api.cloudscale.ch/v1/servers/47cec963-fcd2-482f-bdb6-24461b2d47b1", @@ -23,177 +24,103 @@ } -@responses.activate -def test_floating_ip_get_all(): - network_id = "192.0.2.123" - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/floating-ips", - json=[FLOATING_IP_RESP], - status=200, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/floating-ips", - json=[FLOATING_IP_RESP], - status=200, - ) - responses.add( - responses.GET, CLOUDSCALE_API_URL + "/floating-ips", json={}, status=500 - ) +def test_floating_ip_get_all(responses, cloudscale): + responses.get(f"{API_URL}/floating-ips", json=[FLOATING_IP_RESP], status=200) - cloudscale = Cloudscale(api_token="token") floating_ips = cloudscale.floating_ip.get_all() - assert floating_ips[0]["network"] == network_id + "/32" + assert floating_ips[0]["network"] == f"{NETWORK_ID}/32" + assert floating_ips[0]["ip_version"] == 4 -@responses.activate -def test_floating_ip_get_by_uuid(): - network_id = "192.0.2.123" - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/floating-ips/" + network_id, - json=FLOATING_IP_RESP, - status=200, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/floating-ips/" + network_id, - json=FLOATING_IP_RESP, - status=200, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/floating-ips/" + network_id, - json={}, - status=500, +def test_floating_ip_get_by_uuid(responses, cloudscale): + responses.get( + f"{API_URL}/floating-ips/{NETWORK_ID}", json=FLOATING_IP_RESP, status=200 ) - cloudscale = Cloudscale(api_token="token") - floating_ip = cloudscale.floating_ip.get_by_uuid(uuid=network_id) - assert floating_ip["network"] == network_id + "/32" + floating_ip = cloudscale.floating_ip.get_by_uuid(uuid=NETWORK_ID) + assert floating_ip["network"] == f"{NETWORK_ID}/32" -@responses.activate -def test_floating_ip_delete(): - network_id = "192.0.2.123" - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/floating-ips/" + network_id, - json=FLOATING_IP_RESP, - status=200, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/floating-ips/unknown", - json=FLOATING_IP_RESP, - status=200, - ) - responses.add( - responses.DELETE, CLOUDSCALE_API_URL + "/floating-ips/" + network_id, status=204 - ) - responses.add( - responses.DELETE, - CLOUDSCALE_API_URL + "/floating-ips/unknown", - json={"detail": "Not found."}, - status=404, +def test_floating_ip_get_by_uuid_not_found(responses, cloudscale): + responses.get( + f"{API_URL}/floating-ips/unknown", json={"detail": "Not found."}, status=404 ) - cloudscale = Cloudscale(api_token="token") - floating_ip = cloudscale.floating_ip.delete(uuid=network_id) - assert not floating_ip + with pytest.raises(CloudscaleApiException) as exc_info: + cloudscale.floating_ip.get_by_uuid(uuid="unknown") - try: - cloudscale = Cloudscale(api_token="token") - cloudscale.floating_ip.delete(uuid="unknown") - except CloudscaleApiException as e: - assert e.status_code == 404 + assert exc_info.value.status_code == 404 -@responses.activate -def test_floating_ip_create(): - ip_version = 4 - server_uuid = "47cec963-fcd2-482f-bdb6-24461b2d47b1" - responses.add( - responses.POST, - CLOUDSCALE_API_URL + "/floating-ips", - json=FLOATING_IP_RESP, - status=201, - ) - responses.add( - responses.POST, - CLOUDSCALE_API_URL + "/floating-ips", +def test_floating_ip_delete(responses, cloudscale): + responses.delete(f"{API_URL}/floating-ips/{NETWORK_ID}", status=204) + + assert not cloudscale.floating_ip.delete(uuid=NETWORK_ID) + + +def test_floating_ip_create(responses, cloudscale): + responses.post( + f"{API_URL}/floating-ips", json=FLOATING_IP_RESP, status=201, - ) - responses.add( - responses.POST, - CLOUDSCALE_API_URL + "/floating-ips", - json=FLOATING_IP_RESP, - status=500, + match=[ + responses_module.matchers.json_params_matcher( + { + "ip_version": 4, + "prefix_length": 32, + "reverse_ptr": "192.0.2.123.cust.cloudscale.ch", + "server": "47cec963-fcd2-482f-bdb6-24461b2d47b1", + "type": "regional", + "region": "lpg", + "tags": {"project": "apollo"}, + } + ) + ], ) - cloudscale = Cloudscale(api_token="token") - cloudscale.floating_ip.create( - ip_version=ip_version, + floating_ip = cloudscale.floating_ip.create( + ip_version=4, + prefix_length=32, + reverse_ptr="192.0.2.123.cust.cloudscale.ch", + server_uuid="47cec963-fcd2-482f-bdb6-24461b2d47b1", + scope="regional", + region="lpg", + tags={"project": "apollo"}, ) + assert floating_ip["ip_version"] == 4 -@responses.activate -def test_floating_ip_update(): - network_id = "192.0.2.123" - reverse_ptr = "192.0.2.123.cust.cloudscale.ch" - responses.add( - responses.PATCH, - CLOUDSCALE_API_URL + "/floating-ips/" + network_id, - json=FLOATING_IP_RESP, - status=204, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/floating-ips/" + network_id, - json=FLOATING_IP_RESP, - status=200, +def test_floating_ip_create_error(responses, cloudscale): + responses.post( + f"{API_URL}/floating-ips", json={"detail": "Bad request."}, status=400 ) - responses.add( - responses.PATCH, - CLOUDSCALE_API_URL + "/floating-ips/" + network_id, - json=FLOATING_IP_RESP, - status=204, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/floating-ips/" + network_id, + + with pytest.raises(CloudscaleApiException) as exc_info: + cloudscale.floating_ip.create(ip_version=4) + + assert exc_info.value.status_code == 400 + + +def test_floating_ip_update(responses, cloudscale): + responses.patch( + f"{API_URL}/floating-ips/{NETWORK_ID}", json=FLOATING_IP_RESP, status=200, - ) - responses.add( - responses.PATCH, - CLOUDSCALE_API_URL + "/floating-ips/" + network_id, - json={}, - status=500, + match=[ + responses_module.matchers.json_params_matcher( + { + "reverse_ptr": "192.0.2.123.cust.cloudscale.ch", + "server": "47cec963-fcd2-482f-bdb6-24461b2d47b1", + "tags": {}, + } + ) + ], ) - cloudscale = Cloudscale(api_token="token") floating_ip = cloudscale.floating_ip.update( - uuid=network_id, reverse_ptr=reverse_ptr + uuid=NETWORK_ID, + reverse_ptr="192.0.2.123.cust.cloudscale.ch", + server_uuid="47cec963-fcd2-482f-bdb6-24461b2d47b1", + tags={}, ) - assert floating_ip["network"] == network_id + "/32" - assert floating_ip["reverse_ptr"] == reverse_ptr - - -@responses.activate -def test_floating_ip_get_by_uuid_not_found(): - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/floating-ips/unknown", - json={"detail": "Not found."}, - status=404, - ) - try: - cloudscale = Cloudscale(api_token="token") - cloudscale.floating_ip.get_by_uuid(uuid="unknown") - except CloudscaleApiException as e: - assert e.status_code == 404 - assert str(e) == "API Response Error (404): Not found." - assert e.response == {"data": {"detail": "Not found."}, "status_code": 404} + assert floating_ip["network"] == f"{NETWORK_ID}/32" diff --git a/tests/test_http.py b/tests/test_http.py new file mode 100644 index 0000000..86f0c2b --- /dev/null +++ b/tests/test_http.py @@ -0,0 +1,119 @@ +from unittest import mock + +import pytest +import responses as responses_module + +from cloudscale.http import RestAPIClient + +API_URL = "https://api.example.com/v1" + + +@pytest.fixture +def client(): + with RestAPIClient( + api_url=API_URL, api_token="token", user_agent="test-agent", timeout=3 + ) as client: + yield client + + +def test_headers(client): + assert client.headers == { + "Authorization": "Bearer token", + "Content-type": "application/json", + "User-Agent": "test-agent", + } + + +def test_get_collection(responses, client): + responses.get(f"{API_URL}/servers", json=[{"uuid": "1"}], status=200) + + assert client.get_resources("servers") == { + "status_code": 200, + "data": [{"uuid": "1"}], + } + + +def test_get_by_id(responses, client): + responses.get(f"{API_URL}/servers/1", json={"uuid": "1"}, status=200) + + assert client.get_resources("servers", resource_id="1")["data"] == {"uuid": "1"} + + +def test_get_with_query(responses, client): + responses.get( + f"{API_URL}/servers", + json=[], + status=200, + match=[responses_module.matchers.query_string_matcher("tag:project=gemini")], + ) + + client.get_resources("servers", payload={"tag:project": "gemini"}) + + +def test_get_with_valueless_query_key(responses, client): + responses.get( + f"{API_URL}/servers", + json=[], + status=200, + match=[responses_module.matchers.query_string_matcher("tag:project")], + ) + + client.get_resources("servers", payload={"tag:project": None}) + + +def test_non_json_body_yields_empty_data(responses, client): + responses.delete(f"{API_URL}/servers/1", body="", status=204) + + assert client.delete_resource("servers", resource_id="1") == { + "status_code": 204, + "data": {}, + } + + +def test_create_posts_to_collection(responses, client): + responses.post( + f"{API_URL}/servers", + json={"uuid": "1"}, + status=201, + match=[responses_module.matchers.json_params_matcher({"name": "db"})], + ) + + client.post_patch_resource("servers", payload={"name": "db", "zone": None}) + + +def test_update_patches_the_resource(responses, client): + responses.patch( + f"{API_URL}/servers/1", + json={"uuid": "1"}, + status=200, + match=[responses_module.matchers.json_params_matcher({"name": "db"})], + ) + + client.post_patch_resource("servers", resource_id="1", payload={"name": "db"}) + + +def test_action_posts_to_the_sub_path(responses, client): + responses.post( + f"{API_URL}/servers/1/start", + status=204, + match=[responses_module.matchers.json_params_matcher({})], + ) + + client.post_patch_resource("servers", resource_id="1", action="start") + + +def test_timeout_is_passed_through(responses, client): + responses.get(f"{API_URL}/servers", json=[], status=200) + + client.get_resources("servers") + assert responses.calls[0].request.req_kwargs["timeout"] == 3 + + +def test_close_closes_the_session(): + client = RestAPIClient(api_url=API_URL, api_token="token", user_agent="test-agent") + assert client.timeout == 60 + + with mock.patch.object(client._session, "close") as close: + client.close() + + close.assert_called_once_with() diff --git a/tests/test_image.py b/tests/test_image.py index 4f4616d..a9d0fed 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -1,31 +1,17 @@ -import responses -from cloudscale import ( - CLOUDSCALE_API_URL, - Cloudscale, - CloudscaleApiException, - CloudscaleException, -) +from .conftest import API_URL IMAGE_RESP = { - "slug": "arch-18.06", - "name": "Arch 18.06", - "operating_system": "Arch", - "default_username": "arch", + "slug": "debian-10", + "name": "Debian 10 (Buster)", + "operating_system": "Debian", + "default_username": "debian", "zones": [{"slug": "rma1"}, {"slug": "lpg1"}], } -@responses.activate -def test_image_get_all(): - responses.add( - responses.GET, CLOUDSCALE_API_URL + "/images", json=[IMAGE_RESP], status=200 - ) - responses.add( - responses.GET, CLOUDSCALE_API_URL + "/images", json=[IMAGE_RESP], status=200 - ) - responses.add(responses.GET, CLOUDSCALE_API_URL + "/images", json={}, status=500) +def test_image_get_all(responses, cloudscale): + responses.get(f"{API_URL}/images", json=[IMAGE_RESP], status=200) - cloudscale = Cloudscale(api_token="token") images = cloudscale.image.get_all() - assert images[0]["slug"] == "arch-18.06" - assert images[0]["name"] == "Arch 18.06" + assert images[0]["slug"] == "debian-10" + assert images[0]["operating_system"] == "Debian" diff --git a/tests/test_network.py b/tests/test_network.py index fe522e4..327c71c 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -1,14 +1,15 @@ -import responses -from cloudscale import ( - CLOUDSCALE_API_URL, - Cloudscale, - CloudscaleApiException, - CloudscaleException, -) +import pytest +import responses as responses_module + +from cloudscale import CloudscaleApiException + +from .conftest import API_URL + +UUID = "2db69ba3-1864-4608-853a-0771b6885a3a" NETWORK_RESP = { - "href": "https://api.cloudscale.ch/v1/networks/2db69ba3-1864-4608-853a-0771b6885a3a", - "uuid": "2db69ba3-1864-4608-853a-0771b6885a3a", + "href": f"https://api.cloudscale.ch/v1/networks/{UUID}", + "uuid": UUID, "name": "my-network-name", "created_at": "2019-05-29T13:18:42.511407Z", "zone": {"slug": "lpg1"}, @@ -24,146 +25,92 @@ } -@responses.activate -def test_network_get_all(): - responses.add( - responses.GET, CLOUDSCALE_API_URL + "/networks", json=[NETWORK_RESP], status=200 - ) - responses.add( - responses.GET, CLOUDSCALE_API_URL + "/networks", json=[NETWORK_RESP], status=200 - ) - responses.add(responses.GET, CLOUDSCALE_API_URL + "/networks", json={}, status=500) +def test_network_get_all(responses, cloudscale): + responses.get(f"{API_URL}/networks", json=[NETWORK_RESP], status=200) - cloudscale = Cloudscale(api_token="token") networks = cloudscale.network.get_all() assert networks[0]["name"] == "my-network-name" - assert networks[0]["uuid"] == "2db69ba3-1864-4608-853a-0771b6885a3a" + assert networks[0]["uuid"] == UUID -@responses.activate -def test_network_get_by_uuid(): - uuid = "2db69ba3-1864-4608-853a-0771b6885a3a" - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/networks/" + uuid, - json=NETWORK_RESP, - status=200, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/networks/" + uuid, - json=NETWORK_RESP, - status=200, - ) - responses.add( - responses.GET, CLOUDSCALE_API_URL + "/networks/" + uuid, json={}, status=500 - ) - +def test_network_get_by_uuid(responses, cloudscale): + responses.get(f"{API_URL}/networks/{UUID}", json=NETWORK_RESP, status=200) -@responses.activate -def test_network_delete(): - uuid = "2db69ba3-1864-4608-853a-0771b6885a3a" - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/networks/" + uuid, - json=NETWORK_RESP, - status=200, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/networks/unknown", - json=NETWORK_RESP, - status=200, - ) - responses.add( - responses.DELETE, CLOUDSCALE_API_URL + "/networks/" + uuid, status=204 - ) - responses.add( - responses.DELETE, - CLOUDSCALE_API_URL + "/networks/unknown", - json={"detail": "Not found."}, - status=404, - ) + network = cloudscale.network.get_by_uuid(uuid=UUID) + assert network["name"] == "my-network-name" - cloudscale = Cloudscale(api_token="token") - network = cloudscale.network.delete(uuid=uuid) - assert not network - try: - cloudscale = Cloudscale(api_token="token") - cloudscale.network.delete(uuid="unknown") - except CloudscaleApiException as e: - assert e.status_code == 404 +def test_network_delete(responses, cloudscale): + responses.delete(f"{API_URL}/networks/{UUID}", status=204) + assert not cloudscale.network.delete(uuid=UUID) -@responses.activate -def test_network_create(): - name = "my-network-name" - responses.add( - responses.POST, CLOUDSCALE_API_URL + "/networks", json=NETWORK_RESP, status=201 - ) - responses.add( - responses.POST, CLOUDSCALE_API_URL + "/networks", json=NETWORK_RESP, status=201 +def test_network_delete_not_found(responses, cloudscale): + responses.delete( + f"{API_URL}/networks/unknown", json={"detail": "Not found."}, status=404 ) - responses.add(responses.POST, CLOUDSCALE_API_URL + "/networks", json={}, status=500) - cloudscale = Cloudscale(api_token="token") - cloudscale.network.create( - name=name, - ) + with pytest.raises(CloudscaleApiException) as exc_info: + cloudscale.network.delete(uuid="unknown") + assert exc_info.value.status_code == 404 -@responses.activate -def test_network_update(): - uuid = "2db69ba3-1864-4608-853a-0771b6885a3a" - name = "my-network-name" - responses.add( - responses.PATCH, - CLOUDSCALE_API_URL + "/networks/" + uuid, - json=NETWORK_RESP, - status=204, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/networks/" + uuid, + +def test_network_create(responses, cloudscale): + responses.post( + f"{API_URL}/networks", json=NETWORK_RESP, - status=200, - ) - responses.add( - responses.PATCH, - CLOUDSCALE_API_URL + "/networks/" + uuid, + status=201, + match=[ + responses_module.matchers.json_params_matcher( + { + "name": "my-network-name", + "zone": "lpg1", + "mtu": 9000, + "auto_create_ipv4_subnet": False, + "tags": {"project": "apollo"}, + } + ) + ], + ) + + network = cloudscale.network.create( + name="my-network-name", + zone="lpg1", + mtu=9000, + auto_create_ipv4_subnet=False, + tags={"project": "apollo"}, + ) + assert network["uuid"] == UUID + + +def test_network_create_minimal(responses, cloudscale): + responses.post( + f"{API_URL}/networks", json=NETWORK_RESP, - status=204, + status=201, + match=[ + responses_module.matchers.json_params_matcher({"name": "my-network-name"}) + ], ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/networks/" + uuid, + + cloudscale.network.create(name="my-network-name") + + +def test_network_update(responses, cloudscale): + responses.patch( + f"{API_URL}/networks/{UUID}", json=NETWORK_RESP, status=200, + match=[ + responses_module.matchers.json_params_matcher( + {"name": "my-network-name", "mtu": 1500, "tags": {}} + ) + ], ) - responses.add( - responses.PATCH, CLOUDSCALE_API_URL + "/networks/" + uuid, json={}, status=500 - ) - - cloudscale = Cloudscale(api_token="token") - network = cloudscale.network.update(uuid=uuid, name=name) - assert network["name"] == name - assert network["uuid"] == uuid - -@responses.activate -def test_network_get_by_uuid_not_found(): - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/networks/unknown", - json={"detail": "Not found."}, - status=404, + network = cloudscale.network.update( + uuid=UUID, name="my-network-name", mtu=1500, tags={} ) - try: - cloudscale = Cloudscale(api_token="token") - cloudscale.network.get_by_uuid(uuid="unknown") - except CloudscaleApiException as e: - assert e.status_code == 404 - assert str(e) == "API Response Error (404): Not found." - assert e.response == {"data": {"detail": "Not found."}, "status_code": 404} + assert network["uuid"] == UUID diff --git a/tests/test_objects_user.py b/tests/test_objects_user.py index 613432f..84d1281 100644 --- a/tests/test_objects_user.py +++ b/tests/test_objects_user.py @@ -1,14 +1,15 @@ -import responses -from cloudscale import ( - CLOUDSCALE_API_URL, - Cloudscale, - CloudscaleApiException, - CloudscaleException, -) +import pytest +import responses as responses_module + +from cloudscale import CloudscaleApiException + +from .conftest import API_URL + +USER_ID = "6fe39134bf4178747eebc429f82cfafdd08891d4279d0d899bc4012db1db6a15" OBJECTS_USER_RESP = { - "href": "https://api.cloudscale.ch/v1/objects-users/6fe39134bf4178747eebc429f82cfafdd08891d4279d0d899bc4012db1db6a15", - "id": "6fe39134bf4178747eebc429f82cfafdd08891d4279d0d899bc4012db1db6a15", + "href": f"https://api.cloudscale.ch/v1/objects-users/{USER_ID}", + "id": USER_ID, "display_name": "alan", "keys": [ { @@ -20,196 +21,104 @@ } -@responses.activate -def test_objects_user_get_all(): - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/objects-users", - json=[OBJECTS_USER_RESP], - status=200, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/objects-users?tag:project=apollo", - json=[OBJECTS_USER_RESP], - status=200, - ) - responses.add( - responses.GET, CLOUDSCALE_API_URL + "/objects-users", json={}, status=500 - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/objects-users", - json=[OBJECTS_USER_RESP], - status=200, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/objects-users?tag:project=apollo", - json=[OBJECTS_USER_RESP], - status=200, - ) - responses.add( - responses.GET, CLOUDSCALE_API_URL + "/objects-users", json={}, status=500 - ) +def test_objects_user_get_all(responses, cloudscale): + responses.get(f"{API_URL}/objects-users", json=[OBJECTS_USER_RESP], status=200) - cloudscale = Cloudscale(api_token="token") objects_users = cloudscale.objects_user.get_all() assert objects_users[0]["display_name"] == "alan" - assert ( - objects_users[0]["id"] - == "6fe39134bf4178747eebc429f82cfafdd08891d4279d0d899bc4012db1db6a15" + assert objects_users[0]["id"] == USER_ID + + +def test_objects_user_get_all_filtered(responses, cloudscale): + responses.get( + f"{API_URL}/objects-users", + json=[OBJECTS_USER_RESP], + status=200, + match=[responses_module.matchers.query_string_matcher("tag:project=apollo")], ) - cloudscale = Cloudscale(api_token="token") objects_users = cloudscale.objects_user.get_all(filter_tag="project=apollo") - assert objects_users[0]["display_name"] == "alan" - assert ( - objects_users[0]["id"] - == "6fe39134bf4178747eebc429f82cfafdd08891d4279d0d899bc4012db1db6a15" - ) + assert objects_users[0]["tags"]["project"] == "apollo" + + +def test_objects_user_get_all_error(responses, cloudscale): + responses.get(f"{API_URL}/objects-users", json={}, status=500) - try: - cloudscale = Cloudscale(api_token="token") + with pytest.raises(CloudscaleApiException) as exc_info: cloudscale.objects_user.get_all() - except CloudscaleApiException as e: - assert e.status_code == 500 - assert str(e).startswith("API Response Error (500):") + assert exc_info.value.status_code == 500 + assert str(exc_info.value).startswith("API Response Error (500):") -@responses.activate -def test_objects_user_get_by_uuid(): - uuid = "6fe39134bf4178747eebc429f82cfafdd08891d4279d0d899bc4012db1db6a15" - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/objects-users/" + uuid, - json=OBJECTS_USER_RESP, - status=200, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/objects-users/unknown", - json={}, - status=404, + +def test_objects_user_get_by_uuid(responses, cloudscale): + responses.get( + f"{API_URL}/objects-users/{USER_ID}", json=OBJECTS_USER_RESP, status=200 ) - cloudscale = Cloudscale(api_token="token") - objects_user = cloudscale.objects_user.get_by_uuid(uuid=uuid) + objects_user = cloudscale.objects_user.get_by_uuid(uuid=USER_ID) assert objects_user["display_name"] == "alan" - assert objects_user["id"] == uuid + assert objects_user["id"] == USER_ID - try: - cloudscale = Cloudscale(api_token="token") - cloudscale.objects_user.get_by_uuid(uuid="unknown") - except CloudscaleApiException as e: - assert e.status_code == 404 +def test_objects_user_delete(responses, cloudscale): + responses.delete(f"{API_URL}/objects-users/{USER_ID}", status=204) -@responses.activate -def test_objects_user_delete(): - uuid = "6fe39134bf4178747eebc429f82cfafdd08891d4279d0d899bc4012db1db6a15" - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/objects-users/" + uuid, - json=OBJECTS_USER_RESP, - status=200, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/objects-users/unknown", - json=OBJECTS_USER_RESP, - status=200, - ) - responses.add( - responses.DELETE, CLOUDSCALE_API_URL + "/objects-users/" + uuid, status=204 - ) - responses.add( - responses.DELETE, - CLOUDSCALE_API_URL + "/objects-users/unknown", - json={}, - status=404, - ) + assert not cloudscale.objects_user.delete(uuid=USER_ID) - cloudscale = Cloudscale(api_token="token") - objects_user = cloudscale.objects_user.delete(uuid=uuid) - assert not objects_user - try: - cloudscale = Cloudscale(api_token="token") +def test_objects_user_delete_not_found(responses, cloudscale): + responses.delete(f"{API_URL}/objects-users/unknown", json={}, status=404) + + with pytest.raises(CloudscaleApiException) as exc_info: cloudscale.objects_user.delete(uuid="unknown") - except CloudscaleApiException as e: - assert e.status_code == 404 + assert exc_info.value.status_code == 404 -@responses.activate -def test_objects_user_create(): - display_name = "alan" - responses.add( - responses.POST, - CLOUDSCALE_API_URL + "/objects-users", - json=OBJECTS_USER_RESP, - status=201, - ) - responses.add( - responses.POST, CLOUDSCALE_API_URL + "/objects-users", json={}, status=500 - ) - responses.add( - responses.POST, - CLOUDSCALE_API_URL + "/objects-users", +def test_objects_user_create(responses, cloudscale): + responses.post( + f"{API_URL}/objects-users", json=OBJECTS_USER_RESP, status=201, - ) - responses.add( - responses.POST, CLOUDSCALE_API_URL + "/objects-users", json={}, status=500 + match=[ + responses_module.matchers.json_params_matcher( + {"display_name": "alan", "tags": {"project": "apollo"}} + ) + ], ) - cloudscale = Cloudscale(api_token="token") - cloudscale.objects_user.create( - display_name=display_name, + objects_user = cloudscale.objects_user.create( + display_name="alan", tags={"project": "apollo"} ) + assert objects_user["id"] == USER_ID - try: - cloudscale = Cloudscale(api_token="token") - cloudscale.objects_user.create( - display_name=display_name, - ) - except CloudscaleApiException as e: - assert e.status_code == 500 - - -@responses.activate -def test_objects_user_update(): - uuid = "6fe39134bf4178747eebc429f82cfafdd08891d4279d0d899bc4012db1db6a15" - display_name = "alan" - responses.add( - responses.PATCH, - CLOUDSCALE_API_URL + "/objects-users/" + uuid, - json=OBJECTS_USER_RESP, - status=204, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/objects-users/" + uuid, + +def test_objects_user_create_error(responses, cloudscale): + responses.post(f"{API_URL}/objects-users", json={}, status=500) + + with pytest.raises(CloudscaleApiException) as exc_info: + cloudscale.objects_user.create(display_name="alan") + + assert exc_info.value.status_code == 500 + + +def test_objects_user_update(responses, cloudscale): + responses.patch( + f"{API_URL}/objects-users/{USER_ID}", json=OBJECTS_USER_RESP, status=200, + match=[responses_module.matchers.json_params_matcher({"display_name": "alan"})], ) - responses.add( - responses.PATCH, - CLOUDSCALE_API_URL + "/objects-users/unknown", - json={}, - status=404, - ) - cloudscale = Cloudscale(api_token="token") - objects_user = cloudscale.objects_user.update(uuid=uuid, display_name=display_name) - assert objects_user["display_name"] == display_name - assert objects_user["id"] == uuid - - try: - cloudscale = Cloudscale(api_token="token") - objects_user = cloudscale.objects_user.update( - uuid="unknown", display_name=display_name - ) - except CloudscaleApiException as e: - assert e.status_code == 404 + objects_user = cloudscale.objects_user.update(uuid=USER_ID, display_name="alan") + assert objects_user["display_name"] == "alan" + + +def test_objects_user_update_not_found(responses, cloudscale): + responses.patch(f"{API_URL}/objects-users/unknown", json={}, status=404) + + with pytest.raises(CloudscaleApiException) as exc_info: + cloudscale.objects_user.update(uuid="unknown", display_name="alan") + + assert exc_info.value.status_code == 404 diff --git a/tests/test_region.py b/tests/test_region.py index 85db569..9f22254 100644 --- a/tests/test_region.py +++ b/tests/test_region.py @@ -1,33 +1,14 @@ -from cloudscale import Cloudscale, CloudscaleApiException, CloudscaleException, CLOUDSCALE_API_URL -import responses +from .conftest import API_URL REGION_RESP = { - "slug": "rma", - "zones": [ - { - "slug": "rma1" - } - ] + "slug": "lpg", + "zones": [{"slug": "lpg1"}], } -@responses.activate -def test_region_get_all(): - responses.add( - responses.GET, - CLOUDSCALE_API_URL + '/regions', - json=[REGION_RESP], - status=200) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + '/regions', - json=[REGION_RESP], - status=200) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + '/regions', - json={}, - status=500) - cloudscale = Cloudscale(api_token="token") +def test_region_get_all(responses, cloudscale): + responses.get(f"{API_URL}/regions", json=[REGION_RESP], status=200) + regions = cloudscale.region.get_all() - assert regions[0]['slug'] == "rma" + assert regions[0]["slug"] == "lpg" + assert regions[0]["zones"] == [{"slug": "lpg1"}] diff --git a/tests/test_server.py b/tests/test_server.py index e673f97..cab2103 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,330 +1,292 @@ -import responses -from cloudscale import ( - CLOUDSCALE_API_URL, - Cloudscale, - CloudscaleApiException, - CloudscaleException, -) +import pytest +import responses as responses_module + +from cloudscale import CloudscaleApiException + +from .conftest import API_URL + +UUID = "47cec963-fcd2-482f-bdb6-24461b2d47b1" SERVER_RESP = { - "uuid": "47cec963-fcd2-482f-bdb6-24461b2d47b1", + "uuid": UUID, "name": "db-master", "status": "running", "zone": {"slug": "lpg1"}, - "flavor": { - "slug": "flex-4", - }, - "image": { - "slug": "debian-9", - }, + "flavor": {"slug": "flex-4"}, + "image": {"slug": "debian-9"}, "server_groups": [], "anti_affinity_with": [], "tags": {"project": "gemini"}, } -@responses.activate -def test_server_get_all(): - responses.add( - responses.GET, CLOUDSCALE_API_URL + "/servers", json=[SERVER_RESP], status=200 - ) - responses.add( - responses.GET, CLOUDSCALE_API_URL + "/servers", json=[SERVER_RESP], status=200 - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/servers", - json={"detail": "Server error."}, - status=500, - ) +def test_server_get_all(responses, cloudscale): + responses.get(f"{API_URL}/servers", json=[SERVER_RESP], status=200) - cloudscale = Cloudscale(api_token="token") servers = cloudscale.server.get_all() assert servers[0]["name"] == "db-master" - assert servers[0]["uuid"] == "47cec963-fcd2-482f-bdb6-24461b2d47b1" + assert servers[0]["uuid"] == UUID assert servers[0]["tags"]["project"] == "gemini" -@responses.activate -def test_server_get_all_fitlered(): - responses.add( - responses.GET, CLOUDSCALE_API_URL + "/servers", json=[SERVER_RESP], status=200 - ) - - cloudscale = Cloudscale(api_token="token") - servers = cloudscale.server.get_all(filter_tag="project=gemini") - assert servers[0]["name"] == "db-master" - assert servers[0]["uuid"] == "47cec963-fcd2-482f-bdb6-24461b2d47b1" - assert servers[0]["tags"]["project"] == "gemini" - - servers = cloudscale.server.get_all(filter_tag="project") - assert servers[0]["name"] == "db-master" - assert servers[0]["uuid"] == "47cec963-fcd2-482f-bdb6-24461b2d47b1" - assert servers[0]["tags"]["project"] == "gemini" - - -@responses.activate -def test_server_get_by_uuid(): - uuid = "47cec963-fcd2-482f-bdb6-24461b2d47b1" - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/servers/" + uuid, - json=SERVER_RESP, - status=200, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/servers/" + uuid, - json=SERVER_RESP, - status=200, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/servers/" + uuid, - json={"detail": "Server error."}, - status=500, - ) +def test_server_get_by_uuid(responses, cloudscale): + responses.get(f"{API_URL}/servers/{UUID}", json=SERVER_RESP, status=200) - cloudscale = Cloudscale(api_token="token") - server = cloudscale.server.get_by_uuid(uuid=uuid) + server = cloudscale.server.get_by_uuid(uuid=UUID) assert server["name"] == "db-master" - assert server["uuid"] == uuid - assert server["tags"]["project"] == "gemini" + assert server["uuid"] == UUID -@responses.activate -def test_server_delete(): - uuid = "47cec963-fcd2-482f-bdb6-24461b2d47b1" - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/servers/" + uuid, - json=SERVER_RESP, - status=200, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/servers/unknown", - json=SERVER_RESP, - status=200, - ) - responses.add(responses.DELETE, CLOUDSCALE_API_URL + "/servers/" + uuid, status=204) - responses.add( - responses.DELETE, - CLOUDSCALE_API_URL + "/servers/unknown", - json={"detail": "Not found."}, - status=404, +def test_server_get_by_uuid_not_found(responses, cloudscale): + responses.get( + f"{API_URL}/servers/unknown", json={"detail": "Not found."}, status=404 ) - cloudscale = Cloudscale(api_token="token") - server = cloudscale.server.delete(uuid=uuid) - assert not server + with pytest.raises(CloudscaleApiException) as exc_info: + cloudscale.server.get_by_uuid(uuid="unknown") - try: - cloudscale = Cloudscale(api_token="token") - cloudscale.server.delete(uuid="unknown") - except CloudscaleApiException as e: - assert e.status_code == 404 + assert exc_info.value.status_code == 404 + assert str(exc_info.value) == "API Response Error (404): Not found." -@responses.activate -def test_server_get_auth_not_provided(): - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/servers/unknown", +def test_server_get_unauthenticated(responses, cloudscale): + responses.get( + f"{API_URL}/servers/unknown", json={"detail": "Authentication credentials were not provided."}, status=401, ) - try: - cloudscale = Cloudscale(api_token="token") + + with pytest.raises(CloudscaleApiException) as exc_info: cloudscale.server.get_by_uuid(uuid="unknown") - except CloudscaleApiException as e: - assert e.status_code == 401 - assert ( - str(e) - == "API Response Error (401): Authentication credentials were not provided." - ) + assert exc_info.value.status_code == 401 + assert str(exc_info.value) == ( + "API Response Error (401): Authentication credentials were not provided." + ) -@responses.activate -def test_server_create(): - name = "db-master" - flavor = "flex-4" - image = "debian9" - responses.add( - responses.POST, CLOUDSCALE_API_URL + "/servers", json=SERVER_RESP, status=201 - ) - responses.add( - responses.POST, CLOUDSCALE_API_URL + "/servers", json=SERVER_RESP, status=201 - ) - responses.add( - responses.POST, - CLOUDSCALE_API_URL + "/servers", - json={"detail": "Server error."}, - status=500, - ) +def test_server_delete(responses, cloudscale): + responses.delete(f"{API_URL}/servers/{UUID}", status=204) - cloudscale = Cloudscale(api_token="token") - cloudscale.server.create( - name=name, - flavor=flavor, - image=image, - ) + assert not cloudscale.server.delete(uuid=UUID) -@responses.activate -def test_server_update(): - uuid = "47cec963-fcd2-482f-bdb6-24461b2d47b1" - name = "db-master" - responses.add( - responses.PATCH, - CLOUDSCALE_API_URL + "/servers/" + uuid, - json=SERVER_RESP, - status=204, +def test_server_delete_not_found(responses, cloudscale): + responses.delete( + f"{API_URL}/servers/unknown", json={"detail": "Not found."}, status=404 ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/servers/" + uuid, + + with pytest.raises(CloudscaleApiException) as exc_info: + cloudscale.server.delete(uuid="unknown") + + assert exc_info.value.status_code == 404 + + +def test_server_create(responses, cloudscale): + responses.post( + f"{API_URL}/servers", json=SERVER_RESP, - status=200, + status=201, + match=[ + responses_module.matchers.json_params_matcher( + {"name": "db-master", "flavor": "flex-4", "image": "debian-9"} + ) + ], ) - responses.add( - responses.PATCH, - CLOUDSCALE_API_URL + "/servers/" + uuid, - json=SERVER_RESP, - status=204, + + server = cloudscale.server.create( + name="db-master", flavor="flex-4", image="debian-9" ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/servers/" + uuid, + assert server["uuid"] == UUID + + +def test_server_create_with_all_options(responses, cloudscale): + responses.post( + f"{API_URL}/servers", json=SERVER_RESP, - status=200, + status=201, + match=[ + responses_module.matchers.json_params_matcher( + { + "name": "db-master", + "flavor": "flex-4", + "image": "debian-9", + "zone": "lpg1", + "volume_size_gb": 50, + "volumes": [{"size_gb": 100}], + "interfaces": [{"network": "public"}], + "ssh_keys": ["ssh-rsa AAAA"], + "password": "secret", + "use_public_network": True, + "use_private_network": False, + "use_ipv6": True, + "server_groups": ["group-uuid"], + "user_data": "#cloud-config", + "tags": {"project": "gemini"}, + } + ) + ], ) - responses.add( - responses.PATCH, - CLOUDSCALE_API_URL + "/servers/" + uuid, - json={"detail": "Server error."}, - status=500, + + cloudscale.server.create( + name="db-master", + flavor="flex-4", + image="debian-9", + zone="lpg1", + volume_size_gb=50, + volumes=[{"size_gb": 100}], + interfaces=[{"network": "public"}], + ssh_keys=["ssh-rsa AAAA"], + password="secret", + use_public_network=True, + use_private_network=False, + use_ipv6=True, + server_groups=["group-uuid"], + user_data="#cloud-config", + tags={"project": "gemini"}, ) - cloudscale = Cloudscale(api_token="token") - server = cloudscale.server.update(uuid=uuid, name=name) - assert server["name"] == name - assert server["uuid"] == uuid - assert server["tags"]["project"] == "gemini" +def test_server_create_error(responses, cloudscale): + responses.post(f"{API_URL}/servers", json={"detail": "Server error."}, status=500) -@responses.activate -def test_server_start(): - uuid = "47cec963-fcd2-482f-bdb6-24461b2d47b1" - responses.add( - responses.POST, CLOUDSCALE_API_URL + "/servers/" + uuid + "/start", status=204 - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/servers/" + uuid, - json=SERVER_RESP, - status=200, - ) - responses.add( - responses.POST, CLOUDSCALE_API_URL + "/servers/" + uuid + "/start", status=204 - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/servers/" + uuid, + with pytest.raises(CloudscaleApiException) as exc_info: + cloudscale.server.create(name="db-master", flavor="flex-4", image="debian-9") + + assert exc_info.value.status_code == 500 + + +def test_server_create_with_volume_size_gb(responses, cloudscale): + responses.post( + f"{API_URL}/servers", json=SERVER_RESP, - status=200, + status=201, + match=[ + responses_module.matchers.json_params_matcher( + { + "name": "db-master", + "flavor": "flex-4", + "image": "debian-9", + "volume_size_gb": 100, + } + ) + ], ) - responses.add( - responses.POST, - CLOUDSCALE_API_URL + "/servers/" + uuid + "/start", - json={"detail": "Server error."}, - status=500, + + cloudscale.server.create( + name="db-master", flavor="flex-4", image="debian-9", volume_size_gb=100 ) - cloudscale = Cloudscale(api_token="token") - server = cloudscale.server.start(uuid=uuid) - assert not server + +def test_server_create_with_deprecated_volume_size(responses, cloudscale): + """The legacy name still works, but is sent as volume_size_gb.""" + responses.post( + f"{API_URL}/servers", + json=SERVER_RESP, + status=201, + match=[ + responses_module.matchers.json_params_matcher( + { + "name": "db-master", + "flavor": "flex-4", + "image": "debian-9", + "volume_size_gb": 100, + } + ) + ], + ) + + with pytest.warns(DeprecationWarning, match="use volume_size_gb instead"): + cloudscale.server.create( + name="db-master", flavor="flex-4", image="debian-9", volume_size=100 + ) -@responses.activate -def test_server_stop(): - uuid = "47cec963-fcd2-482f-bdb6-24461b2d47b1" - responses.add( - responses.POST, CLOUDSCALE_API_URL + "/servers/" + uuid + "/stop", status=204 - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/servers/" + uuid, +def test_server_create_volume_size_gb_wins_over_the_alias(responses, cloudscale): + responses.post( + f"{API_URL}/servers", json=SERVER_RESP, - status=200, - ) - responses.add( - responses.POST, CLOUDSCALE_API_URL + "/servers/" + uuid + "/stop", status=204 - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/servers/" + uuid, + status=201, + match=[ + responses_module.matchers.json_params_matcher( + { + "name": "db-master", + "flavor": "flex-4", + "image": "debian-9", + "volume_size_gb": 100, + } + ) + ], + ) + + with pytest.warns(DeprecationWarning): + cloudscale.server.create( + name="db-master", + flavor="flex-4", + image="debian-9", + volume_size_gb=100, + volume_size=50, + ) + + +def test_server_create_without_volume_size_omits_it(responses, cloudscale): + responses.post( + f"{API_URL}/servers", json=SERVER_RESP, - status=200, - ) - responses.add( - responses.POST, - CLOUDSCALE_API_URL + "/servers/" + uuid + "/stop", - json={"detail": "Server error."}, - status=500, + status=201, + match=[ + responses_module.matchers.json_params_matcher( + {"name": "db-master", "flavor": "flex-4", "image": "debian-9"} + ) + ], ) - cloudscale = Cloudscale(api_token="token") - server = cloudscale.server.stop(uuid=uuid) - assert not server + cloudscale.server.create(name="db-master", flavor="flex-4", image="debian-9") -@responses.activate -def test_server_reboot(): - uuid = "47cec963-fcd2-482f-bdb6-24461b2d47b1" - responses.add( - responses.POST, CLOUDSCALE_API_URL + "/servers/" + uuid + "/reboot", status=204 - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/servers/" + uuid, +def test_server_update(responses, cloudscale): + responses.patch( + f"{API_URL}/servers/{UUID}", json=SERVER_RESP, status=200, + match=[ + responses_module.matchers.json_params_matcher( + { + "name": "db-master", + "flavor": "flex-8", + "interfaces": {"network": "public"}, + "tags": {"project": "gemini"}, + } + ) + ], ) - responses.add( - responses.POST, CLOUDSCALE_API_URL + "/servers/" + uuid + "/reboot", status=204 - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/servers/" + uuid, - json=SERVER_RESP, - status=200, + + server = cloudscale.server.update( + uuid=UUID, + name="db-master", + flavor="flex-8", + interfaces={"network": "public"}, + tags={"project": "gemini"}, ) - responses.add( - responses.POST, - CLOUDSCALE_API_URL + "/servers/" + uuid + "/reboot", + assert server["uuid"] == UUID + + +@pytest.mark.parametrize("action", ["start", "stop", "reboot"]) +def test_server_actions(responses, cloudscale, action): + responses.post(f"{API_URL}/servers/{UUID}/{action}", status=204) + + assert getattr(cloudscale.server, action)(uuid=UUID) == {} + + +@pytest.mark.parametrize("action", ["start", "stop", "reboot"]) +def test_server_action_error(responses, cloudscale, action): + responses.post( + f"{API_URL}/servers/{UUID}/{action}", json={"detail": "Server error."}, status=500, ) - cloudscale = Cloudscale(api_token="token") - server = cloudscale.server.reboot(uuid=uuid) - assert not server + with pytest.raises(CloudscaleApiException) as exc_info: + getattr(cloudscale.server, action)(uuid=UUID) - -@responses.activate -def test_server_get_by_uuid_not_found(): - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/servers/unknown", - json={"detail": "Not found."}, - status=404, - ) - try: - cloudscale = Cloudscale(api_token="token") - cloudscale.server.get_by_uuid(uuid="unknown") - except CloudscaleApiException as e: - assert e.status_code == 404 - assert str(e) == "API Response Error (404): Not found." - assert e.response == {"data": {"detail": "Not found."}, "status_code": 404} + assert exc_info.value.status_code == 500 diff --git a/tests/test_server_group.py b/tests/test_server_group.py index c2b2660..fa0bcae 100644 --- a/tests/test_server_group.py +++ b/tests/test_server_group.py @@ -1,201 +1,107 @@ -import responses -from cloudscale import ( - CLOUDSCALE_API_URL, - Cloudscale, - CloudscaleApiException, - CloudscaleException, -) +import pytest +import responses as responses_module + +from cloudscale import CloudscaleApiException + +from .conftest import API_URL + +UUID = "e3b63018-fad6-45f2-9f57-3ea0da726d8c" SERVER_GROUP_RESP = { - "href": "https://api.cloudscale.ch/v1/server-groups/e3b63018-fad6-45f2-9f57-3ea0da726d8c", - "uuid": "e3b63018-fad6-45f2-9f57-3ea0da726d8c", + "href": f"https://api.cloudscale.ch/v1/server-groups/{UUID}", + "uuid": UUID, "name": "load balancers", "type": "anti-affinity", + "zone": {"slug": "lpg1"}, "servers": [ { - "href": "https://api.cloudscale.ch/v1/server-groups/32d2f586-14ff-4da9-81df-134ca45d635f", + "href": "https://api.cloudscale.ch/v1/servers/32d2f586-14ff-4da9-81df-134ca45d635f", "uuid": "32d2f586-14ff-4da9-81df-134ca45d635f", "name": "tesla", - }, - { - "href": "https://api.cloudscale.ch/v1/server-groups/375870c2-d4ee-49af-a048-efcf59ef14ef", - "uuid": "375870c2-d4ee-49af-a048-efcf59ef14ef", - "name": "edison", - }, + } ], - "zone": {"slug": "rma1"}, "tags": {}, } -@responses.activate -def test_server_groups_get_all(): - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/server-groups", - json=[SERVER_GROUP_RESP], - status=200, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/server-groups", - json=[SERVER_GROUP_RESP], - status=200, - ) - responses.add( - responses.GET, CLOUDSCALE_API_URL + "/server-groups", json={}, status=500 - ) +def test_server_group_get_all(responses, cloudscale): + responses.get(f"{API_URL}/server-groups", json=[SERVER_GROUP_RESP], status=200) - cloudscale = Cloudscale(api_token="token") server_groups = cloudscale.server_group.get_all() assert server_groups[0]["name"] == "load balancers" - assert server_groups[0]["uuid"] == "e3b63018-fad6-45f2-9f57-3ea0da726d8c" + assert server_groups[0]["uuid"] == UUID -@responses.activate -def test_server_groups_get_by_uuid(): - uuid = "e3b63018-fad6-45f2-9f57-3ea0da726d8c" - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/server-groups/" + uuid, - json=SERVER_GROUP_RESP, - status=200, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/server-groups/" + uuid, - json=SERVER_GROUP_RESP, - status=200, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/server-groups/" + uuid, - json={}, - status=500, - ) +def test_server_group_get_by_uuid(responses, cloudscale): + responses.get(f"{API_URL}/server-groups/{UUID}", json=SERVER_GROUP_RESP, status=200) - cloudscale = Cloudscale(api_token="token") - server_group = cloudscale.server_group.get_by_uuid(uuid=uuid) - assert server_group["name"] == "load balancers" - assert server_group["uuid"] == uuid + server_group = cloudscale.server_group.get_by_uuid(uuid=UUID) + assert server_group["type"] == "anti-affinity" -@responses.activate -def test_server_groups_delete(): - uuid = "e3b63018-fad6-45f2-9f57-3ea0da726d8c" - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/server-groups/" + uuid, - json=SERVER_GROUP_RESP, - status=200, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/server-groups/unknown", - json=SERVER_GROUP_RESP, - status=200, - ) - responses.add( - responses.DELETE, CLOUDSCALE_API_URL + "/server-groups/" + uuid, status=204 - ) - responses.add( - responses.DELETE, - CLOUDSCALE_API_URL + "/server-groups/unknown", - json={"detail": "Not found."}, - status=404, - ) +def test_server_group_delete(responses, cloudscale): + responses.delete(f"{API_URL}/server-groups/{UUID}", status=204) + + assert not cloudscale.server_group.delete(uuid=UUID) + - cloudscale = Cloudscale(api_token="token") - server_group = cloudscale.server_group.delete(uuid=uuid) - assert not server_group +def test_server_group_delete_not_found(responses, cloudscale): + responses.delete( + f"{API_URL}/server-groups/unknown", json={"detail": "Not found."}, status=404 + ) - try: - cloudscale = Cloudscale(api_token="token") + with pytest.raises(CloudscaleApiException) as exc_info: cloudscale.server_group.delete(uuid="unknown") - except CloudscaleApiException as e: - assert e.status_code == 404 + assert exc_info.value.status_code == 404 -@responses.activate -def test_server_groups_create(): - name = "load balancers" - responses.add( - responses.POST, - CLOUDSCALE_API_URL + "/server-groups", - json=SERVER_GROUP_RESP, - status=201, - ) - responses.add( - responses.POST, - CLOUDSCALE_API_URL + "/server-groups", + +def test_server_group_create(responses, cloudscale): + responses.post( + f"{API_URL}/server-groups", json=SERVER_GROUP_RESP, status=201, - ) - responses.add( - responses.POST, - CLOUDSCALE_API_URL + "/server-groups", - json=SERVER_GROUP_RESP, - status=500, + match=[ + responses_module.matchers.json_params_matcher( + { + "name": "load balancers", + "type": "anti-affinity", + "tags": {"project": "apollo"}, + } + ) + ], ) - cloudscale = Cloudscale(api_token="token") - cloudscale.server_group.create( - name=name, + server_group = cloudscale.server_group.create( + name="load balancers", + group_type="anti-affinity", + tags={"project": "apollo"}, ) + assert server_group["uuid"] == UUID -@responses.activate -def test_server_groups_update(): - uuid = "e3b63018-fad6-45f2-9f57-3ea0da726d8c" - name = "load balancers" - responses.add( - responses.PATCH, - CLOUDSCALE_API_URL + "/server-groups/" + uuid, - json=SERVER_GROUP_RESP, - status=204, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/server-groups/" + uuid, - json=SERVER_GROUP_RESP, - status=200, - ) - responses.add( - responses.PATCH, - CLOUDSCALE_API_URL + "/server-groups/" + uuid, - json=SERVER_GROUP_RESP, - status=204, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/server-groups/" + uuid, +def test_server_group_create_error(responses, cloudscale): + responses.post(f"{API_URL}/server-groups", json={"detail": "Bad."}, status=400) + + with pytest.raises(CloudscaleApiException) as exc_info: + cloudscale.server_group.create(name="load balancers") + + assert exc_info.value.status_code == 400 + + +def test_server_group_update(responses, cloudscale): + responses.patch( + f"{API_URL}/server-groups/{UUID}", json=SERVER_GROUP_RESP, status=200, + match=[ + responses_module.matchers.json_params_matcher( + {"name": "load balancers", "tags": {}} + ) + ], ) - responses.add( - responses.PATCH, - CLOUDSCALE_API_URL + "/server-groups/" + uuid, - json={}, - status=500, - ) - cloudscale = Cloudscale(api_token="token") - server_group = cloudscale.server_group.update(uuid=uuid, name=name) - assert server_group["name"] == name - assert server_group["uuid"] == uuid - - -@responses.activate -def test_server_group_get_by_uuid_not_found(): - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/server-groups/unknown", - json={"detail": "Not found."}, - status=404, + + server_group = cloudscale.server_group.update( + uuid=UUID, name="load balancers", tags={} ) - try: - cloudscale = Cloudscale(api_token="token") - cloudscale.server_group.get_by_uuid(uuid="unknown") - except CloudscaleApiException as e: - assert e.status_code == 404 - assert str(e) == "API Response Error (404): Not found." - assert e.response == {"data": {"detail": "Not found."}, "status_code": 404} + assert server_group["uuid"] == UUID diff --git a/tests/test_subnet.py b/tests/test_subnet.py index 0367417..21b9af9 100644 --- a/tests/test_subnet.py +++ b/tests/test_subnet.py @@ -1,13 +1,20 @@ -import responses -from cloudscale import CLOUDSCALE_API_URL, Cloudscale, CloudscaleApiException +import pytest +import responses as responses_module + +from cloudscale import CloudscaleApiException + +from .conftest import API_URL + +UUID = "33333333-1864-4608-853a-0771b6885a3a" +NETWORK_UUID = "2db69ba3-1864-4608-853a-0771b6885a3a" SUBNET_RESP = { - "href": "https://api.cloudscale.ch/v1/subnets/33333333-1864-4608-853a-0771b6885a3a", - "uuid": "33333333-1864-4608-853a-0771b6885a3a", + "href": f"https://api.cloudscale.ch/v1/subnets/{UUID}", + "uuid": UUID, "cidr": "192.0.2.123/24", "network": { - "href": "https://api.cloudscale.ch/v1/networks/2db69ba3-1864-4608-853a-0771b6885a3a", - "uuid": "2db69ba3-1864-4608-853a-0771b6885a3a", + "href": f"https://api.cloudscale.ch/v1/networks/{NETWORK_UUID}", + "uuid": NETWORK_UUID, "name": "my-network-name", }, "gateway_address": None, @@ -16,133 +23,86 @@ } -@responses.activate -def test_subnet_get_all(): - uuid = "33333333-1864-4608-853a-0771b6885a3a" - responses.add( - responses.GET, CLOUDSCALE_API_URL + "/subnets", json=[SUBNET_RESP], status=200 - ) - responses.add( - responses.GET, CLOUDSCALE_API_URL + "/subnets", json=[SUBNET_RESP], status=200 - ) - responses.add(responses.GET, CLOUDSCALE_API_URL + "/subnets", json={}, status=500) +def test_subnet_get_all(responses, cloudscale): + responses.get(f"{API_URL}/subnets", json=[SUBNET_RESP], status=200) - cloudscale = Cloudscale(api_token="token") subnets = cloudscale.subnet.get_all() - assert subnets[0]["uuid"] == uuid + assert subnets[0]["cidr"] == "192.0.2.123/24" + assert subnets[0]["uuid"] == UUID -@responses.activate -def test_subnet_get_by_uuid(): - uuid = "33333333-1864-4608-853a-0771b6885a3a" - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/subnets/" + uuid, - json=SUBNET_RESP, - status=200, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/subnets/" + uuid, - json=SUBNET_RESP, - status=200, - ) - responses.add( - responses.GET, CLOUDSCALE_API_URL + "/subnets/" + uuid, json={}, status=500 - ) +def test_subnet_get_by_uuid(responses, cloudscale): + responses.get(f"{API_URL}/subnets/{UUID}", json=SUBNET_RESP, status=200) - cloudscale = Cloudscale(api_token="token") - subnet = cloudscale.subnet.get_by_uuid(uuid=uuid) - assert subnet["uuid"] == uuid + subnet = cloudscale.subnet.get_by_uuid(uuid=UUID) + assert subnet["uuid"] == UUID -@responses.activate -def test_subnet_delete(): - uuid = "33333333-1864-4608-853a-0771b6885a3a" - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/subnets/" + uuid, - json=SUBNET_RESP, - status=200, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/subnets/unknown", - json=SUBNET_RESP, - status=200, - ) - responses.add(responses.DELETE, CLOUDSCALE_API_URL + "/subnets/" + uuid, status=204) - responses.add( - responses.DELETE, - CLOUDSCALE_API_URL + "/subnets/unknown", - json={"detail": "Not found."}, - status=404, - ) +def test_subnet_delete(responses, cloudscale): + responses.delete(f"{API_URL}/subnets/{UUID}", status=204) - cloudscale = Cloudscale(api_token="token") - subnet = cloudscale.subnet.delete(uuid=uuid) - assert not subnet - - try: - cloudscale = Cloudscale(api_token="token") - cloudscale.subnet.delete(uuid="unknown") - except CloudscaleApiException as e: - assert e.status_code == 404 + assert not cloudscale.subnet.delete(uuid=UUID) -@responses.activate -def test_subnet_create(): - responses.add( - responses.POST, CLOUDSCALE_API_URL + "/subnets", json=SUBNET_RESP, status=201 - ) - responses.add( - responses.POST, CLOUDSCALE_API_URL + "/subnets", json=SUBNET_RESP, status=201 - ) - responses.add( - responses.POST, CLOUDSCALE_API_URL + "/subnets", json=SUBNET_RESP, status=500 +def test_subnet_delete_not_found(responses, cloudscale): + responses.delete( + f"{API_URL}/subnets/unknown", json={"detail": "Not found."}, status=404 ) - cloudscale = Cloudscale(api_token="token") - cloudscale.subnet.create( - cidr="192.0.2.123/24", - network_uuid="2db69ba3-1864-4608-853a-0771b6885a3a", - dns_servers=["185.79.232.101", "185.79.232.102"], - ) + with pytest.raises(CloudscaleApiException) as exc_info: + cloudscale.subnet.delete(uuid="unknown") + assert exc_info.value.status_code == 404 -@responses.activate -def test_subnet_update(): - uuid = "33333333-1864-4608-853a-0771b6885a3a" - dns_servers = ["185.79.232.101", "185.79.232.102"] - responses.add( - responses.PATCH, - CLOUDSCALE_API_URL + "/subnets/" + uuid, - json=SUBNET_RESP, - status=204, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/subnets/" + uuid, +def test_subnet_create(responses, cloudscale): + responses.post( + f"{API_URL}/subnets", json=SUBNET_RESP, - status=200, - ) - responses.add( - responses.PATCH, - CLOUDSCALE_API_URL + "/subnets/" + uuid, - json=SUBNET_RESP, - status=204, + status=201, + match=[ + responses_module.matchers.json_params_matcher( + { + "cidr": "192.0.2.123/24", + "network": NETWORK_UUID, + "gateway_address": "192.0.2.1", + "dns_servers": ["185.79.232.101"], + "tags": {"project": "apollo"}, + } + ) + ], + ) + + subnet = cloudscale.subnet.create( + cidr="192.0.2.123/24", + network_uuid=NETWORK_UUID, + gateway_address="192.0.2.1", + dns_servers=["185.79.232.101"], + tags={"project": "apollo"}, ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/subnets/" + uuid, + assert subnet["uuid"] == UUID + + +def test_subnet_update(responses, cloudscale): + responses.patch( + f"{API_URL}/subnets/{UUID}", json=SUBNET_RESP, status=200, + match=[ + responses_module.matchers.json_params_matcher( + { + "gateway_address": "192.0.2.1", + "dns_servers": ["185.79.232.101", "185.79.232.102"], + "tags": {}, + } + ) + ], + ) + + subnet = cloudscale.subnet.update( + uuid=UUID, + gateway_address="192.0.2.1", + dns_servers=["185.79.232.101", "185.79.232.102"], + tags={}, ) - responses.add( - responses.PATCH, CLOUDSCALE_API_URL + "/subnets/" + uuid, json={}, status=500 - ) - cloudscale = Cloudscale(api_token="token") - subnet = cloudscale.subnet.update(uuid=uuid, dns_servers=dns_servers) - assert subnet["uuid"] == uuid - assert subnet["dns_servers"] == dns_servers + assert subnet["uuid"] == UUID diff --git a/tests/test_volume.py b/tests/test_volume.py index b6d6b83..ab966f9 100644 --- a/tests/test_volume.py +++ b/tests/test_volume.py @@ -1,172 +1,121 @@ -import responses -from cloudscale import ( - CLOUDSCALE_API_URL, - Cloudscale, - CloudscaleApiException, - CloudscaleException, -) +import pytest +import responses as responses_module + +from cloudscale import CloudscaleApiException + +from .conftest import API_URL + +UUID = "2db69ba3-1864-4608-853a-0771b6885a3a" +SERVER_UUID = "9e1f9a7f-e8d0-4086-ad7e-fea161d7c5f7" VOLUME_RESP = { - "href": "https://api.cloudscale.ch/v1/volumes/2db69ba3-1864-4608-853a-0771b6885a3a", + "href": f"https://api.cloudscale.ch/v1/volumes/{UUID}", "created_at": "2019-05-29T13:18:42.511407Z", - "uuid": "2db69ba3-1864-4608-853a-0771b6885a3a", + "uuid": UUID, "name": "capitano-root", "zone": {"slug": "lpg1"}, "size_gb": 150, "type": "ssd", - "server_uuids": ["9e1f9a7f-e8d0-4086-ad7e-fea161d7c5f7"], + "server_uuids": [SERVER_UUID], "tags": {}, } -@responses.activate -def test_volume_get_all(): - responses.add( - responses.GET, CLOUDSCALE_API_URL + "/volumes", json=[VOLUME_RESP], status=200 - ) - responses.add( - responses.GET, CLOUDSCALE_API_URL + "/volumes", json=[VOLUME_RESP], status=200 - ) - responses.add(responses.GET, CLOUDSCALE_API_URL + "/volumes", json={}, status=500) +def test_volume_get_all(responses, cloudscale): + responses.get(f"{API_URL}/volumes", json=[VOLUME_RESP], status=200) - cloudscale = Cloudscale(api_token="token") volumes = cloudscale.volume.get_all() assert volumes[0]["name"] == "capitano-root" - assert volumes[0]["uuid"] == "2db69ba3-1864-4608-853a-0771b6885a3a" + assert volumes[0]["uuid"] == UUID -@responses.activate -def test_volume_get_by_uuid(): - uuid = "2db69ba3-1864-4608-853a-0771b6885a3a" - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/volumes/" + uuid, - json=VOLUME_RESP, - status=200, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/volumes/" + uuid, - json=VOLUME_RESP, - status=200, - ) +def test_volume_get_by_uuid(responses, cloudscale): + responses.get(f"{API_URL}/volumes/{UUID}", json=VOLUME_RESP, status=200) - responses.add( - responses.GET, CLOUDSCALE_API_URL + "/volumes/" + uuid, json={}, status=500 - ) + volume = cloudscale.volume.get_by_uuid(uuid=UUID) + assert volume["size_gb"] == 150 - cloudscale = Cloudscale(api_token="token") - volume = cloudscale.volume.get_by_uuid(uuid=uuid) - assert volume["name"] == "capitano-root" - assert volume["uuid"] == uuid +def test_volume_delete(responses, cloudscale): + responses.delete(f"{API_URL}/volumes/{UUID}", status=204) -@responses.activate -def test_volume_delete(): - uuid = "2db69ba3-1864-4608-853a-0771b6885a3a" - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/volumes/" + uuid, - json=VOLUME_RESP, - status=200, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/volumes/unknown", - json=VOLUME_RESP, - status=200, - ) - responses.add(responses.DELETE, CLOUDSCALE_API_URL + "/volumes/" + uuid, status=204) - responses.add( - responses.DELETE, - CLOUDSCALE_API_URL + "/volumes/unknown", - json={"detail": "Not found."}, - status=404, - ) + assert not cloudscale.volume.delete(uuid=UUID) - cloudscale = Cloudscale(api_token="token") - volume = cloudscale.volume.delete(uuid=uuid) - assert not volume - try: - cloudscale = Cloudscale(api_token="token") - cloudscale.volume.delete(uuid="unknown") - except CloudscaleApiException as e: - assert e.status_code == 404 - - -@responses.activate -def test_volume_create(): - name = "capitano-root" - size_gb = 150 - server_uuids = "2db69ba3-1864-4608-853a-0771b6885a3a" - - responses.add( - responses.POST, CLOUDSCALE_API_URL + "/volumes", json=VOLUME_RESP, status=201 +def test_volume_delete_not_found(responses, cloudscale): + responses.delete( + f"{API_URL}/volumes/unknown", json={"detail": "Not found."}, status=404 ) - responses.add( - responses.POST, CLOUDSCALE_API_URL + "/volumes", json=VOLUME_RESP, status=201 - ) - responses.add(responses.POST, CLOUDSCALE_API_URL + "/volumes", json={}, status=500) - cloudscale = Cloudscale(api_token="token") - cloudscale.volume.create( - name=name, - server_uuids=server_uuids, - size_gb=size_gb, - ) + with pytest.raises(CloudscaleApiException) as exc_info: + cloudscale.volume.delete(uuid="unknown") + assert exc_info.value.status_code == 404 -@responses.activate -def test_volume_update(): - uuid = "2db69ba3-1864-4608-853a-0771b6885a3a" - name = "capitano-root" - responses.add( - responses.PATCH, - CLOUDSCALE_API_URL + "/volumes/" + uuid, - json=VOLUME_RESP, - status=204, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/volumes/" + uuid, - json=VOLUME_RESP, - status=200, - ) - responses.add( - responses.PATCH, - CLOUDSCALE_API_URL + "/volumes/" + uuid, + +def test_volume_create(responses, cloudscale): + responses.post( + f"{API_URL}/volumes", json=VOLUME_RESP, - status=204, - ) - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/volumes/" + uuid, + status=201, + match=[ + responses_module.matchers.json_params_matcher( + { + "name": "capitano-root", + "server_uuids": [SERVER_UUID], + "size_gb": 150, + "type": "ssd", + "zone": "lpg1", + "tags": {"project": "apollo"}, + } + ) + ], + ) + + volume = cloudscale.volume.create( + name="capitano-root", + server_uuids=[SERVER_UUID], + size_gb=150, + volume_type="ssd", + zone="lpg1", + tags={"project": "apollo"}, + ) + assert volume["uuid"] == UUID + + +def test_volume_create_error(responses, cloudscale): + responses.post(f"{API_URL}/volumes", json={"detail": "Bad."}, status=400) + + with pytest.raises(CloudscaleApiException) as exc_info: + cloudscale.volume.create( + name="capitano-root", server_uuids=[SERVER_UUID], size_gb=150 + ) + + assert exc_info.value.status_code == 400 + + +def test_volume_update(responses, cloudscale): + responses.patch( + f"{API_URL}/volumes/{UUID}", json=VOLUME_RESP, status=200, - ) - responses.add( - responses.PATCH, CLOUDSCALE_API_URL + "/volumes/" + uuid, json={}, status=500 - ) - - cloudscale = Cloudscale(api_token="token") - volume = cloudscale.volume.update(uuid=uuid, name=name) - assert volume["name"] == name - assert volume["uuid"] == uuid - - -@responses.activate -def test_volume_get_by_uuid_not_found(): - responses.add( - responses.GET, - CLOUDSCALE_API_URL + "/volumes/unknown", - json={"detail": "Not found."}, - status=404, - ) - try: - cloudscale = Cloudscale(api_token="token") - cloudscale.volume.get_by_uuid(uuid="unknown") - except CloudscaleApiException as e: - assert e.status_code == 404 - assert str(e) == "API Response Error (404): Not found." - assert e.response == {"data": {"detail": "Not found."}, "status_code": 404} + match=[ + responses_module.matchers.json_params_matcher( + { + "name": "capitano-root", + "server_uuids": [SERVER_UUID], + "size_gb": 200, + "tags": {}, + } + ) + ], + ) + + volume = cloudscale.volume.update( + uuid=UUID, + name="capitano-root", + server_uuids=[SERVER_UUID], + size_gb=200, + tags={}, + ) + assert volume["uuid"] == UUID diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 10a0b69..0000000 --- a/tox.ini +++ /dev/null @@ -1,30 +0,0 @@ -[tox] -envlist = py{36,37,38} -skip_missing_interpreters = True -skipsdist = True - -[gh-actions] -python = - 3.6: py36 - 3.7: py37 - 3.8: py38 - -[testenv] -deps = - -r{toxinidir}/requirements.txt - -r{toxinidir}/requirements.dev.txt -commands = - python --version - pytest --cov=cloudscale --cov-report=xml -v -setenv = - CLOUDSCALE_CONFIG = /tmp/test-cloudscale.ini - -[testenv:coverage] -deps = - -r{toxinidir}/requirements.txt - -r{toxinidir}/requirements.dev.txt -commands = - python --version - pytest --cov=cloudscale -v -setenv = - CLOUDSCALE_CONFIG = /tmp/test-cloudscale.ini diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..93d8a6f --- /dev/null +++ b/uv.lock @@ -0,0 +1,622 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa", size = 369072, upload-time = "2026-08-15T08:16:22.964Z" }, + { url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda", size = 251142, upload-time = "2026-08-15T08:16:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45", size = 240714, upload-time = "2026-08-15T08:16:26.392Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab", size = 279637, upload-time = "2026-08-15T08:16:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a", size = 276543, upload-time = "2026-08-15T08:16:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3", size = 261644, upload-time = "2026-08-15T08:16:30.722Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb", size = 259609, upload-time = "2026-08-15T08:16:32.248Z" }, + { url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f", size = 252457, upload-time = "2026-08-15T08:16:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea", size = 242240, upload-time = "2026-08-15T08:16:35.396Z" }, + { url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f", size = 280308, upload-time = "2026-08-15T08:16:36.825Z" }, + { url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182", size = 258679, upload-time = "2026-08-15T08:16:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa", size = 277221, upload-time = "2026-08-15T08:16:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818", size = 263799, upload-time = "2026-08-15T08:16:40.885Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20", size = 182037, upload-time = "2026-08-15T08:16:42.198Z" }, + { url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d", size = 206030, upload-time = "2026-08-15T08:16:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5", size = 185092, upload-time = "2026-08-15T08:16:45.177Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" }, + { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" }, + { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" }, + { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "cloudscale-sdk" +source = { editable = "." } +dependencies = [ + { name = "requests" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "responses" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "requests", specifier = ">=2.25" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-cov", specifier = ">=5.0" }, + { name = "responses", specifier = ">=0.25" }, + { name = "ruff", specifier = ">=0.14" }, +] + +[[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.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/70/b052a519a584663a7bd052841a2debe11c8309ec49a7786340003f9c0a02/coverage-7.15.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d0be6daac4cce6b8c8dc65886bae1b082ddbca4da8e5cbb5e15166acf253e264", size = 222245, upload-time = "2026-08-06T13:46:55.253Z" }, + { url = "https://files.pythonhosted.org/packages/67/39/892fa511aba3d1c3c8f49509a0ff5c71eab9f9f88d08e1a38da395821660/coverage-7.15.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b24e078eabcd6a9caa8b0713f9bc1eeb310bcc960a29d45a3b4fcd4b16d5b11d", size = 222762, upload-time = "2026-08-06T13:46:57.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/95/b2c724ce1e64bc23cb5b1d7eeffa9548dc3d811f7a6297b2d01607f4e062/coverage-7.15.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe20cc8cf8821d4fe54f89106cbf06aa27f37b5bbe3535568065a81539b4150", size = 249498, upload-time = "2026-08-06T13:46:59.012Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4f/b1973f67a1382af65b572a31ed692f8e490a6ad707191eab59148376832a/coverage-7.15.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83cf06cdd687677742caff1a9134833b7a8b75f111519d2cb0e0ba1b9a851e15", size = 251328, upload-time = "2026-08-06T13:47:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/a2/09/03efa6722a132abcac91b32a60b64b240dd707c189c64eee697e48992c96/coverage-7.15.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fa4de68e2a752468ff14b4e15db7def689a71be759e826a31ccecbef69c5fd0", size = 253194, upload-time = "2026-08-06T13:47:01.976Z" }, + { url = "https://files.pythonhosted.org/packages/45/63/8299201d9c80fb65551ce99c966cab83d706ec4066ac999bef08201346de/coverage-7.15.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4dff9daa47d83120c3ec38ce921214242944a832aa04e903e50b5b7ebac8972d", size = 255106, upload-time = "2026-08-06T13:47:03.281Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/26fd8a691eb8d9a230128685f6d23309d7402cb030aa553001788c8c50fc/coverage-7.15.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a093fd37229918976f602aa07aa59e0973cde82186f220c8e197f721f5be0ce4", size = 250177, upload-time = "2026-08-06T13:47:04.713Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/3c7556f33783a0a566e01443ca62bd8eb2cdfe22d271efdc02e08beb5654/coverage-7.15.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:317db01a2cb02552fd67e2b1cca77a4b528a2a277176c5e0bf2cecbb639d3f54", size = 251234, upload-time = "2026-08-06T13:47:06.104Z" }, + { url = "https://files.pythonhosted.org/packages/29/49/640a34043edac950738f36a3567832db5731d4cb2ed84b59cdb89c6bccbf/coverage-7.15.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8ee3838dcb656602c3b51e16aed9bfb0822f8d8d6d1c5966d32ec8c104be8e20", size = 249237, upload-time = "2026-08-06T13:47:07.467Z" }, + { url = "https://files.pythonhosted.org/packages/48/f5/e80f212669dd1be954ff844f883ef11a437ef4fd0089c6e0effc7b66b15d/coverage-7.15.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:425920379052ff1fe465268f3361d35804a241bbdd5a1b592c8cb60df4c52325", size = 253050, upload-time = "2026-08-06T13:47:08.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e9/e5da0fe39f7fde1bca9edc09c60921bb5fdba4cec7db5bbad41ddfd8c230/coverage-7.15.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:69bb2400abef928e365ea7d4d9925169ada78ed2295546780002d4b65de3df88", size = 249508, upload-time = "2026-08-06T13:47:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/41bf25774a0c8bba6b467f917cb1c9a0a2605e02dc93aad489fc7050ed59/coverage-7.15.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:81661f82d302484e3119e7c80c519c02fa9bcc2a6b339baf67d67bc89c580f04", size = 250110, upload-time = "2026-08-06T13:47:11.35Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/26f2e54b79acc29d179ee4272922625aedb69198c4eb61f7ff4f098f3c78/coverage-7.15.4-cp310-cp310-win32.whl", hash = "sha256:cb476b2e828ecb71cb6b6a928d23fd20a7ddb501188022dae1c37499149cc338", size = 224294, upload-time = "2026-08-06T13:47:12.753Z" }, + { url = "https://files.pythonhosted.org/packages/7b/06/9a318fc3ae040d4d6cb2d86101c6aa963fab20899a5c58666adf52cde0ca/coverage-7.15.4-cp310-cp310-win_amd64.whl", hash = "sha256:3fc2130bf37df31852a8384f12601563a45a0024bccc6624f38355cba7a8d360", size = 224919, upload-time = "2026-08-06T13:47:14.17Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/edcec7d7a0b524aa8923e22925fde6fe50ce005a113dca13ae1581455c4c/coverage-7.15.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490", size = 222367, upload-time = "2026-08-06T13:47:15.578Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c6/ab8de429e2e8548faf58ec7e1674a4ce00414b4113942d3fe87109cf0f68/coverage-7.15.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e", size = 222874, upload-time = "2026-08-06T13:47:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/3b7b49587e8a6b9af79b3eb468d443d6042b6d65b47aa26586846a0d6566/coverage-7.15.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7", size = 253287, upload-time = "2026-08-06T13:47:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/fb/65/ec03b743a2a229c72cc1eff3e57be9d3564e9c6b4d5aba2d70744a3fc0d8/coverage-7.15.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6", size = 255199, upload-time = "2026-08-06T13:47:19.765Z" }, + { url = "https://files.pythonhosted.org/packages/41/4b/5163729e4b6582d61975cfd3ccab45b4ec53e21cf156d9941cb025188468/coverage-7.15.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d", size = 257308, upload-time = "2026-08-06T13:47:21.206Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/2167a0f08fb87d702fa423a48578a32865464b7c9e1db3911ad7812ab414/coverage-7.15.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce", size = 259268, upload-time = "2026-08-06T13:47:22.503Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e5/68eebae3053dbd48508edea559c21b23fbdf3460784f91370c83a86a6acd/coverage-7.15.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7", size = 253392, upload-time = "2026-08-06T13:47:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/1a/46/fd4ced40a2b691c774e515c9b69500bfa64c7960b67fcee4b2f6fad97fc3/coverage-7.15.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b", size = 255001, upload-time = "2026-08-06T13:47:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/53/25/ae2e5fa710bb6957a9aadeb9e3598d3b3e4af6587ce857ad42e8639a3f30/coverage-7.15.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc", size = 253061, upload-time = "2026-08-06T13:47:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/d7/31/67ddc0365db2c6e93ac8580bc4bbc50f65273262f973f63ebcdbc15c0495/coverage-7.15.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571", size = 256831, upload-time = "2026-08-06T13:47:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/82b8fd18f57fb13f12d98fe874995bb2c4f9f17be8aff762c426323fdb96/coverage-7.15.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719", size = 252781, upload-time = "2026-08-06T13:47:29.712Z" }, + { url = "https://files.pythonhosted.org/packages/0a/eb/6c74ef4dd12b252e573c49bdef9e2ac265bf3dbb79b8d7feb3266e084e9e/coverage-7.15.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7", size = 253692, upload-time = "2026-08-06T13:47:31.192Z" }, + { url = "https://files.pythonhosted.org/packages/5a/66/eb9aed1c3fd2d36ee00eb173f434b14fa607fc056739c9a89ff4244010ea/coverage-7.15.4-cp311-cp311-win32.whl", hash = "sha256:69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e", size = 224461, upload-time = "2026-08-06T13:47:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6d/81fa4161dfb3ed9d74e40d58647eff83a56b7612e78352581280fce2f477/coverage-7.15.4-cp311-cp311-win_amd64.whl", hash = "sha256:63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc", size = 224937, upload-time = "2026-08-06T13:47:34.205Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c1/d8dacf683c6cad3cf85ce68fd3774a6774ec402128822fdfaed920f11e6a/coverage-7.15.4-cp311-cp311-win_arm64.whl", hash = "sha256:ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890", size = 224479, upload-time = "2026-08-06T13:47:36.118Z" }, + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +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.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "responses" +version = "0.26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/1a/4af3e6d659394b809838490b144e4ab8d7ed3b9fecc7ca78f5d2f79b1a3d/responses-0.26.2.tar.gz", hash = "sha256:9c9259b46a8349197edebf43cfa68a87e1a2802ef503ff8b2fecbabc0b45afd8", size = 84030, upload-time = "2026-07-03T16:44:50.325Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/28/693e1d9ebf72baa062ded80d837a035b86ce75eda5a269379e9e2b1008a8/responses-0.26.2-py3-none-any.whl", hash = "sha256:6fdfeabd58e5ec473b98dfe02e6d46d3173bd8dd573eff2ccccf1a05a5135364", size = 35609, upload-time = "2026-07-03T16:44:49.1Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, + { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, + { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, + { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, + { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +]