Skip to content

Commit f4d3568

Browse files
committed
feat: add rudimentary support for folksonomy tags
This adds initial basic CRUD methods for folksonomy tags centered around products.
1 parent af1f622 commit f4d3568

File tree

3 files changed

+343
-1
lines changed

3 files changed

+343
-1
lines changed

src/openfoodfacts/api.py

Lines changed: 252 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import typing
44
import warnings
55
from pathlib import Path
6-
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
6+
from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple, Union
77

88
import requests
99

@@ -214,6 +214,256 @@ def get_products(
214214
return typing.cast(requests.Response, r).json()
215215

216216

217+
class FolksonomyResource:
218+
def __init__(self, api_config: APIConfig):
219+
self.api_config: APIConfig = api_config
220+
self.base_url = URLBuilder.folksonomy(environment=self.api_config.environment)
221+
222+
def get(
223+
self,
224+
code: str,
225+
owner: str | None = None,
226+
keys: Sequence | str | None = None,
227+
) -> requests.Response:
228+
"""Retrieve folksonomy tags for a product.
229+
230+
:param code: the product barcode
231+
:param owner: the tag owner; requires authentication as that user.
232+
Case-sensitive. Leave empty for public tags (default).
233+
:param keys: List of keys to filter by. Can be provided as either
234+
a Python sequence (list, set, tuple, ...) or as
235+
a comma-separated string.
236+
If None (the default), all keys are returned.
237+
:return: the API response"""
238+
params: dict[str, str] = dict()
239+
if owner:
240+
params["owner"] = owner
241+
if keys:
242+
if not isinstance(keys, str):
243+
keys = ",".join(keys)
244+
params["keys"] = keys
245+
return _send_request(
246+
url=f"{self.base_url}/product/{code}",
247+
api_config=self.api_config,
248+
method="GET",
249+
params=params,
250+
)
251+
252+
def add(
253+
self,
254+
code: str,
255+
key: str,
256+
value: str,
257+
version: Literal[1] | None = None,
258+
owner: str | None = None,
259+
) -> requests.Response:
260+
"""Add a folksonomy tag to a product.
261+
262+
:param code: the product barcode
263+
:param key: the key for the tag
264+
:param value: the value to set for the tag
265+
:param version: version of the tag. Should be None or 1.
266+
:param owner: the tag owner; requires authentication as that user.
267+
Case-sensitive. Leave empty for public tags (default).
268+
:return: the API response"""
269+
params: dict[str, str | int] = dict()
270+
params["code"] = code
271+
params["k"] = key
272+
params["v"] = value
273+
if version:
274+
params["version"] = version
275+
if owner:
276+
params["owner"] = owner
277+
return _send_request(
278+
url=f"{self.base_url}/product",
279+
api_config=self.api_config,
280+
method="POST",
281+
json=params,
282+
)
283+
284+
def update(
285+
self,
286+
code: str,
287+
key: str,
288+
value: str,
289+
version: int,
290+
owner: str | None = None,
291+
) -> requests.Response:
292+
"""Update a folksonomy tag on a product.
293+
294+
:param code: the product barcode
295+
:param key: the key for the tag
296+
:param value: the value to set for the tag
297+
:param version: must be equal to previous version + 1
298+
:param owner: the tag owner; requires authentication as that user.
299+
Case-sensitive. Leave empty for public tags (default).
300+
:return: the API response"""
301+
params: dict[str, str | int] = dict()
302+
params["code"] = code
303+
params["k"] = key
304+
params["v"] = value
305+
params["version"] = version
306+
if owner:
307+
params["owner"] = owner
308+
return _send_request(
309+
url=f"{self.base_url}/product",
310+
api_config=self.api_config,
311+
method="PUT",
312+
json=params,
313+
)
314+
315+
def delete(
316+
self,
317+
code: str,
318+
key: str,
319+
version: int,
320+
owner: str | None = None,
321+
) -> requests.Response:
322+
"""Delete a folksonomy tag on a product.
323+
324+
:param code: the product barcode
325+
:param key: the key to delete
326+
:param version: the version the tag is at
327+
:param owner: the tag owner; requires authentication as that user.
328+
Case-sensitive. Leave empty for public tags (default).
329+
:return: the API response"""
330+
params: dict[str, str | int] = dict()
331+
params["version"] = version
332+
if owner:
333+
params["owner"] = owner
334+
return _send_request(
335+
url=f"{self.base_url}/product/{code}/{key}",
336+
api_config=self.api_config,
337+
method="DELETE",
338+
params=params,
339+
)
340+
341+
342+
class FolksonomyResource:
343+
def __init__(self, api_config: APIConfig):
344+
self.api_config: APIConfig = api_config
345+
self.base_url = URLBuilder.folksonomy(environment=self.api_config.environment)
346+
347+
def get(
348+
self,
349+
code: str,
350+
owner: str | None = None,
351+
keys: Sequence | str | None = None,
352+
) -> requests.Response:
353+
"""Retrieve folksonomy tags for a product.
354+
355+
:param code: the product barcode
356+
:param owner: the tag owner; requires authentication as that user.
357+
Case-sensitive. Leave empty for public tags (default).
358+
:param keys: List of keys to filter by. Can be provided as either
359+
a Python sequence (list, set, tuple, ...) or as
360+
a comma-separated string.
361+
If None (the default), all keys are returned.
362+
:return: the API response"""
363+
params: dict[str, str] = dict()
364+
if owner:
365+
params["owner"] = owner
366+
if keys:
367+
if not isinstance(keys, str):
368+
keys = ",".join(keys)
369+
params["keys"] = keys
370+
return _send_request(
371+
url=f"{self.base_url}/product/{code}",
372+
api_config=self.api_config,
373+
method="GET",
374+
params=params,
375+
)
376+
377+
def add(
378+
self,
379+
code: str,
380+
key: str,
381+
value: str,
382+
version: Literal[1] | None = None,
383+
owner: str | None = None,
384+
) -> requests.Response:
385+
"""Add a folksonomy tag to a product.
386+
387+
:param code: the product barcode
388+
:param key: the key for the tag
389+
:param value: the value to set for the tag
390+
:param version: version of the tag. Should be None or 1.
391+
:param owner: the tag owner; requires authentication as that user.
392+
Case-sensitive. Leave empty for public tags (default).
393+
:return: the API response"""
394+
params: dict[str, str | int] = dict()
395+
params["code"] = code
396+
params["k"] = key
397+
params["v"] = value
398+
if version:
399+
params["version"] = version
400+
if owner:
401+
params["owner"] = owner
402+
return _send_request(
403+
url=f"{self.base_url}/product",
404+
api_config=self.api_config,
405+
method="POST",
406+
json=params,
407+
)
408+
409+
def update(
410+
self,
411+
code: str,
412+
key: str,
413+
value: str,
414+
version: int,
415+
owner: str | None = None,
416+
) -> requests.Response:
417+
"""Update a folksonomy tag on a product.
418+
419+
:param code: the product barcode
420+
:param key: the key for the tag
421+
:param value: the value to set for the tag
422+
:param version: must be equal to previous version + 1
423+
:param owner: the tag owner; requires authentication as that user.
424+
Case-sensitive. Leave empty for public tags (default).
425+
:return: the API response"""
426+
params: dict[str, str | int] = dict()
427+
params["code"] = code
428+
params["k"] = key
429+
params["v"] = value
430+
params["version"] = version
431+
if owner:
432+
params["owner"] = owner
433+
return _send_request(
434+
url=f"{self.base_url}/product",
435+
api_config=self.api_config,
436+
method="PUT",
437+
json=params,
438+
)
439+
440+
def delete(
441+
self,
442+
code: str,
443+
key: str,
444+
version: int,
445+
owner: str | None = None,
446+
) -> requests.Response:
447+
"""Delete a folksonomy tag on a product.
448+
449+
:param code: the product barcode
450+
:param key: the key to delete
451+
:param version: the version the tag is at
452+
:param owner: the tag owner; requires authentication as that user.
453+
Case-sensitive. Leave empty for public tags (default).
454+
:return: the API response"""
455+
params: dict[str, str | int] = dict()
456+
params["version"] = version
457+
if owner:
458+
params["owner"] = owner
459+
return _send_request(
460+
url=f"{self.base_url}/product/{code}/{key}",
461+
api_config=self.api_config,
462+
method="DELETE",
463+
params=params,
464+
)
465+
466+
217467
class ProductResource:
218468
def __init__(self, api_config: APIConfig):
219469
self.api_config = api_config
@@ -637,6 +887,7 @@ def __init__(
637887
self.country = country
638888
self.product = ProductResource(self.api_config)
639889
self.facet = FacetResource(self.api_config)
890+
self.folksonomy = FolksonomyResource(self.api_config)
640891
self.robotoff = RobotoffResource(self.api_config)
641892

642893

src/openfoodfacts/utils/__init__.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,28 @@ def country(flavor: Flavor, environment: Environment, country_code: str) -> str:
127127
base_domain=flavor.get_base_domain(),
128128
)
129129

130+
@staticmethod
131+
def folksonomy(environment: Environment) -> str:
132+
"""Get API endpoint URL for the Folksonomy Engine.
133+
134+
Note that this method will always return the "openfoodfacts.org" top domain as
135+
SDK users don't need to worry about cookie domains for authentication.
136+
This endpoint is project agnostic and will work for all the projects.
137+
138+
Example use:
139+
>>> print(URLBuilder.folksonomy(Environment.net))
140+
"https://api.folksonomy.openfoodfacts.net"
141+
142+
:param environment: Whether to use the production (Environment.org)
143+
or staging (Environment.net) environment.
144+
:return: The Folksonomy Engine's API endpoint URL as a string.
145+
"""
146+
return URLBuilder._get_url(
147+
prefix="api.folksonomy",
148+
tld=environment.value,
149+
base_domain=Flavor.off.get_base_domain(),
150+
)
151+
130152

131153
def jsonl_iter(jsonl_path: Union[str, Path]) -> Iterable[Dict]:
132154
"""Iterate over elements of a JSONL file.

tests/unit/test_api.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,75 @@ def test_upload_image_with_path(self, tmp_path):
402402
}
403403

404404

405+
class TestFolksonomy:
406+
"""Unit tests for api.FolksonomyResource and its methods."""
407+
408+
def test_get_with_entries(self):
409+
api = openfoodfacts.API(user_agent=TEST_USER_AGENT)
410+
code = "7311043021598"
411+
response_data = [
412+
{
413+
"product": "7311043021598",
414+
"k": "ingredients:garlic",
415+
"v": "no",
416+
"owner": "",
417+
"version": 1,
418+
"editor": "freso",
419+
"last_edit": "2025-11-09T00:30:50.871369",
420+
"comment": "",
421+
},
422+
{
423+
"product": "7311043021598",
424+
"k": "packaging:has_character",
425+
"v": "no",
426+
"owner": "",
427+
"version": 1,
428+
"editor": "freso",
429+
"last_edit": "2025-11-09T00:30:44.868444",
430+
"comment": "",
431+
},
432+
{
433+
"product": "7311043021598",
434+
"k": "weight:net",
435+
"v": "75 g",
436+
"owner": "",
437+
"version": 1,
438+
"editor": "freso",
439+
"last_edit": "2025-12-03T21:18:22.202625",
440+
"comment": "",
441+
},
442+
{
443+
"product": "7311043021598",
444+
"k": "weight:net:g",
445+
"v": "75",
446+
"owner": "",
447+
"version": 1,
448+
"editor": "freso",
449+
"last_edit": "2026-03-03T16:32:03.239368",
450+
"comment": "",
451+
},
452+
{
453+
"product": "7311043021598",
454+
"k": "weight:net:source",
455+
"v": "packaging",
456+
"owner": "",
457+
"version": 1,
458+
"editor": "freso",
459+
"last_edit": "2026-03-03T16:32:06.931969",
460+
"comment": "",
461+
},
462+
]
463+
with requests_mock.mock() as mock:
464+
mock.get(
465+
f"https://api.folksonomy.openfoodfacts.org/product/{code}",
466+
text=json.dumps(response_data),
467+
status_code=200,
468+
)
469+
res = api.folksonomy.get(code)
470+
assert res.status_code == 200
471+
assert len(res.json()) == 5
472+
473+
405474
class TestSendRequest:
406475
"""Unit tests for the api._send_request function."""
407476

0 commit comments

Comments
 (0)