rohlik-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rohlik_api/__init__.py ADDED
@@ -0,0 +1,74 @@
1
+ """Rohlik.cz API Python Client.
2
+
3
+ An async Python client for the Rohlik.cz API, built on aiohttp.
4
+ """
5
+
6
+ from .auth import AuthManager
7
+ from .client import RohlikAPI
8
+ from .endpoints import BASE_URL, Endpoints
9
+ from .errors import APIRequestFailedError, InvalidCredentialsError, RohlikAPIError
10
+ from .helpers import format_price, mask_data
11
+ from .http_client import HttpClient
12
+ from .models import (
13
+ AISummary,
14
+ Allergens,
15
+ Cart,
16
+ CartItem,
17
+ DirectionSection,
18
+ DirectionStep,
19
+ IngredientGroup,
20
+ IngredientItem,
21
+ IngredientProduct,
22
+ IngredientProductGroup,
23
+ IngredientProducts,
24
+ NutritionalValue,
25
+ ProductComposition,
26
+ ProductPrice,
27
+ ProductSearchResult,
28
+ RecipeAuthor,
29
+ RecipeDetail,
30
+ RecipeSearchResults,
31
+ RecipeSummary,
32
+ SearchResults,
33
+ ShoppingList,
34
+ )
35
+
36
+ __version__ = "0.1.0"
37
+ __all__ = [
38
+ # Main client (facade)
39
+ "RohlikAPI",
40
+ # Errors
41
+ "RohlikAPIError",
42
+ "InvalidCredentialsError",
43
+ "APIRequestFailedError",
44
+ # Models
45
+ "Cart",
46
+ "CartItem",
47
+ "SearchResults",
48
+ "ProductSearchResult",
49
+ "AISummary",
50
+ "ProductComposition",
51
+ "NutritionalValue",
52
+ "Allergens",
53
+ "ProductPrice",
54
+ "RecipeSearchResults",
55
+ "RecipeSummary",
56
+ "RecipeDetail",
57
+ "RecipeAuthor",
58
+ "IngredientGroup",
59
+ "IngredientItem",
60
+ "DirectionSection",
61
+ "DirectionStep",
62
+ "IngredientProducts",
63
+ "IngredientProductGroup",
64
+ "IngredientProduct",
65
+ "ShoppingList",
66
+ # Utilities
67
+ "mask_data",
68
+ "format_price",
69
+ # Advanced: low-level components
70
+ "HttpClient",
71
+ "AuthManager",
72
+ "Endpoints",
73
+ "BASE_URL",
74
+ ]
rohlik_api/auth.py ADDED
@@ -0,0 +1,173 @@
1
+ """Authentication manager for Rohlik.cz API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from .endpoints import Endpoints
9
+ from .errors import APIRequestFailedError, InvalidCredentialsError, RohlikAPIError
10
+ from .helpers import mask_data
11
+ from .http_client import HTTP_ERRORS, HttpClient
12
+
13
+ _LOGGER = logging.getLogger(__name__)
14
+
15
+
16
+ class AuthManager:
17
+ """Manages authentication state for Rohlik.cz API.
18
+
19
+ This class handles login, logout, and session management.
20
+
21
+ Args:
22
+ http_client: The HTTP client instance to use for requests
23
+ username: Email address used for Rohlik.cz login
24
+ password: Password for Rohlik.cz account
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ http_client: HttpClient,
30
+ username: str,
31
+ password: str,
32
+ ):
33
+ if not username or not password:
34
+ raise ValueError("Username and password are required")
35
+
36
+ self._http = http_client
37
+ self._username = username
38
+ self._password = password
39
+
40
+ self._is_logged_in: bool = False
41
+ self._user_id: int | None = None
42
+ self._address_id: int | None = None
43
+ self._login_response: dict[str, Any] = {}
44
+
45
+ @property
46
+ def is_logged_in(self) -> bool:
47
+ """Check if currently logged in."""
48
+ return self._is_logged_in
49
+
50
+ @property
51
+ def user_id(self) -> int | None:
52
+ """Get the current user ID."""
53
+ return self._user_id
54
+
55
+ @property
56
+ def address_id(self) -> int | None:
57
+ """Get the current address ID."""
58
+ return self._address_id
59
+
60
+ async def login(self) -> dict[str, Any]:
61
+ """Authenticate with the Rohlik.cz service.
62
+
63
+ If already logged in, returns the cached login response from the most
64
+ recent successful login without making a new request.
65
+
66
+ Returns:
67
+ dict: The JSON response containing authentication data
68
+
69
+ Raises:
70
+ InvalidCredentialsError: If credentials are invalid
71
+ APIRequestFailedError: If the login request fails
72
+ """
73
+ if self._is_logged_in:
74
+ _LOGGER.debug("Already logged in, skipping login request")
75
+ return self._login_response
76
+
77
+ login_data = {"email": self._username, "password": self._password, "name": ""}
78
+
79
+ try:
80
+ response = await self._http.post(Endpoints.LOGIN, json=login_data)
81
+ login_response: dict[str, Any] = response.json()
82
+
83
+ if login_response.get("status") != 200:
84
+ messages = login_response.get("messages", [])
85
+ if login_response.get("status") == 401:
86
+ error_msg = (
87
+ messages[0].get("content", "Invalid credentials")
88
+ if messages
89
+ else "Invalid credentials"
90
+ )
91
+ raise InvalidCredentialsError(error_msg)
92
+ error_msg = (
93
+ messages[0].get("content", "Unknown error") if messages else "Unknown error"
94
+ )
95
+ raise RohlikAPIError(f"Unknown error occurred during login: {error_msg}")
96
+
97
+ self._is_logged_in = True
98
+ self._login_response = login_response
99
+
100
+ # Extract user and address IDs. ``address`` may be explicitly null
101
+ # in the response, so guard with ``or {}``.
102
+ data = login_response.get("data", {})
103
+ self._user_id = data.get("user", {}).get("id")
104
+ self._address_id = (data.get("address") or {}).get("id")
105
+ if self._address_id is None:
106
+ _LOGGER.debug(
107
+ "No address ID in login data. Login response: %s",
108
+ mask_data(login_response),
109
+ )
110
+
111
+ return login_response
112
+
113
+ except HTTP_ERRORS as err:
114
+ raise APIRequestFailedError(
115
+ f"Cannot connect to website! Check your internet connection "
116
+ f"and try again: {err}"
117
+ ) from err
118
+
119
+ async def logout(self) -> None:
120
+ """Log out from the Rohlik.cz service.
121
+
122
+ Raises:
123
+ RohlikAPIError: If logout fails
124
+ APIRequestFailedError: If the request fails
125
+ """
126
+ if not self._is_logged_in:
127
+ _LOGGER.debug("Not logged in, skipping logout request")
128
+ return
129
+
130
+ try:
131
+ response = await self._http.post(Endpoints.LOGOUT)
132
+ logout_response = response.json()
133
+
134
+ if logout_response.get("status") != 200:
135
+ raise RohlikAPIError(f"Unknown error occurred during logout: {logout_response}")
136
+
137
+ self._reset_session()
138
+
139
+ except HTTP_ERRORS as err:
140
+ self._reset_session() # Reset state even on error
141
+ raise APIRequestFailedError(
142
+ f"Cannot connect to website! Check your internet connection "
143
+ f"and try again: {err}"
144
+ ) from err
145
+
146
+ async def ensure_logged_in(self) -> None:
147
+ """Ensure the client is logged in, login if not."""
148
+ if not self._is_logged_in:
149
+ await self.login()
150
+
151
+ async def relogin(self) -> dict[str, Any]:
152
+ """Force a fresh login after a session expiry (HTTP 401).
153
+
154
+ Clears the cached session state so :meth:`login` performs a new request
155
+ instead of returning the stale cached response, then logs in again.
156
+
157
+ Returns:
158
+ dict: The JSON response from the new login.
159
+ """
160
+ self._reset_session()
161
+ return await self.login()
162
+
163
+ def _reset_session(self) -> None:
164
+ """Clear all session state so the next login re-fetches it.
165
+
166
+ User and address IDs are cleared too, so that reusing the same instance
167
+ across logins picks up a changed delivery address instead of keeping a
168
+ stale value.
169
+ """
170
+ self._is_logged_in = False
171
+ self._user_id = None
172
+ self._address_id = None
173
+ self._login_response = {}
rohlik_api/client.py ADDED
@@ -0,0 +1,250 @@
1
+ """Rohlik.cz API Client implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from types import TracebackType
7
+ from typing import Any
8
+
9
+ import aiohttp
10
+
11
+ from .auth import AuthManager
12
+ from .endpoints import BASE_URL
13
+ from .errors import APIRequestFailedError
14
+ from .http_client import HTTP_ERRORS, HttpClient
15
+ from .services import (
16
+ AccountService,
17
+ CartService,
18
+ DeliveryService,
19
+ OrderService,
20
+ ProductService,
21
+ RecipeService,
22
+ )
23
+
24
+ _LOGGER = logging.getLogger(__name__)
25
+
26
+
27
+ class RohlikAPI:
28
+ """Async client for interacting with the Rohlik.cz API.
29
+
30
+ The client is built on aiohttp and exposes a service-based API for all
31
+ operations. When used as an async context manager with ``auto_login=True``
32
+ (the default), it logs in on entry and logs out on exit.
33
+
34
+ Args:
35
+ username: Email address used for Rohlik.cz login (required).
36
+ password: Password for the Rohlik.cz account (required).
37
+ base_url: Base URL for the Rohlik.cz API. Defaults to https://www.rohlik.cz
38
+ timeout: Request timeout in seconds. Defaults to 30.0.
39
+ headers: Optional custom headers to include in all requests.
40
+ auto_login: If True (default), log in automatically when used as a
41
+ context manager.
42
+ session: Optional externally managed :class:`aiohttp.ClientSession` to
43
+ reuse (for example Home Assistant's shared session). When provided,
44
+ the session is not closed by this client.
45
+
46
+ Attributes:
47
+ cart (CartService): Cart operations (get_content, add_items, delete_item).
48
+ products (ProductService): Product search and details.
49
+ orders (OrderService): Order operations (get_next, get_last, get_delivered).
50
+ delivery (DeliveryService): Delivery info and timeslots.
51
+ account (AccountService): Account data (premium, bags, shopping lists).
52
+ recipes (RecipeService): Recipe search and ingredients (Rohlík Chef).
53
+
54
+ Example:
55
+ >>> async with RohlikAPI("user@example.com", "password") as client:
56
+ ... cart = await client.cart.get_content()
57
+ ... print(cart.total_price, cart.total_items)
58
+ ... for item in cart.products:
59
+ ... print(item.name, item.quantity, item.price)
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ username: str,
65
+ password: str,
66
+ base_url: str = BASE_URL,
67
+ timeout: float = 30.0,
68
+ headers: dict[str, str] | None = None,
69
+ auto_login: bool = True,
70
+ session: aiohttp.ClientSession | None = None,
71
+ ) -> None:
72
+ # Credential validation is owned by AuthManager (constructed below),
73
+ # which raises ValueError on empty username/password.
74
+ self._auto_login = auto_login
75
+ self.base_url = base_url.rstrip("/")
76
+ self.timeout = timeout
77
+
78
+ # Initialize HTTP client
79
+ self._http = HttpClient(
80
+ base_url=base_url,
81
+ timeout=timeout,
82
+ headers=headers,
83
+ session=session,
84
+ )
85
+
86
+ # Initialize auth manager
87
+ self._auth = AuthManager(
88
+ http_client=self._http,
89
+ username=username,
90
+ password=password,
91
+ )
92
+
93
+ # Wire up transparent re-authentication: when any request hits HTTP 401
94
+ # (expired session), the HTTP client re-logs in and retries once.
95
+ self._http.set_unauthorized_handler(self._auth.relogin)
96
+
97
+ # Initialize services
98
+ self._cart = CartService(self._http, self._auth)
99
+ self._products = ProductService(self._http, self._auth)
100
+ self._orders = OrderService(self._http, self._auth)
101
+ self._delivery = DeliveryService(self._http, self._auth)
102
+ self._account = AccountService(self._http, self._auth)
103
+ self._recipes = RecipeService(self._http, self._auth)
104
+
105
+ # -------------------------------------------------------------------------
106
+ # Service Properties
107
+ # -------------------------------------------------------------------------
108
+
109
+ @property
110
+ def cart(self) -> CartService:
111
+ """Access cart operations."""
112
+ return self._cart
113
+
114
+ @property
115
+ def products(self) -> ProductService:
116
+ """Access product operations."""
117
+ return self._products
118
+
119
+ @property
120
+ def orders(self) -> OrderService:
121
+ """Access order operations."""
122
+ return self._orders
123
+
124
+ @property
125
+ def delivery(self) -> DeliveryService:
126
+ """Access delivery operations."""
127
+ return self._delivery
128
+
129
+ @property
130
+ def account(self) -> AccountService:
131
+ """Access account operations."""
132
+ return self._account
133
+
134
+ @property
135
+ def recipes(self) -> RecipeService:
136
+ """Access recipe operations (Rohlík Chef)."""
137
+ return self._recipes
138
+
139
+ @property
140
+ def session(self) -> aiohttp.ClientSession:
141
+ """Get or create the underlying aiohttp session."""
142
+ return self._http.session
143
+
144
+ @property
145
+ def is_logged_in(self) -> bool:
146
+ """Check if the client is currently logged in."""
147
+ return self._auth.is_logged_in
148
+
149
+ @property
150
+ def user_id(self) -> int | None:
151
+ """The authenticated user's ID, or None if not logged in."""
152
+ return self._auth.user_id
153
+
154
+ @property
155
+ def address_id(self) -> int | None:
156
+ """The authenticated user's delivery address ID, or None if not logged in."""
157
+ return self._auth.address_id
158
+
159
+ # -------------------------------------------------------------------------
160
+ # Authentication
161
+ # -------------------------------------------------------------------------
162
+
163
+ async def login(self) -> dict[str, Any]:
164
+ """Authenticate with the Rohlik.cz service.
165
+
166
+ Returns:
167
+ The JSON response containing authentication data.
168
+
169
+ Raises:
170
+ InvalidCredentialsError: If the credentials are invalid.
171
+ APIRequestFailedError: If the request fails.
172
+ """
173
+ return await self._auth.login()
174
+
175
+ async def logout(self) -> None:
176
+ """Log out from the Rohlik.cz service.
177
+
178
+ Raises:
179
+ RohlikAPIError: If logout fails.
180
+ APIRequestFailedError: If the request fails.
181
+ """
182
+ await self._auth.logout()
183
+
184
+ # -------------------------------------------------------------------------
185
+ # Context Manager
186
+ # -------------------------------------------------------------------------
187
+
188
+ async def __aenter__(self) -> RohlikAPI:
189
+ """Enter the context manager, logging in if ``auto_login`` is True."""
190
+ if self._auto_login:
191
+ await self._auth.login()
192
+ return self
193
+
194
+ async def __aexit__(
195
+ self,
196
+ exc_type: type[BaseException] | None,
197
+ exc_val: BaseException | None,
198
+ exc_tb: TracebackType | None,
199
+ ) -> None:
200
+ """Exit the context manager, logging out and releasing resources."""
201
+ await self.close()
202
+
203
+ async def close(self) -> None:
204
+ """Close the HTTP client and release resources. Logs out if logged in."""
205
+ if self._auth.is_logged_in:
206
+ try:
207
+ await self._auth.logout()
208
+ except Exception as err: # noqa: BLE001 - best-effort logout on close
209
+ _LOGGER.warning("Error during logout on close: %s", err)
210
+
211
+ await self._http.close()
212
+
213
+ # -------------------------------------------------------------------------
214
+ # Aggregated data retrieval
215
+ # -------------------------------------------------------------------------
216
+
217
+ async def get_data(self) -> dict[str, Any]:
218
+ """Retrieve account data from Rohlik.cz in a single aggregated call.
219
+
220
+ Returns:
221
+ A dictionary containing delivery info, orders, cart contents,
222
+ premium profile, announcements and more.
223
+
224
+ Raises:
225
+ APIRequestFailedError: If the underlying requests fail.
226
+ """
227
+ result: dict[str, Any] = {}
228
+
229
+ result["login"] = await self._auth.login()
230
+
231
+ try:
232
+ result["delivery"] = await self._delivery.get_info()
233
+ result["next_order"] = await self._orders.get_next()
234
+ result["last_order"] = await self._orders.get_last()
235
+ result["delivered_orders"] = await self._orders.get_delivered()
236
+ result["announcements"] = await self._account.get_announcements()
237
+ result["bags"] = await self._account.get_bags_info()
238
+ result["timeslot"] = await self._delivery.get_timeslot_reservation()
239
+ result["premium_profile"] = await self._account.get_premium_profile()
240
+ result["delivery_announcements"] = await self._delivery.get_announcements()
241
+ result["next_delivery_slot"] = await self._delivery.get_next_slots()
242
+ result["cart"] = await self._cart.get_content()
243
+
244
+ return result
245
+
246
+ except HTTP_ERRORS as err:
247
+ raise APIRequestFailedError(
248
+ f"Cannot connect to website! Check your internet connection "
249
+ f"and try again: {err}"
250
+ ) from err
@@ -0,0 +1,104 @@
1
+ """API endpoint definitions for Rohlik.cz."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import quote
6
+
7
+ BASE_URL = "https://www.rohlik.cz"
8
+
9
+
10
+ class Endpoints:
11
+ """API endpoint paths for Rohlik.cz."""
12
+
13
+ # Authentication
14
+ LOGIN = "/services/frontend-service/login"
15
+ LOGOUT = "/services/frontend-service/logout"
16
+
17
+ # Cart
18
+ CART = "/services/frontend-service/v2/cart"
19
+
20
+ # Products
21
+ SEARCH = "/services/frontend-service/search-metadata"
22
+
23
+ # Delivery
24
+ DELIVERY = "/services/frontend-service/first-delivery?reasonableDeliveryTime=true"
25
+ TIMESLOT_RESERVATION = "/services/frontend-service/v1/timeslot-reservation"
26
+ DELIVERY_ANNOUNCEMENTS = "/services/frontend-service/announcements/delivery"
27
+
28
+ # Orders
29
+ NEXT_ORDER = "/api/v3/orders/upcoming"
30
+ LAST_ORDER = "/api/v3/orders/delivered?offset=0&limit=1"
31
+
32
+ # Account
33
+ PREMIUM_PROFILE = "/services/frontend-service/premium/profile"
34
+ BAGS = "/api/v1/reusable-bags/user-info"
35
+ ANNOUNCEMENTS = "/services/frontend-service/announcements/top"
36
+
37
+ # Recipes
38
+ INGREDIENT_PRODUCTS = "/services/frontend-service/v1/chef/ingredients/products"
39
+
40
+ # -------------------------------------------------------------------------
41
+ # Builder methods for dynamic endpoints
42
+ # -------------------------------------------------------------------------
43
+
44
+ @classmethod
45
+ def recipe_search(cls, query: str, limit: int = 10, offset: int = 0) -> str:
46
+ """Build recipe search endpoint URL."""
47
+ encoded_query = quote(query)
48
+ return (
49
+ f"/services/frontend-service/recipe/search/{encoded_query}"
50
+ f"?offset={offset}&limit={limit}"
51
+ )
52
+
53
+ @classmethod
54
+ def recipe_detail(cls, recipe_id: int) -> str:
55
+ """Build recipe detail endpoint URL."""
56
+ return f"/services/frontend-service/recipe/{recipe_id}"
57
+
58
+ @classmethod
59
+ def order_detail(cls, order_id: int) -> str:
60
+ """Build order detail endpoint URL (full order including items)."""
61
+ return f"/api/v3/orders/{order_id}"
62
+
63
+ @classmethod
64
+ def product_detail(cls, product_id: int) -> str:
65
+ """Build product detail endpoint URL."""
66
+ return f"/api/v1/products/{product_id}"
67
+
68
+ @classmethod
69
+ def product_categories(cls, product_id: int) -> str:
70
+ """Build product category-hierarchy endpoint URL."""
71
+ return f"/api/v1/products/{product_id}/categories"
72
+
73
+ @classmethod
74
+ def product_ai_summary(cls, product_id: int) -> str:
75
+ """Build product AI summary endpoint URL."""
76
+ return f"/api/v1/products/{product_id}/ai-summary"
77
+
78
+ @classmethod
79
+ def product_composition(cls, product_id: int) -> str:
80
+ """Build product composition endpoint URL."""
81
+ return f"/api/v1/products/{product_id}/composition"
82
+
83
+ @classmethod
84
+ def product_price(cls, product_id: int) -> str:
85
+ """Build product price endpoint URL."""
86
+ return f"/api/v1/products/{product_id}/prices"
87
+
88
+ @classmethod
89
+ def shopping_list(cls, shopping_list_id: str) -> str:
90
+ """Build shopping list endpoint URL."""
91
+ return f"/api/v1/shopping-lists/id/{shopping_list_id}"
92
+
93
+ @classmethod
94
+ def delivered_orders(cls, limit: int = 50, offset: int = 0) -> str:
95
+ """Build delivered orders endpoint URL with pagination."""
96
+ return f"/api/v3/orders/delivered?offset={offset}&limit={limit}"
97
+
98
+ @classmethod
99
+ def timeslots(cls, user_id: int, address_id: int) -> str:
100
+ """Build timeslots endpoint URL with user and address IDs."""
101
+ return (
102
+ f"/services/frontend-service/timeslots-api/0"
103
+ f"?userId={user_id}&addressId={address_id}&reasonableDeliveryTime=true"
104
+ )
rohlik_api/errors.py ADDED
@@ -0,0 +1,13 @@
1
+ """Custom exceptions for the Rohlik.cz API client."""
2
+
3
+
4
+ class RohlikAPIError(Exception):
5
+ """Base exception for all Rohlik API errors."""
6
+
7
+
8
+ class InvalidCredentialsError(RohlikAPIError):
9
+ """Raised when login credentials are invalid."""
10
+
11
+
12
+ class APIRequestFailedError(RohlikAPIError):
13
+ """Raised when an API request fails (network or HTTP error)."""
rohlik_api/helpers.py ADDED
@@ -0,0 +1,57 @@
1
+ """Helper utilities for the Rohlik.cz API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def format_price(price_info: dict[str, Any] | None) -> str:
9
+ """Format a Rohlik price object into a ``"<amount> <currency>"`` string.
10
+
11
+ Args:
12
+ price_info: A price mapping with optional ``full`` and ``currency`` keys.
13
+
14
+ Returns:
15
+ A string like ``"29.90 Kč"``. Missing parts are rendered as empty.
16
+ """
17
+ price_info = price_info or {}
18
+ full = price_info.get("full", "")
19
+ currency = price_info.get("currency", "")
20
+ return f"{full} {currency}".strip()
21
+
22
+
23
+ def mask_data(input_dict: Any) -> Any:
24
+ """Recursively mask all non-null values in a dictionary with ``"XXXXXXX"``.
25
+
26
+ Useful for logging API payloads without leaking personal data. ``None``
27
+ values are preserved so the shape of the data remains visible.
28
+
29
+ Args:
30
+ input_dict: The value to mask. Non-dict values are returned unchanged.
31
+
32
+ Returns:
33
+ A copy of the input with every non-null leaf value replaced by
34
+ ``"XXXXXXX"``.
35
+ """
36
+ if not isinstance(input_dict, dict):
37
+ return input_dict
38
+
39
+ result: dict[Any, Any] = {}
40
+ for key, value in input_dict.items():
41
+ if value is None:
42
+ result[key] = None
43
+ elif isinstance(value, dict):
44
+ result[key] = mask_data(value)
45
+ elif isinstance(value, list):
46
+ result[key] = [
47
+ (
48
+ mask_data(item)
49
+ if isinstance(item, dict)
50
+ else "XXXXXXX" if item is not None else None
51
+ )
52
+ for item in value
53
+ ]
54
+ else:
55
+ result[key] = "XXXXXXX"
56
+
57
+ return result