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
42 changes: 38 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ online grocery service — search products, manage your cart, browse recipes

## Features

- 🚀 HTTP/2 support for fast, connection-reused requests
- 🔐 Automatic login/logout and session management
- 🚀 Built on aiohttp; bring your own session (e.g. Home Assistant's shared session)
- 🔐 Automatic login/logout, plus transparent re-authentication when a session expires (HTTP 401)
- 🎯 Clean, service-based API (`client.cart`, `client.products`, …)
- 🧩 Fully typed dataclass models for parsed responses (`py.typed`)
- 🔄 Works as an async context manager
Expand All @@ -44,7 +44,7 @@ online grocery service — search products, manage your cart, browse recipes
## Requirements

- Python 3.13+
- [httpx](https://www.python-httpx.org/) with HTTP/2 (installed automatically)
- [aiohttp](https://docs.aiohttp.org/) (installed automatically)

## Installation

Expand Down Expand Up @@ -171,14 +171,22 @@ composition = await client.products.get_composition(product_id=1425155)

# Current price -> ProductPrice | None
price = await client.products.get_price(product_id=1425155)

# Raw product detail (brand, attributes, …) -> dict | None (None on 404)
detail = await client.products.get_detail(product_id=1425155)

# Category hierarchy -> list[dict] | None (None if discontinued / 404)
categories = await client.products.get_categories(product_id=1425155)
```

### Orders service (`client.orders`)

```python
next_order = await client.orders.get_next() # upcoming order
last_order = await client.orders.get_last() # last delivered order
orders = await client.orders.get_delivered(limit=50, offset=0) # history
orders = await client.orders.get_delivered(limit=50, offset=0) # one history page
all_orders = await client.orders.get_all_delivered() # every page, paginated
detail = await client.orders.get_detail(order_id=12345678) # full order incl. items
```

### Delivery service (`client.delivery`)
Expand Down Expand Up @@ -280,6 +288,32 @@ async def main():
await client.close()
```

### Reusing an existing aiohttp session

The client is built on [aiohttp](https://docs.aiohttp.org/). By default it
creates and owns its own `ClientSession`, but you can inject an externally
managed session instead — useful inside a Home Assistant integration, where
the recommended pattern is to share a single session per instance. An injected
session is **never** closed by the client; its lifecycle stays with the owner.

```python
import aiohttp
from rohlik_api import RohlikAPI

async def main(session: aiohttp.ClientSession):
client = RohlikAPI(
username="email@example.com",
password="password",
session=session, # reuse the caller's session
)
async with client:
cart = await client.cart.get_content()
# `session` is left open for the caller to close.
```

Inside a Home Assistant integration you would pass the shared session, for
example `RohlikAPI(..., session=async_get_clientsession(hass))`.

## Development

```bash
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"httpx[http2]>=0.28.1",
"aiohttp>=3.10",
]

[project.optional-dependencies]
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Development requirements
httpx[http2]==0.28.1
aiohttp>=3.10
pytest==9.0.2
pytest-cov==7.0.0
pytest-asyncio==1.3.0
Expand Down
2 changes: 1 addition & 1 deletion rohlik_api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Rohlik.cz API Python Client.

An async Python client for the Rohlik.cz API, built on httpx with HTTP/2 support.
An async Python client for the Rohlik.cz API, built on aiohttp.
"""

from .auth import AuthManager
Expand Down
20 changes: 15 additions & 5 deletions rohlik_api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,10 @@
import logging
from typing import Any

import httpx

from .endpoints import Endpoints
from .errors import APIRequestFailedError, InvalidCredentialsError, RohlikAPIError
from .helpers import mask_data
from .http_client import HttpClient
from .http_client import HTTP_ERRORS, HttpClient

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -112,7 +110,7 @@ async def login(self) -> dict[str, Any]:

return login_response

except httpx.HTTPError as err:
except HTTP_ERRORS as err:
raise APIRequestFailedError(
f"Cannot connect to website! Check your internet connection "
f"and try again: {err}"
Expand All @@ -138,7 +136,7 @@ async def logout(self) -> None:

self._reset_session()

except httpx.HTTPError as err:
except HTTP_ERRORS as err:
self._reset_session() # Reset state even on error
raise APIRequestFailedError(
f"Cannot connect to website! Check your internet connection "
Expand All @@ -150,6 +148,18 @@ async def ensure_logged_in(self) -> None:
if not self._is_logged_in:
await self.login()

async def relogin(self) -> dict[str, Any]:
"""Force a fresh login after a session expiry (HTTP 401).

Clears the cached session state so :meth:`login` performs a new request
instead of returning the stale cached response, then logs in again.

Returns:
dict: The JSON response from the new login.
"""
self._reset_session()
return await self.login()

def _reset_session(self) -> None:
"""Clear all session state so the next login re-fetches it.

Expand Down
31 changes: 21 additions & 10 deletions rohlik_api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
from types import TracebackType
from typing import Any

import httpx
import aiohttp

from .auth import AuthManager
from .endpoints import BASE_URL
from .errors import APIRequestFailedError
from .http_client import HttpClient
from .http_client import HTTP_ERRORS, HttpClient
from .services import (
AccountService,
CartService,
Expand All @@ -27,9 +27,9 @@
class RohlikAPI:
"""Async client for interacting with the Rohlik.cz API.

The client uses httpx with HTTP/2 support 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.
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).
Expand All @@ -39,6 +39,9 @@ class RohlikAPI:
headers: Optional custom headers to include in all requests.
auto_login: If True (default), log in automatically when used as a
context manager.
session: Optional externally managed :class:`aiohttp.ClientSession` to
reuse (for example Home Assistant's shared session). When provided,
the session is not closed by this client.

Attributes:
cart (CartService): Cart operations (get_content, add_items, delete_item).
Expand All @@ -51,7 +54,9 @@ class RohlikAPI:
Example:
>>> async with RohlikAPI("user@example.com", "password") as client:
... cart = await client.cart.get_content()
... print(cart["total_price"])
... print(cart.total_price, cart.total_items)
... for item in cart.products:
... print(item.name, item.quantity, item.price)
"""

def __init__(
Expand All @@ -62,6 +67,7 @@ def __init__(
timeout: float = 30.0,
headers: dict[str, str] | None = None,
auto_login: bool = True,
session: aiohttp.ClientSession | None = None,
) -> None:
# Credential validation is owned by AuthManager (constructed below),
# which raises ValueError on empty username/password.
Expand All @@ -74,6 +80,7 @@ def __init__(
base_url=base_url,
timeout=timeout,
headers=headers,
session=session,
)

# Initialize auth manager
Expand All @@ -83,6 +90,10 @@ def __init__(
password=password,
)

# Wire up transparent re-authentication: when any request hits HTTP 401
# (expired session), the HTTP client re-logs in and retries once.
self._http.set_unauthorized_handler(self._auth.relogin)

# Initialize services
self._cart = CartService(self._http, self._auth)
self._products = ProductService(self._http, self._auth)
Expand Down Expand Up @@ -126,9 +137,9 @@ def recipes(self) -> RecipeService:
return self._recipes

@property
def client(self) -> httpx.AsyncClient:
"""Get or create the underlying async HTTP client."""
return self._http.client
def session(self) -> aiohttp.ClientSession:
"""Get or create the underlying aiohttp session."""
return self._http.session

@property
def is_logged_in(self) -> bool:
Expand Down Expand Up @@ -232,7 +243,7 @@ async def get_data(self) -> dict[str, Any]:

return result

except httpx.HTTPError as err:
except HTTP_ERRORS as err:
raise APIRequestFailedError(
f"Cannot connect to website! Check your internet connection "
f"and try again: {err}"
Expand Down
15 changes: 15 additions & 0 deletions rohlik_api/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,21 @@ def recipe_detail(cls, recipe_id: int) -> str:
"""Build recipe detail endpoint URL."""
return f"/services/frontend-service/recipe/{recipe_id}"

@classmethod
def order_detail(cls, order_id: int) -> str:
"""Build order detail endpoint URL (full order including items)."""
return f"/api/v3/orders/{order_id}"

@classmethod
def product_detail(cls, product_id: int) -> str:
"""Build product detail endpoint URL."""
return f"/api/v1/products/{product_id}"

@classmethod
def product_categories(cls, product_id: int) -> str:
"""Build product category-hierarchy endpoint URL."""
return f"/api/v1/products/{product_id}/categories"

@classmethod
def product_ai_summary(cls, product_id: int) -> str:
"""Build product AI summary endpoint URL."""
Expand Down
Loading
Loading