From a17678b0ec16ffddcbdc05205ff1b5c91dfd9a7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Thu, 7 Mar 2024 23:33:16 +0000 Subject: [PATCH 1/6] Add example code --- api/python/airthings_sdk/parser.py | 229 +++++++++--------- .../examples/fetch_devices_and_sensors.py | 20 ++ 2 files changed, 137 insertions(+), 112 deletions(-) create mode 100644 api/python/examples/fetch_devices_and_sensors.py diff --git a/api/python/airthings_sdk/parser.py b/api/python/airthings_sdk/parser.py index c048f5d..e995b03 100644 --- a/api/python/airthings_sdk/parser.py +++ b/api/python/airthings_sdk/parser.py @@ -5,7 +5,7 @@ from httpx import AsyncClient, Response, TimeoutException, request -from airthings_api_client import AuthenticatedClient +from airthings_api_client import Client from airthings_api_client.api.accounts import get_accounts_ids from airthings_api_client.api.device import get_devices from airthings_api_client.api.sensor import get_multiple_sensors @@ -30,55 +30,6 @@ def __init__(self, status_code: int, content: bytes): super().__init__(f"Rate limit exceeded: {status_code} {content}") -class User: - def __init__( - self, - domain: str, - sn: str, - client_id: str, - client_secret: str, - unit: GetMultipleSensorsUnit = GetMultipleSensorsUnit.METRIC, - ): - self.domain = domain - self.sn = sn - self._client_id = client_id - self._client_secret = client_secret - self.unit = unit - - self.access_token: Optional[str] = None - self.expires: Optional[int] = None - - def __str__(self): - return f"User(user_group={self.user_group})" - - def authenticate(self): - try: - auth_response: Response = request( - method="POST", - url=AUTH_URL, - data={ - "grant_type": "client_credentials", - "client_id": self._client_id, - "client_secret": self._client_secret, - }, - ) - if access_token := auth_response.json()["access_token"]: - self.access_token = access_token - else: - raise ValueError("No access token found") - - if expires := auth_response.json()["expires_in"]: - self.expires = int(time.time()) + expires - else: - raise ValueError("No expires_in found") - - logger.info("Authenticated: %s", self.access_token) - - except Exception as e: - logging.error("Error while authenticating: %s", e) - return None - - @dataclass class AirthingsSensor: sensor_type: str @@ -128,88 +79,142 @@ def init_from_device_response( ) +class AirthingsToken: + _access_token: Optional[str] + _expires: Optional[int] + + def __init__( + self, + ): + self._access_token = None + self._expires = None + + def set_token(self, access_token: str, expires_in: int): + self._access_token = access_token + self._expires = expires_in + int(time.time()) + + def is_valid(self) -> bool: + return self._access_token and self._expires and self._expires > (int(time.time()) + 20) + + def as_header(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self._access_token}"} + + class Airthings: _access_token: Optional[str] = None _client_id: str _client_secret: str - _websession: AsyncClient + _unit: GetMultipleSensorsUnit + _websession: Optional[AsyncClient] + _access_token: AirthingsToken devices: List[AirthingsDevice] - def __init__(self, client_id, client_secret, websession) -> 'Airthings': + def __init__( + self, + client_id: str, + client_secret: str, + is_metric: bool, + websession: Optional[AsyncClient] = None, + ) -> 'Airthings': """Init Airthings data handler.""" self._client_id = client_id self._client_secret = client_secret + self._unit = GetMultipleSensorsUnit.METRIC if is_metric else GetMultipleSensorsUnit.IMPERIAL self._websession = websession - self._access_token = None + self._access_token = AirthingsToken() self._devices = {} - def update_devices(self) -> Optional[dict[str, AirthingsDevice]]: - user = User( - domain="https://accounts-api.airthings.com", - sn="sn", - client_id=self._client_id, - client_secret=self._client_secret, - ) - - try: - user.authenticate() - except Exception as e: - logging.error("Error while authenticating: %s", e) - exit(1) + def _authenticate(self): + with Client( + base_url="https://accounts-api.airthings.com", + timeout=10, + ) as client: + if websession := self._websession: + client.set_async_httpx_client(websession) + auth_response = client.get_httpx_client().request( + url=AUTH_URL, + method="POST", + data={ + "grant_type": "client_credentials", + "client_id": self._client_id, + "client_secret": self._client_secret, + }, + ) + if access_token := auth_response.json().get("access_token"): + self._access_token.set_token( + access_token=access_token, + expires=int(auth_response.json()["expires_in"]), + ) + else: + raise ValueError("No access token found") - with AuthenticatedClient( + def update_devices(self) -> Optional[dict[str, AirthingsDevice]]: + if not self._access_token.is_valid(): + try: + self._authenticate() + except Exception as e: + logging.error("Error while authenticating: %s", e) + return None + + with Client( base_url="https://consumer-api.airthings.com", timeout=10, - verify_ssl=False, + verify_ssl=True, token=self._access_token, ) as client: - client.set_async_httpx_client(self._websession) - client.with_headers({"Authorization": f"Bearer {user.access_token}"}) + if websession := self._websession: + client.set_async_httpx_client(websession) + client.with_headers() logging.info("Client setup complete") - if accounts := self.fetch_accounts(client): - logging.info("Accounts found: %s", len(accounts.accounts)) - for account in accounts.accounts: - logging.info("Account: %s", account) - - if devices := self.fetch_devices( - account_id=account.id, - client=client - ): - logging.info( - "%s devices found in account %s", - len(devices.devices), account.id - ) - logging.info("Devices: %s", devices) - - while True: - all_sensors: List[SensorsResponse] = [] - if sensors := self.fetch_sensors( - account_id=account.id, unit=user.unit, client=client - ): - logging.info( - "%s sensors found in account %s", - len(sensors.results), account.id - ) - logging.info("Sensors: %s,", sensors.results) - all_sensors.extend(sensors.results) - logging.info("Pages: %s", sensors.total_pages) - if not sensors.has_next: - break - - res = {} - for device in devices.devices: - for sensor in sensors.results: - if device.serial_number == sensor.serial_number: - res[device.serial_number] = AirthingsDevice.init_from_device_response(device, sensor) - - logger.info("Mapped devices: %s", res) - return res - return None + accounts = self.fetch_accounts(client) + if not accounts: + logging.error("No accounts found") + return None + + logging.info("Accounts found: %s", len(accounts.accounts)) + for account in accounts.accounts: + logging.info("Account: %s", account) + + devices = self.fetch_devices( + account_id=account.id, + client=client + ) + if not devices: + logging.error("No devices found in account %s", account.id) + continue + + logging.info( + "%s devices found in account %s", + len(devices.devices), account.id + ) + logging.info("Devices: %s", devices) + + sensors = self.fetch_sensors( + account_id=account.id, unit=self._unit, client=client + ) + if not sensors: + logging.error("No sensors found in account %s", account.id) + break + logging.info( + "%s sensors found in account %s", + len(sensors.results), account.id + ) + logging.info("Sensors: %s,", sensors.results) + logging.info("Pages: %s", sensors.total_pages) + + res = {} + for device in devices.devices: + for sensor in sensors.results: + if device.serial_number == sensor.serial_number: + res[device.serial_number] = AirthingsDevice.init_from_device_response(device, sensor) + + logger.info("Mapped devices: %s", res) + return res def fetch_accounts( self, - client: AuthenticatedClient + client: Client ) -> Optional[get_accounts_ids.AccountsResponse]: try: accounts_response = get_accounts_ids.sync_detailed( @@ -231,7 +236,7 @@ def fetch_accounts( def fetch_devices( self, account_id: str, - client: AuthenticatedClient + client: Client ) -> Optional[List[DeviceResponse]]: try: sensors_response = get_devices.sync_detailed( @@ -252,7 +257,7 @@ def fetch_devices( def fetch_sensors( self, account_id: str, - client: AuthenticatedClient, + client: Client, page_number: int = 1, unit: Optional[GetMultipleSensorsUnit] = None, ) -> Optional[List[SensorsResponse]]: diff --git a/api/python/examples/fetch_devices_and_sensors.py b/api/python/examples/fetch_devices_and_sensors.py new file mode 100644 index 0000000..b1e5029 --- /dev/null +++ b/api/python/examples/fetch_devices_and_sensors.py @@ -0,0 +1,20 @@ +import sys +from airthings_sdk import Airthings + +if __name__ == "__main__": + if len(sys.argv) <= 2: + print("Please add client id and client secret as parameters.") + print("Usage:") + print("python fetch_devices_and_sensors.py ") + exit(1) + client_id = sys.argv[1] + client_secret = sys.argv[2] + + airthings = Airthings( + client_id=client_id, + client_secret=client_secret, + is_metric=True, + ) + + devices = airthings.update_devices() + print(devices) From ef3d7e453d58f0e16eafdfa45ce1dfad43f8e0cf Mon Sep 17 00:00:00 2001 From: Matej Horak Date: Fri, 8 Mar 2024 11:59:34 +0100 Subject: [PATCH 2/6] refactor(ci-cd): add simple CI with lint --- .github/workflows/ci.yml | 32 ++++ api/python/airthings_sdk/__init__.py | 1 + api/python/airthings_sdk/parser.py | 100 +++++++----- api/python/poetry.lock | 229 ++++++++++++++++++++++++++- api/python/pyproject.toml | 4 + atlantis.yaml | 22 --- 6 files changed, 325 insertions(+), 63 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 atlantis.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..503cff2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: pull_request + +jobs: + lint: + runs-on: ubuntu-latest + name: Lint Python API SDK + defaults: + run: + working-directory: api/python + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.12 + + - name: Install Poetry + uses: snok/install-poetry@v1 + + - name: Install dependencies + run: poetry install + + - name: Check formatting + run: poetry run black --check airthings_sdk + + - name: Check code style + run: poetry run pylint airthings_sdk + diff --git a/api/python/airthings_sdk/__init__.py b/api/python/airthings_sdk/__init__.py index edb929c..a3d3f44 100644 --- a/api/python/airthings_sdk/__init__.py +++ b/api/python/airthings_sdk/__init__.py @@ -1,4 +1,5 @@ """ A client library for accessing Airthings for Consumer API """ + from .parser import Airthings, AirthingsDevice, AirthingsSensor __all__ = ( diff --git a/api/python/airthings_sdk/parser.py b/api/python/airthings_sdk/parser.py index e995b03..d42e520 100644 --- a/api/python/airthings_sdk/parser.py +++ b/api/python/airthings_sdk/parser.py @@ -1,9 +1,11 @@ +"""Airthings API data handler.""" + import logging import time from dataclasses import dataclass, field from typing import List, Optional -from httpx import AsyncClient, Response, TimeoutException, request +from httpx import AsyncClient, TimeoutException from airthings_api_client import Client from airthings_api_client.api.accounts import get_accounts_ids @@ -32,15 +34,17 @@ def __init__(self, status_code: int, content: bytes): @dataclass class AirthingsSensor: + """Representation of Airthings device sensor.""" + sensor_type: str value: int | float unit: str @classmethod def init_from_sensor_response( - cls, - sensor_response: SensorResponseType0 + cls, sensor_response: SensorResponseType0 ) -> "AirthingsSensor": + """Create an AirthingsSensor from a SensorResponseType0""" return cls( sensor_type=sensor_response.sensor_type, value=sensor_response.value, @@ -50,6 +54,8 @@ def init_from_sensor_response( @dataclass class AirthingsDevice: + """Representation of an Airthings device""" + serial_number: str name: str home: str @@ -59,16 +65,20 @@ class AirthingsDevice: @classmethod def init_from_device_response( - cls, - device_response: DeviceResponse, - sensors_response: SensorsResponse + cls, device_response: DeviceResponse, sensors_response: SensorsResponse ) -> "AirthingsDevice": - sensors = [AirthingsSensor.init_from_sensor_response(sensor) for sensor in sensors_response.sensors] - sensors.append(AirthingsSensor( - sensor_type="battery", - value=sensors_response.battery_percentage, - unit="%" - )) + """Create an AirthingsDevice from a DeviceResponse and a SensorsResponse""" + sensors = [ + AirthingsSensor.init_from_sensor_response(sensor) + for sensor in sensors_response.sensors + ] + sensors.append( + AirthingsSensor( + sensor_type="battery", + value=sensors_response.battery_percentage, + unit="%", + ) + ) return cls( serial_number=device_response.serial_number, name=device_response.name, @@ -80,6 +90,8 @@ def init_from_device_response( class AirthingsToken: + """Representation of an Airthings API token.""" + _access_token: Optional[str] _expires: Optional[int] @@ -88,19 +100,28 @@ def __init__( ): self._access_token = None self._expires = None - + def set_token(self, access_token: str, expires_in: int): + """Set the token and its expiration time.""" self._access_token = access_token self._expires = expires_in + int(time.time()) - + def is_valid(self) -> bool: - return self._access_token and self._expires and self._expires > (int(time.time()) + 20) + """Check if the token is valid.""" + return ( + self._access_token + and self._expires + and self._expires > (int(time.time()) + 20) + ) def as_header(self) -> dict[str, str]: + """Return the token as a header.""" return {"Authorization": f"Bearer {self._access_token}"} class Airthings: + """Representation of Airthings API data handler.""" + _access_token: Optional[str] = None _client_id: str _client_secret: str @@ -115,11 +136,15 @@ def __init__( client_secret: str, is_metric: bool, websession: Optional[AsyncClient] = None, - ) -> 'Airthings': + ) -> "Airthings": """Init Airthings data handler.""" self._client_id = client_id self._client_secret = client_secret - self._unit = GetMultipleSensorsUnit.METRIC if is_metric else GetMultipleSensorsUnit.IMPERIAL + self._unit = ( + GetMultipleSensorsUnit.METRIC + if is_metric + else GetMultipleSensorsUnit.IMPERIAL + ) self._websession = websession self._access_token = AirthingsToken() self._devices = {} @@ -127,7 +152,7 @@ def __init__( def _authenticate(self): with Client( base_url="https://accounts-api.airthings.com", - timeout=10, + # timeout=10, ) as client: if websession := self._websession: client.set_async_httpx_client(websession) @@ -143,16 +168,17 @@ def _authenticate(self): if access_token := auth_response.json().get("access_token"): self._access_token.set_token( access_token=access_token, - expires=int(auth_response.json()["expires_in"]), + expires_in=int(auth_response.json()["expires_in"]), ) else: raise ValueError("No access token found") def update_devices(self) -> Optional[dict[str, AirthingsDevice]]: + """Update devices and sensors from Airthings API. Return a dict of devices.""" if not self._access_token.is_valid(): try: self._authenticate() - except Exception as e: + except Exception as e: # pylint: disable=broad-except logging.error("Error while authenticating: %s", e) return None @@ -164,7 +190,7 @@ def update_devices(self) -> Optional[dict[str, AirthingsDevice]]: ) as client: if websession := self._websession: client.set_async_httpx_client(websession) - client.with_headers() + client.with_headers(self._access_token.as_header()) logging.info("Client setup complete") accounts = self.fetch_accounts(client) @@ -176,17 +202,13 @@ def update_devices(self) -> Optional[dict[str, AirthingsDevice]]: for account in accounts.accounts: logging.info("Account: %s", account) - devices = self.fetch_devices( - account_id=account.id, - client=client - ) + devices = self.fetch_devices(account_id=account.id, client=client) if not devices: logging.error("No devices found in account %s", account.id) continue logging.info( - "%s devices found in account %s", - len(devices.devices), account.id + "%s devices found in account %s", len(devices.devices), account.id ) logging.info("Devices: %s", devices) @@ -197,8 +219,7 @@ def update_devices(self) -> Optional[dict[str, AirthingsDevice]]: logging.error("No sensors found in account %s", account.id) break logging.info( - "%s sensors found in account %s", - len(sensors.results), account.id + "%s sensors found in account %s", len(sensors.results), account.id ) logging.info("Sensors: %s,", sensors.results) logging.info("Pages: %s", sensors.total_pages) @@ -207,15 +228,17 @@ def update_devices(self) -> Optional[dict[str, AirthingsDevice]]: for device in devices.devices: for sensor in sensors.results: if device.serial_number == sensor.serial_number: - res[device.serial_number] = AirthingsDevice.init_from_device_response(device, sensor) + res[device.serial_number] = ( + AirthingsDevice.init_from_device_response(device, sensor) + ) logger.info("Mapped devices: %s", res) return res def fetch_accounts( - self, - client: Client + self, client: Client ) -> Optional[get_accounts_ids.AccountsResponse]: + """Fetch accounts for the given client""" try: accounts_response = get_accounts_ids.sync_detailed( client=client, @@ -229,15 +252,14 @@ def fetch_accounts( except TimeoutException as e: logging.error("Timeout while fetching accounts: %s", e) return None - except Exception as e: + except Exception as e: # pylint: disable=broad-except logging.error("Error while fetching accounts: %s", e) return None def fetch_devices( - self, - account_id: str, - client: Client + self, account_id: str, client: Client ) -> Optional[List[DeviceResponse]]: + """Fetch devices for a given account""" try: sensors_response = get_devices.sync_detailed( account_id=account_id, @@ -261,6 +283,7 @@ def fetch_sensors( page_number: int = 1, unit: Optional[GetMultipleSensorsUnit] = None, ) -> Optional[List[SensorsResponse]]: + """Fetch sensors for a given account""" try: sensors_response = get_multiple_sensors.sync_detailed( account_id=account_id, @@ -269,14 +292,13 @@ def fetch_sensors( unit=unit, ) - sensors_response.parsed.results - if sensors := sensors_response.parsed: if sensors.has_next: return self.fetch_sensors( account_id=account_id, page_number=page_number + 1, - unit=unit + unit=unit, + client=client, ) return sensors return None diff --git a/api/python/poetry.lock b/api/python/poetry.lock index 9f6577c..bca972d 100644 --- a/api/python/poetry.lock +++ b/api/python/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "anyio" @@ -22,6 +22,20 @@ doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphin test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] trio = ["trio (>=0.23)"] +[[package]] +name = "astroid" +version = "3.1.0" +description = "An abstract syntax tree for Python with inference support." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "astroid-3.1.0-py3-none-any.whl", hash = "sha256:951798f922990137ac090c53af473db7ab4e70c770e6d7fae0cec59f74411819"}, + {file = "astroid-3.1.0.tar.gz", hash = "sha256:ac248253bfa4bd924a0de213707e7ebeeb3138abeb48d798784ead1e56d419d4"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} + [[package]] name = "attrs" version = "23.2.0" @@ -41,6 +55,52 @@ tests = ["attrs[tests-no-zope]", "zope-interface"] tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] +[[package]] +name = "black" +version = "24.2.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.8" +files = [ + {file = "black-24.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6981eae48b3b33399c8757036c7f5d48a535b962a7c2310d19361edeef64ce29"}, + {file = "black-24.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d533d5e3259720fdbc1b37444491b024003e012c5173f7d06825a77508085430"}, + {file = "black-24.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61a0391772490ddfb8a693c067df1ef5227257e72b0e4108482b8d41b5aee13f"}, + {file = "black-24.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:992e451b04667116680cb88f63449267c13e1ad134f30087dec8527242e9862a"}, + {file = "black-24.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:163baf4ef40e6897a2a9b83890e59141cc8c2a98f2dda5080dc15c00ee1e62cd"}, + {file = "black-24.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e37c99f89929af50ffaf912454b3e3b47fd64109659026b678c091a4cd450fb2"}, + {file = "black-24.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9de21bafcba9683853f6c96c2d515e364aee631b178eaa5145fc1c61a3cc92"}, + {file = "black-24.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:9db528bccb9e8e20c08e716b3b09c6bdd64da0dd129b11e160bf082d4642ac23"}, + {file = "black-24.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d84f29eb3ee44859052073b7636533ec995bd0f64e2fb43aeceefc70090e752b"}, + {file = "black-24.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e08fb9a15c914b81dd734ddd7fb10513016e5ce7e6704bdd5e1251ceee51ac9"}, + {file = "black-24.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:810d445ae6069ce64030c78ff6127cd9cd178a9ac3361435708b907d8a04c693"}, + {file = "black-24.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ba15742a13de85e9b8f3239c8f807723991fbfae24bad92d34a2b12e81904982"}, + {file = "black-24.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7e53a8c630f71db01b28cd9602a1ada68c937cbf2c333e6ed041390d6968faf4"}, + {file = "black-24.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:93601c2deb321b4bad8f95df408e3fb3943d85012dddb6121336b8e24a0d1218"}, + {file = "black-24.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0057f800de6acc4407fe75bb147b0c2b5cbb7c3ed110d3e5999cd01184d53b0"}, + {file = "black-24.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:faf2ee02e6612577ba0181f4347bcbcf591eb122f7841ae5ba233d12c39dcb4d"}, + {file = "black-24.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:057c3dc602eaa6fdc451069bd027a1b2635028b575a6c3acfd63193ced20d9c8"}, + {file = "black-24.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08654d0797e65f2423f850fc8e16a0ce50925f9337fb4a4a176a7aa4026e63f8"}, + {file = "black-24.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca610d29415ee1a30a3f30fab7a8f4144e9d34c89a235d81292a1edb2b55f540"}, + {file = "black-24.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:4dd76e9468d5536abd40ffbc7a247f83b2324f0c050556d9c371c2b9a9a95e31"}, + {file = "black-24.2.0-py3-none-any.whl", hash = "sha256:e8a6ae970537e67830776488bca52000eaa37fa63b9988e8c487458d9cd5ace6"}, + {file = "black-24.2.0.tar.gz", hash = "sha256:bce4f25c27c3435e4dace4815bcb2008b87e167e3bf4ee47ccdc5ce906eb4894"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + [[package]] name = "certifi" version = "2023.11.17" @@ -52,6 +112,46 @@ files = [ {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, ] +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "dill" +version = "0.3.8" +description = "serialize all of Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "dill-0.3.8-py3-none-any.whl", hash = "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7"}, + {file = "dill-0.3.8.tar.gz", hash = "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca"}, +] + +[package.extras] +graph = ["objgraph (>=1.7.2)"] +profile = ["gprof2dot (>=2022.7.29)"] + [[package]] name = "exceptiongroup" version = "1.2.0" @@ -133,6 +233,109 @@ files = [ {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, ] +[[package]] +name = "isort" +version = "5.13.2" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, + {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, +] + +[package.extras] +colors = ["colorama (>=0.4.6)"] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "packaging" +version = "23.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.2.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, + {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] + +[[package]] +name = "pylint" +version = "3.1.0" +description = "python code static checker" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "pylint-3.1.0-py3-none-any.whl", hash = "sha256:507a5b60953874766d8a366e8e8c7af63e058b26345cfcb5f91f89d987fd6b74"}, + {file = "pylint-3.1.0.tar.gz", hash = "sha256:6a69beb4a6f63debebaab0a3477ecd0f559aa726af4954fc948c51f7a2549e23"}, +] + +[package.dependencies] +astroid = ">=3.1.0,<=3.2.0-dev0" +colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} +dill = [ + {version = ">=0.2", markers = "python_version < \"3.11\""}, + {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, + {version = ">=0.3.6", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, +] +isort = ">=4.2.5,<5.13.0 || >5.13.0,<6" +mccabe = ">=0.6,<0.8" +platformdirs = ">=2.2.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +tomlkit = ">=0.10.1" +typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} + +[package.extras] +spelling = ["pyenchant (>=3.2,<4.0)"] +testutils = ["gitpython (>3)"] + [[package]] name = "python-dateutil" version = "2.8.2" @@ -169,6 +372,28 @@ files = [ {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, ] +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "tomlkit" +version = "0.12.4" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomlkit-0.12.4-py3-none-any.whl", hash = "sha256:5cd82d48a3dd89dee1f9d64420aa20ae65cfbd00668d6f094d7578a78efbb77b"}, + {file = "tomlkit-0.12.4.tar.gz", hash = "sha256:7ca1cfc12232806517a8515047ba66a19369e71edf2439d0f5824f91032b6cc3"}, +] + [[package]] name = "typing-extensions" version = "4.9.0" @@ -183,4 +408,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "9f4a2b6c56d09c48930d920ed402ab7aa70a79101a66a7254bd936b09fc8188a" +content-hash = "10cfd78a5f9721e054af5a324767795ab9e242f72263d293b9603be5d62b9be7" diff --git a/api/python/pyproject.toml b/api/python/pyproject.toml index 8de30f7..a525641 100644 --- a/api/python/pyproject.toml +++ b/api/python/pyproject.toml @@ -17,6 +17,10 @@ httpx = ">=0.20.0,<0.27.0" attrs = ">=21.3.0" python-dateutil = "^2.8.0" +[tool.poetry.group.dev.dependencies] +pylint = "^3.1.0" +black = "^24.2.0" + [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" diff --git a/atlantis.yaml b/atlantis.yaml deleted file mode 100644 index ab36d15..0000000 --- a/atlantis.yaml +++ /dev/null @@ -1,22 +0,0 @@ -## atlantis.yaml file - This file overwrites the Server Side Repo Config section specified in the values.yaml file -version: 3 -automerge: true -projects: - - name: dev - dir: /infra/environments/dev - workspace: default - autoplan: - when_modified: [ "../../modules/**/*.tf", "../*.tf", "*.tf*" ] - enabled: true - - name: production - dir: /infra/environments/prod - workspace: default - autoplan: - enabled: true - apply_requirements: [mergeable, approved, undiverged] - - name: insight - dir: /infra/environments/insight - workspace: default - autoplan: - enabled: true - apply_requirements: [mergeable, approved, undiverged] From c3f73c8e871f8115c1e051f9ceaa325c843716c5 Mon Sep 17 00:00:00 2001 From: Matej Horak Date: Fri, 8 Mar 2024 12:54:52 +0100 Subject: [PATCH 3/6] refactor(api-python-sdk): unify fetch functions (currently without error handling) --- .github/workflows/ci.yml | 2 + api/python/airthings_sdk/parser.py | 93 ++++++++++++++---------------- api/python/poetry.lock | 49 +++++++++++++++- api/python/pyproject.toml | 1 + 4 files changed, 95 insertions(+), 50 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 503cff2..d0736ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,3 +30,5 @@ jobs: - name: Check code style run: poetry run pylint airthings_sdk + # - name: Check types + # run: poetry run mypy airthings_sdk \ No newline at end of file diff --git a/api/python/airthings_sdk/parser.py b/api/python/airthings_sdk/parser.py index d42e520..b6a9890 100644 --- a/api/python/airthings_sdk/parser.py +++ b/api/python/airthings_sdk/parser.py @@ -12,6 +12,7 @@ from airthings_api_client.api.device import get_devices from airthings_api_client.api.sensor import get_multiple_sensors from airthings_api_client.errors import UnexpectedStatus +from airthings_api_client.models import Error, AccountResponse from airthings_api_client.models.device_response import DeviceResponse from airthings_api_client.models.get_multiple_sensors_unit import GetMultipleSensorsUnit from airthings_api_client.models.sensor_response_type_0 import SensorResponseType0 @@ -184,49 +185,41 @@ def update_devices(self) -> Optional[dict[str, AirthingsDevice]]: with Client( base_url="https://consumer-api.airthings.com", - timeout=10, + # timeout=10, verify_ssl=True, - token=self._access_token, ) as client: if websession := self._websession: client.set_async_httpx_client(websession) client.with_headers(self._access_token.as_header()) logging.info("Client setup complete") - accounts = self.fetch_accounts(client) + accounts = self.fetch_all_accounts(client) if not accounts: logging.error("No accounts found") return None - logging.info("Accounts found: %s", len(accounts.accounts)) - for account in accounts.accounts: + logging.info("Accounts found: %s", len(accounts)) + for account in accounts: logging.info("Account: %s", account) - devices = self.fetch_devices(account_id=account.id, client=client) - if not devices: - logging.error("No devices found in account %s", account.id) - continue + devices = self.fetch_all_devices(account_id=account.id, client=client) - logging.info( - "%s devices found in account %s", len(devices.devices), account.id - ) + logging.info("%s devices found in account %s", len(devices), account.id) logging.info("Devices: %s", devices) - sensors = self.fetch_sensors( + sensors = self.fetch_all_device_sensors( account_id=account.id, unit=self._unit, client=client ) if not sensors: logging.error("No sensors found in account %s", account.id) break - logging.info( - "%s sensors found in account %s", len(sensors.results), account.id - ) - logging.info("Sensors: %s,", sensors.results) - logging.info("Pages: %s", sensors.total_pages) + logging.info("%s sensors found in account %s", len(sensors), account.id) + logging.info("Sensors: %s,", sensors) + logging.info("Pages: %s", sensors) res = {} - for device in devices.devices: - for sensor in sensors.results: + for device in devices: + for sensor in sensors: if device.serial_number == sensor.serial_number: res[device.serial_number] = ( AirthingsDevice.init_from_device_response(device, sensor) @@ -235,30 +228,30 @@ def update_devices(self) -> Optional[dict[str, AirthingsDevice]]: logger.info("Mapped devices: %s", res) return res - def fetch_accounts( + def fetch_all_accounts( self, client: Client - ) -> Optional[get_accounts_ids.AccountsResponse]: + ) -> List[AccountResponse]: """Fetch accounts for the given client""" try: accounts_response = get_accounts_ids.sync_detailed( client=client, ) if accounts := accounts_response.parsed: - return accounts - return None + return accounts.accounts + return [] except UnexpectedStatus as e: logging.error("Unexpected status while fetching accounts: %s", e) - return None + return [] except TimeoutException as e: logging.error("Timeout while fetching accounts: %s", e) - return None + return [] except Exception as e: # pylint: disable=broad-except logging.error("Error while fetching accounts: %s", e) - return None + return [] - def fetch_devices( + def fetch_all_devices( self, account_id: str, client: Client - ) -> Optional[List[DeviceResponse]]: + ) -> List[DeviceResponse]: """Fetch devices for a given account""" try: sensors_response = get_devices.sync_detailed( @@ -266,23 +259,21 @@ def fetch_devices( client=client, ) logger.info("Device headers: %s", sensors_response.headers) - if sensors := sensors_response.parsed: - return sensors - return None + return sensors_response.parsed.devices except UnexpectedStatus as e: logging.error("Unexpected status while fetching devices: %s", e) - return None + return [] except TimeoutException as e: logging.error("Timeout while fetching devices: %s", e) - return None + return [] - def fetch_sensors( + def fetch_all_device_sensors( self, account_id: str, client: Client, page_number: int = 1, unit: Optional[GetMultipleSensorsUnit] = None, - ) -> Optional[List[SensorsResponse]]: + ) -> List[SensorsResponse]: """Fetch sensors for a given account""" try: sensors_response = get_multiple_sensors.sync_detailed( @@ -290,21 +281,25 @@ def fetch_sensors( client=client, page_number=page_number, unit=unit, - ) + ).parsed + + if sensors_response is Error or sensors_response is None: + return [] + + device_sensors = sensors_response.results - if sensors := sensors_response.parsed: - if sensors.has_next: - return self.fetch_sensors( - account_id=account_id, - page_number=page_number + 1, - unit=unit, - client=client, - ) - return sensors - return None + if sensors_response.has_next is False: + return device_sensors + + return device_sensors + self.fetch_all_device_sensors( + account_id=account_id, + page_number=page_number + 1, + unit=unit, + client=client, + ) except UnexpectedStatus as e: logging.error("Unexpected status while fetching sensors: %s", e) - return None + return [] except TimeoutException as e: logging.error("Timeout while fetching sensors: %s", e) - return None + return [] diff --git a/api/python/poetry.lock b/api/python/poetry.lock index bca972d..7c9ac46 100644 --- a/api/python/poetry.lock +++ b/api/python/poetry.lock @@ -258,6 +258,53 @@ files = [ {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] +[[package]] +name = "mypy" +version = "1.8.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3"}, + {file = "mypy-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4"}, + {file = "mypy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d"}, + {file = "mypy-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9"}, + {file = "mypy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410"}, + {file = "mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae"}, + {file = "mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3"}, + {file = "mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817"}, + {file = "mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d"}, + {file = "mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835"}, + {file = "mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd"}, + {file = "mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55"}, + {file = "mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218"}, + {file = "mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3"}, + {file = "mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e"}, + {file = "mypy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6"}, + {file = "mypy-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66"}, + {file = "mypy-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6"}, + {file = "mypy-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d"}, + {file = "mypy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02"}, + {file = "mypy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8"}, + {file = "mypy-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259"}, + {file = "mypy-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b"}, + {file = "mypy-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592"}, + {file = "mypy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a"}, + {file = "mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d"}, + {file = "mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=4.1.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + [[package]] name = "mypy-extensions" version = "1.0.0" @@ -408,4 +455,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "10cfd78a5f9721e054af5a324767795ab9e242f72263d293b9603be5d62b9be7" +content-hash = "fb93b80365f23338f28d1ee6ad412264de01fe1eb14f4ee4fe316d4bc4b196d5" diff --git a/api/python/pyproject.toml b/api/python/pyproject.toml index a525641..d5b6e23 100644 --- a/api/python/pyproject.toml +++ b/api/python/pyproject.toml @@ -20,6 +20,7 @@ python-dateutil = "^2.8.0" [tool.poetry.group.dev.dependencies] pylint = "^3.1.0" black = "^24.2.0" +mypy = "^1.8.0" [build-system] requires = ["poetry-core>=1.0.0"] From 552859b6e1994ee78384fde20991d18d879efcce Mon Sep 17 00:00:00 2001 From: Matej Horak Date: Fri, 8 Mar 2024 13:31:14 +0100 Subject: [PATCH 4/6] refactor(api-python-sdk): improve auth flow --- api/python/airthings_sdk/parser.py | 183 ++++++++++++----------------- 1 file changed, 78 insertions(+), 105 deletions(-) diff --git a/api/python/airthings_sdk/parser.py b/api/python/airthings_sdk/parser.py index b6a9890..6e182d3 100644 --- a/api/python/airthings_sdk/parser.py +++ b/api/python/airthings_sdk/parser.py @@ -7,7 +7,7 @@ from httpx import AsyncClient, TimeoutException -from airthings_api_client import Client +from airthings_api_client import Client, AuthenticatedClient from airthings_api_client.api.accounts import get_accounts_ids from airthings_api_client.api.device import get_devices from airthings_api_client.api.sensor import get_multiple_sensors @@ -19,20 +19,10 @@ from airthings_api_client.models.sensors_response import SensorsResponse AUTH_URL = "https://accounts-api.airthings.com/v1/token" +API_URL = "https://consumer-api.airthings.com" logger = logging.getLogger(__name__) - -class RateLimitError(Exception): - """Rate limit exceeded""" - - def __init__(self, status_code: int, content: bytes): - self.status_code = status_code - self.content = content - - super().__init__(f"Rate limit exceeded: {status_code} {content}") - - @dataclass class AirthingsSensor: """Representation of Airthings device sensor.""" @@ -93,31 +83,25 @@ def init_from_device_response( class AirthingsToken: """Representation of an Airthings API token.""" - _access_token: Optional[str] + value: Optional[str] _expires: Optional[int] - def __init__( - self, - ): - self._access_token = None + def __init__(self): + self.value = None self._expires = None def set_token(self, access_token: str, expires_in: int): """Set the token and its expiration time.""" - self._access_token = access_token + self.value = access_token self._expires = expires_in + int(time.time()) def is_valid(self) -> bool: """Check if the token is valid.""" - return ( - self._access_token - and self._expires - and self._expires > (int(time.time()) + 20) - ) + return self.value and self._expires and self._expires > (int(time.time()) + 20) def as_header(self) -> dict[str, str]: """Return the token as a header.""" - return {"Authorization": f"Bearer {self._access_token}"} + return {"Authorization": f"Bearer {self.value}"} class Airthings: @@ -127,10 +111,12 @@ class Airthings: _client_id: str _client_secret: str _unit: GetMultipleSensorsUnit - _websession: Optional[AsyncClient] _access_token: AirthingsToken devices: List[AirthingsDevice] + _authApiClient: Client + _apiClient: AuthenticatedClient + def __init__( self, client_id: str, @@ -146,78 +132,73 @@ def __init__( if is_metric else GetMultipleSensorsUnit.IMPERIAL ) - self._websession = websession self._access_token = AirthingsToken() self._devices = {} - def _authenticate(self): - with Client( - base_url="https://accounts-api.airthings.com", - # timeout=10, - ) as client: - if websession := self._websession: - client.set_async_httpx_client(websession) - auth_response = client.get_httpx_client().request( - url=AUTH_URL, - method="POST", - data={ - "grant_type": "client_credentials", - "client_id": self._client_id, - "client_secret": self._client_secret, - }, + self._authClient = Client(base_url="https://accounts-api.airthings.com") + self._apiClient = AuthenticatedClient( + base_url=API_URL, token="invalid_token" # Should authenticate before using + ) + if websession: + self._authClient.set_async_httpx_client(websession) + self._apiClient.set_async_httpx_client(websession) + + def _verify_auth(self): + """Authenticate to Airthings API and set the access token and updates internal api clients.""" + + if self._access_token.is_valid(): + return + + auth_response = self._authClient.get_httpx_client().request( + url=AUTH_URL, + method="POST", + data={ + "grant_type": "client_credentials", + "client_id": self._client_id, + "client_secret": self._client_secret, + }, + ) + + if access_token := auth_response.json().get("access_token"): + self._access_token.set_token( + access_token=access_token, + expires_in=int(auth_response.json()["expires_in"]), ) - if access_token := auth_response.json().get("access_token"): - self._access_token.set_token( - access_token=access_token, - expires_in=int(auth_response.json()["expires_in"]), - ) - else: - raise ValueError("No access token found") + print(self._access_token.value) + self._apiClient.token = self._access_token.value + else: + raise ValueError("No access token found") def update_devices(self) -> Optional[dict[str, AirthingsDevice]]: """Update devices and sensors from Airthings API. Return a dict of devices.""" - if not self._access_token.is_valid(): - try: - self._authenticate() - except Exception as e: # pylint: disable=broad-except - logging.error("Error while authenticating: %s", e) - return None - - with Client( - base_url="https://consumer-api.airthings.com", - # timeout=10, - verify_ssl=True, - ) as client: - if websession := self._websession: - client.set_async_httpx_client(websession) - client.with_headers(self._access_token.as_header()) - logging.info("Client setup complete") - - accounts = self.fetch_all_accounts(client) - if not accounts: - logging.error("No accounts found") - return None - - logging.info("Accounts found: %s", len(accounts)) - for account in accounts: - logging.info("Account: %s", account) - - devices = self.fetch_all_devices(account_id=account.id, client=client) - - logging.info("%s devices found in account %s", len(devices), account.id) - logging.info("Devices: %s", devices) - - sensors = self.fetch_all_device_sensors( - account_id=account.id, unit=self._unit, client=client - ) - if not sensors: - logging.error("No sensors found in account %s", account.id) - break - logging.info("%s sensors found in account %s", len(sensors), account.id) - logging.info("Sensors: %s,", sensors) - logging.info("Pages: %s", sensors) - - res = {} + self._verify_auth() + + accounts = self.fetch_all_accounts() + + if not accounts: + logging.error("No accounts found") + return None + + res = {} + logging.info("Accounts found: %s", len(accounts)) + for account in accounts: + logging.info("Account: %s", account) + + devices = self.fetch_all_devices(account_id=account.id) + + logging.info("%s devices found in account %s", len(devices), account.id) + logging.info("Devices: %s", devices) + + sensors = self.fetch_all_device_sensors( + account_id=account.id, unit=self._unit + ) + if not sensors: + logging.error("No sensors found in account %s", account.id) + break + logging.info("%s sensors found in account %s", len(sensors), account.id) + logging.info("Sensors: %s,", sensors) + logging.info("Pages: %s", sensors) + for device in devices: for sensor in sensors: if device.serial_number == sensor.serial_number: @@ -225,17 +206,13 @@ def update_devices(self) -> Optional[dict[str, AirthingsDevice]]: AirthingsDevice.init_from_device_response(device, sensor) ) - logger.info("Mapped devices: %s", res) - return res + logger.info("Mapped devices: %s", res) + return res - def fetch_all_accounts( - self, client: Client - ) -> List[AccountResponse]: + def fetch_all_accounts(self) -> List[AccountResponse]: """Fetch accounts for the given client""" try: - accounts_response = get_accounts_ids.sync_detailed( - client=client, - ) + accounts_response = get_accounts_ids.sync_detailed(client=self._apiClient) if accounts := accounts_response.parsed: return accounts.accounts return [] @@ -249,14 +226,12 @@ def fetch_all_accounts( logging.error("Error while fetching accounts: %s", e) return [] - def fetch_all_devices( - self, account_id: str, client: Client - ) -> List[DeviceResponse]: + def fetch_all_devices(self, account_id: str) -> List[DeviceResponse]: """Fetch devices for a given account""" try: sensors_response = get_devices.sync_detailed( account_id=account_id, - client=client, + client=self._apiClient, ) logger.info("Device headers: %s", sensors_response.headers) return sensors_response.parsed.devices @@ -270,7 +245,6 @@ def fetch_all_devices( def fetch_all_device_sensors( self, account_id: str, - client: Client, page_number: int = 1, unit: Optional[GetMultipleSensorsUnit] = None, ) -> List[SensorsResponse]: @@ -278,7 +252,7 @@ def fetch_all_device_sensors( try: sensors_response = get_multiple_sensors.sync_detailed( account_id=account_id, - client=client, + client=self._apiClient, page_number=page_number, unit=unit, ).parsed @@ -295,7 +269,6 @@ def fetch_all_device_sensors( account_id=account_id, page_number=page_number + 1, unit=unit, - client=client, ) except UnexpectedStatus as e: logging.error("Unexpected status while fetching sensors: %s", e) From 2cda6f75be909c3c44b7a164775f8bd7f88d4e8e Mon Sep 17 00:00:00 2001 From: Matej Horak Date: Fri, 8 Mar 2024 15:13:40 +0100 Subject: [PATCH 5/6] refactor(api-python-sdk): fix type issues --- .github/workflows/ci.yml | 4 +- api/python/airthings_sdk/parser.py | 193 +++++++++++++++-------------- 2 files changed, 105 insertions(+), 92 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0736ef..07f0e10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,5 +30,5 @@ jobs: - name: Check code style run: poetry run pylint airthings_sdk - # - name: Check types - # run: poetry run mypy airthings_sdk \ No newline at end of file + - name: Check types + run: poetry run mypy airthings_sdk \ No newline at end of file diff --git a/api/python/airthings_sdk/parser.py b/api/python/airthings_sdk/parser.py index 6e182d3..888b918 100644 --- a/api/python/airthings_sdk/parser.py +++ b/api/python/airthings_sdk/parser.py @@ -1,9 +1,9 @@ -"""Airthings API data handler.""" +"""Module providing an Airthings API SDK.""" import logging import time from dataclasses import dataclass, field -from typing import List, Optional +from typing import List, Optional, cast from httpx import AsyncClient, TimeoutException @@ -12,17 +12,18 @@ from airthings_api_client.api.device import get_devices from airthings_api_client.api.sensor import get_multiple_sensors from airthings_api_client.errors import UnexpectedStatus -from airthings_api_client.models import Error, AccountResponse +from airthings_api_client.models import Error, SensorResponseType0 from airthings_api_client.models.device_response import DeviceResponse from airthings_api_client.models.get_multiple_sensors_unit import GetMultipleSensorsUnit -from airthings_api_client.models.sensor_response_type_0 import SensorResponseType0 from airthings_api_client.models.sensors_response import SensorsResponse +from airthings_api_client.types import Unset, UNSET -AUTH_URL = "https://accounts-api.airthings.com/v1/token" +AUTH_URL = "https://accounts-api.airthings.com" API_URL = "https://consumer-api.airthings.com" logger = logging.getLogger(__name__) + @dataclass class AirthingsSensor: """Representation of Airthings device sensor.""" @@ -33,13 +34,17 @@ class AirthingsSensor: @classmethod def init_from_sensor_response( - cls, sensor_response: SensorResponseType0 - ) -> "AirthingsSensor": + cls, sensor_response: SensorResponseType0 | None | Unset + ): """Create an AirthingsSensor from a SensorResponseType0""" + + if sensor_response is None or isinstance(sensor_response, Unset): + return None + return cls( - sensor_type=sensor_response.sensor_type, - value=sensor_response.value, - unit=sensor_response.unit, + sensor_type=cast(str, sensor_response.sensor_type), + value=cast(float | int, sensor_response.value), + unit=cast(str, sensor_response.unit), ) @@ -48,9 +53,9 @@ class AirthingsDevice: """Representation of an Airthings device""" serial_number: str - name: str - home: str type: str + name: str + home: Optional[str] recorded: Optional[str] sensors: list[AirthingsSensor] = field(default_factory=list) @@ -59,36 +64,35 @@ def init_from_device_response( cls, device_response: DeviceResponse, sensors_response: SensorsResponse ) -> "AirthingsDevice": """Create an AirthingsDevice from a DeviceResponse and a SensorsResponse""" - sensors = [ - AirthingsSensor.init_from_sensor_response(sensor) - for sensor in sensors_response.sensors - ] - sensors.append( - AirthingsSensor( - sensor_type="battery", - value=sensors_response.battery_percentage, - unit="%", - ) + + mapped = map( + AirthingsSensor.init_from_sensor_response, sensors_response.sensors or [] ) + filtered = list(filter(lambda sensor: sensor is not None, mapped)) + + if sensors_response.battery_percentage is not None: + filtered.append( + AirthingsSensor( + sensor_type="battery", + value=cast(int, sensors_response.battery_percentage), + unit="%", + ) + ) return cls( - serial_number=device_response.serial_number, - name=device_response.name, - home=device_response.home, - type=device_response.type, - recorded=sensors_response.recorded, - sensors=sensors, + serial_number=cast(str, device_response.serial_number), + name=cast(str, device_response.name), + type=cast(str, device_response.type), + home=cast(str | None, device_response.home), + recorded=cast(str | None, sensors_response.recorded), + sensors=filtered, ) class AirthingsToken: """Representation of an Airthings API token.""" - value: Optional[str] - _expires: Optional[int] - - def __init__(self): - self.value = None - self._expires = None + value: Optional[str] = None + _expires: Optional[int] = None def set_token(self, access_token: str, expires_in: int): """Set the token and its expiration time.""" @@ -97,7 +101,11 @@ def set_token(self, access_token: str, expires_in: int): def is_valid(self) -> bool: """Check if the token is valid.""" - return self.value and self._expires and self._expires > (int(time.time()) + 20) + return ( + self.value is not None + and self._expires is not None + and self._expires > (int(time.time()) + 20) + ) def as_header(self) -> dict[str, str]: """Return the token as a header.""" @@ -107,15 +115,14 @@ def as_header(self) -> dict[str, str]: class Airthings: """Representation of Airthings API data handler.""" - _access_token: Optional[str] = None _client_id: str _client_secret: str _unit: GetMultipleSensorsUnit _access_token: AirthingsToken devices: List[AirthingsDevice] - _authApiClient: Client - _apiClient: AuthenticatedClient + _auth_api_client: Client + _api_client: AuthenticatedClient def __init__( self, @@ -123,7 +130,7 @@ def __init__( client_secret: str, is_metric: bool, websession: Optional[AsyncClient] = None, - ) -> "Airthings": + ): """Init Airthings data handler.""" self._client_id = client_id self._client_secret = client_secret @@ -133,24 +140,24 @@ def __init__( else GetMultipleSensorsUnit.IMPERIAL ) self._access_token = AirthingsToken() - self._devices = {} + self._devices: dict[str, AirthingsDevice] = {} - self._authClient = Client(base_url="https://accounts-api.airthings.com") - self._apiClient = AuthenticatedClient( + self._auth_api_client = Client(base_url=AUTH_URL) + self._api_client = AuthenticatedClient( base_url=API_URL, token="invalid_token" # Should authenticate before using ) if websession: - self._authClient.set_async_httpx_client(websession) - self._apiClient.set_async_httpx_client(websession) + self._auth_api_client.set_async_httpx_client(websession) + self._api_client.set_async_httpx_client(websession) - def _verify_auth(self): - """Authenticate to Airthings API and set the access token and updates internal api clients.""" + def verify_auth(self): + """Make sure the access token is valid. If not, fetch a new one.""" if self._access_token.is_valid(): return - auth_response = self._authClient.get_httpx_client().request( - url=AUTH_URL, + auth_response = self._auth_api_client.get_httpx_client().request( + url=AUTH_URL + "/v1/token", method="POST", data={ "grant_type": "client_credentials", @@ -164,58 +171,59 @@ def _verify_auth(self): access_token=access_token, expires_in=int(auth_response.json()["expires_in"]), ) - print(self._access_token.value) - self._apiClient.token = self._access_token.value + self._api_client.token = self._access_token.value else: raise ValueError("No access token found") - def update_devices(self) -> Optional[dict[str, AirthingsDevice]]: + def update_devices(self) -> dict[str, AirthingsDevice]: """Update devices and sensors from Airthings API. Return a dict of devices.""" - self._verify_auth() + self.verify_auth() - accounts = self.fetch_all_accounts() - - if not accounts: - logging.error("No accounts found") - return None + account_ids = self._fetch_all_accounts_ids() res = {} - logging.info("Accounts found: %s", len(accounts)) - for account in accounts: - logging.info("Account: %s", account) + logging.info("Accounts found: %s", len(account_ids)) + for account_id in account_ids: + logging.info("Account: %s", account_id) - devices = self.fetch_all_devices(account_id=account.id) + devices = self._fetch_all_devices(account_id=account_id) - logging.info("%s devices found in account %s", len(devices), account.id) + logging.info("%s devices found in account %s", len(devices), account_id) logging.info("Devices: %s", devices) - sensors = self.fetch_all_device_sensors( - account_id=account.id, unit=self._unit + sensors = self._fetch_all_device_sensors( + account_id=account_id, unit=self._unit ) if not sensors: - logging.error("No sensors found in account %s", account.id) + logging.error("No sensors found in account %s", account_id) break - logging.info("%s sensors found in account %s", len(sensors), account.id) + logging.info("%s sensors found in account %s", len(sensors), account_id) logging.info("Sensors: %s,", sensors) logging.info("Pages: %s", sensors) for device in devices: for sensor in sensors: - if device.serial_number == sensor.serial_number: - res[device.serial_number] = ( - AirthingsDevice.init_from_device_response(device, sensor) - ) + if device.serial_number != sensor.serial_number: + continue + res[cast(str, device.serial_number)] = ( + AirthingsDevice.init_from_device_response(device, sensor) + ) logger.info("Mapped devices: %s", res) return res - def fetch_all_accounts(self) -> List[AccountResponse]: + def _fetch_all_accounts_ids(self) -> List[str]: """Fetch accounts for the given client""" try: - accounts_response = get_accounts_ids.sync_detailed(client=self._apiClient) - if accounts := accounts_response.parsed: - return accounts.accounts - return [] + response = get_accounts_ids.sync_detailed(client=self._api_client).parsed + + if response is None: + return [] + return [ + account.id + for account in (response.accounts or []) + if isinstance(account.id, str) + ] except UnexpectedStatus as e: logging.error("Unexpected status while fetching accounts: %s", e) return [] @@ -226,15 +234,17 @@ def fetch_all_accounts(self) -> List[AccountResponse]: logging.error("Error while fetching accounts: %s", e) return [] - def fetch_all_devices(self, account_id: str) -> List[DeviceResponse]: + def _fetch_all_devices(self, account_id: str) -> List[DeviceResponse]: """Fetch devices for a given account""" try: sensors_response = get_devices.sync_detailed( - account_id=account_id, - client=self._apiClient, - ) - logger.info("Device headers: %s", sensors_response.headers) - return sensors_response.parsed.devices + account_id=account_id, client=self._api_client + ).parsed + + if sensors_response is None or isinstance(sensors_response, Unset): + return [] + + return sensors_response.devices or [] except UnexpectedStatus as e: logging.error("Unexpected status while fetching devices: %s", e) return [] @@ -242,7 +252,7 @@ def fetch_all_devices(self, account_id: str) -> List[DeviceResponse]: logging.error("Timeout while fetching devices: %s", e) return [] - def fetch_all_device_sensors( + def _fetch_all_device_sensors( self, account_id: str, page_number: int = 1, @@ -252,23 +262,26 @@ def fetch_all_device_sensors( try: sensors_response = get_multiple_sensors.sync_detailed( account_id=account_id, - client=self._apiClient, + client=self._api_client, page_number=page_number, - unit=unit, + unit=unit or UNSET, ).parsed - if sensors_response is Error or sensors_response is None: + if sensors_response is None or isinstance(sensors_response, Error): return [] - device_sensors = sensors_response.results + device_sensors = sensors_response.results or [] if sensors_response.has_next is False: return device_sensors - return device_sensors + self.fetch_all_device_sensors( - account_id=account_id, - page_number=page_number + 1, - unit=unit, + return ( + self._fetch_all_device_sensors( + account_id=account_id, + page_number=page_number + 1, + unit=unit, + ) + + device_sensors ) except UnexpectedStatus as e: logging.error("Unexpected status while fetching sensors: %s", e) From 4a586ff34566ae57d02aa048498247c5dec09f97 Mon Sep 17 00:00:00 2001 From: Matej Horak Date: Fri, 15 Mar 2024 12:45:54 +0100 Subject: [PATCH 6/6] refactor(api-python-sdk): introduce error handling --- api/python/airthings_sdk/__init__.py | 7 +- api/python/airthings_sdk/const.py | 4 + api/python/airthings_sdk/errors.py | 34 ++ api/python/airthings_sdk/mapper.py | 202 ++++++++++++ api/python/airthings_sdk/parser.py | 291 ------------------ api/python/airthings_sdk/types.py | 92 ++++++ .../examples/fetch_devices_and_sensors.py | 10 +- 7 files changed, 343 insertions(+), 297 deletions(-) create mode 100644 api/python/airthings_sdk/const.py create mode 100644 api/python/airthings_sdk/errors.py create mode 100644 api/python/airthings_sdk/mapper.py delete mode 100644 api/python/airthings_sdk/parser.py create mode 100644 api/python/airthings_sdk/types.py diff --git a/api/python/airthings_sdk/__init__.py b/api/python/airthings_sdk/__init__.py index a3d3f44..6dd6790 100644 --- a/api/python/airthings_sdk/__init__.py +++ b/api/python/airthings_sdk/__init__.py @@ -1,9 +1,14 @@ """ A client library for accessing Airthings for Consumer API """ -from .parser import Airthings, AirthingsDevice, AirthingsSensor +from .mapper import Airthings +from .types import AirthingsDevice, AirthingsSensor +from .errors import UnexpectedStatusError, ApiError, UnexpectedPayloadError __all__ = ( "Airthings", "AirthingsDevice", "AirthingsSensor", + "UnexpectedStatusError", + "ApiError", + "UnexpectedPayloadError", ) diff --git a/api/python/airthings_sdk/const.py b/api/python/airthings_sdk/const.py new file mode 100644 index 0000000..b0ec331 --- /dev/null +++ b/api/python/airthings_sdk/const.py @@ -0,0 +1,4 @@ +"""Module providing constants for the Airthings API SDK.""" + +AUTH_URL = "https://accounts-api.airthings.com" +API_URL = "https://consumer-api.airthings.com" diff --git a/api/python/airthings_sdk/errors.py b/api/python/airthings_sdk/errors.py new file mode 100644 index 0000000..3277b1a --- /dev/null +++ b/api/python/airthings_sdk/errors.py @@ -0,0 +1,34 @@ +"""Module providing an Airthings API SDK errors.""" + + +class UnexpectedStatusError(Exception): + """Unexpected status error.""" + + message = "Unexpected status code received from Airthings API." + + def __init__(self, status_code: int, content: bytes): + self.status_code = status_code + self.content = content.decode("utf-8") + super().__init__( + f"{self.message} Status code: {status_code}, content: {self.content}" + ) + + +class UnexpectedPayloadError(Exception): + """Unexpected payload error.""" + + message = "Unexpected payload received from Airthings API." + + def __init__(self, payload: bytes): + self.payload = payload.decode("utf-8") + super().__init__(f"{self.message} Payload: {self.payload}") + + +class ApiError(Exception): + """Airthings API error.""" + + message = "Received an error response from Airthings API." + + def __init__(self, error: str): + self.error = error + super().__init__(f"{self.message} Error: {error}") diff --git a/api/python/airthings_sdk/mapper.py b/api/python/airthings_sdk/mapper.py new file mode 100644 index 0000000..700a2c6 --- /dev/null +++ b/api/python/airthings_sdk/mapper.py @@ -0,0 +1,202 @@ +"""Module providing an Airthings API SDK.""" + +import logging +from typing import List, Optional + +from httpx import AsyncClient + +from airthings_api_client import Client, AuthenticatedClient +from airthings_api_client.api.accounts import get_accounts_ids +from airthings_api_client.api.device import get_devices +from airthings_api_client.api.sensor import get_multiple_sensors +from airthings_api_client.errors import UnexpectedStatus as LibUnexpectedStatus +from airthings_api_client.models import ( + Error, + GetMultipleSensorsResponse200, +) +from airthings_api_client.models.device_response import DeviceResponse +from airthings_api_client.models.get_multiple_sensors_unit import GetMultipleSensorsUnit +from airthings_api_client.models.sensors_response import SensorsResponse +from airthings_api_client.types import Unset +from airthings_sdk.const import AUTH_URL, API_URL +from airthings_sdk.errors import UnexpectedStatusError, UnexpectedPayloadError, ApiError +from airthings_sdk.types import AirthingsToken, AirthingsDevice + +logger = logging.getLogger(__name__) + + +class Airthings: + """Representation of Airthings API data handler.""" + + _client_id: str + _client_secret: str + + _unit: GetMultipleSensorsUnit + _access_token: AirthingsToken = AirthingsToken() + + _auth_api_client: Client = Client( + base_url=AUTH_URL, + raise_on_unexpected_status=True, + ) + _api_client: AuthenticatedClient = AuthenticatedClient( + base_url=API_URL, + token="invalid_token", # Should authenticate and update before using + raise_on_unexpected_status=True, + ) + + devices: dict[str, AirthingsDevice] = {} + + def __init__( + self, + client_id: str, + client_secret: str, + is_metric: bool, + web_session: Optional[AsyncClient] = None, + ): + """Init Airthings data handler.""" + self._client_id = client_id + self._client_secret = client_secret + self._unit = ( + GetMultipleSensorsUnit.METRIC + if is_metric + else GetMultipleSensorsUnit.IMPERIAL + ) + + if web_session: + self._auth_api_client.set_async_httpx_client(web_session) + self._api_client.set_async_httpx_client(web_session) + + def verify_auth(self): + """Make sure the access token is valid. If not, fetch a new one.""" + + if self._access_token.is_valid(): + return + + try: + auth_response = self._auth_api_client.get_httpx_client().request( + url=AUTH_URL + "/v1/token", + method="POST", + data={ + "grant_type": "client_credentials", + "client_id": self._client_id, + "client_secret": self._client_secret, + }, + ) + + access_token = auth_response.json().get("access_token") + expires_in = auth_response.json().get("expires_in") + + self._access_token.set_token( + access_token=access_token, expires_in=expires_in + ) + self._api_client.token = self._access_token.value + except LibUnexpectedStatus as e: + raise UnexpectedStatusError(e.status_code, e.content) from e + + def update_devices(self) -> dict[str, AirthingsDevice]: + """Update devices and sensors from Airthings API. Return a dict of devices.""" + logger.info("Fetching devices and sensors from Airthings API.") + + self.verify_auth() + + try: + account_ids = self._fetch_all_accounts_ids() + + res = {} + for account_id in account_ids: + + devices = self._fetch_all_devices(account_id=account_id) + + device_map = {} + for device in devices: + device_map[device.serial_number] = device + + sensors = self._fetch_all_device_sensors( + account_id=account_id, unit=self._unit + ) + + for sensor in sensors: + serial_number = sensor.serial_number + + if isinstance(serial_number, Unset): + continue + + sensor_device = device_map.get(serial_number) + if sensor_device is None: + continue + mapped = AirthingsDevice.from_response(sensor_device, sensor) + res[serial_number] = mapped + + self.devices = res + logger.info("Fetched %s devices and sensors from Airthings API.", len(res)) + return res + except LibUnexpectedStatus as e: + logger.error( + "Unexpected status code %s received when fetching devices and sensors.", + e.status_code, + ) + raise UnexpectedStatusError(e.status_code, e.content) from e + + def _fetch_all_accounts_ids(self) -> List[str]: + """Fetch accounts for the given client""" + response = get_accounts_ids.sync_detailed(client=self._api_client) + + payload = response.parsed + + if payload is None: + raise UnexpectedPayloadError(response.content) + + return [ + account.id + for account in (payload.accounts or []) + if isinstance(account.id, str) + ] + + def _fetch_all_devices(self, account_id: str) -> List[DeviceResponse]: + """Fetch devices for a given account""" + response = get_devices.sync_detailed( + account_id=account_id, client=self._api_client + ) + + payload = response.parsed + + if payload is None: + raise UnexpectedPayloadError(response.content) + + return payload.devices or [] + + def _fetch_all_device_sensors( + self, + account_id: str, + unit: GetMultipleSensorsUnit, + page_number: int = 1, + ) -> List[SensorsResponse]: + """Fetch sensors for a given account""" + response = get_multiple_sensors.sync_detailed( + account_id=account_id, + client=self._api_client, + page_number=page_number, + unit=unit, + ) + + payload = response.parsed + + if isinstance(payload, Error): + raise ApiError(payload.message or "Unknown error") + + if ( + payload is None + or isinstance(payload, GetMultipleSensorsResponse200) is False + ): + raise UnexpectedPayloadError(response.content) + + sensors = payload.results or [] + + if payload.has_next is not True: + return sensors + + return sensors + self._fetch_all_device_sensors( + account_id=account_id, + page_number=page_number + 1, + unit=unit, + ) diff --git a/api/python/airthings_sdk/parser.py b/api/python/airthings_sdk/parser.py deleted file mode 100644 index 888b918..0000000 --- a/api/python/airthings_sdk/parser.py +++ /dev/null @@ -1,291 +0,0 @@ -"""Module providing an Airthings API SDK.""" - -import logging -import time -from dataclasses import dataclass, field -from typing import List, Optional, cast - -from httpx import AsyncClient, TimeoutException - -from airthings_api_client import Client, AuthenticatedClient -from airthings_api_client.api.accounts import get_accounts_ids -from airthings_api_client.api.device import get_devices -from airthings_api_client.api.sensor import get_multiple_sensors -from airthings_api_client.errors import UnexpectedStatus -from airthings_api_client.models import Error, SensorResponseType0 -from airthings_api_client.models.device_response import DeviceResponse -from airthings_api_client.models.get_multiple_sensors_unit import GetMultipleSensorsUnit -from airthings_api_client.models.sensors_response import SensorsResponse -from airthings_api_client.types import Unset, UNSET - -AUTH_URL = "https://accounts-api.airthings.com" -API_URL = "https://consumer-api.airthings.com" - -logger = logging.getLogger(__name__) - - -@dataclass -class AirthingsSensor: - """Representation of Airthings device sensor.""" - - sensor_type: str - value: int | float - unit: str - - @classmethod - def init_from_sensor_response( - cls, sensor_response: SensorResponseType0 | None | Unset - ): - """Create an AirthingsSensor from a SensorResponseType0""" - - if sensor_response is None or isinstance(sensor_response, Unset): - return None - - return cls( - sensor_type=cast(str, sensor_response.sensor_type), - value=cast(float | int, sensor_response.value), - unit=cast(str, sensor_response.unit), - ) - - -@dataclass -class AirthingsDevice: - """Representation of an Airthings device""" - - serial_number: str - type: str - name: str - home: Optional[str] - recorded: Optional[str] - sensors: list[AirthingsSensor] = field(default_factory=list) - - @classmethod - def init_from_device_response( - cls, device_response: DeviceResponse, sensors_response: SensorsResponse - ) -> "AirthingsDevice": - """Create an AirthingsDevice from a DeviceResponse and a SensorsResponse""" - - mapped = map( - AirthingsSensor.init_from_sensor_response, sensors_response.sensors or [] - ) - filtered = list(filter(lambda sensor: sensor is not None, mapped)) - - if sensors_response.battery_percentage is not None: - filtered.append( - AirthingsSensor( - sensor_type="battery", - value=cast(int, sensors_response.battery_percentage), - unit="%", - ) - ) - return cls( - serial_number=cast(str, device_response.serial_number), - name=cast(str, device_response.name), - type=cast(str, device_response.type), - home=cast(str | None, device_response.home), - recorded=cast(str | None, sensors_response.recorded), - sensors=filtered, - ) - - -class AirthingsToken: - """Representation of an Airthings API token.""" - - value: Optional[str] = None - _expires: Optional[int] = None - - def set_token(self, access_token: str, expires_in: int): - """Set the token and its expiration time.""" - self.value = access_token - self._expires = expires_in + int(time.time()) - - def is_valid(self) -> bool: - """Check if the token is valid.""" - return ( - self.value is not None - and self._expires is not None - and self._expires > (int(time.time()) + 20) - ) - - def as_header(self) -> dict[str, str]: - """Return the token as a header.""" - return {"Authorization": f"Bearer {self.value}"} - - -class Airthings: - """Representation of Airthings API data handler.""" - - _client_id: str - _client_secret: str - _unit: GetMultipleSensorsUnit - _access_token: AirthingsToken - devices: List[AirthingsDevice] - - _auth_api_client: Client - _api_client: AuthenticatedClient - - def __init__( - self, - client_id: str, - client_secret: str, - is_metric: bool, - websession: Optional[AsyncClient] = None, - ): - """Init Airthings data handler.""" - self._client_id = client_id - self._client_secret = client_secret - self._unit = ( - GetMultipleSensorsUnit.METRIC - if is_metric - else GetMultipleSensorsUnit.IMPERIAL - ) - self._access_token = AirthingsToken() - self._devices: dict[str, AirthingsDevice] = {} - - self._auth_api_client = Client(base_url=AUTH_URL) - self._api_client = AuthenticatedClient( - base_url=API_URL, token="invalid_token" # Should authenticate before using - ) - if websession: - self._auth_api_client.set_async_httpx_client(websession) - self._api_client.set_async_httpx_client(websession) - - def verify_auth(self): - """Make sure the access token is valid. If not, fetch a new one.""" - - if self._access_token.is_valid(): - return - - auth_response = self._auth_api_client.get_httpx_client().request( - url=AUTH_URL + "/v1/token", - method="POST", - data={ - "grant_type": "client_credentials", - "client_id": self._client_id, - "client_secret": self._client_secret, - }, - ) - - if access_token := auth_response.json().get("access_token"): - self._access_token.set_token( - access_token=access_token, - expires_in=int(auth_response.json()["expires_in"]), - ) - self._api_client.token = self._access_token.value - else: - raise ValueError("No access token found") - - def update_devices(self) -> dict[str, AirthingsDevice]: - """Update devices and sensors from Airthings API. Return a dict of devices.""" - self.verify_auth() - - account_ids = self._fetch_all_accounts_ids() - - res = {} - logging.info("Accounts found: %s", len(account_ids)) - for account_id in account_ids: - logging.info("Account: %s", account_id) - - devices = self._fetch_all_devices(account_id=account_id) - - logging.info("%s devices found in account %s", len(devices), account_id) - logging.info("Devices: %s", devices) - - sensors = self._fetch_all_device_sensors( - account_id=account_id, unit=self._unit - ) - if not sensors: - logging.error("No sensors found in account %s", account_id) - break - logging.info("%s sensors found in account %s", len(sensors), account_id) - logging.info("Sensors: %s,", sensors) - logging.info("Pages: %s", sensors) - - for device in devices: - for sensor in sensors: - if device.serial_number != sensor.serial_number: - continue - res[cast(str, device.serial_number)] = ( - AirthingsDevice.init_from_device_response(device, sensor) - ) - - logger.info("Mapped devices: %s", res) - return res - - def _fetch_all_accounts_ids(self) -> List[str]: - """Fetch accounts for the given client""" - try: - response = get_accounts_ids.sync_detailed(client=self._api_client).parsed - - if response is None: - return [] - return [ - account.id - for account in (response.accounts or []) - if isinstance(account.id, str) - ] - except UnexpectedStatus as e: - logging.error("Unexpected status while fetching accounts: %s", e) - return [] - except TimeoutException as e: - logging.error("Timeout while fetching accounts: %s", e) - return [] - except Exception as e: # pylint: disable=broad-except - logging.error("Error while fetching accounts: %s", e) - return [] - - def _fetch_all_devices(self, account_id: str) -> List[DeviceResponse]: - """Fetch devices for a given account""" - try: - sensors_response = get_devices.sync_detailed( - account_id=account_id, client=self._api_client - ).parsed - - if sensors_response is None or isinstance(sensors_response, Unset): - return [] - - return sensors_response.devices or [] - except UnexpectedStatus as e: - logging.error("Unexpected status while fetching devices: %s", e) - return [] - except TimeoutException as e: - logging.error("Timeout while fetching devices: %s", e) - return [] - - def _fetch_all_device_sensors( - self, - account_id: str, - page_number: int = 1, - unit: Optional[GetMultipleSensorsUnit] = None, - ) -> List[SensorsResponse]: - """Fetch sensors for a given account""" - try: - sensors_response = get_multiple_sensors.sync_detailed( - account_id=account_id, - client=self._api_client, - page_number=page_number, - unit=unit or UNSET, - ).parsed - - if sensors_response is None or isinstance(sensors_response, Error): - return [] - - device_sensors = sensors_response.results or [] - - if sensors_response.has_next is False: - return device_sensors - - return ( - self._fetch_all_device_sensors( - account_id=account_id, - page_number=page_number + 1, - unit=unit, - ) - + device_sensors - ) - except UnexpectedStatus as e: - logging.error("Unexpected status while fetching sensors: %s", e) - return [] - except TimeoutException as e: - logging.error("Timeout while fetching sensors: %s", e) - return [] diff --git a/api/python/airthings_sdk/types.py b/api/python/airthings_sdk/types.py new file mode 100644 index 0000000..7a48b8a --- /dev/null +++ b/api/python/airthings_sdk/types.py @@ -0,0 +1,92 @@ +"""Airthings API SDK types.""" + +import time +from dataclasses import dataclass, field +from typing import Optional, cast + +from airthings_api_client.models import ( + SensorResponseType0, + DeviceResponse, + SensorsResponse, +) +from airthings_api_client.types import Unset + + +@dataclass +class AirthingsSensor: + """Representation of Airthings device sensor.""" + + sensor_type: str + value: int | float + unit: str + + @classmethod + def from_response(cls, sensor_response: SensorResponseType0 | None | Unset): + """Create an AirthingsSensor from a SensorResponseType0""" + + if sensor_response is None or isinstance(sensor_response, Unset): + return None + + return cls( + sensor_type=cast(str, sensor_response.sensor_type), + value=cast(float | int, sensor_response.value), + unit=cast(str, sensor_response.unit), + ) + + +@dataclass +class AirthingsDevice: + """Representation of an Airthings device""" + + serial_number: str + type: str + name: str + home: Optional[str] + recorded: Optional[str] + sensors: list[AirthingsSensor] = field(default_factory=list) + + @classmethod + def from_response( + cls, device_response: DeviceResponse, sensors_response: SensorsResponse + ) -> "AirthingsDevice": + """Create an AirthingsDevice from a DeviceResponse and a SensorsResponse""" + + mapped = map(AirthingsSensor.from_response, sensors_response.sensors or []) + filtered = list(filter(lambda sensor: sensor is not None, mapped)) + + if sensors_response.battery_percentage is not None: + filtered.append( + AirthingsSensor( + sensor_type="battery", + value=cast(int, sensors_response.battery_percentage), + unit="%", + ) + ) + return cls( + serial_number=cast(str, device_response.serial_number), + name=cast(str, device_response.name), + type=cast(str, device_response.type), + home=cast(str | None, device_response.home), + recorded=cast(str | None, sensors_response.recorded), + sensors=filtered, + ) + + +class AirthingsToken: + """Representation of an Airthings API token.""" + + value: Optional[str] = None + _expires: Optional[int] = None + + def set_token(self, access_token: str, expires_in: int): + """Set the token and its expiration time.""" + self.value = access_token + self._expires = expires_in + int(time.time()) + + def is_valid(self) -> bool: + """Check if the token is valid.""" + return ( + self.value is not None + and self._expires is not None + and self._expires > (int(time.time()) + 20) + ) diff --git a/api/python/examples/fetch_devices_and_sensors.py b/api/python/examples/fetch_devices_and_sensors.py index b1e5029..c80e84e 100644 --- a/api/python/examples/fetch_devices_and_sensors.py +++ b/api/python/examples/fetch_devices_and_sensors.py @@ -1,3 +1,5 @@ +""" Example of fetching devices and sensors from Airthings API. """ + import sys from airthings_sdk import Airthings @@ -6,15 +8,13 @@ print("Please add client id and client secret as parameters.") print("Usage:") print("python fetch_devices_and_sensors.py ") - exit(1) + sys.exit(1) client_id = sys.argv[1] client_secret = sys.argv[2] airthings = Airthings( - client_id=client_id, - client_secret=client_secret, - is_metric=True, + client_id=client_id, client_secret=client_secret, is_metric=True ) devices = airthings.update_devices() - print(devices) + print(airthings.devices)