pax_global_header00006660000000000000000000000064147716773660014542gustar00rootroot0000000000000052 comment=ad4f1f03af3be3dae74b3485b84bba61463186af iaqualink-py-0.5.3/000077500000000000000000000000001477167736600141535ustar00rootroot00000000000000iaqualink-py-0.5.3/.github/000077500000000000000000000000001477167736600155135ustar00rootroot00000000000000iaqualink-py-0.5.3/.github/dependabot.yml000066400000000000000000000012321477167736600203410ustar00rootroot00000000000000# To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: pip directory: "/" schedule: interval: daily time: "06:00" groups: python-packages: patterns: - "*" - package-ecosystem: github-actions directory: "/" schedule: # Check for updates to GitHub Actions every week interval: "weekly" iaqualink-py-0.5.3/.github/workflows/000077500000000000000000000000001477167736600175505ustar00rootroot00000000000000iaqualink-py-0.5.3/.github/workflows/ci.yaml000066400000000000000000000026111477167736600210270ustar00rootroot00000000000000name: CI on: push: branches: - master pull_request: branches: - master jobs: run: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest] python-version: ["3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Install uv and set the python version uses: astral-sh/setup-uv@v5 with: python-version: ${{ matrix.python-version }} - name: Set up Python uses: actions/setup-python@v5 - name: Install the project run: uv sync --all-extras --dev - name: Run linters (pre-commit) run: uv run pre-commit run --show-diff-on-failure --color=always --all-files - name: Run unit tests run: uv run pytest coverage: runs-on: ${{ matrix.os }} needs: [run] strategy: matrix: os: [ubuntu-latest] steps: - uses: actions/checkout@v4 - name: Install uv uses: astral-sh/setup-uv@v5 - name: Set up Python uses: actions/setup-python@v5 with: python-version-file: pyproject.toml - name: Install the project run: uv sync --all-extras --dev - name: Generate coverage report run: uv run pytest --cov-report=xml --cov=iaqualink - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: flags: full-suite iaqualink-py-0.5.3/.github/workflows/release.yaml000066400000000000000000000021121477167736600220500ustar00rootroot00000000000000name: Release on: push: tags: - "v*" jobs: github: runs-on: ubuntu-latest permissions: contents: write steps: - name: Checkout uses: actions/checkout@v4 - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: generate_release_notes: true pypi: runs-on: ubuntu-latest needs: github environment: release permissions: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - uses: actions/checkout@v4 with: fetch-depth: "0" # Versioning needs this. - name: Install uv uses: astral-sh/setup-uv@v5 - name: Set up Python uses: actions/setup-python@v5 with: python-version-file: "pyproject.toml" - name: Install the project run: uv sync --all-extras --dev - name: Build package run: uv build - name: Test package run: uv run pytest - name: Publish package to PyPI uses: pypa/gh-action-pypi-publish@release/v1 iaqualink-py-0.5.3/.gitignore000066400000000000000000000024131477167736600161430ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ # Editors .vscode/ # Version file is automatically generated src/iaqualink/version.py iaqualink-py-0.5.3/.pre-commit-config.yaml000066400000000000000000000012641477167736600204370ustar00rootroot00000000000000# See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.9.10 hooks: - id: ruff args: [--fix] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.15.0 hooks: - id: mypy exclude: ^(tests/.*) - repo: https://github.com/astral-sh/uv-pre-commit rev: 0.6.5 hooks: - id: uv-lock iaqualink-py-0.5.3/LICENSE000066400000000000000000000027631477167736600151700ustar00rootroot00000000000000BSD 3-Clause License Copyright (c) 2019, Florent Thoumie All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. iaqualink-py-0.5.3/MANIFEST.in000066400000000000000000000000201477167736600157010ustar00rootroot00000000000000include LICENSE iaqualink-py-0.5.3/README.md000066400000000000000000000056001477167736600154330ustar00rootroot00000000000000# Asynchronous library for Jandy iAqualink Usage (using apython): ```python >>> async with AqualinkClient('xxx@example.com', 'password') as c: ... s = await c.get_systems() ... print(s) ... d = await list(s.values())[0].get_devices() ... print(d) ... {'XXX': AqualinkPoolSystem(name='Pool' serial='XXX' data={'id': 1234, 'serial_number': 'XXX', 'created_at': '2017-09-23T01:00:08.000Z', 'updated_at': '2017-09-23T01:00:08.000Z', 'name': 'Pool', 'device_type': 'iaqua', 'owner_id': None, 'updating': False, 'firmware_version': None, 'target_firmware_version': None, 'update_firmware_start_at': None, 'last_activity_at': None})} {'spa_temp': AqualinkSensor(name='spa_temp' data={'name': 'spa_temp', 'state': '100'}), 'pool_temp': AqualinkSensor(name='pool_temp' data={'name': 'pool_temp', 'state': ''}), 'air_temp': AqualinkSensor(name='air_temp' data={'name': 'air_temp', 'state': '76'}), 'spa_set_point': AqualinkThermostat(name='spa_set_point' data={'name': 'spa_set_point', 'state': '102'}), 'pool_set_point': AqualinkThermostat(name='pool_set_point' data={'name': 'pool_set_point', 'state': '84'}), 'cover_pool': AqualinkSensor(name='cover_pool' data={'name': 'cover_pool', 'state': ''}), 'freeze_protection': AqualinkBinarySensor(name='freeze_protection' data={'name': 'freeze_protection', 'state': '0'}), 'spa_pump': AqualinkPump(name='spa_pump' data={'name': 'spa_pump', 'state': '1'}), 'pool_pump': AqualinkPump(name='pool_pump' data={'name': 'pool_pump', 'state': '1'}), 'spa_heater': AqualinkHeater(name='spa_heater' data={'name': 'spa_heater', 'state': '0'}), 'pool_heater': AqualinkHeater(name='pool_heater' data={'name': 'pool_heater', 'state': '0'}), 'solar_heater': AqualinkHeater(name='solar_heater' data={'name': 'solar_heater', 'state': '1'}), 'spa_salinity': AqualinkSensor(name='spa_salinity' data={'name': 'spa_salinity', 'state': ''}), 'pool_salinity': AqualinkSensor(name='pool_salinity' data={'name': 'pool_salinity', 'state': ''}), 'orp': AqualinkSensor(name='orp' data={'name': 'orp', 'state': ''}), 'ph': AqualinkSensor(name='ph' data={'name': 'ph', 'state': ''}), 'aux_1': AqualinkAuxToggle(name='aux_1' data={'aux': '1', 'name': 'aux_1', 'state': '0', 'label': 'CLEANER', 'icon': 'aux_1_0.png', 'type': '0', 'subtype': '0'}), 'aux_2': AqualinkLightToggle(name='aux_2' data={'aux': '2', 'name': 'aux_2', 'state': '0', 'label': 'SPA LIGHT', 'icon': 'aux_1_0.png', 'type': '0', 'subtype': '0'}), 'aux_3': AqualinkLightToggle(name='aux_3' data={'aux': '3', 'name': 'aux_3', 'state': '0', 'label': 'POOL LIGHT', 'icon': 'aux_1_0.png', 'type': '0', 'subtype': '0'}), 'aux_4': AqualinkAuxToggle(name='aux_4' data={'aux': '4', 'name': 'aux_4', 'state': '0', 'label': 'AIR BLOWER', 'icon': 'aux_1_0.png', 'type': '0', 'subtype': '0'}), 'aux_5': AqualinkAuxToggle(name='aux_5' data={'aux': '5', 'name': 'aux_5', 'state': '0', 'label': 'SHEER DSCNT', 'icon': 'aux_1_0.png', 'type': '0', 'subtype': '0'})} ``` iaqualink-py-0.5.3/pyproject.toml000066400000000000000000000032701477167736600170710ustar00rootroot00000000000000[build-system] requires = [ "hatchling>=1.3.1", "hatch-vcs", ] build-backend = "hatchling.build" [project] name = "iaqualink" description = "Asynchronous library for Jandy iAqualink" readme = "README.md" license = "BSD-3-Clause" requires-python = ">=3.12" authors = [ { name = "Florent Thoumie", email = "florent@thoumie.net" }, ] keywords = [ "iaqualink", ] classifiers = [ "Development Status :: 2 - Pre-Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Natural Language :: English", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ] dependencies = [ "httpx[http2]>=0.27.0", ] dynamic = [ "version", ] [project.optional-dependencies] dev = [ "pre-commit==4.2.0", "mypy==1.15.0", "ruff==0.11.2", ] test = [ "coverage[toml]==7.7.1", "pytest==8.3.5", "pytest-cov==6.0.0", "pytest-icdiff==0.9", "pytest-sugar==1.0.0", "respx==0.22.0", ] [project.urls] Homepage = "https://github.com/flz/iaqualink-py" [tool.hatch.version] source = "vcs" [tool.hatch.build.hooks.vcs] version-file = "src/iaqualink/version.py" [tool.hatch.build.targets.sdist] [tool.hatch.build.targets.wheel] packages = ["src/iaqualink"] [tool.ruff] line-length = 80 [tool.ruff.lint] ignore = [ "SLF001", # Some tests currently use private members "G004", # Will fix all f-string logging calls later ] [tool.coverage.run] omit = [ ".venv/*", ] [tool.coverage.report] exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", ] [tool.mypy] ignore_missing_imports = true [tool.pytest.ini_options] filterwarnings = [ "error", "ignore::DeprecationWarning", ] iaqualink-py-0.5.3/src/000077500000000000000000000000001477167736600147425ustar00rootroot00000000000000iaqualink-py-0.5.3/src/__init__.py000066400000000000000000000000001477167736600170410ustar00rootroot00000000000000iaqualink-py-0.5.3/src/conftest.py000066400000000000000000000000001477167736600171270ustar00rootroot00000000000000iaqualink-py-0.5.3/src/iaqualink/000077500000000000000000000000001477167736600167205ustar00rootroot00000000000000iaqualink-py-0.5.3/src/iaqualink/__init__.py000066400000000000000000000000001477167736600210170ustar00rootroot00000000000000iaqualink-py-0.5.3/src/iaqualink/client.py000066400000000000000000000110311477167736600205440ustar00rootroot00000000000000from __future__ import annotations import contextlib import logging from typing import TYPE_CHECKING, Any, Self import httpx from iaqualink.const import ( AQUALINK_API_KEY, AQUALINK_DEVICES_URL, AQUALINK_LOGIN_URL, KEEPALIVE_EXPIRY, ) from iaqualink.exception import ( AqualinkServiceException, AqualinkServiceUnauthorizedException, AqualinkSystemUnsupportedException, ) from iaqualink.system import AqualinkSystem from iaqualink.systems import * # noqa: F403 if TYPE_CHECKING: from types import TracebackType AQUALINK_HTTP_HEADERS = { "user-agent": "okhttp/3.14.7", "content-type": "application/json", } LOGGER = logging.getLogger("iaqualink") class AqualinkClient: def __init__( self, username: str, password: str, httpx_client: httpx.AsyncClient | None = None, ): self._username = username self._password = password self._logged = False self._client: httpx.AsyncClient | None = None if httpx_client is None: self._client = None self._must_close_client = True else: self._client = httpx_client self._must_close_client = False self.client_id = "" self._token = "" self._user_id = "" self._last_refresh = 0 @property def logged(self) -> bool: return self._logged async def close(self) -> None: if self._must_close_client is False: return # There shouldn't be a case where this is None but this quietens mypy. if self._client is not None: await self._client.aclose() self._client = None async def __aenter__(self) -> Self: try: await self.login() except AqualinkServiceException: await self.close() raise return self async def __aexit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None, ) -> bool | None: # All Exceptions get re-raised. await self.close() return exc is None async def send_request( self, url: str, method: str = "get", **kwargs: Any ) -> httpx.Response: if self._client is None: self._client = httpx.AsyncClient( http2=True, limits=httpx.Limits(keepalive_expiry=KEEPALIVE_EXPIRY), ) LOGGER.debug(f"-> {method.upper()} {url} {kwargs}") r = await self._client.request( method, url, headers=AQUALINK_HTTP_HEADERS, **kwargs ) LOGGER.debug(f"<- {r.status_code} {r.reason_phrase} - {url}") if r.status_code == httpx.codes.UNAUTHORIZED: m = "Unauthorized Access, check your credentials and try again" self._logged = False raise AqualinkServiceUnauthorizedException if r.status_code != httpx.codes.OK: m = f"Unexpected response: {r.status_code} {r.reason_phrase}" raise AqualinkServiceException(m) return r async def _send_login_request(self) -> httpx.Response: data = { "api_key": AQUALINK_API_KEY, "email": self._username, "password": self._password, } return await self.send_request( AQUALINK_LOGIN_URL, method="post", json=data ) async def login(self) -> None: r = await self._send_login_request() data = r.json() self.client_id = data["session_id"] self._token = data["authentication_token"] self._user_id = data["id"] self._logged = True async def _send_systems_request(self) -> httpx.Response: params = { "api_key": AQUALINK_API_KEY, "authentication_token": self._token, "user_id": self._user_id, } params_str = "&".join(f"{k}={v}" for k, v in params.items()) url = f"{AQUALINK_DEVICES_URL}?{params_str}" return await self.send_request(url) async def get_systems(self) -> dict[str, AqualinkSystem]: try: r = await self._send_systems_request() except AqualinkServiceException as e: if "404" in str(e): raise AqualinkServiceUnauthorizedException from e raise data = r.json() systems = [] for x in data: with contextlib.suppress(AqualinkSystemUnsupportedException): systems += [AqualinkSystem.from_data(self, x)] return {x.serial: x for x in systems if x is not None} iaqualink-py-0.5.3/src/iaqualink/const.py000066400000000000000000000003751477167736600204250ustar00rootroot00000000000000from __future__ import annotations AQUALINK_API_KEY = "EOOEMOW4YR6QNB07" AQUALINK_LOGIN_URL = "https://prod.zodiac-io.com/users/v1/login" AQUALINK_DEVICES_URL = "https://r-api.iaqualink.net/devices.json" KEEPALIVE_EXPIRY = 30 MIN_SECS_TO_REFRESH = 5 iaqualink-py-0.5.3/src/iaqualink/device.py000066400000000000000000000064121477167736600205340ustar00rootroot00000000000000from __future__ import annotations import logging from typing import TYPE_CHECKING, Any from iaqualink.exception import AqualinkOperationNotSupportedException if TYPE_CHECKING: from iaqualink.typing import DeviceData LOGGER = logging.getLogger("iaqualink") class AqualinkDevice: def __init__( self, system: Any, # Should be AqualinkSystem but causes mypy errors. data: DeviceData, ): self.system = system self.data = data def __repr__(self) -> str: attrs = ["data"] attrs = [f"{i}={getattr(self, i)!r}" for i in attrs] return f"{self.__class__.__name__}({', '.join(attrs)})" def __eq__(self, other: object) -> bool: if not isinstance(other, AqualinkDevice): return NotImplemented if ( self.system.serial == other.system.serial and self.data == other.data ): return True return False @property def label(self) -> str: raise NotImplementedError @property def state(self) -> str: raise NotImplementedError @property def name(self) -> str: raise NotImplementedError @property def manufacturer(self) -> str: raise NotImplementedError @property def model(self) -> str: raise NotImplementedError class AqualinkSensor(AqualinkDevice): pass class AqualinkBinarySensor(AqualinkSensor): """These are non-actionable sensors, essentially read-only on/off.""" @property def is_on(self) -> bool: raise NotImplementedError class AqualinkSwitch(AqualinkBinarySensor, AqualinkDevice): async def turn_on(self) -> None: raise NotImplementedError async def turn_off(self) -> None: raise NotImplementedError class AqualinkLight(AqualinkSwitch, AqualinkDevice): @property def brightness(self) -> int | None: return None @property def supports_brightness(self) -> bool: return self.brightness is not None async def set_brightness(self, _: int) -> None: if self.supports_brightness is True: raise NotImplementedError raise AqualinkOperationNotSupportedException @property def effect(self) -> str | None: return None @property def supports_effect(self) -> bool: return self.effect is not None async def set_effect_by_name(self, _: str) -> None: if self.supports_effect is True: raise NotImplementedError raise AqualinkOperationNotSupportedException async def set_effect_by_id(self, _: int) -> None: if self.supports_effect is True: raise NotImplementedError raise AqualinkOperationNotSupportedException class AqualinkThermostat(AqualinkSwitch, AqualinkDevice): @property def unit(self) -> str: raise NotImplementedError @property def current_temperature(self) -> str: raise NotImplementedError @property def target_temperature(self) -> str: raise NotImplementedError @property def max_temperature(self) -> int: raise NotImplementedError @property def min_temperature(self) -> int: raise NotImplementedError async def set_temperature(self, _: int) -> None: raise NotImplementedError iaqualink-py-0.5.3/src/iaqualink/exception.py000066400000000000000000000020331477167736600212660ustar00rootroot00000000000000from __future__ import annotations class AqualinkException(Exception): # noqa: N818 """Base exception for iAqualink library.""" class AqualinkInvalidParameterException(AqualinkException): """Exception raised when an invalid parameter is passed.""" class AqualinkServiceException(AqualinkException): """Exception raised when an error is raised by the iaqualink service.""" class AqualinkServiceUnauthorizedException(AqualinkServiceException): """Exception raised when service access is unauthorized.""" class AqualinkSystemOfflineException(AqualinkServiceException): """Exception raised when a system is offline.""" class AqualinkSystemUnsupportedException(AqualinkServiceException): """Exception raised when a system isn't supported by the library.""" class AqualinkOperationNotSupportedException(AqualinkException): """Exception raised when trying to issue an unsupported operation.""" class AqualinkDeviceNotSupported(AqualinkException): """Exception raised when a device isn't known-unsupported.""" iaqualink-py-0.5.3/src/iaqualink/system.py000066400000000000000000000036311477167736600206210ustar00rootroot00000000000000from __future__ import annotations import logging from typing import TYPE_CHECKING, ClassVar from iaqualink.exception import AqualinkSystemUnsupportedException if TYPE_CHECKING: from iaqualink.client import AqualinkClient from iaqualink.device import AqualinkDevice from iaqualink.typing import Payload LOGGER = logging.getLogger("iaqualink") class AqualinkSystem: subclasses: ClassVar[dict[str, type[AqualinkSystem]]] = {} def __init__(self, aqualink: AqualinkClient, data: Payload): self.aqualink = aqualink self.data = data self.devices: dict[str, AqualinkDevice] = {} self.last_refresh: int # Semantics here are somewhat odd. # True/False are obvious, None means "unknown". self.online: bool | None = None @classmethod def __init_subclass__(cls) -> None: super().__init_subclass__() if hasattr(cls, "NAME"): cls.subclasses[cls.NAME] = cls def __repr__(self) -> str: attrs = ["name", "serial", "data"] attrs = [f"{i}={getattr(self, i)!r}" for i in attrs] return f"{self.__class__.__name__}({', '.join(attrs)})" @property def name(self) -> str: return self.data["name"] @property def serial(self) -> str: return self.data["serial_number"] @classmethod def from_data( cls, aqualink: AqualinkClient, data: Payload ) -> AqualinkSystem: if data["device_type"] not in cls.subclasses: m = f"{data['device_type']} is not a supported system type." LOGGER.warning(m) raise AqualinkSystemUnsupportedException(m) return cls.subclasses[data["device_type"]](aqualink, data) async def get_devices(self) -> dict[str, AqualinkDevice]: if not self.devices: await self.update() return self.devices async def update(self) -> None: raise NotImplementedError iaqualink-py-0.5.3/src/iaqualink/systems/000077500000000000000000000000001477167736600204275ustar00rootroot00000000000000iaqualink-py-0.5.3/src/iaqualink/systems/__init__.py000066400000000000000000000002321477167736600225350ustar00rootroot00000000000000from os import listdir from os.path import basename, dirname __all__ = [ basename(f) for f in listdir(dirname(__file__)) if not f.startswith("__") ] iaqualink-py-0.5.3/src/iaqualink/systems/iaqua/000077500000000000000000000000001477167736600215275ustar00rootroot00000000000000iaqualink-py-0.5.3/src/iaqualink/systems/iaqua/__init__.py000066400000000000000000000001231477167736600236340ustar00rootroot00000000000000from iaqualink.systems.iaqua import device, system __all__ = ["device", "system"] iaqualink-py-0.5.3/src/iaqualink/systems/iaqua/device.py000066400000000000000000000306001477167736600233370ustar00rootroot00000000000000from __future__ import annotations import logging from enum import Enum, unique from typing import TYPE_CHECKING, cast from iaqualink.device import ( AqualinkBinarySensor, AqualinkDevice, AqualinkLight, AqualinkSensor, AqualinkSwitch, AqualinkThermostat, ) from iaqualink.exception import ( AqualinkDeviceNotSupported, AqualinkInvalidParameterException, ) if TYPE_CHECKING: from iaqualink.systems.iaqua.system import IaquaSystem from iaqualink.typing import DeviceData IAQUA_TEMP_CELSIUS_LOW = 1 IAQUA_TEMP_CELSIUS_HIGH = 40 IAQUA_TEMP_FAHRENHEIT_LOW = 34 IAQUA_TEMP_FAHRENHEIT_HIGH = 104 LOGGER = logging.getLogger("iaqualink") @unique class AqualinkState(Enum): OFF = "0" ON = "1" ENABLED = "3" ABSENT = "absent" PRESENT = "present" class IaquaDevice(AqualinkDevice): def __init__(self, system: IaquaSystem, data: DeviceData): super().__init__(system, data) # This silences mypy errors due to AqualinkDevice type annotations. self.system: IaquaSystem = system @property def label(self) -> str: if "label" in self.data: label = self.data["label"] return " ".join([x.capitalize() for x in label.split()]) label = self.data["name"] return " ".join([x.capitalize() for x in label.split("_")]) @property def state(self) -> str: return self.data["state"] @property def name(self) -> str: return self.data["name"] @property def manufacturer(self) -> str: return "Jandy" @property def model(self) -> str: return self.__class__.__name__.replace("Iaqua", "") @classmethod def from_data(cls, system: IaquaSystem, data: DeviceData) -> IaquaDevice: class_: type[IaquaDevice] # I don't have a system where these fields get populated. # No idea what they are and what to do with them. if isinstance(data["state"], dict | list): raise AqualinkDeviceNotSupported(data) if data["name"].endswith("_heater") or data["name"].endswith("_pump"): class_ = IaquaSwitch elif data["name"].endswith("_set_point"): if data["state"] == "": raise AqualinkDeviceNotSupported(data) class_ = IaquaThermostat elif data["name"] == "freeze_protection" or data["name"].endswith( "_present" ): class_ = IaquaBinarySensor elif data["name"].startswith("aux_"): if data["type"] == "2": class_ = light_subtype_to_class[data["subtype"]] elif data["type"] == "1": class_ = IaquaDimmableLight elif "LIGHT" in data["label"]: class_ = IaquaLightSwitch else: class_ = IaquaAuxSwitch else: class_ = IaquaSensor return class_(system, data) class IaquaSensor(IaquaDevice, AqualinkSensor): pass class IaquaBinarySensor(IaquaSensor, AqualinkBinarySensor): """These are non-actionable sensors, essentially read-only on/off.""" @property def is_on(self) -> bool: return ( AqualinkState(self.state) in [AqualinkState.ON, AqualinkState.ENABLED, AqualinkState.PRESENT] if self.state else False ) class IaquaSwitch(IaquaBinarySensor, AqualinkSwitch): async def _toggle(self) -> None: await self.system.set_switch(f"set_{self.name}") async def turn_on(self) -> None: if not self.is_on: await self._toggle() async def turn_off(self) -> None: if self.is_on: await self._toggle() class IaquaAuxSwitch(IaquaSwitch): @property def is_on(self) -> bool: return ( AqualinkState(self.state) == AqualinkState.ON if self.state else False ) async def _toggle(self) -> None: await self.system.set_aux(self.data["aux"]) class IaquaLightSwitch(IaquaAuxSwitch, AqualinkLight): pass class IaquaDimmableLight(IaquaAuxSwitch, AqualinkLight): async def turn_on(self) -> None: if not self.is_on: await self.set_brightness(100) async def turn_off(self) -> None: if self.is_on: await self.set_brightness(0) @property def brightness(self) -> int | None: return int(self.data["subtype"]) async def set_brightness(self, brightness: int) -> None: # Brightness only works in 25% increments. if brightness not in [0, 25, 50, 75, 100]: msg = f"{brightness}% isn't a valid percentage." msg += " Only use 25% increments." raise AqualinkInvalidParameterException(msg) data = {"aux": self.data["aux"], "light": f"{brightness}"} await self.system.set_light(data) class IaquaColorLight(IaquaAuxSwitch, AqualinkLight): async def turn_on(self) -> None: if not self.is_on: await self.set_effect_by_id(1) async def turn_off(self) -> None: if self.is_on: await self.set_effect_by_id(0) @property def effect(self) -> str | None: # "state"=0 indicates the light is off. # "state"=1 indicates the light is on. # I don't see a way to retrieve the current effect. # The official iAquaLink app doesn't seem to show the current effect # choice either, so perhaps it's an unfortunate limitation of the # current API. return self.data["state"] @property def supported_effects(self) -> dict[str, int]: raise NotImplementedError async def set_effect_by_name(self, effect: str) -> None: try: effect_id = self.supported_effects[effect] except KeyError as e: msg = f"{effect!r} isn't a valid effect." raise AqualinkInvalidParameterException(msg) from e await self.set_effect_by_id(effect_id) async def set_effect_by_id(self, effect_id: int) -> None: try: _ = list(self.supported_effects.values()).index(effect_id) except ValueError as e: msg = f"{effect_id!r} isn't a valid effect." raise AqualinkInvalidParameterException(msg) from e data = { "aux": self.data["aux"], "light": str(effect_id), "subtype": self.data["subtype"], } await self.system.set_light(data) class IaquaColorLightJC(IaquaColorLight): @property def manufacturer(self) -> str: return "Jandy" @property def model(self) -> str: return "Colors Light" @property def supported_effects(self) -> dict[str, int]: return { "Off": 0, "Alpine White": 1, "Sky Blue": 2, "Cobalt Blue": 3, "Caribbean Blue": 4, "Spring Green": 5, "Emerald Green": 6, "Emerald Rose": 7, "Magenta": 8, "Garnet Red": 9, "Violet": 10, "Color Splash": 11, } class IaquaColorLightSL(IaquaColorLight): @property def manufacturer(self) -> str: return "Pentair" @property def model(self) -> str: return "SAm/SAL Light" @property def supported_effects(self) -> dict[str, int]: return { "Off": 0, "White": 1, "Light Green": 2, "Green": 3, "Cyan": 4, "Blue": 5, "Lavender": 6, "Magenta": 7, "Light Magenta": 8, "Color Splash": 9, } class IaquaColorLightCL(IaquaColorLight): @property def manufacturer(self) -> str: return "Pentair" @property def model(self) -> str: return "ColorLogic Light" @property def supported_effects(self) -> dict[str, int]: return { "Off": 0, "Voodoo Lounge": 1, "Deep Blue Sea": 2, "Afternoon Skies": 3, "Emerald": 4, "Sangria": 5, "Cloud White": 6, "Twilight": 7, "Tranquility": 8, "Gemstone": 9, "USA!": 10, "Mardi Gras": 11, "Cool Cabaret": 12, } class IaquaColorLightJL(IaquaColorLight): @property def manufacturer(self) -> str: return "Jandy" @property def model(self) -> str: return "LED WaterColors Light" @property def supported_effects(self) -> dict[str, int]: return { "Off": 0, "Alpine White": 1, "Sky Blue": 2, "Cobalt Blue": 3, "Caribbean Blue": 4, "Spring Green": 5, "Emerald Green": 6, "Emerald Rose": 7, "Magenta": 8, "Violet": 9, "Slow Splash": 10, "Fast Splash": 11, "USA!": 12, "Fat Tuesday": 13, "Disco Tech": 14, } class IaquaColorLightIB(IaquaColorLight): @property def manufacturer(self) -> str: return "Pentair" @property def model(self) -> str: return "Intellibrite Light" @property def supported_effects(self) -> dict[str, int]: return { "Off": 0, "SAm": 1, "Party": 2, "Romance": 3, "Caribbean": 4, "American": 5, "California Sunset": 6, "Royal": 7, "Blue": 8, "Green": 9, "Red": 10, "White": 11, "Magenta": 12, } class IaquaColorLightHU(IaquaColorLight): @property def manufacturer(self) -> str: return "Hayward" @property def model(self) -> str: return "Universal Light" @property def supported_effects(self) -> dict[str, int]: return { "Off": 0, "Voodoo Lounge": 1, "Deep Blue Sea": 2, "Royal Blue": 3, "Afternoon Skies": 4, "Aqua Green": 5, "Emerald": 6, "Cloud White": 7, "Warm Red": 8, "Flamingo": 9, "Vivid Violet": 10, "Sangria": 11, "Twilight": 12, "Tranquility": 13, "Gemstone": 14, "USA!": 15, "Mardi Gras": 16, "Cool Cabaret": 17, } light_subtype_to_class = { "1": IaquaColorLightJC, "2": IaquaColorLightSL, "3": IaquaColorLightCL, "4": IaquaColorLightJL, "5": IaquaColorLightIB, "6": IaquaColorLightHU, } class IaquaThermostat(IaquaSwitch, AqualinkThermostat): @property def _type(self) -> str: return self.name.split("_")[0] @property def _temperature(self) -> str: # Spa takes precedence for temp1 if present. if self._type == "pool" and "spa_set_point" in self.system.devices: return "temp2" return "temp1" @property def unit(self) -> str: return self.system.temp_unit @property def _sensor(self) -> IaquaSensor: return cast(IaquaSensor, self.system.devices[f"{self._type}_temp"]) @property def current_temperature(self) -> str: return self._sensor.state @property def target_temperature(self) -> str: return self.state @property def min_temperature(self) -> int: if self.unit == "F": return IAQUA_TEMP_FAHRENHEIT_LOW return IAQUA_TEMP_CELSIUS_LOW @property def max_temperature(self) -> int: if self.unit == "F": return IAQUA_TEMP_FAHRENHEIT_HIGH return IAQUA_TEMP_CELSIUS_HIGH async def set_temperature(self, temperature: int) -> None: unit = self.unit low = self.min_temperature high = self.max_temperature if temperature not in range(low, high + 1): msg = f"{temperature}{unit} isn't a valid temperature" msg += f" ({low}-{high}{unit})." raise AqualinkInvalidParameterException(msg) data = {self._temperature: str(temperature)} await self.system.set_temps(data) @property def _heater(self) -> IaquaSwitch: return cast(IaquaSwitch, self.system.devices[f"{self._type}_heater"]) @property def is_on(self) -> bool: return self._heater.is_on async def turn_on(self) -> None: if self._heater.is_on is False: await self._heater.turn_on() async def turn_off(self) -> None: if self._heater.is_on is True: await self._heater.turn_off() iaqualink-py-0.5.3/src/iaqualink/systems/iaqua/system.py000066400000000000000000000146421477167736600234340ustar00rootroot00000000000000from __future__ import annotations import logging import time from typing import TYPE_CHECKING from iaqualink.const import MIN_SECS_TO_REFRESH from iaqualink.exception import ( AqualinkDeviceNotSupported, AqualinkServiceException, AqualinkSystemOfflineException, ) from iaqualink.system import AqualinkSystem from iaqualink.systems.iaqua.device import IaquaDevice if TYPE_CHECKING: import httpx from iaqualink.client import AqualinkClient from iaqualink.typing import Payload IAQUA_SESSION_URL = "https://p-api.iaqualink.net/v1/mobile/session.json" IAQUA_COMMAND_GET_DEVICES = "get_devices" IAQUA_COMMAND_GET_HOME = "get_home" IAQUA_COMMAND_GET_ONETOUCH = "get_onetouch" IAQUA_COMMAND_SET_AUX = "set_aux" IAQUA_COMMAND_SET_LIGHT = "set_light" IAQUA_COMMAND_SET_POOL_HEATER = "set_pool_heater" IAQUA_COMMAND_SET_POOL_PUMP = "set_pool_pump" IAQUA_COMMAND_SET_SOLAR_HEATER = "set_solar_heater" IAQUA_COMMAND_SET_SPA_HEATER = "set_spa_heater" IAQUA_COMMAND_SET_SPA_PUMP = "set_spa_pump" IAQUA_COMMAND_SET_TEMPS = "set_temps" LOGGER = logging.getLogger("iaqualink") class IaquaSystem(AqualinkSystem): NAME = "iaqua" def __init__(self, aqualink: AqualinkClient, data: Payload): super().__init__(aqualink, data) self.temp_unit: str = "" self.last_refresh: int = 0 def __repr__(self) -> str: attrs = ["name", "serial", "data"] attrs = [f"{i}={getattr(self, i)!r}" for i in attrs] return f"{self.__class__.__name__}({' '.join(attrs)})" async def _send_session_request( self, command: str, params: Payload | None = None, ) -> httpx.Response: if not params: params = {} params.update( { "actionID": "command", "command": command, "serial": self.serial, "sessionID": self.aqualink.client_id, } ) params_str = "&".join(f"{k}={v}" for k, v in params.items()) url = f"{IAQUA_SESSION_URL}?{params_str}" return await self.aqualink.send_request(url) async def _send_home_screen_request(self) -> httpx.Response: return await self._send_session_request(IAQUA_COMMAND_GET_HOME) async def _send_devices_screen_request(self) -> httpx.Response: return await self._send_session_request(IAQUA_COMMAND_GET_DEVICES) async def update(self) -> None: # Be nice to Aqualink servers since we rely on polling. now = int(time.time()) delta = now - self.last_refresh if delta < MIN_SECS_TO_REFRESH: LOGGER.debug(f"Only {delta}s since last refresh.") return try: r1 = await self._send_home_screen_request() r2 = await self._send_devices_screen_request() except AqualinkServiceException: self.online = None raise try: self._parse_home_response(r1) self._parse_devices_response(r2) except AqualinkSystemOfflineException: self.online = False raise self.online = True self.last_refresh = int(time.time()) def _parse_home_response(self, response: httpx.Response) -> None: data = response.json() LOGGER.debug(f"Home response: {data}") if data["home_screen"][0]["status"] == "Offline": LOGGER.warning(f"Status for system {self.serial} is Offline.") raise AqualinkSystemOfflineException self.temp_unit = data["home_screen"][3]["temp_scale"] # Make the data a bit flatter. devices = {} for x in data["home_screen"][4:]: name = next(iter(x.keys())) state = next(iter(x.values())) attrs = {"name": name, "state": state} devices.update({name: attrs}) for k, v in devices.items(): if k in self.devices: for dk, dv in v.items(): self.devices[k].data[dk] = dv else: try: self.devices[k] = IaquaDevice.from_data(self, v) except AqualinkDeviceNotSupported as e: LOGGER.debug("Device found was ignored: %s", e) def _parse_devices_response(self, response: httpx.Response) -> None: data = response.json() LOGGER.debug(f"Devices response: {data}") if data["devices_screen"][0]["status"] == "Offline": LOGGER.warning(f"Status for system {self.serial} is Offline.") raise AqualinkSystemOfflineException # Make the data a bit flatter. devices = {} for x in data["devices_screen"][3:]: aux = next(iter(x.keys())) attrs = {"aux": aux.replace("aux_", ""), "name": aux} for y in next(iter(x.values())): attrs.update(y) devices.update({aux: attrs}) for k, v in devices.items(): if k in self.devices: for dk, dv in v.items(): self.devices[k].data[dk] = dv else: try: self.devices[k] = IaquaDevice.from_data(self, v) except AqualinkDeviceNotSupported as e: LOGGER.info("Device found was ignored: %s", e) async def set_switch(self, command: str) -> None: r = await self._send_session_request(command) self._parse_home_response(r) async def set_temps(self, temps: Payload) -> None: # I'm not proud of this. If you read this, please submit a PR to make it better. # We need to pass the temperatures for both pool and spa (if present) in the same request. # Set args to current target temperatures and override with the request payload. args = {} i = 1 if "spa_set_point" in self.devices: args[f"temp{i}"] = self.devices["spa_set_point"].target_temperature i += 1 args[f"temp{i}"] = self.devices["pool_set_point"].target_temperature args.update(temps) r = await self._send_session_request(IAQUA_COMMAND_SET_TEMPS, args) self._parse_home_response(r) async def set_aux(self, aux: str) -> None: aux = IAQUA_COMMAND_SET_AUX + "_" + aux.replace("aux_", "") r = await self._send_session_request(aux) self._parse_devices_response(r) async def set_light(self, data: Payload) -> None: r = await self._send_session_request(IAQUA_COMMAND_SET_LIGHT, data) self._parse_devices_response(r) iaqualink-py-0.5.3/src/iaqualink/typing.py000066400000000000000000000001311477167736600205770ustar00rootroot00000000000000from __future__ import annotations DeviceData = dict[str, str] Payload = dict[str, str] iaqualink-py-0.5.3/tests/000077500000000000000000000000001477167736600153155ustar00rootroot00000000000000iaqualink-py-0.5.3/tests/__init__.py000066400000000000000000000000001477167736600174140ustar00rootroot00000000000000iaqualink-py-0.5.3/tests/base.py000066400000000000000000000011611477167736600166000ustar00rootroot00000000000000import unittest import httpx from respx.patterns import M from iaqualink.client import AqualinkClient dotstar = M(host__regex=".*") resp_200 = httpx.Response(status_code=200, json={}) class TestBase(unittest.IsolatedAsyncioTestCase): __test__ = False def __init_subclass__(cls) -> None: if cls.__name__.startswith("TestBase"): cls.__test__ = False else: cls.__test__ = True return super().__init_subclass__() def setUp(self) -> None: super().setUp() self.client = AqualinkClient("foo", "bar") self.addAsyncCleanup(self.client.close) iaqualink-py-0.5.3/tests/base_test_device.py000066400000000000000000000241071477167736600211630ustar00rootroot00000000000000from __future__ import annotations import copy from unittest.mock import PropertyMock, patch import pytest import respx import respx.router from iaqualink.device import ( AqualinkBinarySensor, AqualinkLight, AqualinkSensor, AqualinkSwitch, AqualinkThermostat, ) from iaqualink.exception import ( AqualinkInvalidParameterException, AqualinkOperationNotSupportedException, ) from .base import TestBase, dotstar, resp_200 class TestBaseDevice(TestBase): def test_property_name(self) -> None: assert isinstance(self.sut.name, str) def test_property_label(self) -> None: assert isinstance(self.sut.label, str) def test_property_state(self) -> None: assert isinstance(self.sut.state, str) def test_property_manufacturer(self) -> None: assert isinstance(self.sut.manufacturer, str) def test_property_model(self) -> None: assert isinstance(self.sut.model, str) def test_from_data(self) -> None: if sut_class := getattr(self, "sut_class", None): assert isinstance(self.sut, sut_class) class TestBaseSensor(TestBaseDevice): def test_inheritance(self) -> None: assert isinstance(self.sut, AqualinkSensor) class TestBaseBinarySensor(TestBaseSensor): def test_inheritance(self) -> None: assert isinstance(self.sut, AqualinkBinarySensor) def test_property_is_on_true(self) -> None: assert self.sut.is_on is True def test_property_is_on_false(self) -> None: assert self.sut.is_on is False class TestBaseSwitch(TestBaseBinarySensor): def test_inheritance(self) -> None: assert isinstance(self.sut, AqualinkSwitch) @respx.mock async def test_turn_on(self, respx_mock: respx.router.MockRouter) -> None: respx_mock.route(dotstar).mock(resp_200) await self.sut.turn_on() assert len(respx_mock.calls) > 0 self.respx_calls = copy.copy(respx_mock.calls) @respx.mock async def test_turn_on_noop( self, respx_mock: respx.router.MockRouter ) -> None: respx_mock.route(dotstar).mock(resp_200) await self.sut.turn_on() assert len(respx_mock.calls) == 0 @respx.mock async def test_turn_off(self, respx_mock: respx.router.MockRouter) -> None: respx_mock.route(dotstar).mock(resp_200) await self.sut.turn_off() assert len(respx_mock.calls) > 0 self.respx_calls = copy.copy(respx_mock.calls) @respx.mock async def test_turn_off_noop( self, respx_mock: respx.router.MockRouter ) -> None: respx_mock.route(dotstar).mock(resp_200) await self.sut.turn_off() assert len(respx_mock.calls) == 0 class TestBaseLight(TestBaseSwitch): def test_inheritance(self) -> None: assert isinstance(self.sut, AqualinkLight) def test_property_supports_brightness(self) -> None: assert isinstance(self.sut.supports_brightness, bool) def test_property_supports_effect(self) -> None: assert isinstance(self.sut.supports_effect, bool) def test_property_brightness(self) -> None: if not self.sut.supports_brightness: pytest.skip("Device doesn't support brightness") assert isinstance(self.sut.brightness, int) assert 0 <= self.sut.brightness <= 100 def test_property_effect(self) -> None: if not self.sut.supports_effect: pytest.skip("Device doesn't support effects") assert isinstance(self.sut.effect, str) def test_property_supported_effects(self) -> None: if not self.sut.supports_effect: pytest.skip("Device doesn't support effects") assert isinstance(self.sut.supported_effects, dict) @respx.mock async def test_set_brightness_75( self, respx_mock: respx.router.MockRouter ) -> None: if not self.sut.supports_brightness: with pytest.raises(AqualinkOperationNotSupportedException): await self.sut.set_brightness(75) return respx_mock.route(dotstar).mock(resp_200) await self.sut.set_brightness(75) assert len(respx_mock.calls) > 0 self.respx_calls = copy.copy(respx_mock.calls) @respx.mock async def test_set_brightness_invalid_89( self, respx_mock: respx.router.MockRouter ) -> None: if not self.sut.supports_brightness: with pytest.raises(AqualinkOperationNotSupportedException): await self.sut.set_brightness(89) return respx_mock.route(dotstar).mock(resp_200) with pytest.raises(AqualinkInvalidParameterException): await self.sut.set_brightness(89) assert len(respx_mock.calls) == 0 @respx.mock async def test_set_effect_by_id_4( self, respx_mock: respx.router.MockRouter ) -> None: if not self.sut.supports_effect: with pytest.raises(AqualinkOperationNotSupportedException): await self.sut.set_effect_by_id(4) return respx_mock.route(dotstar).mock(resp_200) await self.sut.set_effect_by_id(4) assert len(respx_mock.calls) > 0 self.respx_calls = copy.copy(respx_mock.calls) @respx.mock async def test_set_effect_by_id_invalid_27( self, respx_mock: respx.router.MockRouter ) -> None: if not self.sut.supports_effect: with pytest.raises(AqualinkOperationNotSupportedException): await self.sut.set_effect_by_id(27) return respx_mock.route(dotstar).mock(resp_200) with pytest.raises(AqualinkInvalidParameterException): await self.sut.set_effect_by_id(27) assert len(respx_mock.calls) == 0 @respx.mock async def test_set_effect_by_name_off( self, respx_mock: respx.router.MockRouter ) -> None: if not self.sut.supports_effect: with pytest.raises(AqualinkOperationNotSupportedException): await self.sut.set_effect_by_name("Off") return respx_mock.route(dotstar).mock(resp_200) await self.sut.set_effect_by_name("Off") assert len(respx_mock.calls) > 0 self.respx_calls = copy.copy(respx_mock.calls) @respx.mock async def test_set_effect_by_name_invalid_amaranth( self, respx_mock: respx.router.MockRouter ) -> None: if not self.sut.supports_effect: with pytest.raises(AqualinkOperationNotSupportedException): await self.sut.set_effect_by_name("Amaranth") return respx_mock.route(dotstar).mock(resp_200) with pytest.raises(AqualinkInvalidParameterException): await self.sut.set_effect_by_name("Amaranth") assert len(respx_mock.calls) == 0 class TestBaseThermostat(TestBaseSwitch): def test_inheritance(self) -> None: assert isinstance(self.sut, AqualinkThermostat) def test_property_unit(self) -> None: assert self.sut.unit in ["C", "F"] def test_property_min_temperature_f(self) -> None: with patch.object( type(self.sut), "unit", new_callable=PropertyMock ) as mock_unit: mock_unit.return_value = "F" assert isinstance(self.sut.min_temperature, int) def test_property_min_temperature_c(self) -> None: with patch.object( type(self.sut), "unit", new_callable=PropertyMock ) as mock_unit: mock_unit.return_value = "C" assert isinstance(self.sut.min_temperature, int) def test_property_max_temperature_f(self) -> None: with patch.object( type(self.sut), "unit", new_callable=PropertyMock ) as mock_unit: mock_unit.return_value = "F" assert isinstance(self.sut.max_temperature, int) def test_property_max_temperature_c(self) -> None: with patch.object( type(self.sut), "unit", new_callable=PropertyMock ) as mock_unit: mock_unit.return_value = "C" assert isinstance(self.sut.max_temperature, int) def test_property_current_temperature(self) -> None: assert isinstance(self.sut.current_temperature, str) def test_property_target_temperature(self) -> None: assert isinstance(self.sut.target_temperature, str) @respx.mock async def test_set_temperature_86f( self, respx_mock: respx.router.MockRouter ) -> None: respx_mock.route(dotstar).mock(resp_200) with patch.object( type(self.sut), "unit", new_callable=PropertyMock ) as mock_unit: mock_unit.return_value = "F" await self.sut.set_temperature(86) assert len(respx_mock.calls) > 0 self.respx_calls = copy.copy(respx_mock.calls) @respx.mock async def test_set_temperature_30c( self, respx_mock: respx.router.MockRouter ) -> None: respx_mock.route(dotstar).mock(resp_200) with patch.object( type(self.sut), "unit", new_callable=PropertyMock ) as mock_unit: mock_unit.return_value = "C" await self.sut.set_temperature(30) assert len(respx_mock.calls) > 0 self.respx_calls = copy.copy(respx_mock.calls) @respx.mock async def test_set_temperature_invalid_400f( self, respx_mock: respx.router.MockRouter ) -> None: respx_mock.route(dotstar).mock(resp_200) with patch.object( type(self.sut), "unit", new_callable=PropertyMock ) as mock_unit: mock_unit.return_value = "F" with pytest.raises(AqualinkInvalidParameterException): await self.sut.set_temperature(400) assert len(respx_mock.calls) == 0 @respx.mock async def test_set_temperature_invalid_204c( self, respx_mock: respx.router.MockRouter ) -> None: respx_mock.route(dotstar).mock(resp_200) with patch.object( type(self.sut), "unit", new_callable=PropertyMock ) as mock_unit: mock_unit.return_value = "C" with pytest.raises(AqualinkInvalidParameterException): await self.sut.set_temperature(204) assert len(respx_mock.calls) == 0 iaqualink-py-0.5.3/tests/base_test_system.py000066400000000000000000000050031477167736600212420ustar00rootroot00000000000000import copy import httpx import pytest import respx.router from iaqualink.exception import ( AqualinkServiceException, AqualinkServiceUnauthorizedException, ) from .base import TestBase, dotstar, resp_200 class TestBaseSystem(TestBase): def test_propery_name(self) -> None: assert isinstance(self.sut.name, str) def test_property_serial(self) -> None: assert isinstance(self.sut.name, str) def test_from_data(self) -> None: if sut_class := getattr(self, "sut_class", None): assert isinstance(self.sut, sut_class) @respx.mock async def test_update_success( self, respx_mock: respx.router.MockRouter ) -> None: respx_mock.route(dotstar).mock(resp_200) await self.sut.update() assert len(respx_mock.calls) > 0 self.respx_calls = copy.copy(respx_mock.calls) @respx.mock async def test_update_consecutive( self, respx_mock: respx.router.MockRouter ) -> None: respx_mock.route(dotstar).mock(resp_200) await self.sut.update() respx_mock.reset() await self.sut.update() assert len(respx_mock.calls) == 0 @respx.mock async def test_update_service_exception( self, respx_mock: respx.router.MockRouter ) -> None: resp_500 = httpx.Response(status_code=500) respx_mock.route(dotstar).mock(resp_500) with pytest.raises(AqualinkServiceException): await self.sut.update() self.respx_calls = copy.copy(respx_mock.calls) @respx.mock async def test_update_request_unauthorized( self, respx_mock: respx.router.MockRouter ) -> None: resp_401 = httpx.Response(status_code=401) respx_mock.route(dotstar).mock(resp_401) with pytest.raises(AqualinkServiceUnauthorizedException): await self.sut.update() assert len(respx_mock.calls) > 0 self.respx_calls = copy.copy(respx_mock.calls) @respx.mock async def test_get_devices( self, respx_mock: respx.router.MockRouter ) -> None: respx_mock.route(dotstar).mock(resp_200) self.sut.devices = {"foo": {}} await self.sut.get_devices() assert len(respx_mock.calls) == 0 @respx.mock async def test_get_devices_needs_update( self, respx_mock: respx.router.MockRouter ) -> None: respx_mock.route(dotstar).mock(resp_200) await self.sut.get_devices() assert len(respx_mock.calls) > 0 self.respx_calls = copy.copy(respx_mock.calls) iaqualink-py-0.5.3/tests/common.py000066400000000000000000000004461477167736600171630ustar00rootroot00000000000000from __future__ import annotations from typing import Any from unittest.mock import AsyncMock async_noop = AsyncMock(return_value=None) def async_returns(x: Any) -> AsyncMock: return AsyncMock(return_value=x) def async_raises(x: Any) -> AsyncMock: return AsyncMock(side_effect=x) iaqualink-py-0.5.3/tests/systems/000077500000000000000000000000001477167736600170245ustar00rootroot00000000000000iaqualink-py-0.5.3/tests/systems/__init__.py000066400000000000000000000000001477167736600211230ustar00rootroot00000000000000iaqualink-py-0.5.3/tests/systems/iaqua/000077500000000000000000000000001477167736600201245ustar00rootroot00000000000000iaqualink-py-0.5.3/tests/systems/iaqua/__init__.py000066400000000000000000000000001477167736600222230ustar00rootroot00000000000000iaqualink-py-0.5.3/tests/systems/iaqua/test_device.py000066400000000000000000000364701477167736600230060ustar00rootroot00000000000000from __future__ import annotations import copy from typing import cast from unittest.mock import patch from iaqualink.systems.iaqua.device import ( IAQUA_TEMP_CELSIUS_HIGH, IAQUA_TEMP_CELSIUS_LOW, IAQUA_TEMP_FAHRENHEIT_HIGH, IAQUA_TEMP_FAHRENHEIT_LOW, IaquaAuxSwitch, IaquaBinarySensor, IaquaColorLight, IaquaDevice, IaquaDimmableLight, IaquaLightSwitch, IaquaSensor, IaquaSwitch, IaquaThermostat, ) from iaqualink.systems.iaqua.system import IaquaSystem from ...base_test_device import ( TestBaseBinarySensor, TestBaseDevice, TestBaseLight, TestBaseSensor, TestBaseSwitch, TestBaseThermostat, ) class TestIaquaDevice(TestBaseDevice): def setUp(self) -> None: super().setUp() data = {"serial_number": "SN123456", "device_type": "iaqua"} self.system = IaquaSystem(self.client, data=data) data = {"name": "device", "state": "42"} self.sut = IaquaDevice(self.system, data) self.sut_class = IaquaDevice def test_equal(self) -> None: assert self.sut == self.sut def test_not_equal(self) -> None: obj2 = copy.deepcopy(self.sut) obj2.data["name"] = "device_2" assert self.sut != obj2 def test_property_name(self) -> None: assert self.sut.name == self.sut.data["name"] def test_property_state(self) -> None: assert self.sut.state == self.sut.data["state"] def test_not_equal_different_type(self) -> None: assert (self.sut == {}) is False def test_property_manufacturer(self) -> None: assert self.sut.manufacturer == "Jandy" def test_property_model(self) -> None: assert self.sut.model == self.sut_class.__name__.replace("Iaqua", "") class TestIaquaSensor(TestIaquaDevice, TestBaseSensor): def setUp(self) -> None: super().setUp() data = {"name": "orp", "state": "42"} self.sut = IaquaDevice.from_data(self.system, data) self.sut_class = IaquaSensor class TestIaquaBinarySensor(TestIaquaSensor, TestBaseBinarySensor): def setUp(self) -> None: super().setUp() data = {"name": "freeze_protection", "state": "0"} self.sut_class = IaquaBinarySensor self.sut = IaquaDevice.from_data(self.system, data) def test_property_is_on_false(self) -> None: self.sut.data["state"] = "0" super().test_property_is_on_false() assert self.sut.is_on is False def test_property_is_on_true(self) -> None: self.sut.data["state"] = "1" super().test_property_is_on_true() assert self.sut.is_on is True class TestIaquaSwitch(TestIaquaBinarySensor, TestBaseSwitch): def setUp(self) -> None: super().setUp() data = { "name": "pool_heater", "state": "0", } self.sut = IaquaDevice.from_data(self.system, data) self.sut_class = IaquaSwitch async def test_turn_on(self) -> None: self.sut.data["state"] = "0" with patch.object(self.sut.system, "_parse_home_response"): await super().test_turn_on() async def test_turn_on_noop(self) -> None: self.sut.data["state"] = "1" with patch.object(self.sut.system, "_parse_home_response"): await super().test_turn_on_noop() async def test_turn_off(self) -> None: self.sut.data["state"] = "1" with patch.object(self.sut.system, "_parse_home_response"): await super().test_turn_off() async def test_turn_off_noop(self) -> None: self.sut.data["state"] = "0" with patch.object(self.sut.system, "_parse_home_response"): await super().test_turn_off_noop() class TestIaquaAuxSwitch(TestIaquaSwitch, TestBaseSwitch): def setUp(self) -> None: super().setUp() data = { "name": "aux_1", "state": "0", "aux": "1", "type": "0", "label": "CLEANER", } self.sut = IaquaDevice.from_data(self.system, data) self.sut_class = IaquaAuxSwitch async def test_turn_on(self) -> None: self.sut.data["state"] = "0" with patch.object(self.sut.system, "_parse_devices_response"): await super().test_turn_on() async def test_turn_on_noop(self) -> None: self.sut.data["state"] = "1" with patch.object(self.sut.system, "_parse_devices_response"): await super().test_turn_on_noop() async def test_turn_off(self) -> None: self.sut.data["state"] = "1" with patch.object(self.sut.system, "_parse_devices_response"): await super().test_turn_off() async def test_turn_off_noop(self) -> None: self.sut.data["state"] = "0" with patch.object(self.sut.system, "_parse_devices_response"): await super().test_turn_off_noop() class TestIaquaLightSwitch(TestIaquaAuxSwitch, TestBaseLight): def setUp(self) -> None: super().setUp() # system.set_aux = async_noop data = { "name": "aux_1", "state": "0", "aux": "1", "label": "POOL LIGHT", "type": "0", } self.sut = IaquaDevice.from_data(self.system, data) self.sut_class = IaquaLightSwitch def test_property_brightness(self) -> None: assert self.sut.brightness is None def test_property_effect(self) -> None: assert self.sut.effect is None class TestIaquaDimmableLight(TestIaquaAuxSwitch, TestBaseLight): def setUp(self) -> None: super().setUp() data = { "name": "aux_1", "state": "1", "aux": "1", "subtype": "25", "type": "1", "label": "SPA LIGHT", } self.sut = IaquaDevice.from_data(self.system, data) self.sut_class = IaquaDimmableLight def test_property_name(self) -> None: super().test_property_name() assert self.sut.name == "aux_1" def test_property_label(self) -> None: super().test_property_label() assert self.sut.label == "Spa Light" def test_property_state(self) -> None: super().test_property_state() assert self.sut.state == "1" def test_property_is_on_false(self) -> None: self.sut.data["state"] = "0" self.sut.data["subtype"] = "0" super().test_property_is_on_false() assert self.sut.is_on is False def test_property_is_on_true(self) -> None: self.sut.data["state"] = "1" self.sut.data["subtype"] = "100" super().test_property_is_on_true() assert self.sut.is_on is True async def test_turn_on(self) -> None: self.sut.data["state"] = "0" self.sut.data["subtype"] = "0" with patch.object(self.sut.system, "_parse_devices_response"): await super().test_turn_on() async def test_turn_on_noop(self) -> None: self.sut.data["state"] = "1" self.sut.data["subtype"] = "25" with patch.object(self.sut.system, "_parse_devices_response"): await super().test_turn_on_noop() async def test_turn_off(self) -> None: self.sut.data["state"] = "1" self.sut.data["subtype"] = "100" with patch.object(self.sut.system, "_parse_devices_response"): await super().test_turn_off() async def test_turn_off_noop(self) -> None: self.sut.data["state"] = "0" self.sut.data["subtype"] = "0" with patch.object(self.sut.system, "_parse_devices_response"): await super().test_turn_off_noop() def test_property_supports_brightness(self) -> None: super().test_property_supports_brightness() assert self.sut.supports_brightness is True def test_property_supports_effect(self) -> None: super().test_property_supports_effect() assert self.sut.supports_effect is False async def test_set_brightness_75(self) -> None: with patch.object(self.sut.system, "_parse_devices_response"): await super().test_set_brightness_75() class TestIaquaColorLight(TestIaquaAuxSwitch, TestBaseLight): def setUp(self) -> None: super().setUp() # system.set_light = async_noop data = { "name": "aux_1", "aux": "1", "state": "0", "type": "2", "subtype": "5", "label": "POOL LIGHT", } self.sut = IaquaDevice.from_data(self.system, data) self.sut_class = IaquaColorLight def test_property_name(self) -> None: super().test_property_name() assert self.sut.name == "aux_1" def test_property_label(self) -> None: super().test_property_label() assert self.sut.label == "Pool Light" def test_property_state(self) -> None: super().test_property_state() def test_property_manufacturer(self) -> None: assert self.sut.manufacturer == "Pentair" def test_property_model(self) -> None: assert self.sut.model == "Intellibrite Light" def test_property_supports_brightness(self) -> None: super().test_property_supports_brightness() assert self.sut.supports_brightness is False def test_property_supports_effect(self) -> None: super().test_property_supports_effect() assert self.sut.supports_effect is True async def test_turn_off(self) -> None: self.sut.data["state"] = "1" with patch.object(self.sut.system, "_parse_devices_response"): await super().test_turn_off() # data = {"aux": "1", "light": "0", "subtype": "5"} async def test_turn_on(self) -> None: self.sut.data["state"] = "0" with patch.object(self.sut.system, "_parse_devices_response"): await super().test_turn_on() # data = {"aux": "1", "light": "1", "subtype": "5"} async def test_set_effect_by_id_4(self) -> None: with patch.object(self.sut.system, "_parse_devices_response"): await super().test_set_effect_by_id_4() # data = {"aux": "1", "light": "2", "subtype": "5"} async def test_set_effect_by_id_invalid_27(self) -> None: with patch.object(self.sut.system, "_parse_devices_response"): await super().test_set_effect_by_id_invalid_27() async def test_set_effect_by_name_off(self) -> None: with patch.object(self.sut.system, "_parse_devices_response"): await super().test_set_effect_by_name_off() async def test_set_effect_by_name_invalid_amaranth(self) -> None: with patch.object(self.sut.system, "_parse_devices_response"): await super().test_set_effect_by_name_invalid_amaranth() class TestIaquaThermostat(TestIaquaDevice, TestBaseThermostat): def setUp(self) -> None: super().setUp() pool_set_point = {"name": "pool_set_point", "state": "86"} self.pool_set_point = cast( IaquaThermostat, IaquaDevice.from_data(self.system, pool_set_point) ) pool_temp = {"name": "pool_temp", "state": "65"} self.pool_temp = IaquaDevice.from_data(self.system, pool_temp) pool_heater = {"name": "pool_heater", "state": "0"} self.pool_heater = IaquaDevice.from_data(self.system, pool_heater) spa_set_point = {"name": "spa_set_point", "state": "102"} self.spa_set_point = cast( IaquaThermostat, IaquaDevice.from_data(self.system, spa_set_point) ) devices = [ self.pool_set_point, self.pool_heater, self.pool_temp, ] self.system.devices = {x.name: x for x in devices} self.sut = self.pool_set_point self.sut_class = IaquaThermostat def test_property_label(self) -> None: assert self.sut.label == "Pool Set Point" def test_property_name(self) -> None: assert self.sut.name == "pool_set_point" def test_property_state(self) -> None: assert self.sut.state == "86" def test_property_is_on_true(self) -> None: self.pool_heater.data["state"] = "1" super().test_property_is_on_true() def test_property_is_on_false(self) -> None: self.pool_heater.data["state"] = "0" super().test_property_is_on_false() def test_property_unit(self) -> None: self.sut.system.temp_unit = "F" super().test_property_unit() def test_property_min_temperature_f(self) -> None: self.sut.system.temp_unit = "F" super().test_property_min_temperature_c() assert self.sut.min_temperature == IAQUA_TEMP_FAHRENHEIT_LOW def test_property_min_temperature_c(self) -> None: self.sut.system.temp_unit = "C" super().test_property_min_temperature_f() assert self.sut.min_temperature == IAQUA_TEMP_CELSIUS_LOW def test_property_max_temperature_f(self) -> None: self.sut.system.temp_unit = "F" super().test_property_max_temperature_f() assert self.sut.max_temperature == IAQUA_TEMP_FAHRENHEIT_HIGH def test_property_max_temperature_c(self) -> None: self.sut.system.temp_unit = "C" super().test_property_max_temperature_c() assert self.sut.max_temperature == IAQUA_TEMP_CELSIUS_HIGH def test_property_current_temperature(self) -> None: super().test_property_current_temperature() assert self.sut.current_temperature == "65" def test_property_target_temperature(self) -> None: super().test_property_target_temperature() assert self.sut.target_temperature == "86" async def test_turn_on(self) -> None: self.pool_heater.data["state"] = "0" with patch.object(self.sut.system, "_parse_home_response"): await super().test_turn_on() assert len(self.respx_calls) == 1 url = str(self.respx_calls[0].request.url) assert "set_pool_heater" in url async def test_turn_on_noop(self) -> None: self.pool_heater.data["state"] = "1" await super().test_turn_on_noop() async def test_turn_off(self) -> None: self.pool_heater.data["state"] = "1" with patch.object(self.sut.system, "_parse_home_response"): await super().test_turn_off() assert len(self.respx_calls) == 1 url = str(self.respx_calls[0].request.url) assert "set_pool_heater" in url async def test_turn_off_noop(self) -> None: self.pool_heater.data["state"] = "0" await super().test_turn_off_noop() async def test_set_temperature_86f(self) -> None: self.sut.system.devices["spa_set_point"] = self.spa_set_point with patch.object(self.sut.system, "_parse_home_response"): await super().test_set_temperature_86f() assert len(self.respx_calls) == 1 url = str(self.respx_calls[0].request.url) assert "temp1=102" in url assert "temp2=86" in url async def test_set_temperature_30c(self) -> None: with patch.object(self.sut.system, "_parse_home_response"): await super().test_set_temperature_30c() assert len(self.respx_calls) == 1 url = str(self.respx_calls[0].request.url) assert "temp1=30" in url assert "temp2" not in url async def test_temp_name_spa_present(self) -> None: self.sut.system.devices["spa_set_point"] = self.spa_set_point assert self.spa_set_point._temperature == "temp1" assert self.pool_set_point._temperature == "temp2" async def test_temp_name_no_spa(self) -> None: assert self.pool_set_point._temperature == "temp1" iaqualink-py-0.5.3/tests/systems/iaqua/test_system.py000066400000000000000000000112221477167736600230570ustar00rootroot00000000000000from __future__ import annotations from unittest.mock import MagicMock, patch import pytest from iaqualink.exception import ( AqualinkServiceUnauthorizedException, AqualinkSystemOfflineException, ) from iaqualink.system import AqualinkSystem from iaqualink.systems.iaqua.device import IaquaAuxSwitch from iaqualink.systems.iaqua.system import IaquaSystem from ...base_test_system import TestBaseSystem class TestIaquaSystem(TestBaseSystem): def setUp(self) -> None: super().setUp() data = { "id": 123456, "serial_number": "SN123456", "created_at": "2017-09-23T01:00:08.000Z", "updated_at": "2017-09-23T01:00:08.000Z", "name": "Pool", "device_type": "iaqua", "owner_id": None, "updating": False, "firmware_version": None, "target_firmware_version": None, "update_firmware_start_at": None, "last_activity_at": None, } self.sut = AqualinkSystem.from_data(self.client, data=data) self.sut_class = IaquaSystem async def test_update_success(self) -> None: with ( patch.object(self.sut, "_parse_home_response"), patch.object(self.sut, "_parse_devices_response"), ): await super().test_update_success() async def test_update_offline(self) -> None: with patch.object(self.sut, "_parse_home_response") as mock_parse: mock_parse.side_effect = AqualinkSystemOfflineException with pytest.raises(AqualinkSystemOfflineException): await super().test_update_success() assert self.sut.online is False async def test_update_consecutive(self) -> None: with ( patch.object(self.sut, "_parse_home_response"), patch.object(self.sut, "_parse_devices_response"), ): await super().test_update_consecutive() async def test_get_devices_needs_update(self) -> None: with ( patch.object(self.sut, "_parse_home_response"), patch.object(self.sut, "_parse_devices_response"), ): await super().test_get_devices_needs_update() async def test_parse_devices_offline(self) -> None: message = {"message": "", "devices_screen": [{"status": "Offline"}]} response = MagicMock() response.json.return_value = message with pytest.raises(AqualinkSystemOfflineException): self.sut._parse_devices_response(response) assert self.sut.devices == {} async def test_parse_devices_good(self) -> None: message = { "message": "", "devices_screen": [ {"status": "Online"}, {"response": ""}, {"group": "1"}, { "aux_B1": [ {"state": "0"}, {"label": "Label B1"}, {"icon": "aux_1_0.png"}, {"type": "0"}, {"subtype": "0"}, ] }, ], } response = MagicMock() response.json.return_value = message expected = { "aux_B1": IaquaAuxSwitch( system=self.sut, data={ "aux": "B1", "name": "aux_B1", "state": "0", "label": "Label B1", "icon": "aux_1_0.png", "type": "0", "subtype": "0", }, ) } self.sut._parse_devices_response(response) assert self.sut.devices == expected @patch("httpx.AsyncClient.request") async def test_home_request(self, mock_request) -> None: mock_request.return_value.status_code = 200 await self.sut._send_home_screen_request() @patch("httpx.AsyncClient.request") async def test_home_request_unauthorized(self, mock_request) -> None: mock_request.return_value.status_code = 401 with pytest.raises(AqualinkServiceUnauthorizedException): await self.sut._send_home_screen_request() @patch("httpx.AsyncClient.request") async def test_devices_request(self, mock_request) -> None: mock_request.return_value.status_code = 200 await self.sut._send_devices_screen_request() @patch("httpx.AsyncClient.request") async def test_devices_request_unauthorized(self, mock_request) -> None: mock_request.return_value.status_code = 401 with pytest.raises(AqualinkServiceUnauthorizedException): await self.sut._send_devices_screen_request() iaqualink-py-0.5.3/tests/test_client.py000066400000000000000000000105271477167736600202110ustar00rootroot00000000000000from __future__ import annotations from unittest.mock import MagicMock, patch import httpx import pytest from iaqualink.client import AqualinkClient from iaqualink.exception import ( AqualinkServiceException, AqualinkServiceUnauthorizedException, ) from .base import TestBase from .common import async_noop, async_raises LOGIN_DATA = { "id": "id", "authentication_token": "token", "session_id": "session_id", } class TestAqualinkClient(TestBase): def setUp(self) -> None: super().setUp() @patch.object(AqualinkClient, "login") async def test_context_manager(self, mock_login) -> None: mock_login.return_value = async_noop async with self.client: pass @patch.object(AqualinkClient, "login") async def test_context_manager_login_exception(self, mock_login) -> None: mock_login.side_effect = async_raises(AqualinkServiceException) with pytest.raises(AqualinkServiceException): async with self.client: pass @patch("iaqualink.client.AqualinkClient.login", async_noop) async def test_context_manager_with_client(self) -> None: client = httpx.AsyncClient() async with AqualinkClient("user", "pass", httpx_client=client): pass # Clean up. await client.aclose() @patch("httpx.AsyncClient.request") async def test_login_success(self, mock_request) -> None: mock_request.return_value.status_code = 200 mock_request.return_value.json = MagicMock(return_value=LOGIN_DATA) assert self.client.logged is False await self.client.login() assert self.client.logged is True @patch("httpx.AsyncClient.request") async def test_login_failed(self, mock_request) -> None: mock_request.return_value.status_code = 401 assert self.client.logged is False with pytest.raises(AqualinkServiceException): await self.client.login() assert self.client.logged is False @patch("httpx.AsyncClient.request") async def test_login_exception(self, mock_request) -> None: mock_request.return_value.status_code = 500 assert self.client.logged is False with pytest.raises(AqualinkServiceException): await self.client.login() assert self.client.logged is False @patch("httpx.AsyncClient.request") async def test_unexpectedly_logged_out(self, mock_request) -> None: mock_request.return_value.status_code = 200 mock_request.return_value.json = MagicMock(return_value=LOGIN_DATA) await self.client.login() assert self.client.logged is True mock_request.return_value.status_code = 401 mock_request.return_value.json = MagicMock(return_value={}) with pytest.raises(AqualinkServiceUnauthorizedException): await self.client.get_systems() assert self.client.logged is False @patch("httpx.AsyncClient.request") async def test_systems_request_system_unsupported( self, mock_request ) -> None: mock_request.return_value.status_code = 200 mock_request.return_value.json = MagicMock(return_value=LOGIN_DATA) await self.client.login() mock_request.return_value.status_code = 200 mock_request.return_value.json.return_value = [ { "device_type": "foo", "serial_number": "SN123456", } ] systems = await self.client.get_systems() assert len(systems) == 0 @patch("httpx.AsyncClient.request") async def test_systems_request(self, mock_request) -> None: mock_request.return_value.status_code = 200 mock_request.return_value.json = MagicMock(return_value=LOGIN_DATA) await self.client.login() mock_request.return_value.status_code = 200 mock_request.return_value.json.return_value = [ { "device_type": "iaqua", "serial_number": "SN123456", } ] systems = await self.client.get_systems() assert len(systems) == 1 @patch("httpx.AsyncClient.request") async def test_systems_request_unauthorized(self, mock_request) -> None: mock_request.return_value.status_code = 404 with pytest.raises(AqualinkServiceUnauthorizedException): await self.client.get_systems() iaqualink-py-0.5.3/tests/test_device.py000066400000000000000000000177631477167736600202030ustar00rootroot00000000000000from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch import pytest from iaqualink.device import ( AqualinkBinarySensor, AqualinkDevice, AqualinkLight, AqualinkSensor, AqualinkSwitch, AqualinkThermostat, ) from .base_test_device import ( TestBaseBinarySensor, TestBaseDevice, TestBaseLight, TestBaseSensor, TestBaseSwitch, TestBaseThermostat, ) class TestAqualinkDevice(TestBaseDevice): def setUp(self) -> None: system = MagicMock() data = {"foo": "bar"} self.sut = AqualinkDevice(system, data) async def test_repr(self) -> None: assert ( repr(self.sut) == f"{self.sut.__class__.__name__}(data={self.sut.data!r})" ) def test_property_name(self) -> None: with pytest.raises(NotImplementedError): super().test_property_name() def test_property_label(self) -> None: with pytest.raises(NotImplementedError): super().test_property_label() def test_property_state(self) -> None: with pytest.raises(NotImplementedError): super().test_property_state() def test_property_manufacturer(self) -> None: with pytest.raises(NotImplementedError): super().test_property_manufacturer() def test_property_model(self) -> None: with pytest.raises(NotImplementedError): super().test_property_model() class TestAqualinkSensor(TestBaseSensor, TestAqualinkDevice): def setUp(self) -> None: system = MagicMock() data: dict[str, str] = {} self.sut = AqualinkSensor(system, data) class TestAqualinkBinarySensor(TestBaseBinarySensor, TestAqualinkSensor): def setUp(self) -> None: system = MagicMock() data: dict[str, str] = {} self.sut = AqualinkBinarySensor(system, data) def test_property_is_on_true(self) -> None: with pytest.raises(NotImplementedError): super().test_property_is_on_true() def test_property_is_on_false(self) -> None: with pytest.raises(NotImplementedError): super().test_property_is_on_false() class TestAqualinkSwitch(TestBaseSwitch, TestAqualinkDevice): def setUp(self) -> None: system = MagicMock() data: dict[str, str] = {} self.sut = AqualinkSwitch(system, data) def test_property_is_on_true(self) -> None: with pytest.raises(NotImplementedError): super().test_property_is_on_true() def test_property_is_on_false(self) -> None: with pytest.raises(NotImplementedError): super().test_property_is_on_false() async def test_turn_on(self) -> None: with pytest.raises(NotImplementedError): await super().test_turn_on() async def test_turn_on_noop(self) -> None: with pytest.raises(NotImplementedError): await super().test_turn_on_noop() async def test_turn_off(self) -> None: with pytest.raises(NotImplementedError): await super().test_turn_off() async def test_turn_off_noop(self) -> None: with pytest.raises(NotImplementedError): await super().test_turn_off_noop() class TestAqualinkLight(TestBaseLight, TestAqualinkDevice): def setUp(self) -> None: system = MagicMock() data: dict[str, str] = {} self.sut = AqualinkLight(system, data) def test_property_is_on_true(self) -> None: with pytest.raises(NotImplementedError): super().test_property_is_on_true() def test_property_is_on_false(self) -> None: with pytest.raises(NotImplementedError): super().test_property_is_on_false() async def test_turn_off_noop(self) -> None: with pytest.raises(NotImplementedError): await super().test_turn_off_noop() async def test_turn_off(self) -> None: with pytest.raises(NotImplementedError): await super().test_turn_off() async def test_turn_on(self) -> None: with pytest.raises(NotImplementedError): await super().test_turn_on() async def test_turn_on_noop(self) -> None: with pytest.raises(NotImplementedError): await super().test_turn_on_noop() async def test_set_brightness_75(self) -> None: with ( patch.object( type(self.sut), "supports_brightness", new_callable=PropertyMock(return_value=True), ), pytest.raises(NotImplementedError), ): await super().test_set_brightness_75() async def test_set_effect_by_name_off(self) -> None: with ( patch.object( type(self.sut), "supports_effect", new_callable=PropertyMock(return_value=True), ), pytest.raises(NotImplementedError), ): await super().test_set_effect_by_name_off() async def test_set_effect_by_id_4(self) -> None: with ( patch.object( type(self.sut), "supports_effect", new_callable=PropertyMock(return_value=True), ), pytest.raises(NotImplementedError), ): await super().test_set_effect_by_id_4() class TestAqualinkThermostat(TestBaseThermostat, TestAqualinkDevice): def setUp(self) -> None: system = AsyncMock() data: dict[str, str] = {} self.sut = AqualinkThermostat(system, data) def test_property_is_on_true(self) -> None: with pytest.raises(NotImplementedError): super().test_property_is_on_true() def test_property_is_on_false(self) -> None: with pytest.raises(NotImplementedError): super().test_property_is_on_false() def test_property_unit(self) -> None: with pytest.raises(NotImplementedError): super().test_property_unit() def test_property_min_temperature_f(self) -> None: with pytest.raises(NotImplementedError): super().test_property_min_temperature_f() def test_property_min_temperature_c(self) -> None: with pytest.raises(NotImplementedError): super().test_property_min_temperature_c() def test_property_max_temperature_f(self) -> None: with pytest.raises(NotImplementedError): super().test_property_max_temperature_f() def test_property_max_temperature_c(self) -> None: with pytest.raises(NotImplementedError): super().test_property_max_temperature_c() def test_property_current_temperature(self) -> None: with pytest.raises(NotImplementedError): super().test_property_current_temperature() def test_property_target_temperature(self) -> None: with pytest.raises(NotImplementedError): super().test_property_target_temperature() async def test_turn_on(self) -> None: with pytest.raises(NotImplementedError): await super().test_turn_on() async def test_turn_on_noop(self) -> None: with pytest.raises(NotImplementedError): await super().test_turn_on_noop() async def test_turn_off(self) -> None: with pytest.raises(NotImplementedError): await super().test_turn_off() async def test_turn_off_noop(self) -> None: with pytest.raises(NotImplementedError): await super().test_turn_off_noop() async def test_set_temperature_86f(self) -> None: with pytest.raises(NotImplementedError): await super().test_set_temperature_86f() async def test_set_temperature_30c(self) -> None: with pytest.raises(NotImplementedError): await super().test_set_temperature_30c() async def test_set_temperature_invalid_400f(self) -> None: with pytest.raises(NotImplementedError): await super().test_set_temperature_invalid_400f() async def test_set_temperature_invalid_204c(self) -> None: with pytest.raises(NotImplementedError): await super().test_set_temperature_invalid_204c() iaqualink-py-0.5.3/tests/test_system.py000066400000000000000000000045411477167736600202560ustar00rootroot00000000000000from __future__ import annotations import unittest from unittest.mock import MagicMock, patch import pytest from iaqualink.client import AqualinkClient from iaqualink.exception import AqualinkSystemUnsupportedException from iaqualink.system import AqualinkSystem class TestAqualinkSystem(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: pass def test_repr(self) -> None: aqualink = MagicMock() data = { "id": 1, "serial_number": "ABCDEFG", "device_type": "iaqua", "name": "foo", } system = AqualinkSystem(aqualink, data) assert ( repr(system) == f"AqualinkSystem(name='foo', serial='ABCDEFG', data={data})" ) def test_from_data_iaqua(self) -> None: aqualink = MagicMock() data = {"id": 1, "serial_number": "ABCDEFG", "device_type": "iaqua"} r = AqualinkSystem.from_data(aqualink, data) assert r is not None def test_from_data_unsupported(self) -> None: aqualink = MagicMock() data = {"id": 1, "serial_number": "ABCDEFG", "device_type": "foo"} with pytest.raises(AqualinkSystemUnsupportedException): AqualinkSystem.from_data(aqualink, data) async def test_get_devices_needs_update(self) -> None: data = {"id": 1, "serial_number": "ABCDEFG", "device_type": "fake"} aqualink = AqualinkClient("user", "pass") system = AqualinkSystem(aqualink, data) system.devices = None with patch.object(system, "update") as mock_update: await system.get_devices() mock_update.assert_called_once() async def test_get_devices(self) -> None: data = {"id": 1, "serial_number": "ABCDEFG", "device_type": "fake"} aqualink = AqualinkClient("user", "pass") system = AqualinkSystem(aqualink, data) system.devices = {"foo": "bar"} with patch.object(system, "update") as mock_update: await system.get_devices() mock_update.assert_not_called() async def test_update_not_implemented(self) -> None: data = {"id": 1, "serial_number": "ABCDEFG", "device_type": "fake"} aqualink = AqualinkClient("user", "pass") system = AqualinkSystem(aqualink, data) with pytest.raises(NotImplementedError): await system.update() iaqualink-py-0.5.3/uv.lock000066400000000000000000001311111477167736600154550ustar00rootroot00000000000000version = 1 revision = 1 requires-python = ">=3.12" [[package]] name = "anyio" version = "4.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 } wheels = [ { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, ] [[package]] name = "certifi" version = "2025.1.31" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } wheels = [ { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, ] [[package]] name = "cfgv" version = "3.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } wheels = [ { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, ] [[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 } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] [[package]] name = "coverage" version = "7.7.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6b/bf/3effb7453498de9c14a81ca21e1f92e6723ce7ebdc5402ae30e4dcc490ac/coverage-7.7.1.tar.gz", hash = "sha256:199a1272e642266b90c9f40dec7fd3d307b51bf639fa0d15980dc0b3246c1393", size = 810332 } wheels = [ { url = "https://files.pythonhosted.org/packages/cf/b0/4eaba302a86ec3528231d7cfc954ae1929ec5d42b032eb6f5b5f5a9155d2/coverage-7.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:eff187177d8016ff6addf789dcc421c3db0d014e4946c1cc3fbf697f7852459d", size = 211253 }, { url = "https://files.pythonhosted.org/packages/fd/68/21b973e6780a3f2457e31ede1aca6c2f84bda4359457b40da3ae805dcf30/coverage-7.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2444fbe1ba1889e0b29eb4d11931afa88f92dc507b7248f45be372775b3cef4f", size = 211504 }, { url = "https://files.pythonhosted.org/packages/d1/b4/c19e9c565407664390254252496292f1e3076c31c5c01701ffacc060e745/coverage-7.7.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:177d837339883c541f8524683e227adcaea581eca6bb33823a2a1fdae4c988e1", size = 245566 }, { url = "https://files.pythonhosted.org/packages/7b/0e/f9829cdd25e5083638559c8c267ff0577c6bab19dacb1a4fcfc1e70e41c0/coverage-7.7.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15d54ecef1582b1d3ec6049b20d3c1a07d5e7f85335d8a3b617c9960b4f807e0", size = 242455 }, { url = "https://files.pythonhosted.org/packages/29/57/a3ada2e50a665bf6d9851b5eb3a9a07d7e38f970bdd4d39895f311331d56/coverage-7.7.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75c82b27c56478d5e1391f2e7b2e7f588d093157fa40d53fd9453a471b1191f2", size = 244713 }, { url = "https://files.pythonhosted.org/packages/0f/d3/f15c7d45682a73eca0611427896016bad4c8f635b0fc13aae13a01f8ed9d/coverage-7.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:315ff74b585110ac3b7ab631e89e769d294f303c6d21302a816b3554ed4c81af", size = 244476 }, { url = "https://files.pythonhosted.org/packages/19/3b/64540074e256082b220e8810fd72543eff03286c59dc91976281dc0a559c/coverage-7.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4dd532dac197d68c478480edde74fd4476c6823355987fd31d01ad9aa1e5fb59", size = 242695 }, { url = "https://files.pythonhosted.org/packages/8a/c1/9cad25372ead7f9395a91bb42d8ae63e6cefe7408eb79fd38797e2b763eb/coverage-7.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:385618003e3d608001676bb35dc67ae3ad44c75c0395d8de5780af7bb35be6b2", size = 243888 }, { url = "https://files.pythonhosted.org/packages/66/c6/c3e6c895bc5b95ccfe4cb5838669dbe5226ee4ad10604c46b778c304d6f9/coverage-7.7.1-cp312-cp312-win32.whl", hash = "sha256:63306486fcb5a827449464f6211d2991f01dfa2965976018c9bab9d5e45a35c8", size = 213744 }, { url = "https://files.pythonhosted.org/packages/cc/8a/6df2fcb4c3e38ec6cd7e211ca8391405ada4e3b1295695d00aa07c6ee736/coverage-7.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:37351dc8123c154fa05b7579fdb126b9f8b1cf42fd6f79ddf19121b7bdd4aa04", size = 214546 }, { url = "https://files.pythonhosted.org/packages/ec/2a/1a254eaadb01c163b29d6ce742aa380fc5cfe74a82138ce6eb944c42effa/coverage-7.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:eebd927b86761a7068a06d3699fd6c20129becf15bb44282db085921ea0f1585", size = 211277 }, { url = "https://files.pythonhosted.org/packages/cf/00/9636028365efd4eb6db71cdd01d99e59f25cf0d47a59943dbee32dd1573b/coverage-7.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2a79c4a09765d18311c35975ad2eb1ac613c0401afdd9cb1ca4110aeb5dd3c4c", size = 211551 }, { url = "https://files.pythonhosted.org/packages/6f/c8/14aed97f80363f055b6cd91e62986492d9fe3b55e06b4b5c82627ae18744/coverage-7.7.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b1c65a739447c5ddce5b96c0a388fd82e4bbdff7251396a70182b1d83631019", size = 245068 }, { url = "https://files.pythonhosted.org/packages/d6/76/9c5fe3f900e01d7995b0cda08fc8bf9773b4b1be58bdd626f319c7d4ec11/coverage-7.7.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:392cc8fd2b1b010ca36840735e2a526fcbd76795a5d44006065e79868cc76ccf", size = 242109 }, { url = "https://files.pythonhosted.org/packages/c0/81/760993bb536fb674d3a059f718145dcd409ed6d00ae4e3cbf380019fdfd0/coverage-7.7.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9bb47cc9f07a59a451361a850cb06d20633e77a9118d05fd0f77b1864439461b", size = 244129 }, { url = "https://files.pythonhosted.org/packages/00/be/1114a19f93eae0b6cd955dabb5bee80397bd420d846e63cd0ebffc134e3d/coverage-7.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b4c144c129343416a49378e05c9451c34aae5ccf00221e4fa4f487db0816ee2f", size = 244201 }, { url = "https://files.pythonhosted.org/packages/06/8d/9128fd283c660474c7dc2b1ea5c66761bc776b970c1724989ed70e9d6eee/coverage-7.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bc96441c9d9ca12a790b5ae17d2fa6654da4b3962ea15e0eabb1b1caed094777", size = 242282 }, { url = "https://files.pythonhosted.org/packages/d4/2a/6d7dbfe9c1f82e2cdc28d48f4a0c93190cf58f057fa91ba2391b92437fe6/coverage-7.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d03287eb03186256999539d98818c425c33546ab4901028c8fa933b62c35c3a", size = 243570 }, { url = "https://files.pythonhosted.org/packages/cf/3e/29f1e4ce3bb951bcf74b2037a82d94c5064b3334304a3809a95805628838/coverage-7.7.1-cp313-cp313-win32.whl", hash = "sha256:8fed429c26b99641dc1f3a79179860122b22745dd9af36f29b141e178925070a", size = 213772 }, { url = "https://files.pythonhosted.org/packages/bc/3a/cf029bf34aefd22ad34f0e808eba8d5830f297a1acb483a2124f097ff769/coverage-7.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:092b134129a8bb940c08b2d9ceb4459af5fb3faea77888af63182e17d89e1cf1", size = 214575 }, { url = "https://files.pythonhosted.org/packages/92/4c/fb8b35f186a2519126209dce91ab8644c9a901cf04f8dfa65576ca2dd9e8/coverage-7.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3154b369141c3169b8133973ac00f63fcf8d6dbcc297d788d36afbb7811e511", size = 212113 }, { url = "https://files.pythonhosted.org/packages/59/90/e834ffc86fd811c5b570a64ee1895b20404a247ec18a896b9ba543b12097/coverage-7.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:264ff2bcce27a7f455b64ac0dfe097680b65d9a1a293ef902675fa8158d20b24", size = 212333 }, { url = "https://files.pythonhosted.org/packages/a5/a1/27f0ad39569b3b02410b881c42e58ab403df13fcd465b475db514b83d3d3/coverage-7.7.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba8480ebe401c2f094d10a8c4209b800a9b77215b6c796d16b6ecdf665048950", size = 256566 }, { url = "https://files.pythonhosted.org/packages/9f/3b/21fa66a1db1b90a0633e771a32754f7c02d60236a251afb1b86d7e15d83a/coverage-7.7.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:520af84febb6bb54453e7fbb730afa58c7178fd018c398a8fcd8e269a79bf96d", size = 252276 }, { url = "https://files.pythonhosted.org/packages/d6/e5/4ab83a59b0f8ac4f0029018559fc4c7d042e1b4552a722e2bfb04f652296/coverage-7.7.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88d96127ae01ff571d465d4b0be25c123789cef88ba0879194d673fdea52f54e", size = 254616 }, { url = "https://files.pythonhosted.org/packages/db/7a/4224417c0ccdb16a5ba4d8d1fcfaa18439be1624c29435bb9bc88ccabdfb/coverage-7.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0ce92c5a9d7007d838456f4b77ea159cb628187a137e1895331e530973dcf862", size = 255707 }, { url = "https://files.pythonhosted.org/packages/51/20/ff18a329ccaa3d035e2134ecf3a2e92a52d3be6704c76e74ca5589ece260/coverage-7.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0dab4ef76d7b14f432057fdb7a0477e8bffca0ad39ace308be6e74864e632271", size = 253876 }, { url = "https://files.pythonhosted.org/packages/e4/e8/1d6f1a6651672c64f45ffad05306dad9c4c189bec694270822508049b2cb/coverage-7.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7e688010581dbac9cab72800e9076e16f7cccd0d89af5785b70daa11174e94de", size = 254687 }, { url = "https://files.pythonhosted.org/packages/6b/ea/1b9a14cf3e2bc3fd9de23a336a8082091711c5f480b500782d59e84a8fe5/coverage-7.7.1-cp313-cp313t-win32.whl", hash = "sha256:e52eb31ae3afacdacfe50705a15b75ded67935770c460d88c215a9c0c40d0e9c", size = 214486 }, { url = "https://files.pythonhosted.org/packages/cc/bb/faa6bcf769cb7b3b660532a30d77c440289b40636c7f80e498b961295d07/coverage-7.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a6b6b3bd121ee2ec4bd35039319f3423d0be282b9752a5ae9f18724bc93ebe7c", size = 215647 }, { url = "https://files.pythonhosted.org/packages/52/26/9f53293ff4cc1d47d98367ce045ca2e62746d6be74a5c6851a474eabf59b/coverage-7.7.1-py3-none-any.whl", hash = "sha256:822fa99dd1ac686061e1219b67868e25d9757989cf2259f735a4802497d6da31", size = 203006 }, ] [[package]] name = "distlib" version = "0.3.9" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } wheels = [ { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, ] [[package]] name = "filelock" version = "3.17.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/dc/9c/0b15fb47b464e1b663b1acd1253a062aa5feecb07d4e597daea542ebd2b5/filelock-3.17.0.tar.gz", hash = "sha256:ee4e77401ef576ebb38cd7f13b9b28893194acc20a8e68e18730ba9c0e54660e", size = 18027 } wheels = [ { url = "https://files.pythonhosted.org/packages/89/ec/00d68c4ddfedfe64159999e5f8a98fb8442729a63e2077eb9dcd89623d27/filelock-3.17.0-py3-none-any.whl", hash = "sha256:533dc2f7ba78dc2f0f531fc6c4940addf7b70a481e269a5a3b93be94ffbe8338", size = 16164 }, ] [[package]] name = "h11" version = "0.14.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } wheels = [ { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, ] [[package]] name = "h2" version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hpack" }, { name = "hyperframe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1b/38/d7f80fd13e6582fb8e0df8c9a653dcc02b03ca34f4d72f34869298c5baf8/h2-4.2.0.tar.gz", hash = "sha256:c8a52129695e88b1a0578d8d2cc6842bbd79128ac685463b887ee278126ad01f", size = 2150682 } wheels = [ { url = "https://files.pythonhosted.org/packages/d0/9e/984486f2d0a0bd2b024bf4bc1c62688fcafa9e61991f041fb0e2def4a982/h2-4.2.0-py3-none-any.whl", hash = "sha256:479a53ad425bb29af087f3458a61d30780bc818e4ebcf01f0b536ba916462ed0", size = 60957 }, ] [[package]] name = "hpack" version = "4.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276 } wheels = [ { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357 }, ] [[package]] name = "httpcore" version = "1.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } wheels = [ { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, ] [[package]] name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "certifi" }, { name = "httpcore" }, { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, ] [package.optional-dependencies] http2 = [ { name = "h2" }, ] [[package]] name = "hyperframe" version = "6.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566 } wheels = [ { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007 }, ] [[package]] name = "iaqualink" source = { editable = "." } dependencies = [ { name = "httpx", extra = ["http2"] }, ] [package.optional-dependencies] dev = [ { name = "mypy" }, { name = "pre-commit" }, { name = "ruff" }, ] test = [ { name = "coverage" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-icdiff" }, { name = "pytest-sugar" }, { name = "respx" }, ] [package.metadata] requires-dist = [ { name = "coverage", extras = ["toml"], marker = "extra == 'test'", specifier = "==7.7.1" }, { name = "httpx", extras = ["http2"], specifier = ">=0.27.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.15.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = "==4.2.0" }, { name = "pytest", marker = "extra == 'test'", specifier = "==8.3.5" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = "==6.0.0" }, { name = "pytest-icdiff", marker = "extra == 'test'", specifier = "==0.9" }, { name = "pytest-sugar", marker = "extra == 'test'", specifier = "==1.0.0" }, { name = "respx", marker = "extra == 'test'", specifier = "==0.22.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.11.2" }, ] provides-extras = ["dev", "test"] [[package]] name = "icdiff" version = "2.0.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/fa/e4/43341832be5f2bcae71eb3ef08a07aaef9b74f74fe0b3675f62bd12057fe/icdiff-2.0.7.tar.gz", hash = "sha256:f79a318891adbf59a45e3a7694f5e1f18c5407065264637072ac8363b759866f", size = 16394 } wheels = [ { url = "https://files.pythonhosted.org/packages/7c/2a/b3178baa75a3ec75a33588252296c82a1332d2b83cd01061539b74bde9dd/icdiff-2.0.7-py3-none-any.whl", hash = "sha256:f05d1b3623223dd1c70f7848da7d699de3d9a2550b902a8234d9026292fb5762", size = 17018 }, ] [[package]] name = "identify" version = "2.6.8" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f9/fa/5eb460539e6f5252a7c5a931b53426e49258cde17e3d50685031c300a8fd/identify-2.6.8.tar.gz", hash = "sha256:61491417ea2c0c5c670484fd8abbb34de34cdae1e5f39a73ee65e48e4bb663fc", size = 99249 } wheels = [ { url = "https://files.pythonhosted.org/packages/78/8c/4bfcab2d8286473b8d83ea742716f4b79290172e75f91142bc1534b05b9a/identify-2.6.8-py2.py3-none-any.whl", hash = "sha256:83657f0f766a3c8d0eaea16d4ef42494b39b34629a4b3192a9d020d349b3e255", size = 99109 }, ] [[package]] name = "idna" version = "3.10" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] [[package]] name = "iniconfig" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, ] [[package]] name = "mypy" version = "1.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717 } wheels = [ { url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981 }, { url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175 }, { url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675 }, { url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020 }, { url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582 }, { url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614 }, { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592 }, { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611 }, { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443 }, { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541 }, { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348 }, { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648 }, { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777 }, ] [[package]] name = "mypy-extensions" version = "1.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } wheels = [ { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, ] [[package]] name = "nodeenv" version = "1.9.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, ] [[package]] name = "packaging" version = "24.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } wheels = [ { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, ] [[package]] name = "platformdirs" version = "4.3.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } wheels = [ { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, ] [[package]] name = "pluggy" version = "1.5.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } wheels = [ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, ] [[package]] name = "pprintpp" version = "0.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/06/1a/7737e7a0774da3c3824d654993cf57adc915cb04660212f03406334d8c0b/pprintpp-0.4.0.tar.gz", hash = "sha256:ea826108e2c7f49dc6d66c752973c3fc9749142a798d6b254e1e301cfdbc6403", size = 17995 } wheels = [ { url = "https://files.pythonhosted.org/packages/4e/d1/e4ed95fdd3ef13b78630280d9e9e240aeb65cc7c544ec57106149c3942fb/pprintpp-0.4.0-py2.py3-none-any.whl", hash = "sha256:b6b4dcdd0c0c0d75e4d7b2f21a9e933e5b2ce62b26e1a54537f9651ae5a5c01d", size = 16952 }, ] [[package]] name = "pre-commit" version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, { name = "identify" }, { name = "nodeenv" }, { name = "pyyaml" }, { name = "virtualenv" }, ] sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424 } wheels = [ { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707 }, ] [[package]] name = "pytest" version = "8.3.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } wheels = [ { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, ] [[package]] name = "pytest-cov" version = "6.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage" }, { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 } wheels = [ { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, ] [[package]] name = "pytest-icdiff" version = "0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "icdiff" }, { name = "pprintpp" }, { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5a/0c/66e1e2590e98f4428e374a3b6448dc086a908d15b1e24b914539d13b7ac4/pytest-icdiff-0.9.tar.gz", hash = "sha256:13aede616202e57fcc882568b64589002ef85438046f012ac30a8d959dac8b75", size = 7110 } wheels = [ { url = "https://files.pythonhosted.org/packages/e2/e1/cafe1edf7a30be6fa1bbbf43f7af12b34682eadcf19eb6e9f7352062c422/pytest_icdiff-0.9-py3-none-any.whl", hash = "sha256:efee0da3bd1b24ef2d923751c5c547fbb8df0a46795553fba08ef57c3ca03d82", size = 4994 }, ] [[package]] name = "pytest-sugar" version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "pytest" }, { name = "termcolor" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/ac/5754f5edd6d508bc6493bc37d74b928f102a5fff82d9a80347e180998f08/pytest-sugar-1.0.0.tar.gz", hash = "sha256:6422e83258f5b0c04ce7c632176c7732cab5fdb909cb39cca5c9139f81276c0a", size = 14992 } wheels = [ { url = "https://files.pythonhosted.org/packages/92/fb/889f1b69da2f13691de09a111c16c4766a433382d44aa0ecf221deded44a/pytest_sugar-1.0.0-py3-none-any.whl", hash = "sha256:70ebcd8fc5795dc457ff8b69d266a4e2e8a74ae0c3edc749381c64b5246c8dfd", size = 10171 }, ] [[package]] name = "pyyaml" version = "6.0.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } wheels = [ { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, ] [[package]] name = "respx" version = "0.22.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439 } wheels = [ { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127 }, ] [[package]] name = "ruff" version = "0.11.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/90/61/fb87430f040e4e577e784e325351186976516faef17d6fcd921fe28edfd7/ruff-0.11.2.tar.gz", hash = "sha256:ec47591497d5a1050175bdf4e1a4e6272cddff7da88a2ad595e1e326041d8d94", size = 3857511 } wheels = [ { url = "https://files.pythonhosted.org/packages/62/99/102578506f0f5fa29fd7e0df0a273864f79af044757aef73d1cae0afe6ad/ruff-0.11.2-py3-none-linux_armv6l.whl", hash = "sha256:c69e20ea49e973f3afec2c06376eb56045709f0212615c1adb0eda35e8a4e477", size = 10113146 }, { url = "https://files.pythonhosted.org/packages/74/ad/5cd4ba58ab602a579997a8494b96f10f316e874d7c435bcc1a92e6da1b12/ruff-0.11.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2c5424cc1c4eb1d8ecabe6d4f1b70470b4f24a0c0171356290b1953ad8f0e272", size = 10867092 }, { url = "https://files.pythonhosted.org/packages/fc/3e/d3f13619e1d152c7b600a38c1a035e833e794c6625c9a6cea6f63dbf3af4/ruff-0.11.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ecf20854cc73f42171eedb66f006a43d0a21bfb98a2523a809931cda569552d9", size = 10224082 }, { url = "https://files.pythonhosted.org/packages/90/06/f77b3d790d24a93f38e3806216f263974909888fd1e826717c3ec956bbcd/ruff-0.11.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c543bf65d5d27240321604cee0633a70c6c25c9a2f2492efa9f6d4b8e4199bb", size = 10394818 }, { url = "https://files.pythonhosted.org/packages/99/7f/78aa431d3ddebfc2418cd95b786642557ba8b3cb578c075239da9ce97ff9/ruff-0.11.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20967168cc21195db5830b9224be0e964cc9c8ecf3b5a9e3ce19876e8d3a96e3", size = 9952251 }, { url = "https://files.pythonhosted.org/packages/30/3e/f11186d1ddfaca438c3bbff73c6a2fdb5b60e6450cc466129c694b0ab7a2/ruff-0.11.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:955a9ce63483999d9f0b8f0b4a3ad669e53484232853054cc8b9d51ab4c5de74", size = 11563566 }, { url = "https://files.pythonhosted.org/packages/22/6c/6ca91befbc0a6539ee133d9a9ce60b1a354db12c3c5d11cfdbf77140f851/ruff-0.11.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:86b3a27c38b8fce73bcd262b0de32e9a6801b76d52cdb3ae4c914515f0cef608", size = 12208721 }, { url = "https://files.pythonhosted.org/packages/19/b0/24516a3b850d55b17c03fc399b681c6a549d06ce665915721dc5d6458a5c/ruff-0.11.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3b66a03b248c9fcd9d64d445bafdf1589326bee6fc5c8e92d7562e58883e30f", size = 11662274 }, { url = "https://files.pythonhosted.org/packages/d7/65/76be06d28ecb7c6070280cef2bcb20c98fbf99ff60b1c57d2fb9b8771348/ruff-0.11.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0397c2672db015be5aa3d4dac54c69aa012429097ff219392c018e21f5085147", size = 13792284 }, { url = "https://files.pythonhosted.org/packages/ce/d2/4ceed7147e05852876f3b5f3fdc23f878ce2b7e0b90dd6e698bda3d20787/ruff-0.11.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:869bcf3f9abf6457fbe39b5a37333aa4eecc52a3b99c98827ccc371a8e5b6f1b", size = 11327861 }, { url = "https://files.pythonhosted.org/packages/c4/78/4935ecba13706fd60ebe0e3dc50371f2bdc3d9bc80e68adc32ff93914534/ruff-0.11.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2a2b50ca35457ba785cd8c93ebbe529467594087b527a08d487cf0ee7b3087e9", size = 10276560 }, { url = "https://files.pythonhosted.org/packages/81/7f/1b2435c3f5245d410bb5dc80f13ec796454c21fbda12b77d7588d5cf4e29/ruff-0.11.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7c69c74bf53ddcfbc22e6eb2f31211df7f65054bfc1f72288fc71e5f82db3eab", size = 9945091 }, { url = "https://files.pythonhosted.org/packages/39/c4/692284c07e6bf2b31d82bb8c32f8840f9d0627d92983edaac991a2b66c0a/ruff-0.11.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6e8fb75e14560f7cf53b15bbc55baf5ecbe373dd5f3aab96ff7aa7777edd7630", size = 10977133 }, { url = "https://files.pythonhosted.org/packages/94/cf/8ab81cb7dd7a3b0a3960c2769825038f3adcd75faf46dd6376086df8b128/ruff-0.11.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:842a472d7b4d6f5924e9297aa38149e5dcb1e628773b70e6387ae2c97a63c58f", size = 11378514 }, { url = "https://files.pythonhosted.org/packages/d9/3a/a647fa4f316482dacf2fd68e8a386327a33d6eabd8eb2f9a0c3d291ec549/ruff-0.11.2-py3-none-win32.whl", hash = "sha256:aca01ccd0eb5eb7156b324cfaa088586f06a86d9e5314b0eb330cb48415097cc", size = 10319835 }, { url = "https://files.pythonhosted.org/packages/86/54/3c12d3af58012a5e2cd7ebdbe9983f4834af3f8cbea0e8a8c74fa1e23b2b/ruff-0.11.2-py3-none-win_amd64.whl", hash = "sha256:3170150172a8f994136c0c66f494edf199a0bbea7a409f649e4bc8f4d7084080", size = 11373713 }, { url = "https://files.pythonhosted.org/packages/d6/d4/dd813703af8a1e2ac33bf3feb27e8a5ad514c9f219df80c64d69807e7f71/ruff-0.11.2-py3-none-win_arm64.whl", hash = "sha256:52933095158ff328f4c77af3d74f0379e34fd52f175144cefc1b192e7ccd32b4", size = 10441990 }, ] [[package]] name = "sniffio" version = "1.3.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] [[package]] name = "termcolor" version = "2.5.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/37/72/88311445fd44c455c7d553e61f95412cf89054308a1aa2434ab835075fc5/termcolor-2.5.0.tar.gz", hash = "sha256:998d8d27da6d48442e8e1f016119076b690d962507531df4890fcd2db2ef8a6f", size = 13057 } wheels = [ { url = "https://files.pythonhosted.org/packages/7f/be/df630c387a0a054815d60be6a97eb4e8f17385d5d6fe660e1c02750062b4/termcolor-2.5.0-py3-none-any.whl", hash = "sha256:37b17b5fc1e604945c2642c872a3764b5d547a48009871aea3edd3afa180afb8", size = 7755 }, ] [[package]] name = "typing-extensions" version = "4.12.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] [[package]] name = "virtualenv" version = "20.29.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c7/9c/57d19fa093bcf5ac61a48087dd44d00655f85421d1aa9722f8befbf3f40a/virtualenv-20.29.3.tar.gz", hash = "sha256:95e39403fcf3940ac45bc717597dba16110b74506131845d9b687d5e73d947ac", size = 4320280 } wheels = [ { url = "https://files.pythonhosted.org/packages/c2/eb/c6db6e3001d58c6a9e67c74bb7b4206767caa3ccc28c6b9eaf4c23fb4e34/virtualenv-20.29.3-py3-none-any.whl", hash = "sha256:3e3d00f5807e83b234dfb6122bf37cfadf4be216c53a49ac059d02414f819170", size = 4301458 }, ]