From 54039dde4af1852fa7f37b537084ea8a179fee51 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 05:29:04 +0000 Subject: [PATCH 1/3] =?UTF-8?q?Publish=20the=20Rohl=C3=ADk=20Group=20shops?= =?UTF-8?q?;=20add=20cart=20currency=20and=20minimum=20order=20value?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Rohlík Group shop runs the same API, and RohlikAPI already took a base_url, but consumers each had to know the other shops' URLs themselves and the models documented every amount as CZK. - New rohlik_api.SITES / Site: code, name, base_url, currency and timezone for Rohlík.cz, Knuspr.de, Gurkerl.at, Kifli.hu and Sezamo.ro (#10). - CartItem.currency from each item's ISO currency code, and Cart.currency from its items (the cart payload itself has none; None for an empty cart) (#10). - Cart.minimum_order_price from minimalOrderPrice, which Cart.from_api used to drop (#9). Cart.can_make_order's docstring no longer claims it tracks the minimum: submitConditionPassed also needs checkout details. - Docstrings and README no longer assume CZK / Rohlik.cz only; README gains an "Other shops" section. Checked against live anonymous carts on knuspr.de (currency "EUR") and rohlik.cz ("CZK"); minimalOrderPrice is present there but 0, so the value on a logged-in cart is still to be confirmed. Closes #9 Closes #10 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0135WLPbnfESWnAJmw4P8uRH --- README.md | 32 +++++++++++++++++++++++++++- rohlik_api/__init__.py | 4 ++++ rohlik_api/client.py | 12 +++++++---- rohlik_api/http_client.py | 2 +- rohlik_api/models.py | 33 ++++++++++++++++++++++------- rohlik_api/sites.py | 44 +++++++++++++++++++++++++++++++++++++++ tests/test_services.py | 42 +++++++++++++++++++++++++++++++++++++ tests/test_sites.py | 40 +++++++++++++++++++++++++++++++++++ 8 files changed, 196 insertions(+), 13 deletions(-) create mode 100644 rohlik_api/sites.py create mode 100644 tests/test_sites.py diff --git a/README.md b/README.md index b4b486a..2fb012f 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ online grocery service — search products, manage your cart, browse recipes - 🔄 Works as an async context manager - 🍳 Recipe search and ingredient shopping (Rohlík Chef) - 📦 Product details, composition/nutrition, prices, and AI summaries +- 🌍 Works with every Rohlík Group shop: Rohlík.cz, Knuspr.de, Gurkerl.at, Kifli.hu and Sezamo.ro ## Related projects @@ -155,7 +156,10 @@ Functionality is grouped into services, accessed as properties on the client: ```python # Get cart contents cart = await client.cart.get_content() -# -> Cart(total_price=199.90, total_items=3, can_make_order=True, products=[CartItem, ...]) +# -> Cart(total_price=199.90, total_items=3, can_make_order=True, products=[CartItem, ...], +# minimum_order_price=..., currency="CZK") +# can_make_order also requires checkout details (e.g. a delivery slot); to check +# the minimum order value, compare total_price with minimum_order_price. # Add items to cart added = await client.cart.add_items([ @@ -269,6 +273,32 @@ except APIRequestFailedError as err: ## Advanced usage +### Other shops + +Rohlík Group runs the same API under several brands. `SITES` holds the known +shops (base URL, currency, timezone), keyed by country code: + +| Code | Shop | Currency | +|------|------|----------| +| `cz` | Rohlík.cz (default) | CZK | +| `de` | Knuspr.de | EUR | +| `at` | Gurkerl.at | EUR | +| `hu` | Kifli.hu | HUF | +| `ro` | Sezamo.ro | RON | + +```python +from rohlik_api import SITES, RohlikAPI + +site = SITES["de"] +async with RohlikAPI("email@example.com", "password", base_url=site.base_url) as client: + cart = await client.cart.get_content() + print(cart.total_price, cart.currency or site.currency) # e.g. 11.99 EUR +``` + +`Cart.currency` comes from the cart's items, so it is `None` for an empty cart; +fall back to `site.currency` then. Delivery announcements and other texts come +back in the shop's language. + ### Configuration ```python diff --git a/rohlik_api/__init__.py b/rohlik_api/__init__.py index d5d10e4..e3b8bca 100644 --- a/rohlik_api/__init__.py +++ b/rohlik_api/__init__.py @@ -33,11 +33,15 @@ SearchResults, ShoppingList, ) +from .sites import SITES, Site __version__ = "0.2.0" __all__ = [ # Main client (facade) "RohlikAPI", + # Shops + "Site", + "SITES", # Errors "RohlikAPIError", "InvalidCredentialsError", diff --git a/rohlik_api/client.py b/rohlik_api/client.py index b700a60..4795ef8 100644 --- a/rohlik_api/client.py +++ b/rohlik_api/client.py @@ -25,16 +25,20 @@ class RohlikAPI: - """Async client for interacting with the Rohlik.cz API. + """Async client for interacting with the Rohlík Group API. + + Talks to Rohlík.cz by default; pass another shop's ``base_url`` (see + :data:`~rohlik_api.SITES`) for Knuspr.de, Gurkerl.at, Kifli.hu or Sezamo.ro. The client is built on aiohttp and exposes a service-based API for all operations. When used as an async context manager with ``auto_login=True`` (the default), it logs in on entry and logs out on exit. Args: - username: Email address used for Rohlik.cz login (required). - password: Password for the Rohlik.cz account (required). - base_url: Base URL for the Rohlik.cz API. Defaults to https://www.rohlik.cz + username: Email address used for the shop login (required). + password: Password for the shop account (required). + base_url: Base URL of the shop's API, e.g. ``SITES["de"].base_url``. + Defaults to https://www.rohlik.cz timeout: Request timeout in seconds. Defaults to 30.0. headers: Optional custom headers to include in all requests. auto_login: If True (default), log in automatically when used as a diff --git a/rohlik_api/http_client.py b/rohlik_api/http_client.py index 7f67e02..f16dbb7 100644 --- a/rohlik_api/http_client.py +++ b/rohlik_api/http_client.py @@ -61,7 +61,7 @@ class HttpClient: session is never closed by this client, leaving its lifecycle to the owner. Args: - base_url: Base URL for the Rohlik.cz API. + base_url: Base URL of the shop's API (see :data:`~rohlik_api.SITES`). timeout: Request timeout in seconds. headers: Optional custom headers added to every request. session: Optional externally managed aiohttp session to reuse. diff --git a/rohlik_api/models.py b/rohlik_api/models.py index 0a701b9..87465cd 100644 --- a/rohlik_api/models.py +++ b/rohlik_api/models.py @@ -8,8 +8,9 @@ Monetary amounts come in two shapes: ``price`` fields typed as ``str`` are pre-formatted for display (for example ``"29.90 Kč"``), while numeric ``price`` -fields are raw amounts paired with a separate ``currency``. Czech crowns (CZK, -"Kč") are the usual currency. +fields are raw amounts in the shop's currency, paired with a separate ISO 4217 +``currency`` code where the API provides one (``"CZK"`` on Rohlík.cz, ``"EUR"`` +on Knuspr.de, see :data:`~rohlik_api.SITES`). """ from __future__ import annotations @@ -35,9 +36,11 @@ class CartItem: line from the cart. name: Product name. quantity: Number of units of this product in the cart. - price: Line price for this product, in the account currency (CZK). + price: Line price for this product, in :attr:`currency`. category_name: Primary category name of the product. brand: Brand name, or an empty string if unknown. + currency: ISO 4217 currency code of :attr:`price`, e.g. ``"CZK"``, or an + empty string if the API omits it. """ id: str @@ -47,6 +50,7 @@ class CartItem: price: float category_name: str = "" brand: str = "" + currency: str = "" @classmethod def from_api(cls, item_id: str, data: dict[str, Any]) -> CartItem: @@ -59,6 +63,7 @@ def from_api(cls, item_id: str, data: dict[str, Any]) -> CartItem: price=data.get("price", 0), category_name=data.get("primaryCategoryName", ""), brand=data.get("brand", ""), + currency=data.get("currency", ""), ) @@ -67,29 +72,43 @@ class Cart: """The current shopping cart. Attributes: - total_price: Total price of the cart, in the account currency (CZK). + total_price: Total price of the cart, in :attr:`currency`. total_items: Number of distinct products in the cart (line count, not the summed quantity). - can_make_order: Whether the cart currently satisfies the conditions to - place an order (e.g. the minimum order value is met). + can_make_order: Whether the cart passes all of the shop's submit + conditions (``submitConditionPassed``). These include checkout + details such as a chosen delivery slot, so this is not a + minimum-order indicator; compare against + :attr:`minimum_order_price` for that. products: The cart's line items. + minimum_order_price: Smallest cart total the shop accepts for an + order (``minimalOrderPrice``), in :attr:`currency`, or ``None`` if + the API does not report one. Anonymous carts report ``0``. + currency: ISO 4217 currency code of the cart's prices, taken from its + items, or ``None`` for an empty cart (the cart payload itself has + no currency; see :data:`~rohlik_api.SITES` for each shop's). """ total_price: float total_items: int can_make_order: bool products: list[CartItem] = field(default_factory=list) + minimum_order_price: float | None = None + currency: str | None = None @classmethod def from_api(cls, payload: dict[str, Any]) -> Cart: """Build a :class:`Cart` from a ``/v2/cart`` response.""" data = payload.get("data", {}) items: dict[str, Any] = data.get("items", {}) + products = [CartItem.from_api(pid, pdata) for pid, pdata in items.items()] return cls( total_price=data.get("totalPrice", 0), total_items=len(items), can_make_order=data.get("submitConditionPassed", False), - products=[CartItem.from_api(pid, pdata) for pid, pdata in items.items()], + products=products, + minimum_order_price=data.get("minimalOrderPrice"), + currency=next((p.currency for p in products if p.currency), None), ) diff --git a/rohlik_api/sites.py b/rohlik_api/sites.py new file mode 100644 index 0000000..09052d3 --- /dev/null +++ b/rohlik_api/sites.py @@ -0,0 +1,44 @@ +"""The Rohlík Group shops this client can talk to. + +Every shop runs the same backend API, so any of them can be used by passing its +``base_url`` to :class:`~rohlik_api.RohlikAPI`:: + + RohlikAPI(username, password, base_url=SITES["de"].base_url) +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + + +@dataclass(frozen=True, slots=True) +class Site: + """A Rohlík Group shop. + + Attributes: + code: Short key of the shop in :data:`SITES` (the country code). + name: Display name, e.g. ``"Knuspr.de"``. + base_url: Base URL to pass to :class:`~rohlik_api.RohlikAPI`. + currency: ISO 4217 code of the shop's prices, e.g. ``"EUR"``. + timezone: IANA timezone the shop's delivery times are in. + """ + + code: str + name: str + base_url: str + currency: str + timezone: str + + +SITES: Final[dict[str, Site]] = { + site.code: site + for site in ( + Site("cz", "Rohlík.cz", "https://www.rohlik.cz", "CZK", "Europe/Prague"), + Site("de", "Knuspr.de", "https://www.knuspr.de", "EUR", "Europe/Berlin"), + Site("at", "Gurkerl.at", "https://www.gurkerl.at", "EUR", "Europe/Vienna"), + Site("hu", "Kifli.hu", "https://www.kifli.hu", "HUF", "Europe/Budapest"), + Site("ro", "Sezamo.ro", "https://www.sezamo.ro", "RON", "Europe/Bucharest"), + ) +} +"""Known shops, keyed by :attr:`Site.code`.""" diff --git a/tests/test_services.py b/tests/test_services.py index a788e34..42270d7 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -104,6 +104,48 @@ async def test_get_content_returns_formatted_data(self, mock_http, mock_auth): assert result.can_make_order is True assert len(result.products) == 1 assert result.products[0].name == "Test Product" + # Neither field is in this payload. + assert result.minimum_order_price is None + assert result.currency is None + + async def test_get_content_currency_and_minimum_order(self, mock_http, mock_auth): + """The cart currency comes from its items; the minimum from minimalOrderPrice.""" + mock_response = MagicMock() + # Shape of a real Knuspr.de /v2/cart response (trimmed). The minimum is + # made up: the anonymous cart this was captured from reported 0. + mock_response.json.return_value = { + "status": 200, + "data": { + "cartId": 66772968, + "totalPrice": 11.99, + "minimalStandardOrderPrice": 0, + "minimalDeliveryPointOrderPrice": 0, + "minimalOrderPrice": 39.0, + "submitConditionPassed": False, + "items": { + "91348": { + "productId": 91348, + "orderFieldId": 270627531, + "productName": "MIIL Haltbare Milch 1,5% Laktosefrei 12 Pack", + "quantity": 1, + "price": 11.99, + "currency": "EUR", + "primaryCategoryName": "Kühlregal", + "brand": "Miil", + } + }, + }, + } + mock_response.raise_for_status = MagicMock() + mock_http.get.return_value = mock_response + + service = CartService(mock_http, mock_auth) + result = await service.get_content() + + assert result.minimum_order_price == 39.0 + assert result.currency == "EUR" + assert result.products[0].currency == "EUR" + assert result.can_make_order is False async def test_add_items_sends_correct_payload(self, mock_http, mock_auth): """Test add_items sends correct payload.""" diff --git a/tests/test_sites.py b/tests/test_sites.py new file mode 100644 index 0000000..98b6ab0 --- /dev/null +++ b/tests/test_sites.py @@ -0,0 +1,40 @@ +"""Tests for the Rohlík Group shop presets.""" + +from zoneinfo import ZoneInfo + +import rohlik_api +from rohlik_api import SITES, RohlikAPI, Site +from rohlik_api.endpoints import BASE_URL + + +def test_default_site_is_the_client_default(): + """Rohlík.cz's preset matches the URL the client uses when none is given.""" + assert SITES["cz"].base_url == BASE_URL + + +def test_sites_are_keyed_by_code(): + assert set(SITES) == {"cz", "de", "at", "hu", "ro"} + for code, site in SITES.items(): + assert isinstance(site, Site) + assert site.code == code + + +def test_site_fields_are_well_formed(): + for site in SITES.values(): + assert site.base_url.startswith("https://www.") + assert not site.base_url.endswith("/") + assert len(site.currency) == 3 and site.currency.isupper() + ZoneInfo(site.timezone) # raises if the zone name is wrong + + +def test_exported_from_package(): + assert "SITES" in rohlik_api.__all__ + assert "Site" in rohlik_api.__all__ + + +async def test_client_targets_site_base_url(): + client = RohlikAPI("user@example.com", "secret", base_url=SITES["de"].base_url) + try: + assert client.base_url == "https://www.knuspr.de" + finally: + await client.close() From 9170f1fc19e421f7237e1c6d25e162781e8dbb6f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 05:31:35 +0000 Subject: [PATCH 2/3] Bump version to 0.3.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0135WLPbnfESWnAJmw4P8uRH --- rohlik_api/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rohlik_api/__init__.py b/rohlik_api/__init__.py index e3b8bca..c9ef3e0 100644 --- a/rohlik_api/__init__.py +++ b/rohlik_api/__init__.py @@ -35,7 +35,7 @@ ) from .sites import SITES, Site -__version__ = "0.2.0" +__version__ = "0.3.0" __all__ = [ # Main client (facade) "RohlikAPI", From f1c5049c42ebaf19f801d5020cf4062238b90c4d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 05:34:29 +0000 Subject: [PATCH 3/3] Cart.currency docs: also None when no item reports a currency Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0135WLPbnfESWnAJmw4P8uRH --- README.md | 2 +- rohlik_api/models.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2fb012f..24de9eb 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,7 @@ async with RohlikAPI("email@example.com", "password", base_url=site.base_url) as print(cart.total_price, cart.currency or site.currency) # e.g. 11.99 EUR ``` -`Cart.currency` comes from the cart's items, so it is `None` for an empty cart; +`Cart.currency` comes from the cart's items, so it is `None` for an empty cart (or if no item reports one); fall back to `site.currency` then. Delivery announcements and other texts come back in the shop's language. diff --git a/rohlik_api/models.py b/rohlik_api/models.py index 87465cd..d1f0c94 100644 --- a/rohlik_api/models.py +++ b/rohlik_api/models.py @@ -85,8 +85,9 @@ class Cart: order (``minimalOrderPrice``), in :attr:`currency`, or ``None`` if the API does not report one. Anonymous carts report ``0``. currency: ISO 4217 currency code of the cart's prices, taken from its - items, or ``None`` for an empty cart (the cart payload itself has - no currency; see :data:`~rohlik_api.SITES` for each shop's). + items, or ``None`` when the cart is empty or no item reports one + (the cart payload itself has no currency; see + :data:`~rohlik_api.SITES` for each shop's). """ total_price: float