Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 135 additions & 1 deletion src/openfoodfacts/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import typing
import warnings
from pathlib import Path
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple, Union

import requests

Expand Down Expand Up @@ -214,6 +214,139 @@
return typing.cast(requests.Response, r).json()


class FolksonomyResource:
def __init__(self, api_config: APIConfig):
self.api_config: APIConfig = api_config
self.base_url = URLBuilder.folksonomy(environment=self.api_config.environment)

def get(
self,
code: str,
owner: str | None = None,
keys: Sequence | str | None = None,
) -> requests.Response:
"""Retrieve folksonomy tags for a product.

:param code: the product barcode
:param owner: the tag owner; requires authentication as that user.
Case-sensitive. Leave empty for public tags (default).
:param keys: List of keys to filter by. Can be provided as either
a Python sequence (list, set, tuple, ...) or as
a comma-separated string.
If None (the default), all keys are returned.
:return: the API response"""
params: dict[str, str] = dict()

Check warning on line 238 in src/openfoodfacts/api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=openfoodfacts_openfoodfacts-python&issues=AZwq8E5KvkZOERQAOm16&open=AZwq8E5KvkZOERQAOm16&pullRequest=418
if owner:
params["owner"] = owner
if keys:
if not isinstance(keys, str):
keys = ",".join(keys)
params["keys"] = keys
r = _send_request(
url=f"{self.base_url}/product/{code}",
api_config=self.api_config,
method="GET",
params=params,
)
assert r is not None
return r

def add(
self,
code: str,
key: str,
value: str,
version: Literal[1] | None = None,
owner: str | None = None,
) -> requests.Response:
"""Add a folksonomy tag to a product.

:param code: the product barcode
:param key: the key for the tag
:param value: the value to set for the tag
:param version: version of the tag. Should be None or 1.
:param owner: the tag owner; requires authentication as that user.
Case-sensitive. Leave empty for public tags (default).
:return: the API response"""
params: dict[str, str | int] = dict()

Check warning on line 271 in src/openfoodfacts/api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=openfoodfacts_openfoodfacts-python&issues=AZwq8E5KvkZOERQAOm17&open=AZwq8E5KvkZOERQAOm17&pullRequest=418
params["code"] = code
params["k"] = key
params["v"] = value
if version:
params["version"] = version
if owner:
params["owner"] = owner
r = _send_request(
url=f"{self.base_url}/product",
api_config=self.api_config,
method="POST",
json=params,
)
assert r is not None
return r

def update(
self,
code: str,
key: str,
value: str,
version: int,
owner: str | None = None,
) -> requests.Response:
"""Update a folksonomy tag on a product.

:param code: the product barcode
:param key: the key for the tag
:param value: the value to set for the tag
:param version: must be equal to previous version + 1
:param owner: the tag owner; requires authentication as that user.
Case-sensitive. Leave empty for public tags (default).
:return: the API response"""
params: dict[str, str | int] = dict()

Check warning on line 305 in src/openfoodfacts/api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=openfoodfacts_openfoodfacts-python&issues=AZwq8E5KvkZOERQAOm18&open=AZwq8E5KvkZOERQAOm18&pullRequest=418
params["code"] = code
params["k"] = key
params["v"] = value
params["version"] = version
if owner:
params["owner"] = owner
r = _send_request(
url=f"{self.base_url}/product",
api_config=self.api_config,
method="PUT",
json=params,
)
assert r is not None
return r

def delete(
self,
code: str,
key: str,
version: int,
owner: str | None = None,
) -> requests.Response:
"""Delete a folksonomy tag on a product.

:param code: the product barcode
:param key: the key to delete
:param version: the version the tag is at
:param owner: the tag owner; requires authentication as that user.
Case-sensitive. Leave empty for public tags (default).
:return: the API response"""
params: dict[str, str | int] = dict()

Check warning on line 336 in src/openfoodfacts/api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=openfoodfacts_openfoodfacts-python&issues=AZwq8E5KvkZOERQAOm19&open=AZwq8E5KvkZOERQAOm19&pullRequest=418
params["version"] = version
if owner:
params["owner"] = owner
r = _send_request(
url=f"{self.base_url}/product/{code}/{key}",
api_config=self.api_config,
method="DELETE",
params=params,
)
assert r is not None
return r


class ProductResource:
def __init__(self, api_config: APIConfig):
self.api_config = api_config
Expand Down Expand Up @@ -637,6 +770,7 @@
self.country = country
self.product = ProductResource(self.api_config)
self.facet = FacetResource(self.api_config)
self.folksonomy = FolksonomyResource(self.api_config)
self.robotoff = RobotoffResource(self.api_config)


Expand Down
22 changes: 22 additions & 0 deletions src/openfoodfacts/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,28 @@ def country(flavor: Flavor, environment: Environment, country_code: str) -> str:
base_domain=flavor.get_base_domain(),
)

@staticmethod
def folksonomy(environment: Environment) -> str:
"""Get API endpoint URL for the Folksonomy Engine.

Note that this method will always return the "openfoodfacts.org" top domain as
SDK users don't need to worry about cookie domains for authentication.
This endpoint is project agnostic and will work for all the projects.

Example use:
>>> print(URLBuilder.folksonomy(Environment.net))
"https://api.folksonomy.openfoodfacts.net"

:param environment: Whether to use the production (Environment.org)
or staging (Environment.net) environment.
:return: The Folksonomy Engine's API endpoint URL as a string.
"""
return URLBuilder._get_url(
prefix="api.folksonomy",
tld=environment.value,
base_domain=Flavor.off.get_base_domain(),
)


def jsonl_iter(jsonl_path: Union[str, Path]) -> Iterable[Dict]:
"""Iterate over elements of a JSONL file.
Expand Down
69 changes: 69 additions & 0 deletions tests/unit/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,75 @@ def test_upload_image_with_path(self, tmp_path):
}


class TestFolksonomy:
"""Unit tests for api.FolksonomyResource and its methods."""

def test_get_with_entries(self):
api = openfoodfacts.API(user_agent=TEST_USER_AGENT)
code = "7311043021598"
response_data = [
{
"product": "7311043021598",
"k": "ingredients:garlic",
"v": "no",
"owner": "",
"version": 1,
"editor": "freso",
"last_edit": "2025-11-09T00:30:50.871369",
"comment": "",
},
{
"product": "7311043021598",
"k": "packaging:has_character",
"v": "no",
"owner": "",
"version": 1,
"editor": "freso",
"last_edit": "2025-11-09T00:30:44.868444",
"comment": "",
},
{
"product": "7311043021598",
"k": "weight:net",
"v": "75 g",
"owner": "",
"version": 1,
"editor": "freso",
"last_edit": "2025-12-03T21:18:22.202625",
"comment": "",
},
{
"product": "7311043021598",
"k": "weight:net:g",
"v": "75",
"owner": "",
"version": 1,
"editor": "freso",
"last_edit": "2026-03-03T16:32:03.239368",
"comment": "",
},
{
"product": "7311043021598",
"k": "weight:net:source",
"v": "packaging",
"owner": "",
"version": 1,
"editor": "freso",
"last_edit": "2026-03-03T16:32:06.931969",
"comment": "",
},
]
with requests_mock.mock() as mock:
mock.get(
f"https://api.folksonomy.openfoodfacts.org/product/{code}",
text=json.dumps(response_data),
status_code=200,
)
res = api.folksonomy.get(code)
assert res.status_code == 200
assert len(res.json()) == 5


class TestSendRequest:
"""Unit tests for the api._send_request function."""

Expand Down
Loading