Repository: SebRut/pygrocy (python)

Issue:
Better error messages
In ha with the component I only get these error messages, no matter what the reason for the 400 is.
``` 
requests.exceptions.HTTPError: 400 Client Error: Bad Request for url: http://192.168.1.78:9192/api/objects/tasks
```

Grocy is however also sending an error_message, like this:
``` 
"{\"error_message\":\"SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: tasks.name\"}"

It would be awesome to get these in the error message from the lib too.
```

Pull request description:
better error handling
fixes #133 

Patch:
diff --git a/pygrocy/errors/__init__.py b/pygrocy/errors/__init__.py
new file mode 100644
index 0000000..3d391b2
--- /dev/null
+++ b/pygrocy/errors/__init__.py
@@ -0,0 +1,1 @@
+from .grocy_error import GrocyError  # noqa: F401
diff --git a/pygrocy/errors/grocy_error.py b/pygrocy/errors/grocy_error.py
new file mode 100644
index 0000000..15627ca
--- /dev/null
+++ b/pygrocy/errors/grocy_error.py
@@ -0,0 +1,28 @@
+from requests import Response
+
+
+class GrocyError(Exception):
+    def __init__(self, response: Response):
+        self._status_code = response.status_code
+
+        if len(response.text) > 0:
+            json = response.json()
+            self._message = json["error_message"]
+        else:
+            self._message = None
+
+    @property
+    def status_code(self) -> int:
+        return self._status_code
+
+    @property
+    def message(self) -> str:
+        return self._message
+
+    @property
+    def is_client_error(self) -> bool:
+        return 400 <= self.status_code < 500
+
+    @property
+    def is_server_error(self) -> bool:
+        return self.status_code >= 500
diff --git a/pygrocy/grocy.py b/pygrocy/grocy.py
index e1afcc0..cab71af 100644
--- a/pygrocy/grocy.py
+++ b/pygrocy/grocy.py
@@ -10,6 +10,7 @@ from .data_models.meal_items import MealPlanItem, RecipeItem
 from .data_models.product import Group, Product, ShoppingListProduct
 from .data_models.task import Task
 from .data_models.user import User  # noqa: F401
+from .errors import GrocyError  # noqa: F401
 from .grocy_api_client import ChoreDetailsResponse  # noqa: F401
 from .grocy_api_client import CurrentChoreResponse  # noqa: F401
 from .grocy_api_client import CurrentStockResponse  # noqa: F401
diff --git a/pygrocy/grocy_api_client.py b/pygrocy/grocy_api_client.py
index d364ce6..09824d0 100644
--- a/pygrocy/grocy_api_client.py
+++ b/pygrocy/grocy_api_client.py
@@ -15,6 +15,8 @@ from pygrocy.utils import (
     parse_int,
 )
 
+from .errors import GrocyError
+
 DEFAULT_PORT_NUMBER = 9192
 
 
@@ -548,7 +550,9 @@ class GrocyApiClient(object):
     def _do_get_request(self, end_url: str):
         req_url = urljoin(self._base_url, end_url)
         resp = requests.get(req_url, verify=self._verify_ssl, headers=self._headers)
-        resp.raise_for_status()
+        if resp.status_code >= 400:
+            raise GrocyError(resp)
+
         if len(resp.content) > 0:
             return resp.json()
 
@@ -557,7 +561,8 @@ class GrocyApiClient(object):
         resp = requests.post(
             req_url, verify=self._verify_ssl, headers=self._headers, json=data
         )
-        resp.raise_for_status()
+        if resp.status_code >= 400:
+            raise GrocyError(resp)
         if len(resp.content) > 0:
             return resp.json()
 
@@ -573,7 +578,9 @@ class GrocyApiClient(object):
         resp = requests.put(
             req_url, verify=self._verify_ssl, headers=up_header, data=data
         )
