diff --git a/README.md b/README.md index 2c7c8dc..0294826 100644 --- a/README.md +++ b/README.md @@ -12,34 +12,22 @@ [![Latest Version](https://img.shields.io/pypi/v/openfoodfacts.svg)](https://pypi.org/project/openfoodfacts) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/openfoodfacts/openfoodfacts-python/blob/master/LICENSE) -## Contributing - -Any help is welcome, as long as you don't break the continuous integration. -Fork the repository and open a Pull Request directly on the "develop" branch. -A maintainer will review and integrate your changes. - -Maintainers: +## Description -- [Anubhav Bhargava](https://github.com/Anubhav-Bhargava) -- [Frank Rousseau](https://github.com/frankrousseau) -- [Pierre Slamich](https://github.com/teolemon) +This is the official Python SDK for the [Open Food Facts](https://world.openfoodfacts.org/) project. +It provides a simple interface to the [Open Food Facts API](https://openfoodfacts.github.io/openfoodfacts-server/api/) and allows you to: -Contributors: - -- Agamit Sudo -- [Daniel Stolpe](https://github.com/numberpi) -- [Enioluwa Segun](https://github.com/enioluwas) -- [Nicolas Leger](https://github.com/nicolasleger) -- [Pablo Hinojosa](https://github.com/Pablohn26) -- [Andrea Stagi](https://github.com/astagi) -- [Benoît Prieur](https://github.com/benprieur) -- [Aadarsh A](https://github.com/aadarsh-ram) +- Get information about a product +- Perform text search +- Create a new product or update an existing one -## Copyright and License +It also provides some helper functions to make it easier to work with Open Food Facts data and APIs, such as: - Copyright 2016-2022 Open Food Facts +- getting translation of a taxonomized field in a given language +- downloading and iterating over the Open Food Facts data dump +- handling OCRs of Open Food Facts images generated by Google Cloud Vision -The Open Food Facts Python SDK is licensed under the [MIT License](https://github.com/openfoodfacts/openfoodfacts-python/blob/develop/LICENSE). +Please note that this SDK is still in beta and the API is subject to change. Make sure to pin the version in your requirements file. ## Installation @@ -55,31 +43,73 @@ or manually from source: ## Examples +All the examples below assume that you have imported the SDK and instanciated the API object: + +```python +import openfoodfacts + +# User-Agent is mandatory +api = openfoodfacts.API(user_agent="MyAwesomeApp/1.0") +``` + *Get information about a product* ```python -api = openfoodfacts.API() code = "3017620422003" -api.product.get(code) +api.product.get(code, fields=["code", "product_name"]) +# {'code': '3017620422003', 'product_name': 'Nutella'} ``` *Perform text search* ```python -api = openfoodfacts.API() -results = api.product.text_search("mineral water") +api.product.text_search("mineral water") +# {"count": 3006628, "page": 1, "page_count": 20, "page_size": 20, "products": [{...}], "skip": 0} ``` *Create a new product or update an existing one* ```python -api = openfoodfacts.API() -results = api.product.update(CODE, body) +results = api.product.update({ + "code": CODE, + "product_name_en": "blueberry jam", + "ingredients_text_en": "blueberries, sugar, pectin, citric acid" +}) ``` -with `CODE` the product barcode and `body` the update body. +with `CODE` the product barcode. The rest of the body should be a dictionary of fields to create/update. To see all possible capabilities, check out the [usage guide](https://openfoodfacts.github.io/openfoodfacts-python/usage/). ## Third party applications If you use this SDK, feel free to open a PR to add your application in this list. + +## Contributing + +Any help is welcome, as long as you don't break the continuous integration. +Fork the repository and open a Pull Request directly on the "develop" branch. +A maintainer will review and integrate your changes. + +Maintainers: + +- [Anubhav Bhargava](https://github.com/Anubhav-Bhargava) +- [Frank Rousseau](https://github.com/frankrousseau) +- [Pierre Slamich](https://github.com/teolemon) +- [Raphaël](https://github.com/raphael0202) + +Contributors: + +- Agamit Sudo +- [Daniel Stolpe](https://github.com/numberpi) +- [Enioluwa Segun](https://github.com/enioluwas) +- [Nicolas Leger](https://github.com/nicolasleger) +- [Pablo Hinojosa](https://github.com/Pablohn26) +- [Andrea Stagi](https://github.com/astagi) +- [Benoît Prieur](https://github.com/benprieur) +- [Aadarsh A](https://github.com/aadarsh-ram) + +## Copyright and License + + Copyright 2016-2022 Open Food Facts + +The Open Food Facts Python SDK is licensed under the [MIT License](https://github.com/openfoodfacts/openfoodfacts-python/blob/develop/LICENSE). \ No newline at end of file diff --git a/openfoodfacts/api.py b/openfoodfacts/api.py index 4631aac..2aa97b2 100644 --- a/openfoodfacts/api.py +++ b/openfoodfacts/api.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union, cast import requests @@ -11,8 +11,20 @@ def get_http_auth(environment: Environment) -> Optional[Tuple[str, str]]: def send_get_request( - url: str, api_config: APIConfig, params: Optional[Dict[str, Any]] = None -) -> dict: + url: str, + api_config: APIConfig, + params: Optional[Dict[str, Any]] = None, + return_none_on_404: bool = False, +) -> Optional[JSONType]: + """Send a GET request to the given URL. + + :param url: the URL to send the request to + :param api_config: the API configuration + :param params: the query parameters, defaults to None + :param return_none_on_404: if True, None is returned if the response + status code is 404, defaults to False + :return: the API response + """ r = http_session.get( url, params=params, @@ -20,6 +32,8 @@ def send_get_request( timeout=api_config.timeout, auth=get_http_auth(api_config.environment), ) + if r.status_code == 404 and return_none_on_404: + return None r.raise_for_status() return r.json() @@ -59,11 +73,13 @@ def __init__(self, api_config: APIConfig): def get(self, facet_name: Union[Facet, str]) -> JSONType: facet = Facet.from_str_or_enum(facet_name) facet_plural = facet.value.replace("_", "-") - return send_get_request( + resp = send_get_request( url=f"{self.base_url}/{facet_plural}", params={"json": "1"}, api_config=self.api_config, ) + resp = cast(JSONType, resp) + return resp def get_products( self, @@ -85,15 +101,17 @@ def get_products( """ facet = Facet.from_str_or_enum(facet_name) facet_singular = facet.name.replace("_", "-") - params = {"page": page, "page_size": page_size} + params: JSONType = {"page": page, "page_size": page_size} if fields is not None: params["fields"] = ",".join(fields) - return send_get_request( + resp = send_get_request( url=f"{self.base_url}/{facet_singular}/{facet_value}.json", params=params, api_config=self.api_config, ) + resp = cast(JSONType, resp) + return resp class ProductResource: @@ -105,14 +123,26 @@ def __init__(self, api_config: APIConfig): country_code=self.api_config.country.name, ) - def get(self, code: str, fields: Optional[List[str]] = None) -> Optional[JSONType]: + def get( + self, + code: str, + fields: Optional[List[str]] = None, + raise_if_invalid: bool = False, + ) -> Optional[JSONType]: """Return a product. + If the product does not exist, None is returned. + :param code: barcode of the product :param fields: a list of fields to return. If None, all fields are returned. + :param raise_if_invalid: if True, a ValueError is raised if the + barcode is invalid, defaults to False. :return: the API response """ + if len(code) == 0: + raise ValueError("code must be a non-empty string") + fields = fields or [] url = f"{self.base_url}/api/{self.api_config.version.value}/product/{code}" @@ -123,7 +153,21 @@ def get(self, code: str, fields: Optional[List[str]] = None) -> Optional[JSONTyp # https://github.com/openfoodfacts/openfoodfacts-server/issues/1607 url += "?fields={}".format(",".join(fields)) - return send_get_request(url=url, api_config=self.api_config) + resp = send_get_request( + url=url, api_config=self.api_config, return_none_on_404=True + ) + + if resp is None: + # product not found + return None + + if resp["status"] == 0: + # invalid barcode + if raise_if_invalid: + raise ValueError(f"invalid barcode: {code}") + return None + + return resp["product"] if resp is not None else None def text_search( self, @@ -259,6 +303,9 @@ def __init__( ) -> None: """Initialize the API instance. + :param user_agent: the user agent to use for HTTP requests, this is + mandatory. Give a meaningful user agent that describes your + app/script. :param username: user username, only used for write requests, defaults to None :param password: user password, only used for write requests, defaults diff --git a/tests/test_api.py b/tests/test_api.py index 0e1dc79..5bf4917 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,7 @@ import json import unittest +import pytest import requests_mock import openfoodfacts @@ -12,14 +13,73 @@ class TestProducts(unittest.TestCase): def test_get_product(self): api = openfoodfacts.API(user_agent=TEST_USER_AGENT, version="v2") code = "1223435" - response_data = {"product": {"code": "1223435"}} + response_data = { + "product": {"code": "1223435"}, + "status": 1, + "status_verbose": "product found", + } with requests_mock.mock() as mock: mock.get( f"https://world.openfoodfacts.org/api/v2/product/{code}", text=json.dumps(response_data), ) res = api.product.get(code) - self.assertEqual(res, response_data) + self.assertEqual(res, response_data["product"]) + + def test_get_product_missing(self): + api = openfoodfacts.API(user_agent=TEST_USER_AGENT, version="v2") + code = "1223435" + response_data = { + "status": 0, + "status_verbose": "product not found", + } + with requests_mock.mock() as mock: + mock.get( + f"https://world.openfoodfacts.org/api/v2/product/{code}", + text=json.dumps(response_data), + status_code=404, + ) + res = api.product.get(code) + self.assertEqual(res, None) + + def test_get_product_with_fields(self): + api = openfoodfacts.API(user_agent=TEST_USER_AGENT, version="v2") + code = "1223435" + response_data = { + "product": {"code": "1223435"}, + "status": 1, + "status_verbose": "product found", + } + with requests_mock.mock() as mock: + mock.get( + f"https://world.openfoodfacts.org/api/v2/product/{code}", + text=json.dumps(response_data), + ) + res = api.product.get(code, fields=["code"]) + self.assertEqual(res, response_data["product"]) + self.assertEqual(mock.last_request.qs["fields"], ["code"]) + + def test_get_product_invalid_code(self): + api = openfoodfacts.API(user_agent=TEST_USER_AGENT, version="v2") + code = "84800002930392025252502520502" + response_data = { + "status": 0, + "status_verbose": "no code or invalid code", + } + with requests_mock.mock() as mock: + mock.get( + f"https://world.openfoodfacts.org/api/v2/product/{code}", + text=json.dumps(response_data), + status_code=200, + ) + res = api.product.get(code) + self.assertEqual(res, None) + + with pytest.raises( + ValueError, + match="invalid barcode: 84800002930392025252502520502", + ): + api.product.get(code, raise_if_invalid=True) def test_text_search(self): api = openfoodfacts.API(user_agent=TEST_USER_AGENT, version="v2")