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 +74 -0
- rohlik_api/auth.py +173 -0
- rohlik_api/client.py +250 -0
- rohlik_api/endpoints.py +104 -0
- rohlik_api/errors.py +13 -0
- rohlik_api/helpers.py +57 -0
- rohlik_api/http_client.py +273 -0
- rohlik_api/models.py +650 -0
- rohlik_api/py.typed +1 -0
- rohlik_api/services/__init__.py +19 -0
- rohlik_api/services/account.py +70 -0
- rohlik_api/services/base.py +63 -0
- rohlik_api/services/cart.py +90 -0
- rohlik_api/services/delivery.py +67 -0
- rohlik_api/services/orders.py +111 -0
- rohlik_api/services/products.py +176 -0
- rohlik_api/services/recipes.py +87 -0
- rohlik_api-0.1.0.dist-info/METADATA +381 -0
- rohlik_api-0.1.0.dist-info/RECORD +22 -0
- rohlik_api-0.1.0.dist-info/WHEEL +5 -0
- rohlik_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- rohlik_api-0.1.0.dist-info/top_level.txt +1 -0
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
|
rohlik_api/endpoints.py
ADDED
|
@@ -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
|