-        resp.raise_for_status()
+        if resp.status_code >= 400:
+            raise GrocyError(resp)
+
         if len(resp.content) > 0:
             return resp.json()
 


Test patch:
diff --git a/test/cassettes/test_shoppinglist/TestShoppingList.test_add_missing_products_to_nonexistant_shopping_list.yaml b/test/cassettes/test_shoppinglist/TestShoppingList.test_add_missing_products_to_nonexistant_shopping_list.yaml
new file mode 100644
index 0000000..7ac23c5
--- /dev/null
+++ b/test/cassettes/test_shoppinglist/TestShoppingList.test_add_missing_products_to_nonexistant_shopping_list.yaml
@@ -0,0 +1,44 @@
+interactions:
+- request:
+    body: '{"list_id": 3000}'
+    headers:
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '17'
+      Content-Type:
+      - application/json
+      User-Agent:
+      - python-requests/2.25.1
+      accept:
+      - application/json
+    method: POST
+    uri: https://localhost/api/stock/shoppinglist/add-missing-products
+  response:
+    body:
+      string: '{"error_message":"Shopping list does not exist"}'
+    headers:
+      Access-Control-Allow-Headers:
+      - '*'
+      Access-Control-Allow-Methods:
+      - GET, POST, PUT, DELETE, OPTIONS
+      Access-Control-Allow-Origin:
+      - '*'
+      Connection:
+      - keep-alive
+      Content-Type:
+      - application/json
+      Date:
+      - Sat, 20 Feb 2021 16:56:56 GMT
+      Server:
+      - nginx/1.18.0
+      Transfer-Encoding:
+      - chunked
+      X-Powered-By:
+      - PHP/7.4.14
+    status:
+      code: 400
+      message: Bad Request
+version: 1
diff --git a/test/cassettes/test_shoppinglist/TestShoppingList.test_add_nonexistant_product_to_shopping_list.yaml b/test/cassettes/test_shoppinglist/TestShoppingList.test_add_nonexistant_product_to_shopping_list.yaml
new file mode 100644
index 0000000..acb849e
--- /dev/null
+++ b/test/cassettes/test_shoppinglist/TestShoppingList.test_add_nonexistant_product_to_shopping_list.yaml
@@ -0,0 +1,44 @@
+interactions:
+- request:
+    body: '{"product_id": 3000, "list_id": null, "product_amount": null}'
+    headers:
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '61'
+      Content-Type:
+      - application/json
+      User-Agent:
+      - python-requests/2.25.1
+      accept:
+      - application/json
+    method: POST
+    uri: https://localhost/api/stock/shoppinglist/add-product
+  response:
+    body:
+      string: '{"error_message":"Product does not exist or is inactive"}'
+    headers:
+      Access-Control-Allow-Headers:
+      - '*'
+      Access-Control-Allow-Methods:
+      - GET, POST, PUT, DELETE, OPTIONS
+      Access-Control-Allow-Origin:
+      - '*'
+      Connection:
+      - keep-alive
+      Content-Type:
+      - application/json
+      Date:
+      - Sat, 20 Feb 2021 16:56:56 GMT
+      Server:
+      - nginx/1.18.0
+      Transfer-Encoding:
+      - chunked
+      X-Powered-By:
+      - PHP/7.4.14
+    status:
+      code: 400
+      message: Bad Request
+version: 1
diff --git a/test/test_grocy.py b/test/test_grocy.py
index 5ceefbc..4696fd7 100644
--- a/test/test_grocy.py
+++ b/test/test_grocy.py
@@ -5,9 +5,9 @@ from unittest import TestCase
 from unittest.mock import mock_open, patch
 
 import responses
-from requests.exceptions import HTTPError
 
 from pygrocy import Grocy
+from pygrocy.errors import GrocyError
 from pygrocy.grocy_api_client import GrocyApiClient
 
 
@@ -26,7 +26,7 @@ class TestGrocy(TestCase):
         responses.add(
             responses.GET, f"{self.base_url}/objects/shopping_list", status=400
         )
