From 2bb4835910d21fa8aaac46781c9f3926625e6812 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 05:40:15 +0000 Subject: [PATCH] Bring docs up to date for 0.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: document products.get_cards / get_week_sales and delivery.get_addresses / get_active_address_id (added in 0.2.0 but never documented); complete the error contract (batch operations, login's RohlikAPIError, get_shopping_list's ValueError); mention the other Rohlík Group shops in the intro and credentials section; link the "Other shops" section from Features. - PUBLISHING.md: describe the actual release flow (publish a GitHub Release -> trusted-publishing workflow) instead of asking to create a workflow that already exists. - example.py: its docstring claimed the network calls were commented out; it now says what runs, and shows shop selection via SITES plus the newer methods and cart fields. - pyproject: PyPI description and keywords cover the other shops. - Package and client docstrings no longer say Rohlik.cz only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0135WLPbnfESWnAJmw4P8uRH --- PUBLISHING.md | 156 ++++++++++------------------------------- README.md | 52 ++++++++++---- example.py | 24 +++++-- pyproject.toml | 4 +- rohlik_api/__init__.py | 4 +- rohlik_api/client.py | 7 +- 6 files changed, 103 insertions(+), 144 deletions(-) diff --git a/PUBLISHING.md b/PUBLISHING.md index bbc34b5..7006728 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -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`, e.g. `v0.3.0`, created from `main`. It must match + `__version__`; PyPI rejects a version that was already uploaded. + - Title: `v - `. + - 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--py3-none-any.whl` and +`dist/rohlik_api-.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`. diff --git a/README.md b/README.md index 24de9eb..aa5990b 100644 --- a/README.md +++ b/README.md @@ -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 > @@ -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 @@ -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: @@ -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 @@ -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`) @@ -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 @@ -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 @@ -295,9 +320,9 @@ 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 @@ -305,7 +330,7 @@ back in the shop's language. 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 @@ -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: diff --git a/example.py b/example.py index 20d3594..3a481f4 100644 --- a/example.py +++ b/example.py @@ -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}") @@ -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: @@ -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) @@ -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() diff --git a/pyproject.toml b/pyproject.toml index 0046d28..65e448f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ 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" @@ -13,7 +13,7 @@ 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", diff --git a/rohlik_api/__init__.py b/rohlik_api/__init__.py index c9ef3e0..e920d01 100644 --- a/rohlik_api/__init__.py +++ b/rohlik_api/__init__.py @@ -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 diff --git a/rohlik_api/client.py b/rohlik_api/client.py index 4795ef8..c1bdfde 100644 --- a/rohlik_api/client.py +++ b/rohlik_api/client.py @@ -165,7 +165,7 @@ def address_id(self) -> int | None: # ------------------------------------------------------------------------- async def login(self) -> dict[str, Any]: - """Authenticate with the Rohlik.cz service. + """Authenticate with the shop. Returns: The JSON response containing authentication data. @@ -173,11 +173,12 @@ async def login(self) -> dict[str, Any]: Raises: InvalidCredentialsError: If the credentials are invalid. APIRequestFailedError: If the request fails. + RohlikAPIError: If the shop answers with any other non-success status. """ return await self._auth.login() async def logout(self) -> None: - """Log out from the Rohlik.cz service. + """Log out from the shop. Raises: RohlikAPIError: If logout fails. @@ -219,7 +220,7 @@ async def close(self) -> None: # ------------------------------------------------------------------------- async def get_data(self) -> dict[str, Any]: - """Retrieve account data from Rohlik.cz in a single aggregated call. + """Retrieve account data from the shop in a single aggregated call. Returns: A dictionary containing delivery info, orders, cart contents,