From 39675c5fc1c889af16ce975ba6e375514c6d4b6f Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Wed, 27 Dec 2023 21:02:45 +0100 Subject: [PATCH 1/4] use asyncio instead of requests --- .gitignore | 2 +- README.md | 69 ++++- setup.cfg | 2 +- src/python_bring_api/bring.py | 512 +++++++++++++++++++++++++++------- 4 files changed, 465 insertions(+), 120 deletions(-) diff --git a/.gitignore b/.gitignore index 24f9809..e94ccca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ dist/ src/python_bring_api.egg-info/ -src/test.py +src/test*.py HOW-TO-UPLOAD.md HOW-TO-TEST.md test/ diff --git a/README.md b/README.md index 9f7db95..bc8a0e6 100644 --- a/README.md +++ b/README.md @@ -14,30 +14,73 @@ The developers of this module are in no way endorsed by or affiliated with Bring ## Usage Example +The API is available both sync and async, where sync is the default due to simplicity and avoid breaking changes. Both implementation use the same async library `aiohttp` in the back. + +### Sync + +```python +import logging +import sys + +from python_bring_api.bring import Bring + +logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) + +async def main(): + # Create Bring instance with email and password + bring = Bring("MAIL", "PASSWORD") + # Login + bring.login() + + # Get information about all available shopping lists + lists = bring.loadLists()["lists"] + + # Save an item with specifications to a certain shopping list + bring.saveItem(lists[0]['listUuid'], 'Milk', 'low fat') + + # Get all the items of a list + items = bring.getItems(lists[0]['listUuid']) + print(items) + + # Remove an item from a list + bring.removeItem(lists[0]['listUuid'], 'Milk') + +asyncio.run(main()) +``` + +### Async + ```python +import aiohttp +import asyncio import logging import sys + from python_bring_api.bring import Bring logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) -# Create Bring instance with email and password -bring = Bring("EMAIL", "PASSWORD") -# Login -bring.login() +async def main(): + async with aiohttp.ClientSession() as session: + # Create Bring instance with email and password + bring = Bring("MAIL", "PASSWORD", sessionAsync=session) + # Login + await bring.loginAsync() + + # Get information about all available shopping lists + lists = (await bring.loadListsAsync())["lists"] -# Get information about all available shopping lists -lists = bring.loadLists()["lists"] + # Save an item with specifications to a certain shopping list + await bring.saveItemAsync(lists[0]['listUuid'], 'Milk', 'low fat') -# Save an item with specifications to a certain shopping list -bring.saveItem(lists[0]['listUuid'], 'Milk', 'low fat') + # Get all the items of a list + items = await bring.getItemsAsync(lists[0]['listUuid']) + print(items) -# Get all the items of a list -items = bring.getItems(lists[0]['listUuid']) -print(items['purchase']) # [{'specification': 'low fat', 'name': 'Milk'}] + # Remove an item from a list + await bring.removeItemAsync(lists[0]['listUuid'], 'Milk') -# Remove an item from a list -bring.removeItem(lists[0]['listUuid'], 'Milk') +asyncio.run(main()) ``` ## Exceptions diff --git a/setup.cfg b/setup.cfg index 7b4cdcd..a9ed9a4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,7 +19,7 @@ package_dir = packages = find: python_requires = >=3.8 install_requires = - requests + aiohttp [options.packages.find] where = src diff --git a/src/python_bring_api/bring.py b/src/python_bring_api/bring.py index 2206e1e..a175c62 100644 --- a/src/python_bring_api/bring.py +++ b/src/python_bring_api/bring.py @@ -1,12 +1,11 @@ -import requests +from json import JSONDecodeError +import aiohttp +import asyncio import traceback -from requests.exceptions import RequestException -from requests.exceptions import JSONDecodeError -from requests.models import Response from typing import Dict from .types import BringNotificationType, BringAuthResponse, BringItemsResponse, BringListResponse, BringListItemsDetailsResponse -from .exceptions import BringAuthException, BringRequestException, BringParseException +from .exceptions import BringRequestException, BringAuthException, BringRequestException, BringParseException import logging @@ -17,7 +16,9 @@ class Bring: Unofficial Bring API interface. """ - def __init__(self, mail: str, password: str, headers: Dict[str, str] = None) -> None: + def __init__(self, mail: str, password: str, headers: Dict[str, str] = None, sessionAsync: aiohttp.ClientSession = None) -> None: + self._session = sessionAsync + self.mail = mail self.password = password self.uuid = '' @@ -45,7 +46,16 @@ def __init__(self, mail: str, password: str, headers: Dict[str, str] = None) -> 'X-BRING-USER-UUID': '', 'Content-Type': '' } - + self.postHeaders = { + 'Authorization': '', + 'X-BRING-API-KEY': '', + 'X-BRING-CLIENT-SOURCE': '', + 'X-BRING-CLIENT': '', + 'X-BRING-COUNTRY': '', + 'X-BRING-USER-UUID': '', + 'Content-Type': '' + } + def login(self) -> BringAuthResponse: """ @@ -56,6 +66,33 @@ def login(self) -> BringAuthResponse: Response The server response object. + Raises + ------ + BringRequestException + If the request fails. + BringParseException + If the parsing of the request response fails. + BringAuthException + If the login fails due to missing data in the API response. + You should check your email and password. + """ + async def _async(): + async with aiohttp.ClientSession() as session: + self._session = session + res = await self.loginAsync() + self._session = None + return res + return asyncio.run(_async()) + + async def loginAsync(self) -> BringAuthResponse: + """ + Try to login. + + Returns + ------- + Response + The server response object. + Raises ------ BringRequestException @@ -70,30 +107,23 @@ def login(self) -> BringAuthResponse: 'email': self.mail, 'password': self.password } - try: - r = requests.post(f'{self.url}bringauth', data=data) - r.raise_for_status() - except RequestException as e: - if e.response.status_code == 401: - try: - errmsg = e.response.json() - except JSONDecodeError: - _LOGGER.error(f'Exception: Cannot parse login request response:\n{traceback.format_exc()}') - else: - _LOGGER.error(f'Exception: Cannot login: {errmsg["message"]}') - raise BringAuthException('Login failed due to authorization failure, please check your email and password.') from e - elif e.response.status_code == 400: - _LOGGER.error(f'Exception: Cannot login: {e.response.text}') - raise BringAuthException('Login failed due to bad request, please check your email.') from e - else: - _LOGGER.error(f'Exception: Cannot login:\n{traceback.format_exc()}') - raise BringRequestException(f'Authentication failed due to request exception.') from e try: - data = r.json() - except JSONDecodeError as e: - _LOGGER.error(f'Exception: Cannot login:\n{traceback.format_exc()}') - raise BringParseException(f'Cannot parse login request response.') from e + url = f'{self.url}bringauth' + async with self._session.post(url, data=data, raise_for_status=True) as r: + _LOGGER.debug(f"Response from %s: %s", url, r.status) + r.raise_for_status() + try: + data = await r.json() + except JSONDecodeError as e: + _LOGGER.error(f'Exception: Cannot login:\n{traceback.format_exc()}') + raise BringParseException(f'Cannot parse login request response.') from e + except asyncio.TimeoutError as e: + _LOGGER.error('Exception: Cannot login:\n{traceback.format_exc()}') + raise BringRequestException(f"Authentication failed due to connection timeout") from e + except aiohttp.ClientError as e: + _LOGGER.error('Exception: Cannot login:\n{traceback.format_exc()}') + raise BringRequestException(f"Authentication failed due to request exception") from e if 'uuid' not in data or 'access_token' not in data: _LOGGER.error(f'Exception: Cannot login: Data missing in API response.') @@ -104,24 +134,23 @@ def login(self) -> BringAuthResponse: self.headers['X-BRING-USER-UUID'] = self.uuid self.headers['Authorization'] = f'Bearer {data["access_token"]}' self.putHeaders = { - 'Authorization': self.headers['Authorization'], - 'X-BRING-API-KEY': self.headers['X-BRING-API-KEY'], - 'X-BRING-CLIENT-SOURCE': self.headers['X-BRING-CLIENT-SOURCE'], - 'X-BRING-CLIENT': self.headers['X-BRING-CLIENT'], - 'X-BRING-COUNTRY': self.headers['X-BRING-COUNTRY'], - 'X-BRING-USER-UUID': self.headers['X-BRING-USER-UUID'], + **self.headers, 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' } + self.postHeaders = { + **self.headers, + 'Content-Type': 'application/json; charset=UTF-8' + } return r - def loadLists(self) -> BringListResponse: """Load all shopping lists. Returns ------- dict - The JSON response as a dict. + + The JSON response as a dict. Raises ------ @@ -130,19 +159,46 @@ def loadLists(self) -> BringListResponse: BringParseException If the parsing of the request response fails. """ - try: - r = requests.get(f'{self.url}bringusers/{self.uuid}/lists', headers=self.headers) - r.raise_for_status() - except RequestException as e: - _LOGGER.error(f'Exception: Cannot get lists:\n{traceback.format_exc()}') - raise BringRequestException(f'Loading lists failed due to request exception.') from e - - try: - return r.json() - except JSONDecodeError as e: - _LOGGER.error(f'Exception: Cannot get lists:\n{traceback.format_exc()}') - raise BringParseException(f'Loading lists failed during parsing of request response.') from e + async def _async(): + async with aiohttp.ClientSession() as session: + self._session = session + res = await self.loadListsAsync() + self._session = None + return res + return asyncio.run(_async()) + + async def loadListsAsync(self) -> BringListResponse: + """Load all shopping lists. + Returns + ------- + dict + + The JSON response as a dict. + + Raises + ------ + BringRequestException + If the request fails. + BringParseException + If the parsing of the request response fails. + """ + try: + url = f'{self.url}bringusers/{self.uuid}/lists' + async with self._session.get(url, headers=self.headers, raise_for_status=True) as r: + _LOGGER.debug(f"Response from %s: %s", url, r.status) + r.raise_for_status() + try: + return await r.json() + except JSONDecodeError as e: + _LOGGER.error(f'Exception: Cannot get lists:\n{traceback.format_exc()}') + raise BringParseException(f'Loading lists failed during parsing of request response.') from e + except asyncio.TimeoutError as e: + _LOGGER.error('Exception: Cannot get lists:\n{traceback.format_exc()}') + raise BringRequestException(f"Loading list failed due to connection timeout") from e + except aiohttp.ClientError as e: + _LOGGER.error('Exception: Cannot get lists:\n{traceback.format_exc()}') + raise BringRequestException(f"Loading lists failed due to request exception") from e def getItems(self, listUuid: str) -> BringItemsResponse: """ @@ -165,18 +221,51 @@ def getItems(self, listUuid: str) -> BringItemsResponse: BringParseException If the parsing of the request response fails. """ - try: - r = requests.get(f'{self.url}bringlists/{listUuid}', headers = self.headers) - r.raise_for_status() - except RequestException as e: - _LOGGER.error(f'Exception: Cannot get items for list {listUuid}:\n{traceback.format_exc()}') - raise BringRequestException(f'Loading list items failed due to request exception.') from e + async def _async(): + async with aiohttp.ClientSession() as session: + self._session = session + res = await self.getItemsAsync(listUuid) + self._session = None + return res + return asyncio.run(_async()) + + async def getItemsAsync(self, listUuid: str) -> BringItemsResponse: + """ + Get all items from a shopping list. + + Parameters + ---------- + listUuid : str + A list uuid returned by loadLists() + + Returns + ------- + dict + The JSON response as a dict. + Raises + ------ + BringRequestException + If the request fails. + BringParseException + If the parsing of the request response fails. + """ try: - return r.json() - except JSONDecodeError as e: + url = f'{self.url}bringlists/{listUuid}' + async with self._session.get(url, headers=self.headers, raise_for_status=True) as r: + _LOGGER.debug(f"Response from %s: %s", url, r.status) + r.raise_for_status() + try: + return await r.json() + except JSONDecodeError as e: + _LOGGER.error(f'Exception: Cannot get items for list {listUuid}:\n{traceback.format_exc()}') + raise BringParseException(f'Loading list items failed during parsing of request response.') from e + except asyncio.TimeoutError as e: _LOGGER.error(f'Exception: Cannot get items for list {listUuid}:\n{traceback.format_exc()}') - raise BringParseException(f'Loading list items failed during parsing of request response.') from e + raise BringRequestException(f"Loading list items failed due to connection timeout") from e + except aiohttp.ClientError as e: + _LOGGER.error(f'Exception: Cannot get items for list {listUuid}:\n{traceback.format_exc()}') + raise BringRequestException(f"Loading list items failed due to request exception") from e def getAllItemDetails(self, listUuid: str) -> BringListItemsDetailsResponse: @@ -201,21 +290,85 @@ def getAllItemDetails(self, listUuid: str) -> BringListItemsDetailsResponse: BringParseException If the parsing of the request response fails. """ - try: - r = requests.get(f'{self.url}bringlists/{listUuid}/details', headers = self.headers) - r.raise_for_status() - except RequestException as e: - _LOGGER.error(f'Exception: Cannot get item details for list {listUuid}:\n{traceback.format_exc()}') - raise BringRequestException(f'Loading list item details failed due to request exception.') from e + async def _async(): + async with aiohttp.ClientSession() as session: + self._session = session + res = await self.getAllItemDetailsAsync(listUuid) + self._session = None + return res + return asyncio.run(_async()) + + async def getAllItemDetailsAsync(self, listUuid: str) -> BringItemsResponse: + """ + Get all details from a shopping list. + + Parameters + ---------- + listUuid : str + A list uuid returned by loadLists() + + Returns + ------- + list + The JSON response as a list. A list of item details. + Caution: This is NOT a list of the items currently marked as 'to buy'. See getItems() for that. + Raises + ------ + BringRequestException + If the request fails. + BringParseException + If the parsing of the request response fails. + """ try: - return r.json() - except JSONDecodeError as e: + url = f'{self.url}bringlists/{listUuid}/details' + async with self._session.get(url, headers=self.headers, raise_for_status=True) as r: + _LOGGER.debug(f"Response from %s: %s", url, r.status) + r.raise_for_status() + try: + return await r.json() + except JSONDecodeError as e: + _LOGGER.error(f'Exception: Cannot get item details for list {listUuid}:\n{traceback.format_exc()}') + raise BringParseException(f'Loading list item details failed during parsing of request response.') from e + except asyncio.TimeoutError as e: + _LOGGER.error(f'Exception: Cannot get item details for list {listUuid}:\n{traceback.format_exc()}') + raise BringRequestException(f"Loading list item details failed due to connection timeout") from e + except aiohttp.ClientError as e: _LOGGER.error(f'Exception: Cannot get item details for list {listUuid}:\n{traceback.format_exc()}') - raise BringParseException(f'Loading list item details failed during parsing of request response.') from e + raise BringRequestException(f"Loading list item details failed due to request exception") from e + + def saveItem(self, listUuid: str, itemName: str, specification='') -> aiohttp.ClientResponse: + """ + Save an item to a shopping list. + Parameters + ---------- + listUuid : str + A list uuid returned by loadLists() + itemName : str + The name of the item you want to save. + specification : str, optional + The details you want to add to the item. + + Returns + ------- + Response + The server response object. - def saveItem(self, listUuid: str, itemName: str, specification: str = '') -> Response: + Raises + ------ + BringRequestException + If the request fails. + """ + async def _async(): + async with aiohttp.ClientSession() as session: + self._session = session + res = await self.saveItemAsync(listUuid, itemName, specification) + self._session = None + return res + return asyncio.run(_async()) + + async def saveItemAsync(self, listUuid: str, itemName: str, specification='') -> aiohttp.ClientResponse: """ Save an item to a shopping list. @@ -239,15 +392,51 @@ def saveItem(self, listUuid: str, itemName: str, specification: str = '') -> Res If the request fails. """ try: - r = requests.put(f'{self.url}bringlists/{listUuid}', headers=self.putHeaders, data=f'&purchase={itemName}&recently=&specification={specification}&remove=&sender=null') - r.raise_for_status() - return r - except RequestException as e: - _LOGGER.error(f'Exception: Could not save item {itemName} ({specification}) to list {listUuid}:\n{traceback.format_exc()}') - raise BringRequestException(f'Saving item {itemName} ({specification}) to list {listUuid} failed due to request exception.') from e + url = f'{self.url}bringlists/{listUuid}' + async with self._session.put(url, headers=self.putHeaders, data=f'&purchase={itemName}&recently=&specification={specification}&remove=&sender=null', raise_for_status=True) as r: + _LOGGER.debug(f"Response from %s: %s", url, r.status) + r.raise_for_status() + return r + except asyncio.TimeoutError as e: + _LOGGER.error(f'Exception: Cannot save item {itemName} ({specification}) to list {listUuid}:\n{traceback.format_exc()}') + raise BringRequestException(f"Saving item {itemName} ({specification}) to list {listUuid} failed due to connection timeout") from e + except aiohttp.ClientError as e: + _LOGGER.error(f'Exception: Cannot save item {itemName} ({specification}) to list {listUuid}:\n{traceback.format_exc()}') + raise BringRequestException(f"Saving item {itemName} ({specification}) to list {listUuid} failed due to request exception") from e - def updateItem(self, listUuid: str, itemName: str, specification: str = '') -> Response: + def updateItem(self, listUuid: str, itemName: str, specification='') -> aiohttp.ClientResponse: + """ + Update an existing list item. + + Parameters + ---------- + listUuid : str + A list uuid returned by loadLists() + itemName : str + The name of the item you want to update. + specification : str, optional + The details you want to update on the item. + + Returns + ------- + Response + The server response object. + + Raises + ------ + BringRequestException + If the request fails. + """ + async def _async(): + async with aiohttp.ClientSession() as session: + self._session = session + res = await self.updateItemAsync(listUuid, itemName, specification) + self._session = None + return res + return asyncio.run(_async()) + + async def updateItemAsync(self, listUuid: str, itemName: str, specification='') -> aiohttp.ClientResponse: """ Update an existing list item. @@ -271,15 +460,49 @@ def updateItem(self, listUuid: str, itemName: str, specification: str = '') -> R If the request fails. """ try: - r = requests.put(f'{self.url}bringlists/{listUuid}', headers=self.putHeaders, data=f'&uuid={listUuid}&purchase={itemName}&specification={specification}') - r.raise_for_status() - return r - except RequestException as e: - _LOGGER.error(f'Exception: Could not update item {itemName} ({specification}) in list {listUuid}:\n{traceback.format_exc()}') - raise BringRequestException(f'Updating item {itemName} ({specification}) in list {listUuid} failed due to request exception.') from e + url = f'{self.url}bringlists/{listUuid}' + async with self._session.put(url, headers=self.putHeaders, data=f'&uuid={listUuid}&purchase={itemName}&specification={specification}', raise_for_status=True) as r: + _LOGGER.debug(f"Response from %s: %s", url, r.status) + r.raise_for_status() + return r + except asyncio.TimeoutError as e: + _LOGGER.error(f'Exception: Cannot update item {itemName} ({specification}) to list {listUuid}:\n{traceback.format_exc()}') + raise BringRequestException(f"Updating item {itemName} ({specification}) in list {listUuid} failed due to connection timeout") from e + except aiohttp.ClientError as e: + _LOGGER.error(f'Exception: Cannot update item {itemName} ({specification}) to list {listUuid}:\n{traceback.format_exc()}') + raise BringRequestException(f"Updating item {itemName} ({specification}) in list {listUuid} failed due to request exception") from e - def removeItem(self, listUuid: str, itemName: str) -> Response: + def removeItem(self, listUuid: str, itemName: str) -> aiohttp.ClientResponse: + """ + Remove an item from a shopping list. + + Parameters + ---------- + listUuid : str + A list uuid returned by loadLists() + itemName : str + The name of the item you want to remove. + + Returns + ------- + Response + The server response object. + + Raises + ------ + BringRequestException + If the request fails. + """ + async def _async(): + async with aiohttp.ClientSession() as session: + self._session = session + res = await self.removeItemAsync(listUuid, itemName) + self._session = None + return res + return asyncio.run(_async()) + + async def removeItemAsync(self, listUuid: str, itemName: str) -> aiohttp.ClientResponse: """ Remove an item from a shopping list. @@ -301,15 +524,52 @@ def removeItem(self, listUuid: str, itemName: str) -> Response: If the request fails. """ try: - r = requests.put(f'{self.url}bringlists/{listUuid}', headers=self.putHeaders, data=f'&purchase=&recently=&specification=&remove={itemName}&sender=null') - r.raise_for_status() - return r - except RequestException as e: - _LOGGER.error(f'Exception: Could not remove item {itemName} from list {listUuid}:\n{traceback.format_exc()}') - raise BringRequestException(f'Removing item {itemName} from list {listUuid} failed due to request exception.') from e + url = f'{self.url}bringlists/{listUuid}' + async with self._session.put(url, headers=self.putHeaders, data=f'&purchase=&recently=&specification=&remove={itemName}&sender=null', raise_for_status=True) as r: + _LOGGER.debug(f"Response from %s: %s", url, r.status) + r.raise_for_status() + return r + except asyncio.TimeoutError as e: + _LOGGER.error(f'Exception: Cannot remove item {itemName} to list {listUuid}:\n{traceback.format_exc()}') + _LOGGER.debug(_LOGGER.debug(traceback.print_exc())) + raise BringRequestException(f"Removing item {itemName} from list {listUuid} failed due to connection timeout") from e + except aiohttp.ClientError as e: + _LOGGER.error(f'Exception: Cannot remove item {itemName} to list {listUuid}:\n{traceback.format_exc()}') + _LOGGER.debug(traceback.print_exc()) + raise BringRequestException(f"Removing item {itemName} from list {listUuid} failed due to request exception") from e + + def completeItem(self, listUuid: str, itemName: str) -> aiohttp.ClientResponse: + """ + Complete an item from a shopping list. This will add it to recent items. + If it was not on the list, it will still be added to recent items. + Parameters + ---------- + listUuid : str + A list uuid returned by loadLists() + itemName : str + The name of the item you want to complete. + + Returns + ------- + Response + The server response object. - def completeItem(self, listUuid: str, itemName: str) -> Response: + Raises + ------ + BringRequestException + If the request fails. + """ + async def _async(): + async with aiohttp.ClientSession() as session: + self._session = session + res = await self.completeItemAsync(listUuid, itemName) + self._session = None + return res + return asyncio.run(_async()) + + + async def completeItemAsync(self, listUuid: str, itemName: str) -> aiohttp.ClientResponse: """ Complete an item from a shopping list. This will add it to recent items. If it was not on the list, it will still be added to recent items. @@ -332,15 +592,53 @@ def completeItem(self, listUuid: str, itemName: str) -> Response: If the request fails. """ try: - r = requests.put(f'{self.url}bringlists/{listUuid}', headers=self.putHeaders, data=f'&uuid={listUuid}&recently={itemName}') - r.raise_for_status() - return r - except RequestException as e: - _LOGGER.error(f'Exception: Could not complete item {itemName} from list {listUuid}:\n{traceback.format_exc()}') - raise BringRequestException(f'Completing item {itemName} from list {listUuid} failed due to request exception.') from e + url = f'{self.url}bringlists/{listUuid}' + async with self._session.put(url, headers=self.putHeaders, data=f'&uuid={listUuid}&recently={itemName}', raise_for_status=True) as r: + _LOGGER.debug(f"Response from %s: %s", url, r.status) + r.raise_for_status() + return r + except asyncio.TimeoutError as e: + _LOGGER.error(f'Exception: Cannot complete item {itemName} to list {listUuid}:\n{traceback.format_exc()}') + _LOGGER.debug(_LOGGER.debug(traceback.print_exc())) + raise BringRequestException(f"Completing item {itemName} from list {listUuid} failed due to connection timeout") from e + except aiohttp.ClientError as e: + _LOGGER.error(f'Exception: Cannot complete item {itemName} to list {listUuid}:\n{traceback.format_exc()}') + _LOGGER.debug(traceback.print_exc()) + raise BringRequestException(f"Completing item {itemName} from list {listUuid} failed due to request exception") from e + + + def notify(self, listUuid: str, notificationType: BringNotificationType, itemName: str = None) -> aiohttp.ClientResponse: + """ + Send a push notification to all other members of a shared list. + Parameters + ---------- + listUuid : str + A list uuid returned by loadLists() + notificationType : BringNotificationType + itemName : str, optional + The text that **must** be included in the URGENT_MESSAGE BringNotificationType. - def notify(self, listUuid: str, notificationType: BringNotificationType, itemName: str = None) -> Response: + Returns + ------- + Response + The server response object. + + Raises + ------ + BringRequestException + If the request fails. + """ + async def _async(): + async with aiohttp.ClientSession() as session: + self._session = session + res = await self.notifyAsync(listUuid, notificationType, itemName) + self._session = None + return res + return asyncio.run(_async()) + + + async def notifyAsync(self, listUuid: str, notificationType: BringNotificationType, itemName: str = None) -> aiohttp.ClientResponse: """ Send a push notification to all other members of a shared list. @@ -377,13 +675,17 @@ def notify(self, listUuid: str, notificationType: BringNotificationType, itemNam raise ValueError('notificationType is URGENT_MESSAGE but argument itemName missing.') else: json['arguments'] = [itemName] - - headers = self.putHeaders.copy() - headers['Content-Type'] = 'application/json; charset=UTF-8' try: - r = requests.post(f'{self.url}bringnotifications/lists/{listUuid}', headers=headers, json=json) - r.raise_for_status() - return r - except RequestException as e: - _LOGGER.error(f'Exception: Could not send notification {notificationType} for list {listUuid}:\n{traceback.format_exc()}') - raise BringRequestException(f'Sending notification {notificationType} for list {listUuid} failed due to request exception.') from e \ No newline at end of file + url = f'{self.url}bringnotifications/lists/{listUuid}' + async with self._session.post(url, headers=self.postHeaders, json=json, raise_for_status=True) as r: + _LOGGER.debug(f"Response from %s: %s", url, r.status) + r.raise_for_status() + return r + except asyncio.TimeoutError as e: + _LOGGER.error(f'Exception: Cannot send notification {notificationType} for list {listUuid}:\n{traceback.format_exc()}') + _LOGGER.debug(_LOGGER.debug(traceback.print_exc())) + raise BringRequestException(f"Sending notification {notificationType} for list {listUuid} failed due to connection timeout") from e + except aiohttp.ClientError as e: + _LOGGER.error(f'Exception: Cannot send notification {notificationType} for list {listUuid}:\n{traceback.format_exc()}') + _LOGGER.debug(traceback.print_exc()) + raise BringRequestException(f"Sending notification {notificationType} for list {listUuid} failed due to request exception") from e From 817dd279b2706441c0c7e2d66f8b5b41c0ef563f Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Sun, 14 Jan 2024 16:44:03 +0100 Subject: [PATCH 2/4] add complete item to readme --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index bc8a0e6..78c0f52 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,13 @@ async def main(): items = bring.getItems(lists[0]['listUuid']) print(items) + # Check of an item + bring.completeItem(lists[0]['listUuid'], items["purchase"][0]['name']) + + # Get all the recent items of a list + items = bring.getItems(lists[0]['listUuid']) + print(items) + # Remove an item from a list bring.removeItem(lists[0]['listUuid'], 'Milk') @@ -77,6 +84,13 @@ async def main(): items = await bring.getItemsAsync(lists[0]['listUuid']) print(items) + # Check of an item + await bring.completeItemAsync(lists[0]['listUuid'], items["purchase"][0]['name']) + + # Get all the recent items of a list + items = await bring.getItemsAsync(lists[0]['listUuid']) + print(items) + # Remove an item from a list await bring.removeItemAsync(lists[0]['listUuid'], 'Milk') From 5666180c5badbede7024e801eb54cc87c099b515 Mon Sep 17 00:00:00 2001 From: Elias Ball Date: Sat, 3 Feb 2024 17:32:26 +0100 Subject: [PATCH 3/4] Add small fixes, fix encoding of request data --- src/python_bring_api/bring.py | 126 ++++++++++++++++++++-------------- 1 file changed, 75 insertions(+), 51 deletions(-) diff --git a/src/python_bring_api/bring.py b/src/python_bring_api/bring.py index a175c62..bc85a05 100644 --- a/src/python_bring_api/bring.py +++ b/src/python_bring_api/bring.py @@ -5,7 +5,7 @@ from typing import Dict from .types import BringNotificationType, BringAuthResponse, BringItemsResponse, BringListResponse, BringListItemsDetailsResponse -from .exceptions import BringRequestException, BringAuthException, BringRequestException, BringParseException +from .exceptions import BringAuthException, BringRequestException, BringParseException import logging @@ -110,23 +110,36 @@ async def loginAsync(self) -> BringAuthResponse: try: url = f'{self.url}bringauth' - async with self._session.post(url, data=data, raise_for_status=True) as r: - _LOGGER.debug(f"Response from %s: %s", url, r.status) + async with self._session.post(url, data=data) as r: + _LOGGER.debug(f'Response from %s: %s', url, r.status) + + if r.status == 401: + try: + errmsg = await r.json() + except JSONDecodeError: + _LOGGER.error(f'Exception: Cannot parse login request response:\n{traceback.format_exc()}') + else: + _LOGGER.error(f'Exception: Cannot login: {errmsg["message"]}') + raise BringAuthException('Login failed due to authorization failure, please check your email and password.') + elif r.status == 400: + _LOGGER.error(f'Exception: Cannot login: {await r.text()}') + raise BringAuthException('Login failed due to bad request, please check your email.') r.raise_for_status() + try: data = await r.json() except JSONDecodeError as e: _LOGGER.error(f'Exception: Cannot login:\n{traceback.format_exc()}') raise BringParseException(f'Cannot parse login request response.') from e except asyncio.TimeoutError as e: - _LOGGER.error('Exception: Cannot login:\n{traceback.format_exc()}') - raise BringRequestException(f"Authentication failed due to connection timeout") from e + _LOGGER.error(f'Exception: Cannot login:\n{traceback.format_exc()}') + raise BringRequestException('Authentication failed due to connection timeout.') from e except aiohttp.ClientError as e: - _LOGGER.error('Exception: Cannot login:\n{traceback.format_exc()}') - raise BringRequestException(f"Authentication failed due to request exception") from e + _LOGGER.error(f'Exception: Cannot login:\n{traceback.format_exc()}') + raise BringRequestException(f'Authentication failed due to request exception.') from e if 'uuid' not in data or 'access_token' not in data: - _LOGGER.error(f'Exception: Cannot login: Data missing in API response.') + _LOGGER.error('Exception: Cannot login: Data missing in API response.') raise BringAuthException('Login failed due to missing data in the API response, please check your email and password.') self.uuid = data['uuid'] @@ -141,7 +154,7 @@ async def loginAsync(self) -> BringAuthResponse: **self.headers, 'Content-Type': 'application/json; charset=UTF-8' } - return r + return data def loadLists(self) -> BringListResponse: """Load all shopping lists. @@ -185,20 +198,21 @@ async def loadListsAsync(self) -> BringListResponse: """ try: url = f'{self.url}bringusers/{self.uuid}/lists' - async with self._session.get(url, headers=self.headers, raise_for_status=True) as r: - _LOGGER.debug(f"Response from %s: %s", url, r.status) + async with self._session.get(url, headers=self.headers) as r: + _LOGGER.debug(f'Response from %s: %s', url, r.status) r.raise_for_status() + try: return await r.json() except JSONDecodeError as e: _LOGGER.error(f'Exception: Cannot get lists:\n{traceback.format_exc()}') raise BringParseException(f'Loading lists failed during parsing of request response.') from e except asyncio.TimeoutError as e: - _LOGGER.error('Exception: Cannot get lists:\n{traceback.format_exc()}') - raise BringRequestException(f"Loading list failed due to connection timeout") from e + _LOGGER.error(f'Exception: Cannot get lists:\n{traceback.format_exc()}') + raise BringRequestException('Loading list failed due to connection timeout.') from e except aiohttp.ClientError as e: - _LOGGER.error('Exception: Cannot get lists:\n{traceback.format_exc()}') - raise BringRequestException(f"Loading lists failed due to request exception") from e + _LOGGER.error(f'Exception: Cannot get lists:\n{traceback.format_exc()}') + raise BringRequestException('Loading lists failed due to request exception.') from e def getItems(self, listUuid: str) -> BringItemsResponse: """ @@ -252,20 +266,21 @@ async def getItemsAsync(self, listUuid: str) -> BringItemsResponse: """ try: url = f'{self.url}bringlists/{listUuid}' - async with self._session.get(url, headers=self.headers, raise_for_status=True) as r: - _LOGGER.debug(f"Response from %s: %s", url, r.status) + async with self._session.get(url, headers=self.headers) as r: + _LOGGER.debug(f'Response from %s: %s', url, r.status) r.raise_for_status() + try: return await r.json() except JSONDecodeError as e: _LOGGER.error(f'Exception: Cannot get items for list {listUuid}:\n{traceback.format_exc()}') - raise BringParseException(f'Loading list items failed during parsing of request response.') from e + raise BringParseException('Loading list items failed during parsing of request response.') from e except asyncio.TimeoutError as e: _LOGGER.error(f'Exception: Cannot get items for list {listUuid}:\n{traceback.format_exc()}') - raise BringRequestException(f"Loading list items failed due to connection timeout") from e + raise BringRequestException('Loading list items failed due to connection timeout.') from e except aiohttp.ClientError as e: _LOGGER.error(f'Exception: Cannot get items for list {listUuid}:\n{traceback.format_exc()}') - raise BringRequestException(f"Loading list items failed due to request exception") from e + raise BringRequestException('Loading list items failed due to request exception.') from e def getAllItemDetails(self, listUuid: str) -> BringListItemsDetailsResponse: @@ -322,20 +337,21 @@ async def getAllItemDetailsAsync(self, listUuid: str) -> BringItemsResponse: """ try: url = f'{self.url}bringlists/{listUuid}/details' - async with self._session.get(url, headers=self.headers, raise_for_status=True) as r: - _LOGGER.debug(f"Response from %s: %s", url, r.status) + async with self._session.get(url, headers=self.headers) as r: + _LOGGER.debug(f'Response from %s: %s', url, r.status) r.raise_for_status() + try: return await r.json() except JSONDecodeError as e: _LOGGER.error(f'Exception: Cannot get item details for list {listUuid}:\n{traceback.format_exc()}') - raise BringParseException(f'Loading list item details failed during parsing of request response.') from e + raise BringParseException(f'Loading list details failed during parsing of request response.') from e except asyncio.TimeoutError as e: _LOGGER.error(f'Exception: Cannot get item details for list {listUuid}:\n{traceback.format_exc()}') - raise BringRequestException(f"Loading list item details failed due to connection timeout") from e + raise BringRequestException('Loading list details failed due to connection timeout.') from e except aiohttp.ClientError as e: _LOGGER.error(f'Exception: Cannot get item details for list {listUuid}:\n{traceback.format_exc()}') - raise BringRequestException(f"Loading list item details failed due to request exception") from e + raise BringRequestException('Loading list details failed due to request exception.') from e def saveItem(self, listUuid: str, itemName: str, specification='') -> aiohttp.ClientResponse: """ @@ -391,18 +407,22 @@ async def saveItemAsync(self, listUuid: str, itemName: str, specification='') -> BringRequestException If the request fails. """ + data = { + 'purchase': itemName, + 'specification': specification, + } try: url = f'{self.url}bringlists/{listUuid}' - async with self._session.put(url, headers=self.putHeaders, data=f'&purchase={itemName}&recently=&specification={specification}&remove=&sender=null', raise_for_status=True) as r: - _LOGGER.debug(f"Response from %s: %s", url, r.status) + async with self._session.put(url, headers=self.putHeaders, data=data) as r: + _LOGGER.debug(f'Response from %s: %s', url, r.status) r.raise_for_status() return r except asyncio.TimeoutError as e: _LOGGER.error(f'Exception: Cannot save item {itemName} ({specification}) to list {listUuid}:\n{traceback.format_exc()}') - raise BringRequestException(f"Saving item {itemName} ({specification}) to list {listUuid} failed due to connection timeout") from e + raise BringRequestException(f'Saving item {itemName} ({specification}) to list {listUuid} failed due to connection timeout.') from e except aiohttp.ClientError as e: _LOGGER.error(f'Exception: Cannot save item {itemName} ({specification}) to list {listUuid}:\n{traceback.format_exc()}') - raise BringRequestException(f"Saving item {itemName} ({specification}) to list {listUuid} failed due to request exception") from e + raise BringRequestException(f'Saving item {itemName} ({specification}) to list {listUuid} failed due to request exception.') from e def updateItem(self, listUuid: str, itemName: str, specification='') -> aiohttp.ClientResponse: @@ -459,18 +479,22 @@ async def updateItemAsync(self, listUuid: str, itemName: str, specification='') BringRequestException If the request fails. """ + data = { + 'purchase': itemName, + 'specification': specification + } try: url = f'{self.url}bringlists/{listUuid}' - async with self._session.put(url, headers=self.putHeaders, data=f'&uuid={listUuid}&purchase={itemName}&specification={specification}', raise_for_status=True) as r: - _LOGGER.debug(f"Response from %s: %s", url, r.status) + async with self._session.put(url, headers=self.putHeaders, data=data) as r: + _LOGGER.debug(f'Response from %s: %s', url, r.status) r.raise_for_status() return r except asyncio.TimeoutError as e: _LOGGER.error(f'Exception: Cannot update item {itemName} ({specification}) to list {listUuid}:\n{traceback.format_exc()}') - raise BringRequestException(f"Updating item {itemName} ({specification}) in list {listUuid} failed due to connection timeout") from e + raise BringRequestException(f'Updating item {itemName} ({specification}) in list {listUuid} failed due to connection timeout.') from e except aiohttp.ClientError as e: _LOGGER.error(f'Exception: Cannot update item {itemName} ({specification}) to list {listUuid}:\n{traceback.format_exc()}') - raise BringRequestException(f"Updating item {itemName} ({specification}) in list {listUuid} failed due to request exception") from e + raise BringRequestException(f'Updating item {itemName} ({specification}) in list {listUuid} failed due to request exception.') from e def removeItem(self, listUuid: str, itemName: str) -> aiohttp.ClientResponse: @@ -523,20 +547,21 @@ async def removeItemAsync(self, listUuid: str, itemName: str) -> aiohttp.ClientR BringRequestException If the request fails. """ + data = { + 'remove': itemName, + } try: url = f'{self.url}bringlists/{listUuid}' - async with self._session.put(url, headers=self.putHeaders, data=f'&purchase=&recently=&specification=&remove={itemName}&sender=null', raise_for_status=True) as r: - _LOGGER.debug(f"Response from %s: %s", url, r.status) + async with self._session.put(url, headers=self.putHeaders, data=data) as r: + _LOGGER.debug(f'Response from %s: %s', url, r.status) r.raise_for_status() return r except asyncio.TimeoutError as e: _LOGGER.error(f'Exception: Cannot remove item {itemName} to list {listUuid}:\n{traceback.format_exc()}') - _LOGGER.debug(_LOGGER.debug(traceback.print_exc())) - raise BringRequestException(f"Removing item {itemName} from list {listUuid} failed due to connection timeout") from e + raise BringRequestException(f'Removing item {itemName} from list {listUuid} failed due to connection timeout.') from e except aiohttp.ClientError as e: _LOGGER.error(f'Exception: Cannot remove item {itemName} to list {listUuid}:\n{traceback.format_exc()}') - _LOGGER.debug(traceback.print_exc()) - raise BringRequestException(f"Removing item {itemName} from list {listUuid} failed due to request exception") from e + raise BringRequestException(f'Removing item {itemName} from list {listUuid} failed due to request exception.') from e def completeItem(self, listUuid: str, itemName: str) -> aiohttp.ClientResponse: """ @@ -591,20 +616,21 @@ async def completeItemAsync(self, listUuid: str, itemName: str) -> aiohttp.Clien BringRequestException If the request fails. """ + data = { + 'recently': itemName + } try: url = f'{self.url}bringlists/{listUuid}' - async with self._session.put(url, headers=self.putHeaders, data=f'&uuid={listUuid}&recently={itemName}', raise_for_status=True) as r: - _LOGGER.debug(f"Response from %s: %s", url, r.status) + async with self._session.put(url, headers=self.putHeaders, data=data) as r: + _LOGGER.debug(f'Response from %s: %s', url, r.status) r.raise_for_status() return r except asyncio.TimeoutError as e: _LOGGER.error(f'Exception: Cannot complete item {itemName} to list {listUuid}:\n{traceback.format_exc()}') - _LOGGER.debug(_LOGGER.debug(traceback.print_exc())) - raise BringRequestException(f"Completing item {itemName} from list {listUuid} failed due to connection timeout") from e + raise BringRequestException(f'Completing item {itemName} from list {listUuid} failed due to connection timeout.') from e except aiohttp.ClientError as e: _LOGGER.error(f'Exception: Cannot complete item {itemName} to list {listUuid}:\n{traceback.format_exc()}') - _LOGGER.debug(traceback.print_exc()) - raise BringRequestException(f"Completing item {itemName} from list {listUuid} failed due to request exception") from e + raise BringRequestException(f'Completing item {itemName} from list {listUuid} failed due to request exception.') from e def notify(self, listUuid: str, notificationType: BringNotificationType, itemName: str = None) -> aiohttp.ClientResponse: @@ -677,15 +703,13 @@ async def notifyAsync(self, listUuid: str, notificationType: BringNotificationTy json['arguments'] = [itemName] try: url = f'{self.url}bringnotifications/lists/{listUuid}' - async with self._session.post(url, headers=self.postHeaders, json=json, raise_for_status=True) as r: - _LOGGER.debug(f"Response from %s: %s", url, r.status) + async with self._session.post(url, headers=self.postHeaders, json=json) as r: + _LOGGER.debug(f'Response from %s: %s', url, r.status) r.raise_for_status() return r except asyncio.TimeoutError as e: _LOGGER.error(f'Exception: Cannot send notification {notificationType} for list {listUuid}:\n{traceback.format_exc()}') - _LOGGER.debug(_LOGGER.debug(traceback.print_exc())) - raise BringRequestException(f"Sending notification {notificationType} for list {listUuid} failed due to connection timeout") from e + raise BringRequestException(f'Sending notification {notificationType} for list {listUuid} failed due to connection timeout.') from e except aiohttp.ClientError as e: _LOGGER.error(f'Exception: Cannot send notification {notificationType} for list {listUuid}:\n{traceback.format_exc()}') - _LOGGER.debug(traceback.print_exc()) - raise BringRequestException(f"Sending notification {notificationType} for list {listUuid} failed due to request exception") from e + raise BringRequestException(f'Sending notification {notificationType} for list {listUuid} failed due to request exception.') from e From 6a1eff0c465cac59c81931cfa281e7395c8dd47b Mon Sep 17 00:00:00 2001 From: Elias Ball Date: Sat, 3 Feb 2024 17:32:34 +0100 Subject: [PATCH 4/4] Update README --- README.md | 89 ++++++++++++++++++++++++++++++++++++++----------------- setup.cfg | 2 +- 2 files changed, 62 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 41e266b..271cbcb 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,13 @@ The developers of this module are in no way endorsed by or affiliated with Bring `pip install python-bring-api` +## Documentation + +See below for usage examples. See [Exceptions](#exceptions) for API-specific exceptions and mitigation strategies for common exceptions. + ## Usage Example -The API is available both sync and async, where sync is the default due to simplicity and avoid breaking changes. Both implementation use the same async library `aiohttp` in the back. +The API is available both sync and async, where sync is the default for simplicity. Both implementations of each function use the same async HTTP library `aiohttp` in the back. ### Sync @@ -26,33 +30,29 @@ from python_bring_api.bring import Bring logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) -async def main(): - # Create Bring instance with email and password - bring = Bring("MAIL", "PASSWORD") - # Login - bring.login() +# Create Bring instance with email and password +bring = Bring("MAIL", "PASSWORD") +# Login +bring.login() - # Get information about all available shopping lists - lists = bring.loadLists()["lists"] +# Get information about all available shopping lists +lists = bring.loadLists()["lists"] - # Save an item with specifications to a certain shopping list - bring.saveItem(lists[0]['listUuid'], 'Milk', 'low fat') +# Save an item with specifications to a certain shopping list +bring.saveItem(lists[0]['listUuid'], 'Milk', 'low fat') - # Get all the items of a list - items = bring.getItems(lists[0]['listUuid']) - print(items) - - # Check of an item - bring.completeItem(lists[0]['listUuid'], items["purchase"][0]['name']) +# Save another item +bring.saveItem(lists[0]['listUuid'], 'Carrots') - # Get all the recent items of a list - items = bring.getItems(lists[0]['listUuid']) - print(items) +# Get all the items of a list +items = bring.getItems(lists[0]['listUuid']) +print(items) - # Remove an item from a list - bring.removeItem(lists[0]['listUuid'], 'Milk') +# Check off an item +bring.completeItem(lists[0]['listUuid'], 'Carrots') -asyncio.run(main()) +# Remove an item from a list +bring.removeItem(lists[0]['listUuid'], 'Milk') ``` ### Async @@ -80,16 +80,15 @@ async def main(): # Save an item with specifications to a certain shopping list await bring.saveItemAsync(lists[0]['listUuid'], 'Milk', 'low fat') + # Save another item + await bring.saveItemAsync(lists[0]['listUuid'], 'Carrots') + # Get all the items of a list items = await bring.getItemsAsync(lists[0]['listUuid']) print(items) - # Check of an item - await bring.completeItemAsync(lists[0]['listUuid'], items["purchase"][0]['name']) - - # Get all the recent items of a list - items = await bring.getItemsAsync(lists[0]['listUuid']) - print(items) + # Check off an item + await bring.completeItemAsync(lists[0]['listUuid'], 'Carrots') # Remove an item from a list await bring.removeItemAsync(lists[0]['listUuid'], 'Milk') @@ -101,8 +100,42 @@ asyncio.run(main()) In case something goes wrong during a request, several exceptions can be thrown. They will either be BringRequestException, BringParseException, or BringAuthException, depending on the context. All inherit from BringException. +### Another asyncio event loop is already running + +Because even the sync methods use async calls under the hood, you might encounter an error that another asyncio event loop is already running on the same thread. This is expected behavior according to the asyncio.run() [documentation](https://docs.python.org/3/library/asyncio-runner.html#asyncio.run). You cannot call the sync methods when another event loop is already running. When you are already inside an async function, you should use the async methods instead. + +### Exception ignored: RuntimeError: Event loop is closed + +Due to a known issue in some versions of aiohttp when using Windows, you might encounter a similar error to this: + +```python +Exception ignored in: +Traceback (most recent call last): + File "C:\...\py38\lib\asyncio\proactor_events.py", line 116, in __del__ + self.close() + File "C:\...\py38\lib\asyncio\proactor_events.py", line 108, in close + self._loop.call_soon(self._call_connection_lost, None) + File "C:\...\py38\lib\asyncio\base_events.py", line 719, in call_soon + self._check_closed() + File "C:\...\py38\lib\asyncio\base_events.py", line 508, in _check_closed + raise RuntimeError('Event loop is closed') +RuntimeError: Event loop is closed +``` + +You can fix this according to [this](https://stackoverflow.com/questions/68123296/asyncio-throws-runtime-error-with-exception-ignored) stackoverflow answer by adding the following line of code before executing the library: +```python +asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) +``` + ## Changelog +### 3.0.0 + +Change backend library from requests to aiohttp, thanks to [@miaucl](https://github.com/miaucl)! +This makes available async versions of all methods. + +Fix encoding of request data, thanks to [@miaucl](https://github.com/miaucl)! + ### 2.1.0 Add notify() method to send push notifications to other list members, thanks to [@tr4nt0r](https://github.com/tr4nt0r)! diff --git a/setup.cfg b/setup.cfg index 741fa5f..9497976 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = python-bring-api -version = 2.1.0 +version = 3.0.0 author = Elias Ball author_email = contact.eliasball@gmail.com description = Unofficial python package to access Bring! shopping lists API.