-        self.assertRaises(HTTPError, self.grocy.shopping_list)
+        self.assertRaises(GrocyError, self.grocy.shopping_list)
 
     @responses.activate
     def test_get_shopping_list_invalid_missing_data(self):
@@ -39,21 +39,12 @@ class TestGrocy(TestCase):
         )
         self.assertEqual(len(self.grocy.shopping_list()), 0)
 
-    @responses.activate
-    def test_add_missing_product_to_shopping_list_error(self):
-        responses.add(
-            responses.POST,
-            f"{self.base_url}/stock/shoppinglist/add-missing-products",
-            status=400,
-        )
-        self.assertRaises(HTTPError, self.grocy.add_missing_product_to_shopping_list)
-
     @responses.activate
     def test_clear_shopping_list_error(self):
         responses.add(
             responses.POST, f"{self.base_url}/stock/shoppinglist/clear", status=400
         )
-        self.assertRaises(HTTPError, self.grocy.clear_shopping_list)
+        self.assertRaises(GrocyError, self.grocy.clear_shopping_list)
 
     @responses.activate
     def test_remove_product_in_shopping_list_error(self):
@@ -62,14 +53,14 @@ class TestGrocy(TestCase):
             f"{self.base_url}/stock/shoppinglist/remove-product",
             status=400,
         )
-        self.assertRaises(HTTPError, self.grocy.remove_product_in_shopping_list, 1)
+        self.assertRaises(GrocyError, self.grocy.remove_product_in_shopping_list, 1)
 
     @responses.activate
     def test_get_product_groups_invalid_no_data(self):
         responses.add(
             responses.GET, f"{self.base_url}/objects/product_groups", status=400
         )
-        self.assertRaises(HTTPError, self.grocy.product_groups)
+        self.assertRaises(GrocyError, self.grocy.product_groups)
 
     @responses.activate
     def test_get_product_groups_invalid_missing_data(self):
@@ -104,7 +95,10 @@ class TestGrocy(TestCase):
                     status=400,
                 )
                 self.assertRaises(
-                    HTTPError, api_client.upload_product_picture, 1, "/somepath/pic.jpg"
+                    GrocyError,
+                    api_client.upload_product_picture,
+                    1,
+                    "/somepath/pic.jpg",
                 )
 
     @responses.activate
@@ -113,7 +107,7 @@ class TestGrocy(TestCase):
             CONST_BASE_URL, "demo_mode", port=CONST_PORT, verify_ssl=CONST_SSL
         )
         responses.add(responses.PUT, f"{self.base_url}/objects/products/1", status=400)
-        self.assertRaises(HTTPError, api_client.update_product_pic, 1)
+        self.assertRaises(GrocyError, api_client.update_product_pic, 1)
 
     @responses.activate
     def test_get_due_invalid_no_data(self):
@@ -177,7 +171,7 @@ class TestGrocy(TestCase):
     def test_set_userfields_error(self):
         responses.add(responses.PUT, f"{self.base_url}/userfields/chores/1", status=400)
         self.assertRaises(
-            HTTPError, self.grocy.set_userfields, "chores", 1, "auserfield", "value"
+            GrocyError, self.grocy.set_userfields, "chores", 1, "auserfield", "value"
         )
 
     @responses.activate
@@ -205,7 +199,7 @@ class TestGrocy(TestCase):
             responses.POST, f"{self.base_url}/stock/products/1/add", status=400
         )
         self.assertRaises(
-            HTTPError, self.grocy.add_product, 1, 1.3, 2.44, self.date_test
+            GrocyError, self.grocy.add_product, 1, 1.3, 2.44, self.date_test
         )
 
     @responses.activate
