Skip to content

Commit 10c42d2

Browse files
authored
feat: add ingredient analysis endpoint (#285)
1 parent c9508d7 commit 10c42d2

File tree

3 files changed

+209
-3
lines changed

3 files changed

+209
-3
lines changed

docs/usage.md

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,20 @@ All parameters are optional with the exception of user_agent, but here is a desc
3030
- `version`: API version (v2 is the default)
3131
- `environment`: either `org` for production environment (openfoodfacts.org) or `net` for staging (openfoodfacts.net)
3232

33-
*Get information about a product*
33+
### Get information about a product
3434

3535
```python
3636
code = "3017620422003"
3737
api.product.get(code)
3838
```
3939

40-
*Perform text search*
40+
### Perform text search
4141

4242
```python
4343
results = api.product.text_search("pizza")
4444
```
4545

46-
*Create a new product or update an existing one*
46+
### Create a new product or update an existing one
4747

4848
```python
4949
results = api.product.update(body)
@@ -54,6 +54,52 @@ the key "code" and its value, corresponding to the product that we
5454
want to update. Example:
5555
```body = {'code': '3850334341389', 'product_name': 'Mlinci'}```
5656

57+
### Perform ingredient analysis
58+
59+
You can perform the ingredient analysis of a text in a given language using the API. Please note that ingredient analysis is costly, so prefer using the preprod server for this operation.
60+
61+
```python
62+
from openfoodfacts import API, APIVersion, Environment
63+
64+
api = API(user_agent="<application name>",
65+
version=APIVersion.v3,
66+
environment=Environment.net)
67+
68+
results = api.product.parse_ingredients("water, sugar, salt", lang="en")
69+
70+
print(results)
71+
72+
## [{'ciqual_food_code': '18066',
73+
# 'ecobalyse_code': 'tap-water',
74+
# 'id': 'en:water',
75+
# 'is_in_taxonomy': 1,
76+
# 'percent_estimate': 66.6666666666667,
77+
# 'percent_max': 100,
78+
# 'percent_min': 33.3333333333333,
79+
# 'text': 'water',
80+
# 'vegan': 'yes',
81+
# 'vegetarian': 'yes'},
82+
# {'ciqual_proxy_food_code': '31016',
83+
# 'ecobalyse_code': 'sugar',
84+
# 'id': 'en:sugar',
85+
# 'is_in_taxonomy': 1,
86+
# 'percent_estimate': 16.6666666666667,
87+
# 'percent_max': 50,
88+
# 'percent_min': 0,
89+
# 'text': 'sugar',
90+
# 'vegan': 'yes',
91+
# 'vegetarian': 'yes'},
92+
# {'ciqual_food_code': '11058',
93+
# 'id': 'en:salt',
94+
# 'is_in_taxonomy': 1,
95+
# 'percent_estimate': 16.6666666666667,
96+
# 'percent_max': 33.3333333333333,
97+
# 'percent_min': 0,
98+
# 'text': 'salt',
99+
# 'vegan': 'yes',
100+
# 'vegetarian': 'yes'}]
101+
```
102+
57103
## Using the dataset
58104

59105
If you're planning to perform data analysis on Open Food Facts, the easiest way is to download and use the Open Food Facts dataset dump. Fortunately it can be done really easily using the SDK:

openfoodfacts/api.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import logging
12
from typing import Any, Dict, List, Optional, Tuple, Union, cast
23

34
import requests
45

56
from .types import APIConfig, APIVersion, Country, Environment, Facet, Flavor, JSONType
67
from .utils import URLBuilder, http_session
78

9+
logger = logging.getLogger(__name__)
10+
811

912
def get_http_auth(environment: Environment) -> Optional[Tuple[str, str]]:
1013
return ("off", "off") if environment is Environment.net else None
@@ -311,6 +314,88 @@ def select_image(
311314
r.raise_for_status()
312315
return r
313316

317+
def parse_ingredients(
318+
self, text: str, lang: str, timeout: int = 10
319+
) -> list[JSONType]:
320+
"""Parse ingredients text using Product Opener API.
321+
322+
It is only available for `off` flavor (food).
323+
324+
The result is a list of ingredients, each ingredient is a dict with the
325+
following keys:
326+
327+
- id: the ingredient ID. Having an ID does not means that the
328+
ingredient is recognized, you must check if it exists in the
329+
taxonomy.
330+
- text: the ingredient text (as it appears in the input ingredients
331+
list)
332+
- percent_min: the minimum percentage of the ingredient in the product
333+
- percent_max: the maximum percentage of the ingredient in the product
334+
- percent_estimate: the estimated percentage of the ingredient in the
335+
product
336+
- vegan (bool): optional key indicating if the ingredient is vegan
337+
- vegetarian (bool): optional key indicating if the ingredient is
338+
vegetarian
339+
340+
:param server_type: the server type (project) to use
341+
:param text: the ingredients text to parse
342+
:param lang: the language of the text (used for parsing) as a 2-letter
343+
code
344+
:param timeout: the request timeout in seconds, defaults to 10s
345+
:raises RuntimeError: a RuntimeError is raised if the parsing fails
346+
:return: the list of parsed ingredients
347+
"""
348+
if self.api_config.flavor != Flavor.off:
349+
raise ValueError("ingredient parsing is only available for food")
350+
351+
if self.api_config.version != APIVersion.v3:
352+
logger.warning(
353+
"ingredient parsing is only available in v3 of the API (here: %s), using v3",
354+
self.api_config.version,
355+
)
356+
# by using "test" as code, we don't save any information to database
357+
# This endpoint is specifically designed for testing purposes
358+
url = f"{self.base_url}/api/v3/product/test"
359+
360+
if len(text) == 0:
361+
raise ValueError("text must be a non-empty string")
362+
363+
try:
364+
r = http_session.patch(
365+
url,
366+
auth=get_http_auth(self.api_config.environment),
367+
json={
368+
"fields": "ingredients",
369+
"lc": lang,
370+
"tags_lc": lang,
371+
"product": {
372+
"lang": lang,
373+
f"ingredients_text_{lang}": text,
374+
},
375+
},
376+
timeout=timeout,
377+
)
378+
except (
379+
requests.exceptions.ConnectionError,
380+
requests.exceptions.SSLError,
381+
requests.exceptions.Timeout,
382+
) as e:
383+
raise RuntimeError(
384+
f"Unable to parse ingredients: error during HTTP request: {e}"
385+
)
386+
387+
if not r.ok:
388+
raise RuntimeError(
389+
f"Unable to parse ingredients (non-200 status code): {r.status_code}, {r.text}"
390+
)
391+
392+
response_data = r.json()
393+
394+
if response_data.get("status") != "success":
395+
raise RuntimeError(f"Unable to parse ingredients: {response_data}")
396+
397+
return response_data["product"].get("ingredients", [])
398+
314399

315400
class API:
316401
def __init__(

tests/test_api.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import re
23
import unittest
34

45
import pytest
@@ -105,6 +106,80 @@ def test_text_search(self):
105106
)
106107
self.assertEqual(res["products"], ["banania", "banania big"])
107108

109+
def test_parse_ingredients(self):
110+
api = openfoodfacts.API(user_agent=TEST_USER_AGENT, version="v2")
111+
ingredients_data = [
112+
{
113+
"ciqual_food_code": "18066",
114+
"ecobalyse_code": "tap-water",
115+
"id": "en:water",
116+
"is_in_taxonomy": 1,
117+
"percent_estimate": 75,
118+
"percent_max": 100,
119+
"percent_min": 50,
120+
"text": "eau",
121+
"vegan": "yes",
122+
"vegetarian": "yes",
123+
},
124+
{
125+
"ciqual_proxy_food_code": "31016",
126+
"ecobalyse_code": "sugar",
127+
"id": "en:sugar",
128+
"is_in_taxonomy": 1,
129+
"percent_estimate": 25,
130+
"percent_max": 50,
131+
"percent_min": 0,
132+
"text": "sucre",
133+
"vegan": "yes",
134+
"vegetarian": "yes",
135+
},
136+
]
137+
with requests_mock.mock() as mock:
138+
response_data = {
139+
"product": {"ingredients": ingredients_data},
140+
"status": "success",
141+
}
142+
mock.patch(
143+
"https://world.openfoodfacts.org/api/v3/product/test",
144+
text=json.dumps(response_data),
145+
)
146+
res = api.product.parse_ingredients("eau, sucre", lang="fr")
147+
assert res == ingredients_data
148+
149+
def test_parse_ingredients_fail(self):
150+
api = openfoodfacts.API(user_agent=TEST_USER_AGENT, version="v2")
151+
with requests_mock.mock() as mock:
152+
response_data = {
153+
"status": "fail",
154+
}
155+
mock.patch(
156+
"https://world.openfoodfacts.org/api/v3/product/test",
157+
text=json.dumps(response_data),
158+
)
159+
160+
with pytest.raises(
161+
RuntimeError,
162+
match="Unable to parse ingredients: {'status': 'fail'}",
163+
):
164+
api.product.parse_ingredients("eau, sucre", lang="fr")
165+
166+
def test_parse_ingredients_fail_non_HTTP_200(self):
167+
api = openfoodfacts.API(user_agent=TEST_USER_AGENT, version="v2")
168+
with requests_mock.mock() as mock:
169+
mock.patch(
170+
"https://world.openfoodfacts.org/api/v3/product/test",
171+
status_code=400,
172+
text='{"error": "Bad Request"}',
173+
)
174+
175+
with pytest.raises(
176+
RuntimeError,
177+
match=re.escape(
178+
'Unable to parse ingredients (non-200 status code): 400, {"error": "Bad Request"}'
179+
),
180+
):
181+
api.product.parse_ingredients("eau, sucre", lang="fr")
182+
108183

109184
if __name__ == "__main__":
110185
unittest.main()

0 commit comments

Comments
 (0)