From 101c8538512f92c1ee911e1391ce751547813aa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frederik=20=E2=80=9CFreso=E2=80=9D=20S=2E=20Olesen?= Date: Wed, 4 Feb 2026 23:46:15 +0100 Subject: [PATCH] feat: add rudimentary support for folksonomy tags This adds initial basic CRUD methods for folksonomy tags centered around products. --- src/openfoodfacts/api.py | 136 +++++++++++++++++++++++++++- src/openfoodfacts/utils/__init__.py | 22 +++++ tests/unit/test_api.py | 69 ++++++++++++++ 3 files changed, 226 insertions(+), 1 deletion(-) diff --git a/src/openfoodfacts/api.py b/src/openfoodfacts/api.py index be5ee07d..ce28bd71 100644 --- a/src/openfoodfacts/api.py +++ b/src/openfoodfacts/api.py @@ -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 @@ -214,6 +214,139 @@ def get_products( 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() + 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() + 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() + 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() + 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 @@ -637,6 +770,7 @@ def __init__( 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) diff --git a/src/openfoodfacts/utils/__init__.py b/src/openfoodfacts/utils/__init__.py index 30aea633..3f620890 100644 --- a/src/openfoodfacts/utils/__init__.py +++ b/src/openfoodfacts/utils/__init__.py @@ -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. diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index f9c314bd..2e43a05e 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -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."""