Skip to content
Merged
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
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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 (or if no item reports one);
fall back to `site.currency` then. Delivery announcements and other texts come
back in the shop's language.

### Configuration

```python
Expand Down
6 changes: 5 additions & 1 deletion rohlik_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,15 @@
SearchResults,
ShoppingList,
)
from .sites import SITES, Site

__version__ = "0.2.0"
__version__ = "0.3.0"
__all__ = [
# Main client (facade)
"RohlikAPI",
# Shops
"Site",
"SITES",
# Errors
"RohlikAPIError",
"InvalidCredentialsError",
Expand Down
12 changes: 8 additions & 4 deletions rohlik_api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion rohlik_api/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 27 additions & 7 deletions rohlik_api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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", ""),
)


Expand All @@ -67,29 +72,44 @@ 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`` 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
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),
)


Expand Down
44 changes: 44 additions & 0 deletions rohlik_api/sites.py
Original file line number Diff line number Diff line change
@@ -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`."""
42 changes: 42 additions & 0 deletions tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
40 changes: 40 additions & 0 deletions tests/test_sites.py
Original file line number Diff line number Diff line change
@@ -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()
Loading