@@ -217,7 +211,7 @@ class TestGrocy(TestCase):
     def test_add_generic_error(self):
         responses.add(responses.POST, f"{self.base_url}/objects/tasks", status=400)
         self.assertRaises(
-            HTTPError, self.grocy.add_generic, "tasks", self.add_generic_data
+            GrocyError, self.grocy.add_generic, "tasks", self.add_generic_data
         )
 
     @responses.activate
@@ -232,7 +226,7 @@ class TestGrocy(TestCase):
         responses.add(
             responses.POST, f"{self.base_url}/stock/products/1/consume", status=400
         )
-        self.assertRaises(HTTPError, self.grocy.consume_product, 1, 1.3)
+        self.assertRaises(GrocyError, self.grocy.consume_product, 1, 1.3)
 
     @responses.activate
     def test_execute_chore_valid(self):
@@ -242,7 +236,7 @@ class TestGrocy(TestCase):
     @responses.activate
     def test_execute_chore_error(self):
         responses.add(responses.POST, f"{self.base_url}/chores/1/execute", status=400)
-        self.assertRaises(HTTPError, self.grocy.execute_chore, 1, 1, self.date_test)
+        self.assertRaises(GrocyError, self.grocy.execute_chore, 1, 1, self.date_test)
 
     @responses.activate
     def test_get_meal_plan(self):
diff --git a/test/test_product.py b/test/test_product.py
index 80a5870..5137cb1 100644
--- a/test/test_product.py
+++ b/test/test_product.py
@@ -1,11 +1,15 @@
 import pytest
 
+from pygrocy.data_models.product import Product
+from pygrocy.errors.grocy_error import GrocyError
+
 
 class TestProduct:
     @pytest.mark.vcr
     def test_product_get_details_valid(self, grocy):
         product = grocy.product(10)
 
+        assert isinstance(product, Product)
         assert product.name == "Cheese"
         assert product.available_amount == 5
         assert len(product.barcodes) == 0
@@ -23,11 +27,13 @@ class TestProduct:
 
     @pytest.mark.vcr
     def test_product_get_details_non_existant(self, grocy):
-        import requests
-
-        with pytest.raises(requests.exceptions.HTTPError):
+        with pytest.raises(GrocyError) as exc_info:
             grocy.product(200)
 
+        error = exc_info.value
+        assert error.status_code == 400
+        assert error.message == "Product does not exist or is inactive"
+
     @pytest.mark.vcr
     def test_add_product_pic_valid(self, grocy, mocker):
         mocked_exists = mocker.patch("os.path.exists")
diff --git a/test/test_shoppinglist.py b/test/test_shoppinglist.py
index 19e2b26..faabe2e 100644
--- a/test/test_shoppinglist.py
+++ b/test/test_shoppinglist.py
@@ -1,6 +1,7 @@
 import pytest
 
 from pygrocy.data_models.product import Product, ShoppingListProduct
+from pygrocy.errors import GrocyError
 
 
 class TestShoppingList:
@@ -35,12 +36,23 @@ class TestShoppingList:
         grocy.add_product_to_shopping_list(19)
 
     @pytest.mark.vcr
-    def test_add_product_to_shopping_list_error(self, grocy):
-        import requests
-
-        with pytest.raises(requests.exceptions.HTTPError):
+    def test_add_nonexistant_product_to_shopping_list(self, grocy):
+        with pytest.raises(GrocyError) as exc_info:
             grocy.add_product_to_shopping_list(3000)
 
+        error = exc_info.value
+        assert error.status_code == 400
+        assert error.message == "Product does not exist or is inactive"
+
+    @pytest.mark.vcr
+    def test_add_missing_products_to_nonexistant_shopping_list(self, grocy):
+        with pytest.raises(GrocyError) as exc_info:
+            grocy.add_missing_product_to_shopping_list(3000)
+
+        error = exc_info.value
+        assert error.status_code == 400
+        assert error.message == "Shopping list does not exist"
+
     @pytest.mark.vcr
     def test_clear_shopping_list_valid(self, grocy):
         grocy.clear_shopping_list()