Skip to content

Commit

Permalink
fix: improve sdk (#193)
Browse files Browse the repository at this point in the history
* fix: add switch_ingredient_lang.py data quality script

* fix: improve api.products.get method

- return the product directly as response
- handle invalid barcode

* docs: improve documentation in README.md

* fix: remove data quality scripts folder

* fix: return None if product is not found

* fix: fix typing issues

* tests: fix unit tests

* tests: add more unit tests
  • Loading branch information
raphael0202 authored Dec 4, 2023
1 parent 9d99e6c commit 07f224c
Show file tree
Hide file tree
Showing 3 changed files with 177 additions and 40 deletions.
90 changes: 60 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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).
63 changes: 55 additions & 8 deletions openfoodfacts/api.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -11,15 +11,29 @@ 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,
headers={"User-Agent": api_config.user_agent},
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()

Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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}"

Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
64 changes: 62 additions & 2 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import unittest

import pytest
import requests_mock

import openfoodfacts
Expand All @@ -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")
Expand Down

0 comments on commit 07f224c

Please sign in to comment.