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
156 changes: 38 additions & 118 deletions PUBLISHING.md
Original file line number Diff line number Diff line change
@@ -1,135 +1,55 @@
# Publishing to PyPI

This document provides instructions for publishing the `rohlik-api` package to PyPI.

## Prerequisites

1. Create accounts on:
- PyPI: https://pypi.org/account/register/
- TestPyPI (for testing): https://test.pypi.org/account/register/

2. Install required tools:
```bash
pip install build twine
```

## Building the Package

Build the package distributions:
Releases are published to [PyPI](https://pypi.org/project/rohlik-api/) by the
`Publish to PyPI` workflow (`.github/workflows/publish.yml`). It runs when a
GitHub Release is **published**, builds the sdist and wheel, checks them with
`twine`, and uploads them using PyPI Trusted Publishing (OIDC), so no API token
is stored anywhere.

## Releasing a new version

1. **Bump the version** in `rohlik_api/__init__.py` (`__version__`). It is the
single source of truth; `pyproject.toml` reads it dynamically. Follow
semantic versioning: patch (`0.3.1`) for fixes, minor (`0.4.0`) for new
features, and major for breaking changes. Merge the bump to `main`.
2. **Make sure CI is green on `main`** (ruff, black, mypy, pytest).
3. **Create a GitHub Release** (Releases → Draft a new release):
- Tag: `v<version>`, e.g. `v0.3.0`, created from `main`. It must match
`__version__`; PyPI rejects a version that was already uploaded.
- Title: `v<version> - <short headline>`.
- Notes: `## Added` / `## Changed` / `## Fixed` sections as needed, plus a
`## Compatibility` note on breaking changes and the minimum Python version.
4. **Publish the release.** The workflow uploads the package. Watch it under
Actions → Publish to PyPI; the `publish` job runs in the `pypi` environment.

## Verifying a release

```bash
python -m build
pip install --upgrade rohlik-api
python -c "import rohlik_api; print(rohlik_api.__version__)"
```

This creates:
- `dist/rohlik_api-0.1.0-py3-none-any.whl` (wheel distribution)
- `dist/rohlik_api-0.1.0.tar.gz` (source distribution)
## Building locally

## Testing on TestPyPI (Recommended)

Test your package on TestPyPI first:
To check the distribution without publishing:

```bash
python -m twine upload --repository testpypi dist/*
pip install build twine
python -m build
python -m twine check dist/*
```

Then test installation:
This creates `dist/rohlik_api-<version>-py3-none-any.whl` and
`dist/rohlik_api-<version>.tar.gz`. To try an upload without touching the real
index, use [TestPyPI](https://test.pypi.org/):

```bash
python -m twine upload --repository testpypi dist/*
pip install --index-url https://test.pypi.org/simple/ rohlik-api
```

## Publishing to PyPI

Once you've tested on TestPyPI, publish to the real PyPI:

```bash
python -m twine upload dist/*
```

You'll be prompted for your PyPI username and password.

## Using API Tokens (Recommended)

Instead of username/password, use API tokens:

1. Generate an API token on PyPI:
- Go to Account Settings → API tokens
- Create a new token

2. Configure in `~/.pypirc`:
```ini
[pypi]
username = __token__
password = pypi-AgEIcHlwaS5vcmc...your-token-here...

[testpypi]
username = __token__
password = pypi-AgENdGVzdC5weXBpLm9yZw...your-token-here...
```

## Automated Publishing with GitHub Actions

You can automate publishing using GitHub Actions. Create `.github/workflows/publish.yml`:

```yaml
name: Publish to PyPI

on:
release:
types: [published]

jobs:
deploy:
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write # Required for trusted publishing
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install build
- name: Build package
run: python -m build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
```

Configure Trusted Publishing on PyPI:
1. Go to your project on PyPI → Publishing
2. Add a new "pending publisher" with your GitHub repo details
3. Create an environment named `pypi` in your GitHub repo settings

## Version Updates

When releasing a new version:

1. Bump `__version__` in `rohlik_api/__init__.py` (this is the single source of
truth — `pyproject.toml` reads it dynamically).
2. Create a git tag:
```bash
git tag v0.1.1
git push origin v0.1.1
```
3. Build and publish the new version (or let the GitHub Actions release workflow
do it).

## Verification

After publishing, verify the package:
## One-time setup (already done)

1. Check it appears on PyPI: https://pypi.org/project/rohlik-api/
2. Install in a fresh environment:
```bash
pip install rohlik-api
```
3. Test the installation:
```bash
python -c "from rohlik_api import RohlikAPI; print('Success!')"
```
- PyPI project → Publishing: a trusted publisher for this repository, the
`publish.yml` workflow and the `pypi` environment.
- GitHub repository → Settings → Environments: an environment named `pypi`.
52 changes: 39 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# 🛒 Rohlik API Python Client

An async, fully typed Python client for the [Rohlik.cz](https://www.rohlik.cz)
online grocery service — search products, manage your cart, browse recipes
(Rohlík Chef), and read your orders and deliveries, all from Python.
online grocery service and its sister shops [Knuspr.de](https://www.knuspr.de),
[Gurkerl.at](https://www.gurkerl.at), [Kifli.hu](https://www.kifli.hu) and
[Sezamo.ro](https://www.sezamo.ro) — search products, manage your cart, browse
recipes (Rohlík Chef), and read your orders and deliveries, all from Python.

> ## ⚠️ Unofficial — personal use only
>
Expand Down Expand Up @@ -42,6 +44,7 @@ online grocery service — search products, manage your cart, browse recipes
- 🍳 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
([other shops](#other-shops))

## Related projects

Expand Down Expand Up @@ -95,7 +98,7 @@ connection on exit.

## Credentials & security

The client authenticates with your normal Rohlik.cz **email and password**.
The client authenticates with your normal shop account **email and password**.

- **Never hard-code credentials** in source you commit. Prefer environment
variables or a secrets manager:
Expand All @@ -110,8 +113,9 @@ The client authenticates with your normal Rohlik.cz **email and password**.
)
```

- Credentials are only ever sent to Rohlik.cz over HTTPS. This library does not
store or transmit them anywhere else.
- Credentials are only ever sent over HTTPS to the shop you target (Rohlik.cz
unless you pass another `base_url`). This library does not store or transmit
them anywhere else.
- Use a dedicated account if you're uncomfortable automating your primary one.

## Typed models
Expand Down Expand Up @@ -194,6 +198,15 @@ 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)

# Basic data for many products in one request -> list[ProductCard] | None
# (same order as the IDs; IDs the API did not return are skipped)
cards = await client.products.get_cards([1425155, 1384964])

# This week's deals ("Akce týdne") as ProductCards -> list[ProductCard] | None
deals = await client.products.get_week_sales(size=30)
for card in deals or []:
print(card.name, card.price, card.original_price, card.on_sale)
```

### Orders service (`client.orders`)
Expand All @@ -209,12 +222,18 @@ detail = await client.orders.get_detail(order_id=12345678) # full order i
### Delivery service (`client.delivery`)

```python
delivery = await client.delivery.get_info()
timeslot = await client.delivery.get_timeslot_reservation()
slots = await client.delivery.get_next_slots()
announcements = await client.delivery.get_announcements()
delivery = await client.delivery.get_info() # first available delivery
timeslot = await client.delivery.get_timeslot_reservation() # reserved slot, if any
slots = await client.delivery.get_next_slots() # upcoming slots for your address
announcements = await client.delivery.get_announcements() # e.g. courier ETA messages
addresses = await client.delivery.get_addresses() # saved delivery addresses
address_id = await client.delivery.get_active_address_id() # address used for slots
```

`get_next_slots()` needs a delivery address. The login response does not always
include one, so the client falls back to your saved addresses (preferring the one
you are currently delivered to) and caches the result.

### Account service (`client.account`)

```python
Expand Down Expand Up @@ -267,9 +286,15 @@ except APIRequestFailedError as err:

- **Critical / mutating operations** (login, `cart.get_content`, `cart.delete_item`,
`account.get_shopping_list`) **raise** `APIRequestFailedError` on failure.
Login raises `InvalidCredentialsError` for a wrong email or password, and
`RohlikAPIError` for any other status the shop reports.
- **Read / optional fetches** (most `orders`, `delivery`, `account`, `products`,
and `recipes` getters) **return `None`** on failure, so an aggregate fetch can
continue gracefully.
- **Batch operations** do not fail as a whole: `cart.add_items` returns only the
IDs that were added (failures are logged), and `orders.get_all_delivered`
returns the orders gathered so far if a page fails.
- `account.get_shopping_list` raises `ValueError` if called without an ID.

## Advanced usage

Expand All @@ -295,17 +320,17 @@ 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 (or if no item reports one);
fall back to `site.currency` then. Delivery announcements and other texts come
back in the shop's language.
`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
client = RohlikAPI(
username="your_email@example.com",
password="your_password",
base_url="https://www.rohlik.cz", # optional
base_url="https://www.rohlik.cz", # optional; e.g. SITES["de"].base_url
timeout=30.0, # optional
headers={"Custom-Header": "Value"}, # optional
auto_login=True, # optional, default True
Expand All @@ -325,6 +350,7 @@ async def main():
)
try:
await client.login()
print(client.is_logged_in, client.user_id, client.address_id)
cart = await client.cart.get_content()
await client.logout()
finally:
Expand Down
24 changes: 17 additions & 7 deletions example.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,28 @@
"""Example usage of the Rohlik API client.

Replace USERNAME and PASSWORD with your real Rohlik.cz credentials and run:
Replace USERNAME and PASSWORD with your real credentials and run:

python example.py

The network calls are commented out so the file runs without credentials.
Uncomment the blocks you want to exercise once you have set your credentials.
It logs in and runs a product search, a cart fetch and a recipe search; the
other calls are commented out. Uncomment the ones you want to try. For a shop
other than Rohlík.cz, set SITE (see rohlik_api.SITES).
"""

import asyncio

from rohlik_api import APIRequestFailedError, InvalidCredentialsError, RohlikAPI
from rohlik_api import SITES, APIRequestFailedError, InvalidCredentialsError, RohlikAPI

USERNAME = "your_email@example.com"
PASSWORD = "your_password"
SITE = SITES["cz"] # or "de" (Knuspr.de), "at", "hu", "ro"


async def main() -> None:
"""Demonstrate the service-based API of the Rohlik client."""
# The recommended pattern: an async context manager with auto-login.
# On entry it logs in; on exit it logs out and releases resources.
async with RohlikAPI(username=USERNAME, password=PASSWORD) as client:
async with RohlikAPI(username=USERNAME, password=PASSWORD, base_url=SITE.base_url) as client:
print(f"Logged in: {client.is_logged_in}")
print(f"User ID: {client.user_id}, Address ID: {client.address_id}")

Expand All @@ -33,10 +35,15 @@ async def main() -> None:
# composition = await client.products.get_composition(product_id=1425155)
# price = await client.products.get_price(product_id=1425155)
# summary = await client.products.get_ai_summary(product_id=1384964)
# cards = await client.products.get_cards([1425155, 1384964])
# deals = await client.products.get_week_sales(size=10)

# --- Cart ----------------------------------------------------------
cart = await client.cart.get_content()
print(f"Cart total: {cart.total_price} ({cart.total_items} items)")
print(
f"Cart total: {cart.total_price} {cart.currency or SITE.currency} "
f"({cart.total_items} items, minimum order {cart.minimum_order_price})"
)

# await client.cart.add_items([{"product_id": 1234567, "quantity": 2}])
# if cart.products:
Expand All @@ -45,6 +52,7 @@ async def main() -> None:
# --- Delivery & orders --------------------------------------------
# delivery = await client.delivery.get_info()
# slots = await client.delivery.get_next_slots()
# addresses = await client.delivery.get_addresses()
# next_order = await client.orders.get_next()
# history = await client.orders.get_delivered(limit=10)

Expand All @@ -66,7 +74,9 @@ async def main() -> None:

async def manual_session() -> None:
"""Demonstrate manual session management without the context manager."""
client = RohlikAPI(username=USERNAME, password=PASSWORD, auto_login=False)
client = RohlikAPI(
username=USERNAME, password=PASSWORD, base_url=SITE.base_url, auto_login=False
)
try:
await client.login()
cart = await client.cart.get_content()
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ build-backend = "setuptools.build_meta"
[project]
name = "rohlik-api"
dynamic = ["version"]
description = "Async Python client for the Rohlik.cz API"
description = "Async Python client for the Rohlik.cz API (also Knuspr.de, Gurkerl.at, Kifli.hu and Sezamo.ro)"
readme = "README.md"
requires-python = ">=3.13"
license = "MIT"
license-files = ["LICENSE"]
authors = [
{name = "Daniel Vejsada", email = "dan.vejsada@gmail.com"}
]
keywords = ["rohlik", "api", "client", "grocery", "async"]
keywords = ["rohlik", "knuspr", "gurkerl", "kifli", "sezamo", "api", "client", "grocery", "async"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
Expand Down
4 changes: 3 additions & 1 deletion rohlik_api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Rohlik.cz API Python Client.

An async Python client for the Rohlik.cz API, built on aiohttp.
An async Python client for the Rohlik.cz API, built on aiohttp. The same API
serves the other Rohlík Group shops (Knuspr.de, Gurkerl.at, Kifli.hu,
Sezamo.ro); see :data:`SITES`.
"""

from .auth import AuthManager
Expand Down
Loading
Loading