From bdee342dc582e9bdf80f5f17a38da46d4168e6f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 12:01:06 +0000 Subject: [PATCH 1/5] Finish package: fix packaging, modernize, add public API, CI and docs Make the package professional and installable as the basis for the Home Assistant integration and a future MCP server. Packaging (blocking fixes): - Include the rohlik_api.services subpackage in builds (was omitted, so installed wheels would ImportError) - Use PEP 639 SPDX license metadata (license = "MIT") with setuptools>=77; the previous table form broke the build - Single-source the version from rohlik_api.__version__ (dynamic in pyproject, read via importlib.metadata for the User-Agent) - Target Python 3.11+, real author email, Beta status API & code quality: - Add public RohlikAPI.login()/logout() plus user_id/address_id properties so callers no longer need private _auth access - Modern typing (PEP 604 unions, builtin generics) across all modules - Consistent exception chaining (raise ... from err) and %-style logging - Add format_price helper to remove duplicated price formatting - Remove unused models.py dead code Tooling, tests & docs: - Add GitHub Actions CI (ruff, black, mypy, pytest) on 3.11-3.13 - Clean pass: ruff, black, mypy all green; 115 tests passing - Add tests for login/logout, client properties and helpers - Rewrite the outdated example.py against the current service API - Update README (error contract, requirements, development) and PUBLISHING Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PmotTwydT558t4JHd5Cnwm --- .github/workflows/ci.yml | 42 +++++ PUBLISHING.md | 10 +- README.md | 55 ++++++- example.py | 280 +++++++++----------------------- pyproject.toml | 47 +++--- rohlik_api/__init__.py | 14 +- rohlik_api/auth.py | 57 ++++--- rohlik_api/client.py | 166 ++++++++++--------- rohlik_api/endpoints.py | 15 +- rohlik_api/errors.py | 10 +- rohlik_api/helpers.py | 46 +++++- rohlik_api/http_client.py | 36 ++-- rohlik_api/models.py | 62 ------- rohlik_api/services/__init__.py | 8 +- rohlik_api/services/account.py | 20 ++- rohlik_api/services/base.py | 27 +-- rohlik_api/services/cart.py | 36 ++-- rohlik_api/services/delivery.py | 26 ++- rohlik_api/services/orders.py | 21 ++- rohlik_api/services/products.py | 73 +++++---- rohlik_api/services/recipes.py | 78 ++++----- tests/test_auth.py | 3 +- tests/test_client.py | 88 +++++++--- tests/test_endpoints.py | 48 +++--- tests/test_helpers.py | 37 +++++ tests/test_http_client.py | 3 +- tests/test_models.py | 169 ------------------- tests/test_recipes.py | 41 ++--- tests/test_services.py | 46 ++++-- 29 files changed, 729 insertions(+), 835 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 rohlik_api/models.py create mode 100644 tests/test_helpers.py delete mode 100644 tests/test_models.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6fc45d2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Lint (ruff) + run: ruff check . + + - name: Format check (black) + run: black --check . + + - name: Type check (mypy) + run: mypy rohlik_api + + - name: Test (pytest) + run: pytest --cov=rohlik_api --cov-report=term-missing diff --git a/PUBLISHING.md b/PUBLISHING.md index 2011aab..bbc34b5 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -110,15 +110,15 @@ Configure Trusted Publishing on PyPI: When releasing a new version: -1. Update version in `pyproject.toml` -2. Update version in `rohlik_api/__init__.py` -3. Update CHANGELOG in README.md -4. Create a git tag: +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 ``` -5. Build and publish the new version +3. Build and publish the new version (or let the GitHub Actions release workflow + do it). ## Verification diff --git a/README.md b/README.md index acdb784..afa5864 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,15 @@ An async Python client library for interacting with the Rohlik.cz API using http - 🍳 Recipe search and ingredient products (Rohlík Chef) - 📦 Product details, composition, and AI summaries +## Requirements + +- Python 3.11+ +- [httpx](https://www.python-httpx.org/) with HTTP/2 support (installed automatically) + +> **Disclaimer:** This is an unofficial client for the non-public Rohlik.cz API +> and is not affiliated with or endorsed by Rohlik.cz. The API may change without +> notice. + ## Installation ```bash @@ -182,6 +191,30 @@ all_data = await client.get_data() # Returns dict with: login, delivery, next_order, last_order, cart, premium_profile, etc. ``` +## Error Handling + +All errors derive from `RohlikAPIError`: + +```python +from rohlik_api import RohlikAPI, InvalidCredentialsError, APIRequestFailedError + +try: + async with RohlikAPI(username="email@example.com", password="password") as client: + cart = await client.cart.get_content() +except InvalidCredentialsError: + print("Wrong username or password") +except APIRequestFailedError as err: + print(f"Request failed: {err}") +``` + +Note on the error contract: + +- **Write/critical operations** (login, `cart.get_content`, `cart.delete_item`, + `account.get_shopping_list`) **raise** `APIRequestFailedError` on failure. +- **Read/optional fetches** (most `orders`, `delivery`, `account`, `products`, + and `recipes` getters) **return `None`** on failure so an aggregate fetch can + continue gracefully. + ## Advanced Usage ### Manual Session Management @@ -196,17 +229,33 @@ async def main(): auto_login=False # Disable auto-login ) try: - await client._auth.login() + await client.login() cart = await client.cart.get_content() - await client._auth.logout() + await client.logout() finally: await client.close() ``` +## Development + +```bash +# Install with development dependencies +pip install -e ".[dev]" + +# Run the test suite +pytest + +# Lint, format check and type check +ruff check . +black --check . +mypy rohlik_api +``` + ## License MIT License - see LICENSE file for details. ## Contributing -Contributions are welcome! Please feel free to submit a Pull Request. +Contributions are welcome! Please feel free to submit a Pull Request. Make sure +`pytest`, `ruff`, `black` and `mypy` all pass before opening one. diff --git a/example.py b/example.py index 5a9dcf9..c01d0ac 100644 --- a/example.py +++ b/example.py @@ -1,215 +1,85 @@ -"""Example usage of the Rohlik API client.""" +"""Example usage of the Rohlik API client. + +Replace USERNAME and PASSWORD with your real Rohlik.cz 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. +""" import asyncio -from rohlik_api import RohlikAPI, InvalidCredentialsError, APIRequestFailedError - - -async def main(): - """Demonstrate basic usage of the Rohlik API client.""" - print("Rohlik API Client Example") - print("=" * 60) - - # Replace with your actual credentials - USERNAME = "your_email@example.com" - PASSWORD = "your_password" - - # ------------------------------------------------------------------------- - # Example 1: Using async context manager (recommended) - # ------------------------------------------------------------------------- - print("\n[Example 1] Using async context manager with auto-login") - print("-" * 60) - - # For demo purposes, we disable auto_login. In real usage, omit auto_login=False - async with RohlikAPI(username=USERNAME, password=PASSWORD, auto_login=False) as client: - print(f"Client initialized with base URL: {client.base_url}") - print(f"Is logged in: {client.is_logged_in}") # False because auto_login=False - - # With real credentials, use auto_login=True (default): - # async with RohlikAPI(username=USERNAME, password=PASSWORD) as client: - # # client.is_logged_in is True - auto-login happened! - # - # # Get cart contents - # cart = await client.get_cart_content() - # print(f"Cart total: {cart['total_price']} CZK") - # print(f"Items in cart: {cart['total_items']}") - # - # # Get delivery information - # delivery = await client.get_delivery_info() - # print(f"Delivery info: {delivery}") - # - # # Search for products - # results = await client.search_product("mleko", limit=5) - # if results: - # print(f"Found {len(results['search_results'])} products:") - # for product in results['search_results']: - # print(f" - {product['name']} ({product['price']})") - - print("(Requires valid credentials to run)") - # Logout is called automatically when exiting the context manager - - # ------------------------------------------------------------------------- - # Example 2: Get all account data at once - # ------------------------------------------------------------------------- - print("\n[Example 2] Get all account data at once") - print("-" * 60) - - async with RohlikAPI(username=USERNAME, password=PASSWORD, auto_login=False) as client: - # Uncomment to test with real credentials: - # try: - # # get_data() fetches everything (login already done automatically) - # data = await client.get_data() - # print(f"Login status: {data.get('login', {}).get('status')}") - # print(f"Cart: {data.get('cart')}") - # print(f"Delivery: {data.get('delivery')}") - # print(f"Next order: {data.get('next_order')}") - # print(f"Premium profile: {data.get('premium_profile')}") - # except Exception as e: - # print(f"Error: {e}") - - print("get_data() fetches all account data in one call") - print("(Requires valid credentials to run)") - - # ------------------------------------------------------------------------- - # Example 3: Cart operations - # ------------------------------------------------------------------------- - print("\n[Example 3] Cart operations") - print("-" * 60) - - async with RohlikAPI(username=USERNAME, password=PASSWORD, auto_login=False) as client: - # Uncomment to test with real credentials: - # # Add products to cart - # products_to_add = [ - # {"product_id": 1234567, "quantity": 2}, - # {"product_id": 7654321, "quantity": 1} - # ] - # result = await client.add_to_cart(products_to_add) - # print(f"Added products: {result['added_products']}") - # - # # Get current cart - # cart = await client.get_cart_content() - # for product in cart['products']: - # print(f" - {product['name']}: {product['quantity']}x ({product['price']} CZK)") - # - # # Delete item from cart (using cart_item_id from get_cart_content) - # if cart['products']: - # item_to_delete = cart['products'][0]['cart_item_id'] - # await client.delete_from_cart(item_to_delete) - # print(f"Deleted item: {item_to_delete}") - - print("Cart operations: add_to_cart(), get_cart_content(), delete_from_cart()") - print("(Requires valid credentials to run)") - - # ------------------------------------------------------------------------- - # Example 4: Delivery and orders - # ------------------------------------------------------------------------- - print("\n[Example 4] Delivery and orders") - print("-" * 60) - - async with RohlikAPI(username=USERNAME, password=PASSWORD, auto_login=False) as client: - # Uncomment to test with real credentials: - # # Get delivery info - # delivery = await client.get_delivery_info() - # print(f"Delivery info: {delivery}") - # - # # Get timeslot reservation - # timeslot = await client.get_timeslot_reservation() - # print(f"Timeslot: {timeslot}") - # - # # Get next upcoming order - # next_order = await client.get_next_order() - # print(f"Next order: {next_order}") - # - # # Get last delivered order - # last_order = await client.get_last_order() - # print(f"Last order: {last_order}") - # - # # Get history of delivered orders - # orders = await client.get_delivered_orders(limit=10) - # print(f"Delivered orders count: {len(orders) if orders else 0}") - - print("Delivery methods: get_delivery_info(), get_timeslot_reservation()") - print("Order methods: get_next_order(), get_last_order(), get_delivered_orders()") - print("(Requires valid credentials to run)") - - # ------------------------------------------------------------------------- - # Example 5: Account information - # ------------------------------------------------------------------------- - print("\n[Example 5] Account information") - print("-" * 60) - - async with RohlikAPI(username=USERNAME, password=PASSWORD, auto_login=False) as client: - # Uncomment to test with real credentials: - # # Get premium profile (Rohlik Premium subscription) - # premium = await client.get_premium_profile() - # print(f"Premium profile: {premium}") - # - # # Get reusable bags info - # bags = await client.get_bags_info() - # print(f"Bags info: {bags}") - # - # # Get announcements - # announcements = await client.get_announcements() - # print(f"Announcements: {announcements}") - # - # # Get delivery announcements - # delivery_announcements = await client.get_delivery_announcements() - # print(f"Delivery announcements: {delivery_announcements}") - - print("Account methods: get_premium_profile(), get_bags_info()") - print("Announcement methods: get_announcements(), get_delivery_announcements()") - print("(Requires valid credentials to run)") - - # ------------------------------------------------------------------------- - # Example 6: Shopping lists - # ------------------------------------------------------------------------- - print("\n[Example 6] Shopping lists") - print("-" * 60) - - async with RohlikAPI(username=USERNAME, password=PASSWORD, auto_login=False) as client: - # Uncomment to test with real credentials: - # # Get a shopping list by ID - # shopping_list = await client.get_shopping_list("your-shopping-list-id") - # print(f"Shopping list: {shopping_list['name']}") - # print(f"Products: {shopping_list['products_in_list']}") - - print("Shopping list methods: get_shopping_list(shopping_list_id)") - print("(Requires valid credentials to run)") - - # ------------------------------------------------------------------------- - # Example 7: Manual session management (without context manager) - # ------------------------------------------------------------------------- - print("\n[Example 7] Manual session management (without context manager)") - print("-" * 60) +from rohlik_api import APIRequestFailedError, InvalidCredentialsError, RohlikAPI + +USERNAME = "your_email@example.com" +PASSWORD = "your_password" + + +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: + print(f"Logged in: {client.is_logged_in}") + print(f"User ID: {client.user_id}, Address ID: {client.address_id}") + + # --- Products ------------------------------------------------------ + results = await client.products.search("mleko", limit=5) + if results: + for product in results["search_results"]: + print(f" {product['name']} - {product['price']}") + + # 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) + + # --- Cart ---------------------------------------------------------- + cart = await client.cart.get_content() + print(f"Cart total: {cart['total_price']} ({cart['total_items']} items)") + + # await client.cart.add_items([{"product_id": 1234567, "quantity": 2}]) + # if cart["products"]: + # await client.cart.delete_item(cart["products"][0]["cart_item_id"]) + + # --- Delivery & orders -------------------------------------------- + # delivery = await client.delivery.get_info() + # slots = await client.delivery.get_next_slots() + # next_order = await client.orders.get_next() + # history = await client.orders.get_delivered(limit=10) + + # --- Account ------------------------------------------------------- + # premium = await client.account.get_premium_profile() + # bags = await client.account.get_bags_info() + # shopping_list = await client.account.get_shopping_list("list-id") + + # --- Recipes (Rohlík Chef) ---------------------------------------- + recipes = await client.recipes.search("rajská", limit=5) + if recipes: + print(f"Found {recipes['total_hits']} recipes") + # detail = await client.recipes.get_detail(recipe_id=59) + # products = await client.recipes.get_ingredient_products([102, 56], limit=5) + + # --- Aggregated snapshot ------------------------------------------ + # all_data = await client.get_data() + + +async def manual_session() -> None: + """Demonstrate manual session management without the context manager.""" client = RohlikAPI(username=USERNAME, password=PASSWORD, auto_login=False) try: - # Uncomment to test with real credentials: - # # When not using context manager, you must call login() manually - # await client.login() - # print(f"Logged in: {client.is_logged_in}") - # - # # Perform operations... - # cart = await client.get_cart_content() - # - # # Explicit logout when done - # await client.logout() - # print(f"Logged out: {not client.is_logged_in}") - - print("Without context manager, call login() and logout() manually") - print("(Requires valid credentials to run)") + await client.login() + cart = await client.cart.get_content() + print(f"Cart total: {cart['total_price']}") + await client.logout() finally: await client.close() - # ------------------------------------------------------------------------- - # Summary - # ------------------------------------------------------------------------- - print("\n" + "=" * 60) - print("All examples completed!") - print("\nTo use this API client:") - print("1. Replace USERNAME and PASSWORD with your Rohlik.cz credentials") - print("2. Uncomment the example code sections you want to run") - print("3. Run: python example.py") - if __name__ == "__main__": - asyncio.run(main()) + try: + asyncio.run(main()) + except InvalidCredentialsError: + print("Invalid credentials - set USERNAME and PASSWORD in example.py") + except APIRequestFailedError as err: + print(f"Request failed: {err}") diff --git a/pyproject.toml b/pyproject.toml index ea3fe9e..72a4bd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,32 +1,29 @@ [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = ["setuptools>=77.0", "wheel"] build-backend = "setuptools.build_meta" [project] name = "rohlik-api" -version = "0.1.0" -description = "Python client for Rohlik.cz API " +dynamic = ["version"] +description = "Async Python client for the Rohlik.cz API" readme = "README.md" -requires-python = ">=3.8" -license = {text = "MIT"} +requires-python = ">=3.11" +license = "MIT" license-files = ["LICENSE"] authors = [ - {name = "Daniel Vejsada", email = "noreply@github.com"} + {name = "Daniel Vejsada", email = "dan.vejsada@gmail.com"} ] -keywords = ["rohlik", "api", "client", "grocery"] +keywords = ["rohlik", "api", "client", "grocery", "async"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Framework :: AsyncIO", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed", ] @@ -36,12 +33,12 @@ dependencies = [ [project.optional-dependencies] dev = [ - "pytest>=9.0.0", - "pytest-cov>=7.0.0", - "pytest-asyncio>=1.3.0", - "black>=25.0.0", - "ruff>=0.14.0", - "mypy>=1.19.0", + "pytest>=8.0.0", + "pytest-cov>=5.0.0", + "pytest-asyncio>=0.24.0", + "black>=24.0.0", + "ruff>=0.6.0", + "mypy>=1.11.0", ] [project.urls] @@ -49,19 +46,22 @@ Homepage = "https://github.com/dvejsada/rohlik_api_python" Repository = "https://github.com/dvejsada/rohlik_api_python" Issues = "https://github.com/dvejsada/rohlik_api_python/issues" -[tool.setuptools] -packages = ["rohlik_api"] +[tool.setuptools.packages.find] +include = ["rohlik_api*"] + +[tool.setuptools.dynamic] +version = {attr = "rohlik_api.__version__"} [tool.setuptools.package-data] rohlik_api = ["py.typed"] [tool.black] line-length = 100 -target-version = ["py38", "py39", "py310", "py311", "py312", "py313", "py314"] +target-version = ["py311", "py312", "py313"] [tool.ruff] line-length = 100 -target-version = "py38" +target-version = "py311" [tool.ruff.lint] select = [ @@ -79,7 +79,7 @@ ignore = [] "__init__.py" = ["F401"] [tool.mypy] -python_version = "3.8" +python_version = "3.11" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = false @@ -90,6 +90,7 @@ testpaths = ["tests"] python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] +asyncio_mode = "auto" addopts = [ "--strict-markers", "--strict-config", diff --git a/rohlik_api/__init__.py b/rohlik_api/__init__.py index d5591b4..0a3b717 100644 --- a/rohlik_api/__init__.py +++ b/rohlik_api/__init__.py @@ -1,26 +1,26 @@ """Rohlik.cz API Python Client. -A Python package for interacting with the Rohlik.cz API using httpx with HTTP/2 support. +An async Python client for the Rohlik.cz API, built on httpx with HTTP/2 support. """ +from .auth import AuthManager from .client import RohlikAPI +from .endpoints import BASE_URL, Endpoints +from .errors import APIRequestFailedError, InvalidCredentialsError, RohlikAPIError from .helpers import mask_data -from .errors import RohlikAPIError, InvalidCredentialsError, APIRequestFailedError from .http_client import HttpClient -from .auth import AuthManager -from .endpoints import Endpoints, BASE_URL __version__ = "0.1.0" __all__ = [ # Main client (facade) "RohlikAPI", - # Utilities - "mask_data", # Errors "RohlikAPIError", "InvalidCredentialsError", "APIRequestFailedError", - # Advanced: Low-level components + # Utilities + "mask_data", + # Advanced: low-level components "HttpClient", "AuthManager", "Endpoints", diff --git a/rohlik_api/auth.py b/rohlik_api/auth.py index f9dff64..bb29215 100644 --- a/rohlik_api/auth.py +++ b/rohlik_api/auth.py @@ -1,14 +1,16 @@ """Authentication manager for Rohlik.cz API.""" +from __future__ import annotations + import logging -from typing import Optional, Dict, Any +from typing import Any import httpx -from .http_client import HttpClient from .endpoints import Endpoints -from .errors import InvalidCredentialsError, RohlikAPIError, APIRequestFailedError +from .errors import APIRequestFailedError, InvalidCredentialsError, RohlikAPIError from .helpers import mask_data +from .http_client import HttpClient _LOGGER = logging.getLogger(__name__) @@ -38,8 +40,8 @@ def __init__( self._password = password self._is_logged_in: bool = False - self._user_id: Optional[int] = None - self._address_id: Optional[int] = None + self._user_id: int | None = None + self._address_id: int | None = None @property def is_logged_in(self) -> bool: @@ -47,16 +49,16 @@ def is_logged_in(self) -> bool: return self._is_logged_in @property - def user_id(self) -> Optional[int]: + def user_id(self) -> int | None: """Get the current user ID.""" return self._user_id @property - def address_id(self) -> Optional[int]: + def address_id(self) -> int | None: """Get the current address ID.""" return self._address_id - async def login(self) -> Dict[str, Any]: + async def login(self) -> dict[str, Any]: """Authenticate with the Rohlik.cz service. If already logged in, returns cached response without making a new request. @@ -72,25 +74,25 @@ async def login(self) -> Dict[str, Any]: _LOGGER.debug("Already logged in, skipping login request") return {"status": 200, "message": "Already logged in"} - login_data = { - "email": self._username, - "password": self._password, - "name": "" - } + login_data = {"email": self._username, "password": self._password, "name": ""} try: response = await self._http.post(Endpoints.LOGIN, json=login_data) - login_response = response.json() + login_response: dict[str, Any] = response.json() if login_response.get("status") != 200: + messages = login_response.get("messages", []) if login_response.get("status") == 401: - messages = login_response.get("messages", []) - error_msg = messages[0].get("content", "Invalid credentials") if messages else "Invalid credentials" + error_msg = ( + messages[0].get("content", "Invalid credentials") + if messages + else "Invalid credentials" + ) raise InvalidCredentialsError(error_msg) - else: - messages = login_response.get("messages", []) - error_msg = messages[0].get("content", "Unknown error") if messages else "Unknown error" - raise RohlikAPIError(f"Unknown error occurred during login: {error_msg}") + error_msg = ( + messages[0].get("content", "Unknown error") if messages else "Unknown error" + ) + raise RohlikAPIError(f"Unknown error occurred during login: {error_msg}") self._is_logged_in = True @@ -103,14 +105,18 @@ async def login(self) -> Dict[str, Any]: try: self._address_id = data.get("address", {}).get("id") except AttributeError: - _LOGGER.error(f"Address cannot be retrieved from login data. Login response: {mask_data(login_response)}") + _LOGGER.error( + "Address cannot be retrieved from login data. Login response: %s", + mask_data(login_response), + ) return login_response except httpx.HTTPError as err: raise APIRequestFailedError( - f"Cannot connect to website! Check your internet connection and try again: {err}" - ) + f"Cannot connect to website! Check your internet connection " + f"and try again: {err}" + ) from err async def logout(self) -> None: """Log out from the Rohlik.cz service. @@ -135,8 +141,9 @@ async def logout(self) -> None: except httpx.HTTPError as err: self._is_logged_in = False # Reset state even on error raise APIRequestFailedError( - f"Cannot connect to website! Check your internet connection and try again: {err}" - ) + f"Cannot connect to website! Check your internet connection " + f"and try again: {err}" + ) from err async def ensure_logged_in(self) -> None: """Ensure the client is logged in, login if not.""" diff --git a/rohlik_api/client.py b/rohlik_api/client.py index b78c5e0..cf37218 100644 --- a/rohlik_api/client.py +++ b/rohlik_api/client.py @@ -1,20 +1,23 @@ """Rohlik.cz API Client implementation.""" +from __future__ import annotations + import logging -from typing import Optional, Dict, Any +from types import TracebackType +from typing import Any import httpx -from .http_client import HttpClient from .auth import AuthManager from .endpoints import BASE_URL from .errors import APIRequestFailedError +from .http_client import HttpClient from .services import ( + AccountService, CartService, - ProductService, - OrderService, DeliveryService, - AccountService, + OrderService, + ProductService, RecipeService, ) @@ -22,56 +25,33 @@ class RohlikAPI: - """Async client for interacting with Rohlik.cz API. + """Async client for interacting with the Rohlik.cz API. - This client uses httpx with HTTP/2 support for optimal performance - when communicating with the Rohlik.cz API endpoints. The client provides - a clean service-based API for all operations. - - When used as an async context manager with auto_login=True (default), - the client automatically logs in on entry and logs out on exit. + 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. Args: - username: Email address used for Rohlik.cz login (required) - password: Password for Rohlik.cz account (required) + username: Email address used for Rohlik.cz login (required). + password: Password for the Rohlik.cz account (required). base_url: Base URL for the Rohlik.cz API. Defaults to https://www.rohlik.cz - timeout: Request timeout in seconds. Defaults to 30.0 - headers: Optional custom headers to include in all requests - auto_login: If True (default), automatically login when using context manager + timeout: Request timeout in seconds. Defaults to 30.0. + headers: Optional custom headers to include in all requests. + auto_login: If True (default), log in automatically when used as a + context manager. Attributes: - cart (CartService): Service for cart operations (get_content, add_items, delete_item) - products (ProductService): Service for product search - orders (OrderService): Service for order operations (get_next, get_last, get_delivered) - delivery (DeliveryService): Service for delivery info and timeslots - account (AccountService): Service for account data (premium, bags, shopping lists) - recipes (RecipeService): Service for recipe search and ingredient products (Rohlík Chef) + cart (CartService): Cart operations (get_content, add_items, delete_item). + products (ProductService): Product search and details. + orders (OrderService): Order operations (get_next, get_last, get_delivered). + delivery (DeliveryService): Delivery info and timeslots. + account (AccountService): Account data (premium, bags, shopping lists). + recipes (RecipeService): Recipe search and ingredients (Rohlík Chef). Example: - Basic usage with context manager: - >>> async with RohlikAPI("user@example.com", "password") as client: ... cart = await client.cart.get_content() - ... print(f"Cart total: {cart['total_price']}") - - Full example with all services: - - >>> async with RohlikAPI("user@example.com", "password") as client: - ... # Cart operations - ... cart = await client.cart.get_content() - ... await client.cart.add_items([{"product_id": 123, "quantity": 2}]) - ... - ... # Search products - ... results = await client.products.search("milk") - ... - ... # Order history - ... orders = await client.orders.get_delivered(limit=10) - ... - ... # Delivery info - ... slots = await client.delivery.get_next_slots() - ... - ... # Account info - ... premium = await client.account.get_premium_profile() + ... print(cart["total_price"]) """ def __init__( @@ -80,26 +60,16 @@ def __init__( password: str, base_url: str = BASE_URL, timeout: float = 30.0, - headers: Optional[Dict[str, str]] = None, + headers: dict[str, str] | None = None, auto_login: bool = True, - ): - """Initialize the Rohlik API client. - - Args: - username: Email address used for Rohlik.cz login (required) - password: Password for Rohlik.cz account (required) - base_url: Base URL for the Rohlik.cz API - timeout: Request timeout in seconds - headers: Optional custom headers to include in all requests - auto_login: If True, automatically login when using context manager - """ + ) -> None: if not username or not password: raise ValueError("Username and password are required") self._auto_login = auto_login self.base_url = base_url.rstrip("/") self.timeout = timeout - + # Initialize HTTP client self._http = HttpClient( base_url=base_url, @@ -158,7 +128,7 @@ def recipes(self) -> RecipeService: @property def client(self) -> httpx.AsyncClient: - """Get or create the async HTTP client.""" + """Get or create the underlying async HTTP client.""" return self._http.client @property @@ -166,49 +136,89 @@ def is_logged_in(self) -> bool: """Check if the client is currently logged in.""" return self._auth.is_logged_in + @property + def user_id(self) -> int | None: + """The authenticated user's ID, or None if not logged in.""" + return self._auth.user_id + + @property + def address_id(self) -> int | None: + """The authenticated user's delivery address ID, or None if not logged in.""" + return self._auth.address_id + + # ------------------------------------------------------------------------- + # Authentication + # ------------------------------------------------------------------------- + + async def login(self) -> dict[str, Any]: + """Authenticate with the Rohlik.cz service. + + Returns: + The JSON response containing authentication data. + + Raises: + InvalidCredentialsError: If the credentials are invalid. + APIRequestFailedError: If the request fails. + """ + return await self._auth.login() + + async def logout(self) -> None: + """Log out from the Rohlik.cz service. + + Raises: + RohlikAPIError: If logout fails. + APIRequestFailedError: If the request fails. + """ + await self._auth.logout() # ------------------------------------------------------------------------- # Context Manager # ------------------------------------------------------------------------- - async def __aenter__(self): - """Async context manager entry - logs in automatically if auto_login is True.""" + async def __aenter__(self) -> RohlikAPI: + """Enter the context manager, logging in if ``auto_login`` is True.""" if self._auto_login: await self._auth.login() return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - """Async context manager exit - logout and close.""" + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Exit the context manager, logging out and releasing resources.""" await self.close() - async def close(self): + async def close(self) -> None: """Close the HTTP client and release resources. Logs out if logged in.""" if self._auth.is_logged_in: try: await self._auth.logout() - except Exception as err: - _LOGGER.error(f"Error during logout on close: {err}") + except Exception as err: # noqa: BLE001 - best-effort logout on close + _LOGGER.error("Error during logout on close: %s", err) await self._http.close() - # ------------------------------------------------------------------------- - # Data Retrieval Methods + # Aggregated data retrieval # ------------------------------------------------------------------------- - async def get_data(self) -> Dict[str, Any]: - """Retrieve all account data from Rohlik.cz in a single operation. + async def get_data(self) -> dict[str, Any]: + """Retrieve account data from Rohlik.cz in a single aggregated call. Returns: - dict: Dictionary containing all account data including delivery info, - orders, premium profile, cart contents, etc. + A dictionary containing delivery info, orders, cart contents, + premium profile, announcements and more. + + Raises: + APIRequestFailedError: If the underlying requests fail. """ - result: Dict[str, Any] = {} + result: dict[str, Any] = {} result["login"] = await self._auth.login() try: - # Fetch all data using services result["delivery"] = await self._delivery.get_info() result["next_order"] = await self._orders.get_next() result["last_order"] = await self._orders.get_last() @@ -224,5 +234,7 @@ async def get_data(self) -> Dict[str, Any]: return result except httpx.HTTPError as err: - raise APIRequestFailedError(f"Cannot connect to website! Check your internet connection and try again: {err}") - + raise APIRequestFailedError( + f"Cannot connect to website! Check your internet connection " + f"and try again: {err}" + ) from err diff --git a/rohlik_api/endpoints.py b/rohlik_api/endpoints.py index 4d4428d..cb27015 100644 --- a/rohlik_api/endpoints.py +++ b/rohlik_api/endpoints.py @@ -1,5 +1,9 @@ """API endpoint definitions for Rohlik.cz.""" +from __future__ import annotations + +from urllib.parse import quote + BASE_URL = "https://www.rohlik.cz" @@ -40,9 +44,11 @@ class Endpoints: @classmethod def recipe_search(cls, query: str, limit: int = 10, offset: int = 0) -> str: """Build recipe search endpoint URL.""" - from urllib.parse import quote encoded_query = quote(query) - return f"/services/frontend-service/recipe/search/{encoded_query}?offset={offset}&limit={limit}" + return ( + f"/services/frontend-service/recipe/search/{encoded_query}" + f"?offset={offset}&limit={limit}" + ) @classmethod def recipe_detail(cls, recipe_id: int) -> str: @@ -77,4 +83,7 @@ def delivered_orders(cls, limit: int = 50, offset: int = 0) -> str: @classmethod def timeslots(cls, user_id: int, address_id: int) -> str: """Build timeslots endpoint URL with user and address IDs.""" - return f"/services/frontend-service/timeslots-api/0?userId={user_id}&addressId={address_id}&reasonableDeliveryTime=true" + return ( + f"/services/frontend-service/timeslots-api/0" + f"?userId={user_id}&addressId={address_id}&reasonableDeliveryTime=true" + ) diff --git a/rohlik_api/errors.py b/rohlik_api/errors.py index 2a61972..6331dc0 100644 --- a/rohlik_api/errors.py +++ b/rohlik_api/errors.py @@ -1,17 +1,13 @@ -"""Custom exceptions for Rohlik.cz API client.""" +"""Custom exceptions for the Rohlik.cz API client.""" class RohlikAPIError(Exception): - """Base exception for Rohlik API errors.""" - pass + """Base exception for all Rohlik API errors.""" class InvalidCredentialsError(RohlikAPIError): """Raised when login credentials are invalid.""" - pass class APIRequestFailedError(RohlikAPIError): - """Raised when an API request fails.""" - pass - + """Raised when an API request fails (network or HTTP error).""" diff --git a/rohlik_api/helpers.py b/rohlik_api/helpers.py index 7d287e6..7a47ebd 100644 --- a/rohlik_api/helpers.py +++ b/rohlik_api/helpers.py @@ -1,20 +1,56 @@ +"""Helper utilities for the Rohlik.cz API client.""" + +from __future__ import annotations + from typing import Any + +def format_price(price_info: dict[str, Any] | None) -> str: + """Format a Rohlik price object into a ``" "`` string. + + Args: + price_info: A price mapping with optional ``full`` and ``currency`` keys. + + Returns: + A string like ``"29.90 Kč"``. Missing parts are rendered as empty. + """ + price_info = price_info or {} + full = price_info.get("full", "") + currency = price_info.get("currency", "") + return f"{full} {currency}".strip() + + def mask_data(input_dict: Any) -> Any: - """Takes a dictionary and replaces all non-null values with "XXXXXXX". Null values (None) remain unchanged.""" + """Recursively mask all non-null values in a dictionary with ``"XXXXXXX"``. + + Useful for logging API payloads without leaking personal data. ``None`` + values are preserved so the shape of the data remains visible. + + Args: + input_dict: The value to mask. Non-dict values are returned unchanged. + + Returns: + A copy of the input with every non-null leaf value replaced by + ``"XXXXXXX"``. + """ if not isinstance(input_dict, dict): return input_dict - result = {} + result: dict[Any, Any] = {} for key, value in input_dict.items(): if value is None: result[key] = None elif isinstance(value, dict): result[key] = mask_data(value) elif isinstance(value, list): - result[key] = [mask_data(item) if isinstance(item, dict) - else "XXXXXXX" if item is not None else None - for item in value] + result[key] = [ + ( + mask_data(item) + if isinstance(item, dict) + else "XXXXXXX" if item is not None else None + ) + for item in value + ] else: result[key] = "XXXXXXX" diff --git a/rohlik_api/http_client.py b/rohlik_api/http_client.py index ff7716e..88daad4 100644 --- a/rohlik_api/http_client.py +++ b/rohlik_api/http_client.py @@ -1,7 +1,10 @@ """HTTP client for Rohlik.cz API.""" +from __future__ import annotations + import logging -from typing import Optional, Dict, Any +from importlib.metadata import PackageNotFoundError, version +from typing import Any import httpx @@ -9,18 +12,23 @@ _LOGGER = logging.getLogger(__name__) +try: + _VERSION = version("rohlik-api") +except PackageNotFoundError: # pragma: no cover - package not installed + _VERSION = "0.0.0" + class HttpClient: """Async HTTP client with HTTP/2 support for Rohlik.cz API.""" - DEFAULT_USER_AGENT = "rohlik-api-python/0.1.0" + DEFAULT_USER_AGENT = f"rohlik-api-python/{_VERSION}" def __init__( self, base_url: str = BASE_URL, timeout: float = 30.0, - headers: Optional[Dict[str, str]] = None, - ): + headers: dict[str, str] | None = None, + ) -> None: self.base_url = base_url.rstrip("/") self.timeout = timeout @@ -31,7 +39,7 @@ def __init__( if headers: self._headers.update(headers) - self._client: Optional[httpx.AsyncClient] = None + self._client: httpx.AsyncClient | None = None @property def client(self) -> httpx.AsyncClient: @@ -60,8 +68,8 @@ async def close(self) -> None: async def get( self, endpoint: str, - params: Optional[Dict[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, ) -> httpx.Response: """Make a GET request.""" return await self.client.get(endpoint, params=params, headers=headers) @@ -69,9 +77,9 @@ async def get( async def post( self, endpoint: str, - data: Optional[Dict[str, Any]] = None, - json: Optional[Dict[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, + data: dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, ) -> httpx.Response: """Make a POST request.""" return await self.client.post(endpoint, data=data, json=json, headers=headers) @@ -79,16 +87,16 @@ async def post( async def delete( self, endpoint: str, - params: Optional[Dict[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, ) -> httpx.Response: """Make a DELETE request.""" return await self.client.delete(endpoint, params=params, headers=headers) - async def __aenter__(self): + async def __aenter__(self) -> HttpClient: """Async context manager entry.""" return self - async def __aexit__(self, exc_type, exc_val, exc_tb): + async def __aexit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None: """Async context manager exit.""" await self.close() diff --git a/rohlik_api/models.py b/rohlik_api/models.py deleted file mode 100644 index 7c1e3f8..0000000 --- a/rohlik_api/models.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Data models for Rohlik.cz API.""" - -from dataclasses import dataclass, field -from typing import List, Optional - - -@dataclass -class CartItem: - """Represents an item in the shopping cart.""" - id: str - cart_item_id: str - name: str - quantity: int - price: float - category_name: str = "" - brand: str = "" - - -@dataclass -class Cart: - """Represents the shopping cart.""" - total_price: float - total_items: int - can_make_order: bool - products: List[CartItem] = field(default_factory=list) - - -@dataclass -class ProductSearchResult: - """Represents a product from search results.""" - id: int - name: str - price: str - brand: Optional[str] = None - amount: Optional[str] = None - - -@dataclass -class SearchResults: - """Container for product search results.""" - results: List[ProductSearchResult] = field(default_factory=list) - - -@dataclass -class ShoppingListItem: - """Represents an item in a shopping list.""" - product_id: int - quantity: int - - -@dataclass -class ShoppingList: - """Represents a shopping list.""" - name: str - products: List[ShoppingListItem] = field(default_factory=list) - - -@dataclass -class AddToCartRequest: - """Request to add a product to cart.""" - product_id: int - quantity: int diff --git a/rohlik_api/services/__init__.py b/rohlik_api/services/__init__.py index 1e0b34e..b42e012 100644 --- a/rohlik_api/services/__init__.py +++ b/rohlik_api/services/__init__.py @@ -1,11 +1,11 @@ -"""Services package for Rohlik.cz API.""" +"""Services package for the Rohlik.cz API client.""" +from .account import AccountService from .base import BaseService from .cart import CartService -from .products import ProductService -from .orders import OrderService from .delivery import DeliveryService -from .account import AccountService +from .orders import OrderService +from .products import ProductService from .recipes import RecipeService __all__ = [ diff --git a/rohlik_api/services/account.py b/rohlik_api/services/account.py index 877405c..3525582 100644 --- a/rohlik_api/services/account.py +++ b/rohlik_api/services/account.py @@ -1,13 +1,15 @@ """Account service for Rohlik.cz API.""" +from __future__ import annotations + import logging -from typing import Dict, Any, Optional +from typing import Any import httpx -from .base import BaseService from ..endpoints import Endpoints from ..errors import APIRequestFailedError +from .base import BaseService _LOGGER = logging.getLogger(__name__) @@ -15,7 +17,7 @@ class AccountService(BaseService): """Service for account-related operations.""" - async def get_premium_profile(self) -> Optional[Dict[str, Any]]: + async def get_premium_profile(self) -> dict[str, Any] | None: """Get premium profile information. Returns: @@ -23,7 +25,7 @@ async def get_premium_profile(self) -> Optional[Dict[str, Any]]: """ return await self._fetch_endpoint(Endpoints.PREMIUM_PROFILE, "premium profile") - async def get_bags_info(self) -> Optional[Dict[str, Any]]: + async def get_bags_info(self) -> dict[str, Any] | None: """Get reusable bags user information. Returns: @@ -31,7 +33,7 @@ async def get_bags_info(self) -> Optional[Dict[str, Any]]: """ return await self._fetch_endpoint(Endpoints.BAGS, "bags info") - async def get_announcements(self) -> Optional[Dict[str, Any]]: + async def get_announcements(self) -> dict[str, Any] | None: """Get top announcements. Returns: @@ -39,7 +41,7 @@ async def get_announcements(self) -> Optional[Dict[str, Any]]: """ return await self._fetch_endpoint(Endpoints.ANNOUNCEMENTS, "announcements") - async def get_shopping_list(self, shopping_list_id: str) -> Dict[str, Any]: + async def get_shopping_list(self, shopping_list_id: str) -> dict[str, Any]: """Retrieve a shopping list by its ID. Args: @@ -66,9 +68,9 @@ async def get_shopping_list(self, shopping_list_id: str) -> Dict[str, Any]: return { "name": search_data.get("name"), - "products_in_list": search_data.get("products", []) + "products_in_list": search_data.get("products", []), } except httpx.HTTPError as err: - _LOGGER.error(f"Request failed: {err}") - raise APIRequestFailedError(f"Request failed: {err}") + _LOGGER.error("Request failed: %s", err) + raise APIRequestFailedError(f"Request failed: {err}") from err diff --git a/rohlik_api/services/base.py b/rohlik_api/services/base.py index 4a7c343..4177e19 100644 --- a/rohlik_api/services/base.py +++ b/rohlik_api/services/base.py @@ -1,12 +1,14 @@ """Base service class for Rohlik.cz API services.""" +from __future__ import annotations + import logging -from typing import Optional, Dict, Any +from typing import Any import httpx -from ..http_client import HttpClient from ..auth import AuthManager +from ..http_client import HttpClient _LOGGER = logging.getLogger(__name__) @@ -17,11 +19,11 @@ class BaseService: Provides common functionality like HTTP client access and authentication. Args: - http_client: The HTTP client instance - auth_manager: The authentication manager instance + http_client: The HTTP client instance. + auth_manager: The authentication manager instance. """ - def __init__(self, http_client: HttpClient, auth_manager: AuthManager): + def __init__(self, http_client: HttpClient, auth_manager: AuthManager) -> None: self._http = http_client self._auth = auth_manager @@ -32,23 +34,24 @@ async def _ensure_logged_in(self) -> None: async def _fetch_endpoint( self, endpoint: str, - error_context: str - ) -> Optional[Dict[str, Any]]: + error_context: str, + ) -> dict[str, Any] | None: """Fetch data from an endpoint with error handling. Args: - endpoint: The API endpoint path - error_context: Context string for error logging + endpoint: The API endpoint path. + error_context: Context string for error logging. Returns: - dict: Response data or None if request fails + The parsed JSON response, or None if the request fails. """ await self._ensure_logged_in() try: response = await self._http.get(endpoint) response.raise_for_status() - return response.json() + data: dict[str, Any] = response.json() + return data except httpx.HTTPError as err: - _LOGGER.error(f"Error fetching {error_context}: {err}") + _LOGGER.error("Error fetching %s: %s", error_context, err) return None diff --git a/rohlik_api/services/cart.py b/rohlik_api/services/cart.py index 0f91137..253b8fe 100644 --- a/rohlik_api/services/cart.py +++ b/rohlik_api/services/cart.py @@ -1,13 +1,15 @@ """Cart service for Rohlik.cz API.""" +from __future__ import annotations + import logging -from typing import Dict, Any, List +from typing import Any import httpx -from .base import BaseService from ..endpoints import Endpoints from ..errors import APIRequestFailedError +from .base import BaseService _LOGGER = logging.getLogger(__name__) @@ -15,7 +17,7 @@ class CartService(BaseService): """Service for shopping cart operations.""" - async def get_content(self) -> Dict[str, Any]: + async def get_content(self) -> dict[str, Any]: """Fetch the current cart contents. Returns: @@ -34,11 +36,11 @@ async def get_content(self) -> Dict[str, Any]: data = cart_content.get("data", {}) - cart_info: Dict[str, Any] = { + cart_info: dict[str, Any] = { "total_price": data.get("totalPrice", 0), "total_items": len(data.get("items", {})), "can_make_order": data.get("submitConditionPassed", False), - "products": [] + "products": [], } for product_id, product_data in data.get("items", {}).items(): @@ -49,17 +51,17 @@ async def get_content(self) -> Dict[str, Any]: "quantity": product_data.get("quantity", 0), "price": product_data.get("price", 0), "category_name": product_data.get("primaryCategoryName", ""), - "brand": product_data.get("brand", "") + "brand": product_data.get("brand", ""), } cart_info["products"].append(product_info) return cart_info except httpx.HTTPError as err: - _LOGGER.error(f"Request failed: {err}") - raise APIRequestFailedError(f"Failed to fetch cart: {err}") + _LOGGER.error("Request failed: %s", err) + raise APIRequestFailedError(f"Failed to fetch cart: {err}") from err - async def add_items(self, product_list: List[Dict[str, Any]]) -> Dict[str, List[int]]: + async def add_items(self, product_list: list[dict[str, Any]]) -> dict[str, list[int]]: """Add multiple products to the shopping cart. Args: @@ -74,7 +76,7 @@ async def add_items(self, product_list: List[Dict[str, Any]]) -> Dict[str, List[ """ await self._ensure_logged_in() - added_products: List[int] = [] + added_products: list[int] = [] for product in product_list: cart_payload = { @@ -82,7 +84,7 @@ async def add_items(self, product_list: List[Dict[str, Any]]) -> Dict[str, List[ "productId": int(product["product_id"]), "quantity": int(product["quantity"]), "recipeId": None, - "source": "true:Shopping Lists" + "source": "true:Shopping Lists", } try: response = await self._http.post(Endpoints.CART, json=cart_payload) @@ -93,7 +95,7 @@ async def add_items(self, product_list: List[Dict[str, Any]]) -> Dict[str, List[ return {"added_products": added_products} - async def delete_item(self, order_field_id: str) -> Dict[str, Any]: + async def delete_item(self, order_field_id: str) -> dict[str, Any]: """Delete an item from the shopping cart using orderFieldId. Args: @@ -109,16 +111,16 @@ async def delete_item(self, order_field_id: str) -> Dict[str, Any]: try: response = await self._http.delete( - Endpoints.CART, - params={"orderFieldId": order_field_id} + Endpoints.CART, params={"orderFieldId": order_field_id} ) response.raise_for_status() try: - return response.json() + result: dict[str, Any] = response.json() + return result except Exception: return {"success": True, "status_code": response.status_code} except httpx.HTTPError as err: - _LOGGER.error(f"Error deleting item with orderFieldId {order_field_id}: {err}") - raise APIRequestFailedError(f"Failed to delete item from cart: {err}") + _LOGGER.error("Error deleting item with orderFieldId %s: %s", order_field_id, err) + raise APIRequestFailedError(f"Failed to delete item from cart: {err}") from err diff --git a/rohlik_api/services/delivery.py b/rohlik_api/services/delivery.py index 9590cb2..4790732 100644 --- a/rohlik_api/services/delivery.py +++ b/rohlik_api/services/delivery.py @@ -1,10 +1,12 @@ """Delivery service for Rohlik.cz API.""" +from __future__ import annotations + import logging -from typing import Dict, Any, Optional +from typing import Any -from .base import BaseService from ..endpoints import Endpoints +from .base import BaseService _LOGGER = logging.getLogger(__name__) @@ -12,7 +14,7 @@ class DeliveryService(BaseService): """Service for delivery-related operations.""" - async def get_info(self) -> Optional[Dict[str, Any]]: + async def get_info(self) -> dict[str, Any] | None: """Get first delivery information. Returns: @@ -20,22 +22,17 @@ async def get_info(self) -> Optional[Dict[str, Any]]: """ return await self._fetch_endpoint(Endpoints.DELIVERY, "delivery info") - async def get_timeslot_reservation(self) -> Optional[Dict[str, Any]]: + async def get_timeslot_reservation(self) -> dict[str, Any] | None: """Get current timeslot reservation. Returns: dict: Timeslot reservation information or None if request fails """ - return await self._fetch_endpoint( - Endpoints.TIMESLOT_RESERVATION, - "timeslot reservation" - ) + return await self._fetch_endpoint(Endpoints.TIMESLOT_RESERVATION, "timeslot reservation") async def get_next_slots( - self, - user_id: Optional[int] = None, - address_id: Optional[int] = None - ) -> Optional[Dict[str, Any]]: + self, user_id: int | None = None, address_id: int | None = None + ) -> dict[str, Any] | None: """Get next available delivery slots. Args: @@ -57,13 +54,12 @@ async def get_next_slots( url = Endpoints.timeslots(user_id=uid, address_id=aid) return await self._fetch_endpoint(url, "next delivery slots") - async def get_announcements(self) -> Optional[Dict[str, Any]]: + async def get_announcements(self) -> dict[str, Any] | None: """Get delivery announcements. Returns: dict: Delivery announcements or None if request fails """ return await self._fetch_endpoint( - Endpoints.DELIVERY_ANNOUNCEMENTS, - "delivery announcements" + Endpoints.DELIVERY_ANNOUNCEMENTS, "delivery announcements" ) diff --git a/rohlik_api/services/orders.py b/rohlik_api/services/orders.py index 1e1710e..216f937 100644 --- a/rohlik_api/services/orders.py +++ b/rohlik_api/services/orders.py @@ -1,12 +1,14 @@ """Orders service for Rohlik.cz API.""" +from __future__ import annotations + import logging -from typing import Dict, Any, List, Optional +from typing import Any import httpx -from .base import BaseService from ..endpoints import Endpoints +from .base import BaseService _LOGGER = logging.getLogger(__name__) @@ -14,7 +16,7 @@ class OrderService(BaseService): """Service for order-related operations.""" - async def get_next(self) -> Optional[Dict[str, Any]]: + async def get_next(self) -> dict[str, Any] | None: """Get upcoming order information. Returns: @@ -22,7 +24,7 @@ async def get_next(self) -> Optional[Dict[str, Any]]: """ return await self._fetch_endpoint(Endpoints.NEXT_ORDER, "next order") - async def get_last(self) -> Optional[Dict[str, Any]]: + async def get_last(self) -> dict[str, Any] | None: """Get last delivered order information. Returns: @@ -30,11 +32,7 @@ async def get_last(self) -> Optional[Dict[str, Any]]: """ return await self._fetch_endpoint(Endpoints.LAST_ORDER, "last order") - async def get_delivered( - self, - limit: int = 50, - offset: int = 0 - ) -> Optional[List[Dict[str, Any]]]: + async def get_delivered(self, limit: int = 50, offset: int = 0) -> list[dict[str, Any]] | None: """Get list of delivered orders. Args: @@ -50,7 +48,8 @@ async def get_delivered( url = Endpoints.delivered_orders(limit=limit, offset=offset) response = await self._http.get(url) response.raise_for_status() - return response.json() + orders: list[dict[str, Any]] = response.json() + return orders except httpx.HTTPError as err: - _LOGGER.error(f"Error fetching delivered orders: {err}") + _LOGGER.error("Error fetching delivered orders: %s", err) return None diff --git a/rohlik_api/services/products.py b/rohlik_api/services/products.py index 92a8e06..678933a 100644 --- a/rohlik_api/services/products.py +++ b/rohlik_api/services/products.py @@ -1,12 +1,15 @@ """Products service for Rohlik.cz API.""" +from __future__ import annotations + import logging -from typing import Dict, Any, List, Optional +from typing import Any import httpx -from .base import BaseService from ..endpoints import Endpoints +from ..helpers import format_price +from .base import BaseService _LOGGER = logging.getLogger(__name__) @@ -15,11 +18,8 @@ class ProductService(BaseService): """Service for product-related operations.""" async def search( - self, - product_name: str, - limit: int = 10, - favourite: bool = False - ) -> Optional[Dict[str, Any]]: + self, product_name: str, limit: int = 10, favourite: bool = False + ) -> dict[str, Any] | None: """Search for products by name. Args: @@ -38,18 +38,19 @@ async def search( "limit": limit + 5, "companyId": 1, "filterData": {"filters": []}, - "canCorrect": True + "canCorrect": True, } try: response = await self._http.get(Endpoints.SEARCH, params=search_payload) response.raise_for_status() search_data = response.json() - found_products: List[Dict] = search_data.get("data", {}).get("productList", []) + found_products: list[dict] = search_data.get("data", {}).get("productList", []) # Remove sponsored content found_products = [ - p for p in found_products + p + for p in found_products if not any(badge.get("slug") == "promoted" for badge in p.get("badge", [])) ] @@ -62,16 +63,17 @@ async def search( found_products = found_products[:limit] if found_products: - search_results = {"search_results": []} + search_results: dict[str, Any] = {"search_results": []} for product in found_products: - price_info = product.get("price", {}) - search_results["search_results"].append({ - "id": product.get("productId"), - "name": product.get("productName"), - "price": f"{price_info.get('full', '')} {price_info.get('currency', '')}", - "brand": product.get("brand"), - "amount": product.get("textualAmount") - }) + search_results["search_results"].append( + { + "id": product.get("productId"), + "name": product.get("productName"), + "price": format_price(product.get("price")), + "brand": product.get("brand"), + "amount": product.get("textualAmount"), + } + ) return search_results else: return None @@ -80,7 +82,7 @@ async def search( _LOGGER.error(f"Request failed: {err}") return None - async def get_ai_summary(self, product_id: int) -> Optional[Dict[str, Any]]: + async def get_ai_summary(self, product_id: int) -> dict[str, Any] | None: """Get AI-generated summary for a product. Args: @@ -108,7 +110,7 @@ async def get_ai_summary(self, product_id: int) -> Optional[Dict[str, Any]]: _LOGGER.error(f"Error fetching AI summary for product {product_id}: {err}") return None - async def get_composition(self, product_id: int) -> Optional[Dict[str, Any]]: + async def get_composition(self, product_id: int) -> dict[str, Any] | None: """Get composition and nutritional values for a product. Args: @@ -129,18 +131,20 @@ async def get_composition(self, product_id: int) -> Optional[Dict[str, Any]]: nutritional_values = [] for nv in data.get("nutritionalValues", []): values = nv.get("values", {}) - nutritional_values.append({ - "portion": nv.get("portion"), - "energy_kj": values.get("energyKJ", {}).get("amount"), - "energy_kcal": values.get("energyKCal", {}).get("amount"), - "fats": values.get("fats", {}).get("amount"), - "saturated_fats": values.get("saturatedFats", {}).get("amount"), - "carbohydrates": values.get("carbohydrates", {}).get("amount"), - "sugars": values.get("sugars", {}).get("amount"), - "protein": values.get("protein", {}).get("amount"), - "salt": values.get("salt", {}).get("amount"), - "fiber": values.get("fiber", {}).get("amount"), - }) + nutritional_values.append( + { + "portion": nv.get("portion"), + "energy_kj": values.get("energyKJ", {}).get("amount"), + "energy_kcal": values.get("energyKCal", {}).get("amount"), + "fats": values.get("fats", {}).get("amount"), + "saturated_fats": values.get("saturatedFats", {}).get("amount"), + "carbohydrates": values.get("carbohydrates", {}).get("amount"), + "sugars": values.get("sugars", {}).get("amount"), + "protein": values.get("protein", {}).get("amount"), + "salt": values.get("salt", {}).get("amount"), + "fiber": values.get("fiber", {}).get("amount"), + } + ) # Parse allergens allergens_data = data.get("allergens", {}) @@ -159,7 +163,7 @@ async def get_composition(self, product_id: int) -> Optional[Dict[str, Any]]: _LOGGER.error(f"Error fetching composition for product {product_id}: {err}") return None - async def get_price(self, product_id: int) -> Optional[Dict[str, Any]]: + async def get_price(self, product_id: int) -> dict[str, Any] | None: """Get current price for a product. Args: @@ -190,4 +194,3 @@ async def get_price(self, product_id: int) -> Optional[Dict[str, Any]]: except httpx.HTTPError as err: _LOGGER.error(f"Error fetching price for product {product_id}: {err}") return None - diff --git a/rohlik_api/services/recipes.py b/rohlik_api/services/recipes.py index 18495d1..ee0d10d 100644 --- a/rohlik_api/services/recipes.py +++ b/rohlik_api/services/recipes.py @@ -1,12 +1,15 @@ """Recipe service for Rohlik.cz API.""" +from __future__ import annotations + import logging -from typing import Dict, Any, List, Optional +from typing import Any import httpx -from .base import BaseService from ..endpoints import Endpoints +from ..helpers import format_price +from .base import BaseService _LOGGER = logging.getLogger(__name__) @@ -14,12 +17,7 @@ class RecipeService(BaseService): """Service for recipe and ingredient operations (Rohlík Chef).""" - async def search( - self, - query: str, - limit: int = 10, - offset: int = 0 - ) -> Optional[Dict[str, Any]]: + async def search(self, query: str, limit: int = 10, offset: int = 0) -> dict[str, Any] | None: """Search for recipes by name. Args: @@ -54,14 +52,14 @@ async def search( } for meal in meals ], - "total_hits": total_hits + "total_hits": total_hits, } except httpx.HTTPError as err: _LOGGER.error(f"Error searching recipes: {err}") return None - async def get_detail(self, recipe_id: int) -> Optional[Dict[str, Any]]: + async def get_detail(self, recipe_id: int) -> dict[str, Any] | None: """Get detailed information about a recipe. Args: @@ -93,7 +91,7 @@ async def get_detail(self, recipe_id: int) -> Optional[Dict[str, Any]]: "image": item.get("imgPath"), } for item in group.get("items", []) - ] + ], } ingredients.append(ingredient_group) @@ -109,7 +107,7 @@ async def get_detail(self, recipe_id: int) -> Optional[Dict[str, Any]]: "content": step.get("content"), } for step in section.get("steps", []) - ] + ], } directions.append(direction_section) @@ -135,11 +133,8 @@ async def get_detail(self, recipe_id: int) -> Optional[Dict[str, Any]]: return None async def get_ingredient_products( - self, - ingredient_ids: List[int], - limit: int = 5, - offset: int = 0 - ) -> Optional[Dict[str, Any]]: + self, ingredient_ids: list[int], limit: int = 5, offset: int = 0 + ) -> dict[str, Any] | None: """Get products for specific ingredients. Args: @@ -152,17 +147,10 @@ async def get_ingredient_products( """ await self._ensure_logged_in() - payload = { - "ingredientIds": ingredient_ids, - "offset": offset, - "limit": limit - } + payload = {"ingredientIds": ingredient_ids, "offset": offset, "limit": limit} try: - response = await self._http.post( - Endpoints.INGREDIENT_PRODUCTS, - json=payload - ) + response = await self._http.post(Endpoints.INGREDIENT_PRODUCTS, json=payload) response.raise_for_status() data = response.json().get("data", {}) @@ -171,23 +159,27 @@ async def get_ingredient_products( products = [] for product in ingredient.get("products", []): price_info = product.get("price", {}) - products.append({ - "product_id": product.get("productId"), - "name": product.get("productName"), - "image": product.get("imgPath"), - "price": f"{price_info.get('full', '')} {price_info.get('currency', '')}", - "price_value": price_info.get("full"), - "unit": product.get("unit"), - "amount": product.get("textualAmount"), - "in_stock": product.get("inStock", False), - "is_favorite": product.get("favourite", False), - }) - - ingredients_data.append({ - "ingredient_id": ingredient.get("id"), - "products": products, - "total_hits": ingredient.get("totalHits", 0), - }) + products.append( + { + "product_id": product.get("productId"), + "name": product.get("productName"), + "image": product.get("imgPath"), + "price": format_price(price_info), + "price_value": price_info.get("full"), + "unit": product.get("unit"), + "amount": product.get("textualAmount"), + "in_stock": product.get("inStock", False), + "is_favorite": product.get("favourite", False), + } + ) + + ingredients_data.append( + { + "ingredient_id": ingredient.get("id"), + "products": products, + "total_hits": ingredient.get("totalHits", 0), + } + ) return {"ingredients": ingredients_data} diff --git a/tests/test_auth.py b/tests/test_auth.py index da71d1f..8dfcf5b 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,8 +1,9 @@ """Tests for the AuthManager class.""" import pytest -from rohlik_api.http_client import HttpClient + from rohlik_api.auth import AuthManager +from rohlik_api.http_client import HttpClient class TestAuthManagerInitialization: diff --git a/tests/test_client.py b/tests/test_client.py index 35d01d3..9e9a7bf 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,7 +1,10 @@ """Tests for the RohlikAPI client class.""" +from unittest.mock import AsyncMock + import pytest -from rohlik_api import RohlikAPI, InvalidCredentialsError, APIRequestFailedError, mask_data + +from rohlik_api import RohlikAPI, mask_data # Test credentials used throughout tests TEST_USERNAME = "test@example.com" @@ -72,7 +75,9 @@ def test_client_lazy_initialization(self): def test_client_base_url_trailing_slash(self): """Test that trailing slash is removed from base URL.""" - client = RohlikAPI(username=TEST_USERNAME, password=TEST_PASSWORD, base_url="https://www.rohlik.cz/") + client = RohlikAPI( + username=TEST_USERNAME, password=TEST_PASSWORD, base_url="https://www.rohlik.cz/" + ) assert client.base_url == "https://www.rohlik.cz" @@ -82,7 +87,9 @@ class TestAsyncContextManager: @pytest.mark.asyncio async def test_async_context_manager(self): """Test client works as async context manager.""" - async with RohlikAPI(username=TEST_USERNAME, password=TEST_PASSWORD, auto_login=False) as client: + async with RohlikAPI( + username=TEST_USERNAME, password=TEST_PASSWORD, auto_login=False + ) as client: assert client.base_url == "https://www.rohlik.cz" @pytest.mark.asyncio @@ -149,6 +156,48 @@ async def test_get_shopping_list_requires_id(self): await client.account.get_shopping_list("") await client.close() + @pytest.mark.asyncio + async def test_login_delegates_to_auth(self): + """Test that client.login() delegates to the auth manager.""" + client = RohlikAPI(username=TEST_USERNAME, password=TEST_PASSWORD, auto_login=False) + client._auth.login = AsyncMock(return_value={"status": 200}) + + result = await client.login() + + client._auth.login.assert_awaited_once() + assert result == {"status": 200} + + @pytest.mark.asyncio + async def test_logout_delegates_to_auth(self): + """Test that client.logout() delegates to the auth manager.""" + client = RohlikAPI(username=TEST_USERNAME, password=TEST_PASSWORD, auto_login=False) + client._auth.logout = AsyncMock() + + await client.logout() + + client._auth.logout.assert_awaited_once() + + @pytest.mark.asyncio + async def test_context_manager_auto_login(self): + """Test that entering the context manager logs in when auto_login is True.""" + client = RohlikAPI(username=TEST_USERNAME, password=TEST_PASSWORD, auto_login=True) + client._auth.login = AsyncMock(return_value={"status": 200}) + client._auth.logout = AsyncMock() + client._http.close = AsyncMock() + + async with client: + client._auth.login.assert_awaited_once() + + def test_user_and_address_id_properties(self): + """Test that user_id and address_id properties expose auth state.""" + client = RohlikAPI(username=TEST_USERNAME, password=TEST_PASSWORD) + assert client.user_id is None + assert client.address_id is None + client._auth._user_id = 111 + client._auth._address_id = 222 + assert client.user_id == 111 + assert client.address_id == 222 + class TestClientEndpoints: """Tests for endpoint configuration.""" @@ -158,26 +207,25 @@ def test_endpoints_defined(self): from rohlik_api import Endpoints # Check that required endpoint constants exist - assert hasattr(Endpoints, 'DELIVERY') - assert hasattr(Endpoints, 'NEXT_ORDER') - assert hasattr(Endpoints, 'ANNOUNCEMENTS') - assert hasattr(Endpoints, 'BAGS') - assert hasattr(Endpoints, 'TIMESLOT_RESERVATION') - assert hasattr(Endpoints, 'LAST_ORDER') - assert hasattr(Endpoints, 'PREMIUM_PROFILE') - assert hasattr(Endpoints, 'DELIVERY_ANNOUNCEMENTS') + assert hasattr(Endpoints, "DELIVERY") + assert hasattr(Endpoints, "NEXT_ORDER") + assert hasattr(Endpoints, "ANNOUNCEMENTS") + assert hasattr(Endpoints, "BAGS") + assert hasattr(Endpoints, "TIMESLOT_RESERVATION") + assert hasattr(Endpoints, "LAST_ORDER") + assert hasattr(Endpoints, "PREMIUM_PROFILE") + assert hasattr(Endpoints, "DELIVERY_ANNOUNCEMENTS") # Check that builder methods exist - assert callable(getattr(Endpoints, 'timeslots', None)) - assert callable(getattr(Endpoints, 'delivered_orders', None)) + assert callable(getattr(Endpoints, "timeslots", None)) + assert callable(getattr(Endpoints, "delivered_orders", None)) def test_service_properties_exist(self): """Test that all service properties are available on RohlikAPI.""" client = RohlikAPI(username=TEST_USERNAME, password=TEST_PASSWORD) - assert hasattr(client, 'cart') - assert hasattr(client, 'products') - assert hasattr(client, 'orders') - assert hasattr(client, 'delivery') - assert hasattr(client, 'account') - assert hasattr(client, 'recipes') - + assert hasattr(client, "cart") + assert hasattr(client, "products") + assert hasattr(client, "orders") + assert hasattr(client, "delivery") + assert hasattr(client, "account") + assert hasattr(client, "recipes") diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 8818fae..58530f6 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -1,7 +1,6 @@ """Tests for the Endpoints class.""" -import pytest -from rohlik_api import Endpoints, BASE_URL +from rohlik_api import BASE_URL, Endpoints class TestEndpointsConstants: @@ -35,7 +34,6 @@ def test_next_order_endpoint(self): """Test NEXT_ORDER endpoint.""" assert Endpoints.NEXT_ORDER == "/api/v3/orders/upcoming" - def test_premium_profile_endpoint(self): """Test PREMIUM_PROFILE endpoint.""" assert "premium/profile" in Endpoints.PREMIUM_PROFILE @@ -91,49 +89,49 @@ class TestEndpointsCompleteness: def test_all_auth_endpoints_exist(self): """Test all authentication endpoints exist.""" - assert hasattr(Endpoints, 'LOGIN') - assert hasattr(Endpoints, 'LOGOUT') + assert hasattr(Endpoints, "LOGIN") + assert hasattr(Endpoints, "LOGOUT") def test_all_cart_endpoints_exist(self): """Test all cart endpoints exist.""" - assert hasattr(Endpoints, 'CART') + assert hasattr(Endpoints, "CART") def test_all_product_endpoints_exist(self): """Test all product endpoints exist.""" - assert hasattr(Endpoints, 'SEARCH') + assert hasattr(Endpoints, "SEARCH") # These are now classmethod builders - assert callable(getattr(Endpoints, 'product_ai_summary', None)) - assert callable(getattr(Endpoints, 'product_composition', None)) - assert callable(getattr(Endpoints, 'product_price', None)) - assert callable(getattr(Endpoints, 'shopping_list', None)) + assert callable(getattr(Endpoints, "product_ai_summary", None)) + assert callable(getattr(Endpoints, "product_composition", None)) + assert callable(getattr(Endpoints, "product_price", None)) + assert callable(getattr(Endpoints, "shopping_list", None)) def test_all_delivery_endpoints_exist(self): """Test all delivery endpoints exist.""" - assert hasattr(Endpoints, 'DELIVERY') - assert hasattr(Endpoints, 'TIMESLOT_RESERVATION') - assert hasattr(Endpoints, 'DELIVERY_ANNOUNCEMENTS') + assert hasattr(Endpoints, "DELIVERY") + assert hasattr(Endpoints, "TIMESLOT_RESERVATION") + assert hasattr(Endpoints, "DELIVERY_ANNOUNCEMENTS") # timeslots is now a classmethod builder - assert callable(getattr(Endpoints, 'timeslots', None)) + assert callable(getattr(Endpoints, "timeslots", None)) def test_all_order_endpoints_exist(self): """Test all order endpoints exist.""" - assert hasattr(Endpoints, 'NEXT_ORDER') - assert hasattr(Endpoints, 'LAST_ORDER') + assert hasattr(Endpoints, "NEXT_ORDER") + assert hasattr(Endpoints, "LAST_ORDER") # delivered_orders is now a classmethod builder - assert callable(getattr(Endpoints, 'delivered_orders', None)) + assert callable(getattr(Endpoints, "delivered_orders", None)) def test_all_account_endpoints_exist(self): """Test all account endpoints exist.""" - assert hasattr(Endpoints, 'PREMIUM_PROFILE') - assert hasattr(Endpoints, 'BAGS') - assert hasattr(Endpoints, 'ANNOUNCEMENTS') + assert hasattr(Endpoints, "PREMIUM_PROFILE") + assert hasattr(Endpoints, "BAGS") + assert hasattr(Endpoints, "ANNOUNCEMENTS") def test_all_recipe_endpoints_exist(self): """Test all recipe endpoints exist.""" - assert hasattr(Endpoints, 'INGREDIENT_PRODUCTS') + assert hasattr(Endpoints, "INGREDIENT_PRODUCTS") # These are now classmethod builders - assert callable(getattr(Endpoints, 'recipe_search', None)) - assert callable(getattr(Endpoints, 'recipe_detail', None)) + assert callable(getattr(Endpoints, "recipe_search", None)) + assert callable(getattr(Endpoints, "recipe_detail", None)) class TestEndpointsRecipeBuilders: @@ -178,5 +176,3 @@ def test_product_price_builder(self): result = Endpoints.product_price(1425155) assert "/1425155/" in result assert "prices" in result - - diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..af71872 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,37 @@ +"""Tests for helper utilities.""" + +from rohlik_api.helpers import format_price, mask_data + + +class TestFormatPrice: + """Tests for the format_price helper.""" + + def test_full_and_currency(self): + assert format_price({"full": "29.90", "currency": "Kč"}) == "29.90 Kč" + + def test_numeric_amount(self): + assert format_price({"full": 10, "currency": "Kč"}) == "10 Kč" + + def test_missing_currency(self): + assert format_price({"full": "29.90"}) == "29.90" + + def test_missing_full(self): + assert format_price({"currency": "Kč"}) == "Kč" + + def test_empty_dict(self): + assert format_price({}) == "" + + def test_none(self): + assert format_price(None) == "" + + +class TestMaskData: + """Additional tests for mask_data covering list-of-dicts and mixed lists.""" + + def test_list_of_dicts(self): + result = mask_data({"users": [{"name": "John"}, {"name": "Jane"}]}) + assert result == {"users": [{"name": "XXXXXXX"}, {"name": "XXXXXXX"}]} + + def test_list_with_none(self): + result = mask_data({"items": ["a", None, "b"]}) + assert result == {"items": ["XXXXXXX", None, "XXXXXXX"]} diff --git a/tests/test_http_client.py b/tests/test_http_client.py index b2b64e8..7e604d6 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -1,8 +1,9 @@ """Tests for the HttpClient class.""" import pytest -from rohlik_api.http_client import HttpClient + from rohlik_api import BASE_URL +from rohlik_api.http_client import HttpClient class TestHttpClientInitialization: diff --git a/tests/test_models.py b/tests/test_models.py deleted file mode 100644 index 924cfbe..0000000 --- a/tests/test_models.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Tests for the data models.""" - -import pytest -from rohlik_api.models import ( - CartItem, - Cart, - ProductSearchResult, - SearchResults, - ShoppingListItem, - ShoppingList, - AddToCartRequest, -) - - -class TestCartItem: - """Tests for CartItem model.""" - - def test_cart_item_creation(self): - """Test CartItem can be created with required fields.""" - item = CartItem( - id="123", - cart_item_id="cart_456", - name="Test Product", - quantity=2, - price=99.90 - ) - assert item.id == "123" - assert item.cart_item_id == "cart_456" - assert item.name == "Test Product" - assert item.quantity == 2 - assert item.price == 99.90 - - def test_cart_item_with_optional_fields(self): - """Test CartItem with optional fields.""" - item = CartItem( - id="123", - cart_item_id="cart_456", - name="Test Product", - quantity=2, - price=99.90, - category_name="Food", - brand="TestBrand" - ) - assert item.category_name == "Food" - assert item.brand == "TestBrand" - - def test_cart_item_default_optional_fields(self): - """Test CartItem default values for optional fields.""" - item = CartItem( - id="123", - cart_item_id="cart_456", - name="Test Product", - quantity=2, - price=99.90 - ) - assert item.category_name == "" - assert item.brand == "" - - -class TestCart: - """Tests for Cart model.""" - - def test_cart_creation(self): - """Test Cart can be created.""" - cart = Cart( - total_price=199.90, - total_items=3, - can_make_order=True - ) - assert cart.total_price == 199.90 - assert cart.total_items == 3 - assert cart.can_make_order is True - assert cart.products == [] - - def test_cart_with_products(self): - """Test Cart with products.""" - item = CartItem( - id="123", - cart_item_id="cart_456", - name="Test Product", - quantity=2, - price=99.90 - ) - cart = Cart( - total_price=99.90, - total_items=1, - can_make_order=True, - products=[item] - ) - assert len(cart.products) == 1 - assert cart.products[0].name == "Test Product" - - -class TestProductSearchResult: - """Tests for ProductSearchResult model.""" - - def test_product_search_result_creation(self): - """Test ProductSearchResult can be created.""" - product = ProductSearchResult( - id=12345, - name="Test Product", - price="99.90 Kč" - ) - assert product.id == 12345 - assert product.name == "Test Product" - assert product.price == "99.90 Kč" - - def test_product_search_result_with_optional(self): - """Test ProductSearchResult with optional fields.""" - product = ProductSearchResult( - id=12345, - name="Test Product", - price="99.90 Kč", - brand="TestBrand", - amount="500g" - ) - assert product.brand == "TestBrand" - assert product.amount == "500g" - - -class TestSearchResults: - """Tests for SearchResults model.""" - - def test_search_results_empty(self): - """Test empty SearchResults.""" - results = SearchResults() - assert results.results == [] - - def test_search_results_with_products(self): - """Test SearchResults with products.""" - product = ProductSearchResult(id=1, name="Test", price="10 Kč") - results = SearchResults(results=[product]) - assert len(results.results) == 1 - - -class TestShoppingListItem: - """Tests for ShoppingListItem model.""" - - def test_shopping_list_item_creation(self): - """Test ShoppingListItem can be created.""" - item = ShoppingListItem(product_id=12345, quantity=3) - assert item.product_id == 12345 - assert item.quantity == 3 - - -class TestShoppingList: - """Tests for ShoppingList model.""" - - def test_shopping_list_creation(self): - """Test ShoppingList can be created.""" - shopping_list = ShoppingList(name="My List") - assert shopping_list.name == "My List" - assert shopping_list.products == [] - - def test_shopping_list_with_products(self): - """Test ShoppingList with products.""" - item = ShoppingListItem(product_id=123, quantity=2) - shopping_list = ShoppingList(name="My List", products=[item]) - assert len(shopping_list.products) == 1 - - -class TestAddToCartRequest: - """Tests for AddToCartRequest model.""" - - def test_add_to_cart_request_creation(self): - """Test AddToCartRequest can be created.""" - request = AddToCartRequest(product_id=12345, quantity=2) - assert request.product_id == 12345 - assert request.quantity == 2 diff --git a/tests/test_recipes.py b/tests/test_recipes.py index c8e6523..d50d0de 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -1,10 +1,11 @@ """Tests for the RecipeService class.""" -import pytest from unittest.mock import AsyncMock, MagicMock -from rohlik_api.http_client import HttpClient +import pytest + from rohlik_api.auth import AuthManager +from rohlik_api.http_client import HttpClient from rohlik_api.services.recipes import RecipeService @@ -54,10 +55,10 @@ async def test_search_returns_recipes(self, mock_http, mock_auth): "isFavorite": True, "isNew": False, "isBestSeller": True, - } + }, ], - "totalHits": 2 - } + "totalHits": 2, + }, } mock_response.raise_for_status = MagicMock() mock_http.get.return_value = mock_response @@ -77,6 +78,7 @@ async def test_search_returns_recipes(self, mock_http, mock_auth): async def test_search_returns_none_on_error(self, mock_http, mock_auth): """Test search returns None when request fails.""" import httpx + mock_http.get.side_effect = httpx.HTTPError("Connection failed") service = RecipeService(mock_http, mock_auth) @@ -116,10 +118,7 @@ async def test_get_detail_returns_recipe(self, mock_http, mock_auth): "duration": "Do hodinky", "servings": [{"name": "4 PORCE", "default": True}], "image": {"path": "/images/meals/large/59.jpg"}, - "author": { - "name": "Roman Vaněk", - "annotation": "Test annotation" - }, + "author": {"name": "Roman Vaněk", "annotation": "Test annotation"}, "tips": [{"content": "Tip 1"}, {"content": "Tip 2"}], "ingredients": [ { @@ -131,9 +130,9 @@ async def test_get_detail_returns_recipe(self, mock_http, mock_auth): "ingredientId": 56, "ingredientName": "2 větší mrkve", "productsCount": 4, - "imgPath": "/images/mrkev.jpg" + "imgPath": "/images/mrkev.jpg", } - ] + ], } ], "directions": [ @@ -142,13 +141,13 @@ async def test_get_detail_returns_recipe(self, mock_http, mock_auth): "position": 0, "steps": [ {"stepNumber": 1, "content": "Step 1 content"}, - {"stepNumber": 2, "content": "Step 2 content"} - ] + {"stepNumber": 2, "content": "Step 2 content"}, + ], } ], "isFavorite": False, - "link": "/chef/59-rajska-omacka" - } + "link": "/chef/59-rajska-omacka", + }, } mock_response.raise_for_status = MagicMock() mock_http.get.return_value = mock_response @@ -171,6 +170,7 @@ async def test_get_detail_returns_recipe(self, mock_http, mock_auth): async def test_get_detail_returns_none_on_error(self, mock_http, mock_auth): """Test get_detail returns None when request fails.""" import httpx + mock_http.get.side_effect = httpx.HTTPError("Connection failed") service = RecipeService(mock_http, mock_auth) @@ -201,7 +201,7 @@ async def test_get_ingredient_products_returns_data(self, mock_http, mock_auth): "unit": "kg", "textualAmount": "cca 1,2 kg", "inStock": True, - "favourite": True + "favourite": True, }, { "productId": 1313889, @@ -211,13 +211,13 @@ async def test_get_ingredient_products_returns_data(self, mock_http, mock_auth): "unit": "ks", "textualAmount": "1 ks", "inStock": True, - "favourite": False - } + "favourite": False, + }, ], - "totalHits": 3 + "totalHits": 3, } ] - } + }, } mock_response.raise_for_status = MagicMock() mock_http.post.return_value = mock_response @@ -255,6 +255,7 @@ async def test_get_ingredient_products_sends_correct_payload(self, mock_http, mo async def test_get_ingredient_products_returns_none_on_error(self, mock_http, mock_auth): """Test get_ingredient_products returns None when request fails.""" import httpx + mock_http.post.side_effect = httpx.HTTPError("Connection failed") service = RecipeService(mock_http, mock_auth) diff --git a/tests/test_services.py b/tests/test_services.py index d8654e8..597e87a 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,17 +1,18 @@ """Tests for the service classes.""" +from unittest.mock import AsyncMock, MagicMock + import pytest -from unittest.mock import AsyncMock, MagicMock, patch -from rohlik_api.http_client import HttpClient from rohlik_api.auth import AuthManager +from rohlik_api.http_client import HttpClient from rohlik_api.services import ( + AccountService, BaseService, CartService, - ProductService, - OrderService, DeliveryService, - AccountService, + OrderService, + ProductService, ) @@ -87,11 +88,11 @@ async def test_get_content_returns_formatted_data(self, mock_http, mock_auth): "quantity": 2, "price": 99.90, "primaryCategoryName": "Food", - "brand": "TestBrand" + "brand": "TestBrand", } }, "totalPrice": 99.90, - "submitConditionPassed": True + "submitConditionPassed": True, } } mock_response.raise_for_status = MagicMock() @@ -149,8 +150,18 @@ async def test_search_filters_promoted_products(self, mock_http, mock_auth): mock_response.json.return_value = { "data": { "productList": [ - {"productId": 1, "productName": "Regular", "badge": [], "price": {"full": 10, "currency": "Kč"}}, - {"productId": 2, "productName": "Promoted", "badge": [{"slug": "promoted"}], "price": {"full": 20, "currency": "Kč"}}, + { + "productId": 1, + "productName": "Regular", + "badge": [], + "price": {"full": 10, "currency": "Kč"}, + }, + { + "productId": 2, + "productName": "Promoted", + "badge": [{"slug": "promoted"}], + "price": {"full": 20, "currency": "Kč"}, + }, ] } } @@ -171,7 +182,7 @@ async def test_get_ai_summary_returns_data(self, mock_http, mock_auth): "productId": 1384964, "rating": "EMPTY", "title": "AI Souhrn", - "content": "Tato vepřová panenka je skvělá volba." + "content": "Tato vepřová panenka je skvělá volba.", } mock_response.raise_for_status = MagicMock() mock_http.get.return_value = mock_response @@ -188,6 +199,7 @@ async def test_get_ai_summary_returns_data(self, mock_http, mock_auth): async def test_get_ai_summary_returns_none_on_error(self, mock_http, mock_auth): """Test get_ai_summary returns None on error.""" import httpx + mock_http.get.side_effect = httpx.HTTPError("Connection failed") service = ProductService(mock_http, mock_auth) @@ -213,15 +225,15 @@ async def test_get_composition_returns_data(self, mock_http, mock_auth): "sugars": {"amount": 0.4, "unit": "g"}, "protein": {"amount": 9.6, "unit": "g"}, "salt": {"amount": 1.9, "unit": "g"}, - "fiber": {"amount": 0.0, "unit": "g"} - } + "fiber": {"amount": 0.0, "unit": "g"}, + }, } ], "plainIngredients": "PŠENIČNÁ mouka, voda, sůl", "allergens": { "contained": ["Obiloviny obsahující lepek"], - "possiblyContained": ["Vejce", "Mléko"] - } + "possiblyContained": ["Vejce", "Mléko"], + }, } mock_response.raise_for_status = MagicMock() mock_http.get.return_value = mock_response @@ -242,6 +254,7 @@ async def test_get_composition_returns_data(self, mock_http, mock_auth): async def test_get_composition_returns_none_on_error(self, mock_http, mock_auth): """Test get_composition returns None on error.""" import httpx + mock_http.get.side_effect = httpx.HTTPError("Connection failed") service = ProductService(mock_http, mock_auth) @@ -257,7 +270,7 @@ async def test_get_price_returns_data(self, mock_http, mock_auth): "productId": 1425155, "price": {"amount": 40.9, "currency": "CZK"}, "pricePerUnit": {"amount": 340.83, "currency": "CZK"}, - "sales": [] + "sales": [], } mock_response.raise_for_status = MagicMock() mock_http.get.return_value = mock_response @@ -276,6 +289,7 @@ async def test_get_price_returns_data(self, mock_http, mock_auth): async def test_get_price_returns_none_on_error(self, mock_http, mock_auth): """Test get_price returns None on error.""" import httpx + mock_http.get.side_effect = httpx.HTTPError("Connection failed") service = ProductService(mock_http, mock_auth) @@ -378,7 +392,7 @@ async def test_get_shopping_list_returns_formatted_data(self, mock_http, mock_au mock_response = MagicMock() mock_response.json.return_value = { "name": "My List", - "products": [{"productId": 123, "quantity": 2}] + "products": [{"productId": 123, "quantity": 2}], } mock_response.raise_for_status = MagicMock() mock_http.get.return_value = mock_response From 33a19160619df30dd9d52ce51293bd56a2b21cd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 12:16:29 +0000 Subject: [PATCH 2/5] Add typed models and require Python 3.13 Service methods that parse responses now return typed dataclasses instead of raw dictionaries, giving callers (the Home Assistant integration and the upcoming MCP server) a fully type-hinted, self-documenting API. - Add rohlik_api/models.py with dataclasses (slots) for cart, products, composition, prices, recipes, ingredient products and shopping lists, each with a from_api parser; exported from the package root - cart.get_content -> Cart, products.search -> SearchResults, get_ai_summary/get_composition/get_price -> typed models, recipes.* -> typed models, account.get_shopping_list -> ShoppingList - cart.add_items now returns list[int]; delete_item returns None and raises on failure; search returns an empty SearchResults (not None) when there are no hits, reserving None for request failures - Raw passthrough endpoints (orders, delivery, premium/bags/announcements, get_data) keep returning decoded JSON dicts/lists; documented in the README - Bump minimum Python to 3.13; update classifiers, tool targets and CI matrix - Update tests to attribute access, example.py and README accordingly All green: ruff, black, mypy; 116 tests passing (87% coverage), twine check passes and the wheel ships models.py and py.typed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PmotTwydT558t4JHd5Cnwm --- .github/workflows/ci.yml | 2 +- README.md | 87 +++--- example.py | 14 +- pyproject.toml | 10 +- rohlik_api/__init__.py | 48 +++- rohlik_api/models.py | 468 ++++++++++++++++++++++++++++++++ rohlik_api/services/account.py | 19 +- rohlik_api/services/cart.py | 73 ++--- rohlik_api/services/products.py | 170 ++++-------- rohlik_api/services/recipes.py | 154 ++--------- tests/test_recipes.py | 44 +-- tests/test_services.py | 67 +++-- 12 files changed, 748 insertions(+), 408 deletions(-) create mode 100644 rohlik_api/models.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fc45d2..98a5d8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.11", "3.12", "3.13"] + python-version: ["3.13"] steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index afa5864..70b144e 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,14 @@ An async Python client library for interacting with the Rohlik.cz API using http - 🚀 HTTP/2 support for improved performance - 🔐 Secure authentication with automatic session management - 🎯 Clean service-based API architecture +- 🧩 Typed dataclass models for all parsed responses (fully type-hinted, `py.typed`) - 🔄 Async context manager support - 🍳 Recipe search and ingredient products (Rohlík Chef) - 📦 Product details, composition, and AI summaries ## Requirements -- Python 3.11+ +- Python 3.13+ - [httpx](https://www.python-httpx.org/) with HTTP/2 support (installed automatically) > **Disclaimer:** This is an unofficial client for the non-public Rohlik.cz API @@ -34,25 +35,44 @@ from rohlik_api import RohlikAPI async def main(): async with RohlikAPI(username="your_email@example.com", password="your_password") as client: - # Search for products + # Search for products (returns a SearchResults model) results = await client.products.search("mleko", limit=5) - print(f"Search results: {results}") - - # Get cart contents + for product in results.results: + print(f"{product.name} - {product.price}") + + # Get cart contents (returns a Cart model) cart = await client.cart.get_content() - print(f"Cart: {cart}") - - # Get delivery information - delivery = await client.delivery.get_info() - print(f"Delivery: {delivery}") - - # Search recipes + print(f"Cart total: {cart.total_price} ({cart.total_items} items)") + + # Search recipes (returns a RecipeSearchResults model) recipes = await client.recipes.search("rajská", limit=5) - print(f"Recipes: {recipes}") + print(f"Found {recipes.total_hits} recipes") asyncio.run(main()) ``` +## Typed Models + +Service methods that parse responses return typed dataclasses (importable from +`rohlik_api`) rather than raw dictionaries, so editors and type checkers know the +shape of the data: + +```python +from dataclasses import asdict +from rohlik_api import Cart, SearchResults + +cart = await client.cart.get_content() # -> Cart +cart.total_price # float +cart.products[0].name # str + +# Convert any model to a plain dict (e.g. for JSON / Home Assistant / MCP): +asdict(cart) +``` + +Raw passthrough endpoints (`orders.*`, `delivery.*`, `account.get_premium_profile`, +`account.get_bags_info`, `account.get_announcements`, and `get_data`) return the +decoded JSON as `dict` / `list`, since they are not reshaped by the client. + ## Configuration ```python @@ -88,37 +108,39 @@ The client provides access to functionality through service properties: ```python # Get cart contents cart = await client.cart.get_content() -# Returns: {"total_price": 199.90, "total_items": 3, "can_make_order": True, "products": [...]} +# -> Cart(total_price=199.90, total_items=3, can_make_order=True, products=[CartItem, ...]) # Add items to cart -result = await client.cart.add_items([ +added = await client.cart.add_items([ {"product_id": 123456, "quantity": 2}, {"product_id": 789012, "quantity": 1} ]) -# Returns: {"added_products": [123456, 789012]} +# -> [123456, 789012] (list of product IDs successfully added) -# Delete item from cart +# Delete item from cart (raises APIRequestFailedError on failure) await client.cart.delete_item(order_field_id="abc123") ``` ### Products Service (`client.products`) ```python -# Search for products +# Search for products -> SearchResults | None (None only on request failure) results = await client.products.search("mléko", limit=10, favourite=False) -# Returns: {"search_results": [{"id": 123, "name": "...", "price": "29.90 Kč", ...}]} +for product in results.results: # ProductSearchResult: id, name, price, brand, amount + print(product.name, product.price) -# Get AI-generated product summary +# Get AI-generated product summary -> AISummary | None summary = await client.products.get_ai_summary(product_id=1384964) -# Returns: {"product_id": 1384964, "title": "AI Souhrn", "content": "..."} +# AISummary(product_id=1384964, rating=..., title="AI Souhrn", content="...") -# Get product composition (nutritional values, allergens) +# Get product composition -> ProductComposition | None composition = await client.products.get_composition(product_id=1425155) -# Returns: {"nutritional_values": [...], "ingredients": "...", "allergens": {...}} +# ProductComposition(product_id, nutritional_values=[NutritionalValue, ...], +# ingredients="...", allergens=Allergens(contained, possibly_contained)) -# Get product price +# Get product price -> ProductPrice | None price = await client.products.get_price(product_id=1425155) -# Returns: {"product_id": 1425155, "price": 40.9, "currency": "CZK", "price_per_unit": 340.83} +# ProductPrice(product_id=1425155, price=40.9, currency="CZK", price_per_unit=340.83, sales=[]) ``` ### Orders Service (`client.orders`) @@ -162,9 +184,9 @@ bags = await client.account.get_bags_info() # Get announcements announcements = await client.account.get_announcements() -# Get shopping list by ID +# Get shopping list by ID -> ShoppingList shopping_list = await client.account.get_shopping_list("list_id_here") -# Returns: {"name": "My List", "products_in_list": [...]} +# ShoppingList(name="My List", products_in_list=[...]) ``` ### Recipes Service (`client.recipes`) @@ -172,15 +194,16 @@ shopping_list = await client.account.get_shopping_list("list_id_here") ```python # Search for recipes recipes = await client.recipes.search("rajská", limit=10, offset=0) -# Returns: {"recipes": [{"id": 59, "name": "Rajská omáčka", "image": "...", ...}], "total_hits": 4} +# -> RecipeSearchResults(recipes=[RecipeSummary, ...], total_hits=4) -# Get recipe details +# Get recipe details -> RecipeDetail | None recipe = await client.recipes.get_detail(recipe_id=59) -# Returns: {"id": 59, "name": "...", "ingredients": [...], "directions": [...], ...} +# RecipeDetail(id=59, name="...", ingredients=[IngredientGroup, ...], +# directions=[DirectionSection, ...], author=RecipeAuthor, ...) -# Get products for ingredients +# Get products for ingredients -> IngredientProducts | None products = await client.recipes.get_ingredient_products(ingredient_ids=[102, 56], limit=5) -# Returns: {"ingredients": [{"ingredient_id": 102, "products": [...], "total_hits": 3}]} +# IngredientProducts(ingredients=[IngredientProductGroup(ingredient_id, products, total_hits)]) ``` ### Aggregated Data diff --git a/example.py b/example.py index c01d0ac..20d3594 100644 --- a/example.py +++ b/example.py @@ -27,8 +27,8 @@ async def main() -> None: # --- Products ------------------------------------------------------ results = await client.products.search("mleko", limit=5) if results: - for product in results["search_results"]: - print(f" {product['name']} - {product['price']}") + for product in results.results: + print(f" {product.name} - {product.price}") # composition = await client.products.get_composition(product_id=1425155) # price = await client.products.get_price(product_id=1425155) @@ -36,11 +36,11 @@ async def main() -> None: # --- 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.total_items} items)") # await client.cart.add_items([{"product_id": 1234567, "quantity": 2}]) - # if cart["products"]: - # await client.cart.delete_item(cart["products"][0]["cart_item_id"]) + # if cart.products: + # await client.cart.delete_item(cart.products[0].cart_item_id) # --- Delivery & orders -------------------------------------------- # delivery = await client.delivery.get_info() @@ -56,7 +56,7 @@ async def main() -> None: # --- Recipes (Rohlík Chef) ---------------------------------------- recipes = await client.recipes.search("rajská", limit=5) if recipes: - print(f"Found {recipes['total_hits']} recipes") + print(f"Found {recipes.total_hits} recipes") # detail = await client.recipes.get_detail(recipe_id=59) # products = await client.recipes.get_ingredient_products([102, 56], limit=5) @@ -70,7 +70,7 @@ async def manual_session() -> None: try: await client.login() cart = await client.cart.get_content() - print(f"Cart total: {cart['total_price']}") + print(f"Cart total: {cart.total_price}") await client.logout() finally: await client.close() diff --git a/pyproject.toml b/pyproject.toml index 72a4bd0..db5695a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "rohlik-api" dynamic = ["version"] description = "Async Python client for the Rohlik.cz API" readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.13" license = "MIT" license-files = ["LICENSE"] authors = [ @@ -19,8 +19,6 @@ classifiers = [ "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Framework :: AsyncIO", @@ -57,11 +55,11 @@ rohlik_api = ["py.typed"] [tool.black] line-length = 100 -target-version = ["py311", "py312", "py313"] +target-version = ["py313"] [tool.ruff] line-length = 100 -target-version = "py311" +target-version = "py313" [tool.ruff.lint] select = [ @@ -79,7 +77,7 @@ ignore = [] "__init__.py" = ["F401"] [tool.mypy] -python_version = "3.11" +python_version = "3.13" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = false diff --git a/rohlik_api/__init__.py b/rohlik_api/__init__.py index 0a3b717..ffd6d8c 100644 --- a/rohlik_api/__init__.py +++ b/rohlik_api/__init__.py @@ -7,8 +7,31 @@ from .client import RohlikAPI from .endpoints import BASE_URL, Endpoints from .errors import APIRequestFailedError, InvalidCredentialsError, RohlikAPIError -from .helpers import mask_data +from .helpers import format_price, mask_data from .http_client import HttpClient +from .models import ( + AISummary, + Allergens, + Cart, + CartItem, + DirectionSection, + DirectionStep, + IngredientGroup, + IngredientItem, + IngredientProduct, + IngredientProductGroup, + IngredientProducts, + NutritionalValue, + ProductComposition, + ProductPrice, + ProductSearchResult, + RecipeAuthor, + RecipeDetail, + RecipeSearchResults, + RecipeSummary, + SearchResults, + ShoppingList, +) __version__ = "0.1.0" __all__ = [ @@ -18,8 +41,31 @@ "RohlikAPIError", "InvalidCredentialsError", "APIRequestFailedError", + # Models + "Cart", + "CartItem", + "SearchResults", + "ProductSearchResult", + "AISummary", + "ProductComposition", + "NutritionalValue", + "Allergens", + "ProductPrice", + "RecipeSearchResults", + "RecipeSummary", + "RecipeDetail", + "RecipeAuthor", + "IngredientGroup", + "IngredientItem", + "DirectionSection", + "DirectionStep", + "IngredientProducts", + "IngredientProductGroup", + "IngredientProduct", + "ShoppingList", # Utilities "mask_data", + "format_price", # Advanced: low-level components "HttpClient", "AuthManager", diff --git a/rohlik_api/models.py b/rohlik_api/models.py new file mode 100644 index 0000000..28e296c --- /dev/null +++ b/rohlik_api/models.py @@ -0,0 +1,468 @@ +"""Typed data models for the Rohlik.cz API client. + +Service methods return these dataclasses instead of raw dictionaries. Each model +provides a ``from_api`` classmethod that parses the relevant slice of a Rohlik +API response. All models are plain dataclasses, so ``dataclasses.asdict`` can be +used to convert them back to JSON-serialisable dictionaries (useful for the +Home Assistant integration and the MCP server). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from .helpers import format_price + +# --------------------------------------------------------------------------- +# Cart +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class CartItem: + """A single item in the shopping cart.""" + + id: str + cart_item_id: str + name: str + quantity: int + price: float + category_name: str = "" + brand: str = "" + + @classmethod + def from_api(cls, item_id: str, data: dict[str, Any]) -> CartItem: + """Build a :class:`CartItem` from a cart ``items`` entry.""" + return cls( + id=item_id, + cart_item_id=data.get("orderFieldId", ""), + name=data.get("productName", ""), + quantity=data.get("quantity", 0), + price=data.get("price", 0), + category_name=data.get("primaryCategoryName", ""), + brand=data.get("brand", ""), + ) + + +@dataclass(slots=True) +class Cart: + """The current shopping cart.""" + + total_price: float + total_items: int + can_make_order: bool + products: list[CartItem] = field(default_factory=list) + + @classmethod + def from_api(cls, payload: dict[str, Any]) -> Cart: + """Build a :class:`Cart` from a ``/v2/cart`` response.""" + data = payload.get("data", {}) + items: dict[str, Any] = data.get("items", {}) + return cls( + total_price=data.get("totalPrice", 0), + total_items=len(items), + can_make_order=data.get("submitConditionPassed", False), + products=[CartItem.from_api(pid, pdata) for pid, pdata in items.items()], + ) + + +# --------------------------------------------------------------------------- +# Products +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class ProductSearchResult: + """A product entry from a search response.""" + + id: int | None + name: str | None + price: str + brand: str | None = None + amount: str | None = None + + @classmethod + def from_api(cls, data: dict[str, Any]) -> ProductSearchResult: + """Build a :class:`ProductSearchResult` from a product list entry.""" + return cls( + id=data.get("productId"), + name=data.get("productName"), + price=format_price(data.get("price")), + brand=data.get("brand"), + amount=data.get("textualAmount"), + ) + + +@dataclass(slots=True) +class SearchResults: + """Container for product search results.""" + + results: list[ProductSearchResult] = field(default_factory=list) + + +@dataclass(slots=True) +class AISummary: + """AI-generated summary for a product.""" + + product_id: int | None + rating: str | None + title: str | None + content: str | None + + @classmethod + def from_api(cls, data: dict[str, Any]) -> AISummary: + """Build an :class:`AISummary` from an ai-summary response.""" + return cls( + product_id=data.get("productId"), + rating=data.get("rating"), + title=data.get("title"), + content=data.get("content"), + ) + + +@dataclass(slots=True) +class NutritionalValue: + """Nutritional values for a single portion.""" + + portion: str | None + energy_kj: float | None + energy_kcal: float | None + fats: float | None + saturated_fats: float | None + carbohydrates: float | None + sugars: float | None + protein: float | None + salt: float | None + fiber: float | None + + @classmethod + def from_api(cls, data: dict[str, Any]) -> NutritionalValue: + """Build a :class:`NutritionalValue` from a nutritionalValues entry.""" + values = data.get("values", {}) + + def amount(key: str) -> float | None: + value: float | None = values.get(key, {}).get("amount") + return value + + return cls( + portion=data.get("portion"), + energy_kj=amount("energyKJ"), + energy_kcal=amount("energyKCal"), + fats=amount("fats"), + saturated_fats=amount("saturatedFats"), + carbohydrates=amount("carbohydrates"), + sugars=amount("sugars"), + protein=amount("protein"), + salt=amount("salt"), + fiber=amount("fiber"), + ) + + +@dataclass(slots=True) +class Allergens: + """Allergen information for a product.""" + + contained: list[str] = field(default_factory=list) + possibly_contained: list[str] = field(default_factory=list) + + +@dataclass(slots=True) +class ProductComposition: + """Composition and nutritional information for a product.""" + + product_id: int | None + nutritional_values: list[NutritionalValue] = field(default_factory=list) + ingredients: str | None = None + allergens: Allergens = field(default_factory=Allergens) + + @classmethod + def from_api(cls, data: dict[str, Any]) -> ProductComposition: + """Build a :class:`ProductComposition` from a composition response.""" + allergens = data.get("allergens", {}) + return cls( + product_id=data.get("productId"), + nutritional_values=[ + NutritionalValue.from_api(nv) for nv in data.get("nutritionalValues", []) + ], + ingredients=data.get("plainIngredients"), + allergens=Allergens( + contained=allergens.get("contained", []), + possibly_contained=allergens.get("possiblyContained", []), + ), + ) + + +@dataclass(slots=True) +class ProductPrice: + """Current price information for a product.""" + + product_id: int | None + price: float | None + currency: str | None + price_per_unit: float | None + sales: list[Any] = field(default_factory=list) + + @classmethod + def from_api(cls, data: dict[str, Any]) -> ProductPrice: + """Build a :class:`ProductPrice` from a prices response.""" + price = data.get("price", {}) + return cls( + product_id=data.get("productId"), + price=price.get("amount"), + currency=price.get("currency"), + price_per_unit=data.get("pricePerUnit", {}).get("amount"), + sales=data.get("sales", []), + ) + + +# --------------------------------------------------------------------------- +# Recipes (Rohlík Chef) +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class RecipeSummary: + """A recipe entry from a recipe search response.""" + + id: int | None + name: str | None + link: str | None + image: str | None + is_favorite: bool = False + is_new: bool = False + is_best_seller: bool = False + + @classmethod + def from_api(cls, data: dict[str, Any]) -> RecipeSummary: + """Build a :class:`RecipeSummary` from a meals entry.""" + return cls( + id=data.get("id"), + name=data.get("name"), + link=data.get("link"), + image=data.get("image"), + is_favorite=data.get("isFavorite", False), + is_new=data.get("isNew", False), + is_best_seller=data.get("isBestSeller", False), + ) + + +@dataclass(slots=True) +class RecipeSearchResults: + """Container for recipe search results.""" + + recipes: list[RecipeSummary] = field(default_factory=list) + total_hits: int = 0 + + @classmethod + def from_api(cls, payload: dict[str, Any]) -> RecipeSearchResults: + """Build :class:`RecipeSearchResults` from a recipe search response.""" + data = payload.get("data", {}) + return cls( + recipes=[RecipeSummary.from_api(meal) for meal in data.get("meals", [])], + total_hits=data.get("totalHits", 0), + ) + + +@dataclass(slots=True) +class IngredientItem: + """A single ingredient within a recipe ingredient group.""" + + name: str | None + ingredient_id: int | None + ingredient_name: str | None + products_count: int | None + image: str | None + + @classmethod + def from_api(cls, data: dict[str, Any]) -> IngredientItem: + """Build an :class:`IngredientItem` from an ingredient entry.""" + return cls( + name=data.get("name"), + ingredient_id=data.get("ingredientId"), + ingredient_name=data.get("ingredientName"), + products_count=data.get("productsCount"), + image=data.get("imgPath"), + ) + + +@dataclass(slots=True) +class IngredientGroup: + """A named group of recipe ingredients.""" + + name: str | None + position: int | None + items: list[IngredientItem] = field(default_factory=list) + + @classmethod + def from_api(cls, data: dict[str, Any]) -> IngredientGroup: + """Build an :class:`IngredientGroup` from an ingredients entry.""" + return cls( + name=data.get("name"), + position=data.get("position"), + items=[IngredientItem.from_api(item) for item in data.get("items", [])], + ) + + +@dataclass(slots=True) +class DirectionStep: + """A single step in a recipe direction section.""" + + step_number: int | None + content: str | None + + @classmethod + def from_api(cls, data: dict[str, Any]) -> DirectionStep: + """Build a :class:`DirectionStep` from a steps entry.""" + return cls(step_number=data.get("stepNumber"), content=data.get("content")) + + +@dataclass(slots=True) +class DirectionSection: + """A named section of recipe directions.""" + + name: str | None + position: int | None + steps: list[DirectionStep] = field(default_factory=list) + + @classmethod + def from_api(cls, data: dict[str, Any]) -> DirectionSection: + """Build a :class:`DirectionSection` from a directions entry.""" + return cls( + name=data.get("name"), + position=data.get("position"), + steps=[DirectionStep.from_api(step) for step in data.get("steps", [])], + ) + + +@dataclass(slots=True) +class RecipeAuthor: + """Author of a recipe.""" + + name: str | None + annotation: str | None + + +@dataclass(slots=True) +class RecipeDetail: + """Detailed information about a recipe.""" + + id: int | None + name: str | None + duration: int | None + servings: list[Any] = field(default_factory=list) + image: str | None = None + author: RecipeAuthor = field(default_factory=lambda: RecipeAuthor(None, None)) + tips: list[str] = field(default_factory=list) + ingredients: list[IngredientGroup] = field(default_factory=list) + directions: list[DirectionSection] = field(default_factory=list) + is_favorite: bool = False + link: str | None = None + + @classmethod + def from_api(cls, payload: dict[str, Any]) -> RecipeDetail: + """Build a :class:`RecipeDetail` from a recipe detail response.""" + data = payload.get("data", {}) + author = data.get("author", {}) + return cls( + id=data.get("id"), + name=data.get("name"), + duration=data.get("duration"), + servings=data.get("servings", []), + image=data.get("image", {}).get("path"), + author=RecipeAuthor(name=author.get("name"), annotation=author.get("annotation")), + tips=[tip.get("content") for tip in data.get("tips", [])], + ingredients=[IngredientGroup.from_api(group) for group in data.get("ingredients", [])], + directions=[ + DirectionSection.from_api(section) for section in data.get("directions", []) + ], + is_favorite=data.get("isFavorite", False), + link=data.get("link"), + ) + + +@dataclass(slots=True) +class IngredientProduct: + """A purchasable product matched to a recipe ingredient.""" + + product_id: int | None + name: str | None + image: str | None + price: str + price_value: Any + unit: str | None + amount: str | None + in_stock: bool = False + is_favorite: bool = False + + @classmethod + def from_api(cls, data: dict[str, Any]) -> IngredientProduct: + """Build an :class:`IngredientProduct` from a product entry.""" + price_info = data.get("price", {}) + return cls( + product_id=data.get("productId"), + name=data.get("productName"), + image=data.get("imgPath"), + price=format_price(price_info), + price_value=price_info.get("full"), + unit=data.get("unit"), + amount=data.get("textualAmount"), + in_stock=data.get("inStock", False), + is_favorite=data.get("favourite", False), + ) + + +@dataclass(slots=True) +class IngredientProductGroup: + """Products available for a single ingredient.""" + + ingredient_id: int | None + products: list[IngredientProduct] = field(default_factory=list) + total_hits: int = 0 + + @classmethod + def from_api(cls, data: dict[str, Any]) -> IngredientProductGroup: + """Build an :class:`IngredientProductGroup` from an ingredients entry.""" + return cls( + ingredient_id=data.get("id"), + products=[IngredientProduct.from_api(p) for p in data.get("products", [])], + total_hits=data.get("totalHits", 0), + ) + + +@dataclass(slots=True) +class IngredientProducts: + """Container for ingredient product groups.""" + + ingredients: list[IngredientProductGroup] = field(default_factory=list) + + @classmethod + def from_api(cls, payload: dict[str, Any]) -> IngredientProducts: + """Build :class:`IngredientProducts` from an ingredient products response.""" + data = payload.get("data", {}) + return cls( + ingredients=[ + IngredientProductGroup.from_api(ing) for ing in data.get("ingredients", []) + ] + ) + + +# --------------------------------------------------------------------------- +# Account +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class ShoppingList: + """A saved shopping list.""" + + name: str | None + products_in_list: list[Any] = field(default_factory=list) + + @classmethod + def from_api(cls, data: dict[str, Any]) -> ShoppingList: + """Build a :class:`ShoppingList` from a shopping list response.""" + return cls( + name=data.get("name"), + products_in_list=data.get("products", []), + ) diff --git a/rohlik_api/services/account.py b/rohlik_api/services/account.py index 3525582..7ee8346 100644 --- a/rohlik_api/services/account.py +++ b/rohlik_api/services/account.py @@ -9,6 +9,7 @@ from ..endpoints import Endpoints from ..errors import APIRequestFailedError +from ..models import ShoppingList from .base import BaseService _LOGGER = logging.getLogger(__name__) @@ -41,18 +42,18 @@ async def get_announcements(self) -> dict[str, Any] | None: """ return await self._fetch_endpoint(Endpoints.ANNOUNCEMENTS, "announcements") - async def get_shopping_list(self, shopping_list_id: str) -> dict[str, Any]: + async def get_shopping_list(self, shopping_list_id: str) -> ShoppingList: """Retrieve a shopping list by its ID. Args: - shopping_list_id: The ID of the shopping list to retrieve + shopping_list_id: The ID of the shopping list to retrieve. Returns: - dict: The shopping list details with 'name' and 'products_in_list' keys + A ShoppingList with its name and products. Raises: - ValueError: If shopping_list_id is not provided - APIRequestFailedError: If the request fails + ValueError: If shopping_list_id is not provided. + APIRequestFailedError: If the request fails. """ if not shopping_list_id: raise ValueError("Missing argument - shopping list id") @@ -64,13 +65,7 @@ async def get_shopping_list(self, shopping_list_id: str) -> dict[str, Any]: try: response = await self._http.get(url) response.raise_for_status() - search_data = response.json() - - return { - "name": search_data.get("name"), - "products_in_list": search_data.get("products", []), - } - + return ShoppingList.from_api(response.json()) except httpx.HTTPError as err: _LOGGER.error("Request failed: %s", err) raise APIRequestFailedError(f"Request failed: {err}") from err diff --git a/rohlik_api/services/cart.py b/rohlik_api/services/cart.py index 253b8fe..89691b9 100644 --- a/rohlik_api/services/cart.py +++ b/rohlik_api/services/cart.py @@ -9,6 +9,7 @@ from ..endpoints import Endpoints from ..errors import APIRequestFailedError +from ..models import Cart from .base import BaseService _LOGGER = logging.getLogger(__name__) @@ -17,71 +18,44 @@ class CartService(BaseService): """Service for shopping cart operations.""" - async def get_content(self) -> dict[str, Any]: + async def get_content(self) -> Cart: """Fetch the current cart contents. Returns: - dict: Dictionary with cart content including total_price, total_items, - can_make_order, and products list + Cart: The current cart with its products. Raises: - APIRequestFailedError: If the request fails + APIRequestFailedError: If the request fails. """ await self._ensure_logged_in() try: response = await self._http.get(Endpoints.CART) response.raise_for_status() - cart_content = response.json() - - data = cart_content.get("data", {}) - - cart_info: dict[str, Any] = { - "total_price": data.get("totalPrice", 0), - "total_items": len(data.get("items", {})), - "can_make_order": data.get("submitConditionPassed", False), - "products": [], - } - - for product_id, product_data in data.get("items", {}).items(): - product_info = { - "id": product_id, - "cart_item_id": product_data.get("orderFieldId", ""), - "name": product_data.get("productName", ""), - "quantity": product_data.get("quantity", 0), - "price": product_data.get("price", 0), - "category_name": product_data.get("primaryCategoryName", ""), - "brand": product_data.get("brand", ""), - } - cart_info["products"].append(product_info) - - return cart_info - + return Cart.from_api(response.json()) except httpx.HTTPError as err: _LOGGER.error("Request failed: %s", err) raise APIRequestFailedError(f"Failed to fetch cart: {err}") from err - async def add_items(self, product_list: list[dict[str, Any]]) -> dict[str, list[int]]: + async def add_items(self, product_list: list[dict[str, Any]]) -> list[int]: """Add multiple products to the shopping cart. Args: - product_list: A list of dictionaries containing product_id and quantity + product_list: A list of dictionaries containing ``product_id`` and + ``quantity``. Returns: - dict: A dictionary with 'added_products' key containing list of product IDs - that were successfully added - - Raises: - APIRequestFailedError: If the request fails + The list of product IDs that were successfully added. """ await self._ensure_logged_in() added_products: list[int] = [] for product in product_list: + product_id = int(product["product_id"]) cart_payload = { "actionId": None, - "productId": int(product["product_id"]), + "productId": product_id, "quantity": int(product["quantity"]), "recipeId": None, "source": "true:Shopping Lists", @@ -89,23 +63,21 @@ async def add_items(self, product_list: list[dict[str, Any]]) -> dict[str, list[ try: response = await self._http.post(Endpoints.CART, json=cart_payload) response.raise_for_status() - added_products.append(product["product_id"]) + added_products.append(product_id) except httpx.HTTPError as err: - _LOGGER.error(f"Error adding {product['product_id']} due to {err}") + _LOGGER.error("Error adding %s due to %s", product_id, err) - return {"added_products": added_products} + return added_products - async def delete_item(self, order_field_id: str) -> dict[str, Any]: - """Delete an item from the shopping cart using orderFieldId. + async def delete_item(self, order_field_id: str) -> None: + """Delete an item from the shopping cart using its ``orderFieldId``. Args: - order_field_id: The orderFieldId of the item to delete - - Returns: - dict: Response from the deletion operation + order_field_id: The ``orderFieldId`` (``cart_item_id``) of the item + to delete. Raises: - APIRequestFailedError: If the deletion fails + APIRequestFailedError: If the deletion fails. """ await self._ensure_logged_in() @@ -114,13 +86,6 @@ async def delete_item(self, order_field_id: str) -> dict[str, Any]: Endpoints.CART, params={"orderFieldId": order_field_id} ) response.raise_for_status() - - try: - result: dict[str, Any] = response.json() - return result - except Exception: - return {"success": True, "status_code": response.status_code} - except httpx.HTTPError as err: _LOGGER.error("Error deleting item with orderFieldId %s: %s", order_field_id, err) raise APIRequestFailedError(f"Failed to delete item from cart: {err}") from err diff --git a/rohlik_api/services/products.py b/rohlik_api/services/products.py index 678933a..79e7a4c 100644 --- a/rohlik_api/services/products.py +++ b/rohlik_api/services/products.py @@ -3,12 +3,11 @@ from __future__ import annotations import logging -from typing import Any import httpx from ..endpoints import Endpoints -from ..helpers import format_price +from ..models import AISummary, ProductComposition, ProductPrice, ProductSearchResult, SearchResults from .base import BaseService _LOGGER = logging.getLogger(__name__) @@ -18,17 +17,21 @@ class ProductService(BaseService): """Service for product-related operations.""" async def search( - self, product_name: str, limit: int = 10, favourite: bool = False - ) -> dict[str, Any] | None: + self, + product_name: str, + limit: int = 10, + favourite: bool = False, + ) -> SearchResults | None: """Search for products by name. Args: - product_name: The name or search term for the product - limit: Number of products returned - favourite: Whether only favourite items shall be returned + product_name: The name or search term for the product. + limit: Maximum number of products returned. + favourite: Whether only favourite items should be returned. Returns: - dict: Search results with product details, or None if no products found + SearchResults with the matching products (possibly empty), or None + if the request fails. """ await self._ensure_logged_in() @@ -44,153 +47,82 @@ async def search( try: response = await self._http.get(Endpoints.SEARCH, params=search_payload) response.raise_for_status() - search_data = response.json() - found_products: list[dict] = search_data.get("data", {}).get("productList", []) - - # Remove sponsored content - found_products = [ - p - for p in found_products - if not any(badge.get("slug") == "promoted" for badge in p.get("badge", [])) - ] - - # Keep only favourites if requested - if favourite: - found_products = [p for p in found_products if p.get("favourite", False)] - - # Keep only results up to the specified limit - if len(found_products) > limit: - found_products = found_products[:limit] - - if found_products: - search_results: dict[str, Any] = {"search_results": []} - for product in found_products: - search_results["search_results"].append( - { - "id": product.get("productId"), - "name": product.get("productName"), - "price": format_price(product.get("price")), - "brand": product.get("brand"), - "amount": product.get("textualAmount"), - } - ) - return search_results - else: - return None - + found_products = response.json().get("data", {}).get("productList", []) except httpx.HTTPError as err: - _LOGGER.error(f"Request failed: {err}") + _LOGGER.error("Request failed: %s", err) return None - async def get_ai_summary(self, product_id: int) -> dict[str, Any] | None: - """Get AI-generated summary for a product. + # Remove sponsored content + found_products = [ + p + for p in found_products + if not any(badge.get("slug") == "promoted" for badge in p.get("badge", [])) + ] + + # Keep only favourites if requested + if favourite: + found_products = [p for p in found_products if p.get("favourite", False)] + + # Keep only results up to the specified limit + found_products = found_products[:limit] + + return SearchResults( + results=[ProductSearchResult.from_api(product) for product in found_products] + ) + + async def get_ai_summary(self, product_id: int) -> AISummary | None: + """Get the AI-generated summary for a product. Args: - product_id: The ID of the product + product_id: The ID of the product. Returns: - dict: AI summary with title and content, or None if request fails + An AISummary, or None if the request fails. """ await self._ensure_logged_in() try: - url = Endpoints.product_ai_summary(product_id) - response = await self._http.get(url) + response = await self._http.get(Endpoints.product_ai_summary(product_id)) response.raise_for_status() - data = response.json() - - return { - "product_id": data.get("productId"), - "rating": data.get("rating"), - "title": data.get("title"), - "content": data.get("content"), - } - + return AISummary.from_api(response.json()) except httpx.HTTPError as err: - _LOGGER.error(f"Error fetching AI summary for product {product_id}: {err}") + _LOGGER.error("Error fetching AI summary for product %s: %s", product_id, err) return None - async def get_composition(self, product_id: int) -> dict[str, Any] | None: + async def get_composition(self, product_id: int) -> ProductComposition | None: """Get composition and nutritional values for a product. Args: - product_id: The ID of the product + product_id: The ID of the product. Returns: - dict: Product composition including nutritional values, ingredients, and allergens + A ProductComposition, or None if the request fails. """ await self._ensure_logged_in() try: - url = Endpoints.product_composition(product_id) - response = await self._http.get(url) + response = await self._http.get(Endpoints.product_composition(product_id)) response.raise_for_status() - data = response.json() - - # Parse nutritional values - nutritional_values = [] - for nv in data.get("nutritionalValues", []): - values = nv.get("values", {}) - nutritional_values.append( - { - "portion": nv.get("portion"), - "energy_kj": values.get("energyKJ", {}).get("amount"), - "energy_kcal": values.get("energyKCal", {}).get("amount"), - "fats": values.get("fats", {}).get("amount"), - "saturated_fats": values.get("saturatedFats", {}).get("amount"), - "carbohydrates": values.get("carbohydrates", {}).get("amount"), - "sugars": values.get("sugars", {}).get("amount"), - "protein": values.get("protein", {}).get("amount"), - "salt": values.get("salt", {}).get("amount"), - "fiber": values.get("fiber", {}).get("amount"), - } - ) - - # Parse allergens - allergens_data = data.get("allergens", {}) - - return { - "product_id": data.get("productId"), - "nutritional_values": nutritional_values, - "ingredients": data.get("plainIngredients"), - "allergens": { - "contained": allergens_data.get("contained", []), - "possibly_contained": allergens_data.get("possiblyContained", []), - }, - } - + return ProductComposition.from_api(response.json()) except httpx.HTTPError as err: - _LOGGER.error(f"Error fetching composition for product {product_id}: {err}") + _LOGGER.error("Error fetching composition for product %s: %s", product_id, err) return None - async def get_price(self, product_id: int) -> dict[str, Any] | None: - """Get current price for a product. + async def get_price(self, product_id: int) -> ProductPrice | None: + """Get the current price for a product. Args: - product_id: The ID of the product + product_id: The ID of the product. Returns: - dict: Product price information including price per unit and sales + A ProductPrice, or None if the request fails. """ await self._ensure_logged_in() try: - url = Endpoints.product_price(product_id) - response = await self._http.get(url) + response = await self._http.get(Endpoints.product_price(product_id)) response.raise_for_status() - data = response.json() - - price = data.get("price", {}) - price_per_unit = data.get("pricePerUnit", {}) - - return { - "product_id": data.get("productId"), - "price": price.get("amount"), - "currency": price.get("currency"), - "price_per_unit": price_per_unit.get("amount"), - "sales": data.get("sales", []), - } - + return ProductPrice.from_api(response.json()) except httpx.HTTPError as err: - _LOGGER.error(f"Error fetching price for product {product_id}: {err}") + _LOGGER.error("Error fetching price for product %s: %s", product_id, err) return None diff --git a/rohlik_api/services/recipes.py b/rohlik_api/services/recipes.py index ee0d10d..5094fa9 100644 --- a/rohlik_api/services/recipes.py +++ b/rohlik_api/services/recipes.py @@ -3,12 +3,11 @@ from __future__ import annotations import logging -from typing import Any import httpx from ..endpoints import Endpoints -from ..helpers import format_price +from ..models import IngredientProducts, RecipeDetail, RecipeSearchResults from .base import BaseService _LOGGER = logging.getLogger(__name__) @@ -17,16 +16,19 @@ class RecipeService(BaseService): """Service for recipe and ingredient operations (Rohlík Chef).""" - async def search(self, query: str, limit: int = 10, offset: int = 0) -> dict[str, Any] | None: + async def search( + self, query: str, limit: int = 10, offset: int = 0 + ) -> RecipeSearchResults | None: """Search for recipes by name. Args: - query: Search term for recipes - limit: Maximum number of results to return - offset: Offset for pagination + query: Search term for recipes. + limit: Maximum number of results to return. + offset: Offset for pagination. Returns: - dict: Search results with recipes list and total hits, or None if request fails + RecipeSearchResults with the matching recipes, or None if the + request fails. """ await self._ensure_logged_in() @@ -34,116 +36,44 @@ async def search(self, query: str, limit: int = 10, offset: int = 0) -> dict[str url = Endpoints.recipe_search(query, limit=limit, offset=offset) response = await self._http.get(url) response.raise_for_status() - data = response.json() - - meals = data.get("data", {}).get("meals", []) - total_hits = data.get("data", {}).get("totalHits", 0) - - return { - "recipes": [ - { - "id": meal.get("id"), - "name": meal.get("name"), - "link": meal.get("link"), - "image": meal.get("image"), - "is_favorite": meal.get("isFavorite", False), - "is_new": meal.get("isNew", False), - "is_best_seller": meal.get("isBestSeller", False), - } - for meal in meals - ], - "total_hits": total_hits, - } - + return RecipeSearchResults.from_api(response.json()) except httpx.HTTPError as err: - _LOGGER.error(f"Error searching recipes: {err}") + _LOGGER.error("Error searching recipes: %s", err) return None - async def get_detail(self, recipe_id: int) -> dict[str, Any] | None: + async def get_detail(self, recipe_id: int) -> RecipeDetail | None: """Get detailed information about a recipe. Args: - recipe_id: The ID of the recipe + recipe_id: The ID of the recipe. Returns: - dict: Recipe details including ingredients and directions, or None if request fails + A RecipeDetail with ingredients and directions, or None if the + request fails. """ await self._ensure_logged_in() try: - url = Endpoints.recipe_detail(recipe_id) - response = await self._http.get(url) + response = await self._http.get(Endpoints.recipe_detail(recipe_id)) response.raise_for_status() - data = response.json().get("data", {}) - - # Parse ingredients - ingredients = [] - for group in data.get("ingredients", []): - ingredient_group = { - "name": group.get("name"), - "position": group.get("position"), - "items": [ - { - "name": item.get("name"), - "ingredient_id": item.get("ingredientId"), - "ingredient_name": item.get("ingredientName"), - "products_count": item.get("productsCount"), - "image": item.get("imgPath"), - } - for item in group.get("items", []) - ], - } - ingredients.append(ingredient_group) - - # Parse directions - directions = [] - for section in data.get("directions", []): - direction_section = { - "name": section.get("name"), - "position": section.get("position"), - "steps": [ - { - "step_number": step.get("stepNumber"), - "content": step.get("content"), - } - for step in section.get("steps", []) - ], - } - directions.append(direction_section) - - return { - "id": data.get("id"), - "name": data.get("name"), - "duration": data.get("duration"), - "servings": data.get("servings", []), - "image": data.get("image", {}).get("path"), - "author": { - "name": data.get("author", {}).get("name"), - "annotation": data.get("author", {}).get("annotation"), - }, - "tips": [tip.get("content") for tip in data.get("tips", [])], - "ingredients": ingredients, - "directions": directions, - "is_favorite": data.get("isFavorite", False), - "link": data.get("link"), - } - + return RecipeDetail.from_api(response.json()) except httpx.HTTPError as err: - _LOGGER.error(f"Error fetching recipe detail: {err}") + _LOGGER.error("Error fetching recipe detail: %s", err) return None async def get_ingredient_products( self, ingredient_ids: list[int], limit: int = 5, offset: int = 0 - ) -> dict[str, Any] | None: - """Get products for specific ingredients. + ) -> IngredientProducts | None: + """Get purchasable products for specific ingredients. Args: - ingredient_ids: List of ingredient IDs to fetch products for - limit: Maximum number of products per ingredient - offset: Offset for pagination + ingredient_ids: List of ingredient IDs to fetch products for. + limit: Maximum number of products per ingredient. + offset: Offset for pagination. Returns: - dict: Ingredients with their available products, or None if request fails + IngredientProducts with the available products per ingredient, or + None if the request fails. """ await self._ensure_logged_in() @@ -152,37 +82,7 @@ async def get_ingredient_products( try: response = await self._http.post(Endpoints.INGREDIENT_PRODUCTS, json=payload) response.raise_for_status() - data = response.json().get("data", {}) - - ingredients_data = [] - for ingredient in data.get("ingredients", []): - products = [] - for product in ingredient.get("products", []): - price_info = product.get("price", {}) - products.append( - { - "product_id": product.get("productId"), - "name": product.get("productName"), - "image": product.get("imgPath"), - "price": format_price(price_info), - "price_value": price_info.get("full"), - "unit": product.get("unit"), - "amount": product.get("textualAmount"), - "in_stock": product.get("inStock", False), - "is_favorite": product.get("favourite", False), - } - ) - - ingredients_data.append( - { - "ingredient_id": ingredient.get("id"), - "products": products, - "total_hits": ingredient.get("totalHits", 0), - } - ) - - return {"ingredients": ingredients_data} - + return IngredientProducts.from_api(response.json()) except httpx.HTTPError as err: - _LOGGER.error(f"Error fetching ingredient products: {err}") + _LOGGER.error("Error fetching ingredient products: %s", err) return None diff --git a/tests/test_recipes.py b/tests/test_recipes.py index d50d0de..f76923e 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -67,12 +67,12 @@ async def test_search_returns_recipes(self, mock_http, mock_auth): result = await service.search("rajská") assert result is not None - assert len(result["recipes"]) == 2 - assert result["total_hits"] == 2 - assert result["recipes"][0]["id"] == 59 - assert result["recipes"][0]["name"] == "Rajská omáčka s hovězím masem" - assert result["recipes"][0]["is_new"] is True - assert result["recipes"][1]["is_favorite"] is True + assert len(result.recipes) == 2 + assert result.total_hits == 2 + assert result.recipes[0].id == 59 + assert result.recipes[0].name == "Rajská omáčka s hovězím masem" + assert result.recipes[0].is_new is True + assert result.recipes[1].is_favorite is True @pytest.mark.asyncio async def test_search_returns_none_on_error(self, mock_http, mock_auth): @@ -156,15 +156,15 @@ async def test_get_detail_returns_recipe(self, mock_http, mock_auth): result = await service.get_detail(59) assert result is not None - assert result["id"] == 59 - assert result["name"] == "Rajská omáčka s hovězím masem" - assert result["duration"] == "Do hodinky" - assert result["author"]["name"] == "Roman Vaněk" - assert len(result["tips"]) == 2 - assert len(result["ingredients"]) == 1 - assert result["ingredients"][0]["items"][0]["ingredient_id"] == 56 - assert len(result["directions"]) == 1 - assert len(result["directions"][0]["steps"]) == 2 + assert result.id == 59 + assert result.name == "Rajská omáčka s hovězím masem" + assert result.duration == "Do hodinky" + assert result.author.name == "Roman Vaněk" + assert len(result.tips) == 2 + assert len(result.ingredients) == 1 + assert result.ingredients[0].items[0].ingredient_id == 56 + assert len(result.directions) == 1 + assert len(result.directions[0].steps) == 2 @pytest.mark.asyncio async def test_get_detail_returns_none_on_error(self, mock_http, mock_auth): @@ -226,13 +226,13 @@ async def test_get_ingredient_products_returns_data(self, mock_http, mock_auth): result = await service.get_ingredient_products([102]) assert result is not None - assert len(result["ingredients"]) == 1 - assert result["ingredients"][0]["ingredient_id"] == 102 - assert len(result["ingredients"][0]["products"]) == 2 - assert result["ingredients"][0]["products"][0]["product_id"] == 1350675 - assert result["ingredients"][0]["products"][0]["price"] == "41.88 Kč" - assert result["ingredients"][0]["products"][0]["in_stock"] is True - assert result["ingredients"][0]["products"][0]["is_favorite"] is True + assert len(result.ingredients) == 1 + assert result.ingredients[0].ingredient_id == 102 + assert len(result.ingredients[0].products) == 2 + assert result.ingredients[0].products[0].product_id == 1350675 + assert result.ingredients[0].products[0].price == "41.88 Kč" + assert result.ingredients[0].products[0].in_stock is True + assert result.ingredients[0].products[0].is_favorite is True @pytest.mark.asyncio async def test_get_ingredient_products_sends_correct_payload(self, mock_http, mock_auth): diff --git a/tests/test_services.py b/tests/test_services.py index 597e87a..b167d84 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -101,11 +101,11 @@ async def test_get_content_returns_formatted_data(self, mock_http, mock_auth): service = CartService(mock_http, mock_auth) result = await service.get_content() - assert result["total_price"] == 99.90 - assert result["total_items"] == 1 - assert result["can_make_order"] is True - assert len(result["products"]) == 1 - assert result["products"][0]["name"] == "Test Product" + assert result.total_price == 99.90 + assert result.total_items == 1 + assert result.can_make_order is True + assert len(result.products) == 1 + assert result.products[0].name == "Test Product" @pytest.mark.asyncio async def test_add_items_sends_correct_payload(self, mock_http, mock_auth): @@ -118,7 +118,7 @@ async def test_add_items_sends_correct_payload(self, mock_http, mock_auth): products = [{"product_id": 123, "quantity": 2}] result = await service.add_items(products) - assert 123 in result["added_products"] + assert 123 in result mock_http.post.assert_called() @@ -131,8 +131,8 @@ def test_product_service_initialization(self, mock_http, mock_auth): assert isinstance(service, BaseService) @pytest.mark.asyncio - async def test_search_returns_none_when_no_products(self, mock_http, mock_auth): - """Test search returns None when no products found.""" + async def test_search_returns_empty_when_no_products(self, mock_http, mock_auth): + """Test search returns empty results when no products found.""" mock_response = MagicMock() mock_response.json.return_value = {"data": {"productList": []}} mock_response.raise_for_status = MagicMock() @@ -141,6 +141,19 @@ async def test_search_returns_none_when_no_products(self, mock_http, mock_auth): service = ProductService(mock_http, mock_auth) result = await service.search("nonexistent") + assert result is not None + assert result.results == [] + + @pytest.mark.asyncio + async def test_search_returns_none_on_error(self, mock_http, mock_auth): + """Test search returns None when the request fails.""" + import httpx + + mock_http.get.side_effect = httpx.HTTPError("Connection failed") + + service = ProductService(mock_http, mock_auth) + result = await service.search("test") + assert result is None @pytest.mark.asyncio @@ -171,8 +184,8 @@ async def test_search_filters_promoted_products(self, mock_http, mock_auth): service = ProductService(mock_http, mock_auth) result = await service.search("test") - assert len(result["search_results"]) == 1 - assert result["search_results"][0]["name"] == "Regular" + assert len(result.results) == 1 + assert result.results[0].name == "Regular" @pytest.mark.asyncio async def test_get_ai_summary_returns_data(self, mock_http, mock_auth): @@ -191,9 +204,9 @@ async def test_get_ai_summary_returns_data(self, mock_http, mock_auth): result = await service.get_ai_summary(1384964) assert result is not None - assert result["product_id"] == 1384964 - assert result["title"] == "AI Souhrn" - assert "vepřová panenka" in result["content"] + assert result.product_id == 1384964 + assert result.title == "AI Souhrn" + assert "vepřová panenka" in result.content @pytest.mark.asyncio async def test_get_ai_summary_returns_none_on_error(self, mock_http, mock_auth): @@ -242,13 +255,13 @@ async def test_get_composition_returns_data(self, mock_http, mock_auth): result = await service.get_composition(1425155) assert result is not None - assert result["product_id"] == 1425155 - assert len(result["nutritional_values"]) == 1 - assert result["nutritional_values"][0]["energy_kcal"] == 313.0 - assert result["nutritional_values"][0]["protein"] == 9.6 - assert "PŠENIČNÁ mouka" in result["ingredients"] - assert "Obiloviny obsahující lepek" in result["allergens"]["contained"] - assert "Mléko" in result["allergens"]["possibly_contained"] + assert result.product_id == 1425155 + assert len(result.nutritional_values) == 1 + assert result.nutritional_values[0].energy_kcal == 313.0 + assert result.nutritional_values[0].protein == 9.6 + assert "PŠENIČNÁ mouka" in result.ingredients + assert "Obiloviny obsahující lepek" in result.allergens.contained + assert "Mléko" in result.allergens.possibly_contained @pytest.mark.asyncio async def test_get_composition_returns_none_on_error(self, mock_http, mock_auth): @@ -279,11 +292,11 @@ async def test_get_price_returns_data(self, mock_http, mock_auth): result = await service.get_price(1425155) assert result is not None - assert result["product_id"] == 1425155 - assert result["price"] == 40.9 - assert result["currency"] == "CZK" - assert result["price_per_unit"] == 340.83 - assert result["sales"] == [] + assert result.product_id == 1425155 + assert result.price == 40.9 + assert result.currency == "CZK" + assert result.price_per_unit == 340.83 + assert result.sales == [] @pytest.mark.asyncio async def test_get_price_returns_none_on_error(self, mock_http, mock_auth): @@ -400,5 +413,5 @@ async def test_get_shopping_list_returns_formatted_data(self, mock_http, mock_au service = AccountService(mock_http, mock_auth) result = await service.get_shopping_list("list_123") - assert result["name"] == "My List" - assert len(result["products_in_list"]) == 1 + assert result.name == "My List" + assert len(result.products_in_list) == 1 From 4c2b0c0a941b59c9a7681d910d84a8d9957a06d9 Mon Sep 17 00:00:00 2001 From: Daniel Vejsada Date: Sat, 27 Jun 2026 05:05:22 +0000 Subject: [PATCH 3/5] Address PR review feedback - auth: cache the real login response and return it on subsequent login() calls instead of a synthetic dict, so get_data()["login"] is meaningful - auth: reset user_id/address_id (and cached response) on logout via _reset_session(), so reusing an instance across logins re-fetches a possibly-changed delivery address - client: drop the duplicate credential validation (AuthManager owns it) - client: log best-effort logout-on-close failures at WARNING, not ERROR - services/base: document the error-handling convention (reads return None, critical/mutating ops raise APIRequestFailedError) - tests: remove redundant @pytest.mark.asyncio decorators (asyncio_mode=auto) and add coverage for login caching and logout session reset Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PmotTwydT558t4JHd5Cnwm --- rohlik_api/auth.py | 23 +++++++++++++--- rohlik_api/client.py | 7 +++-- rohlik_api/services/base.py | 8 ++++++ tests/test_auth.py | 54 ++++++++++++++++++++++++++++++++++++- tests/test_client.py | 6 ----- tests/test_http_client.py | 7 ----- tests/test_recipes.py | 8 ------ tests/test_services.py | 19 ------------- 8 files changed, 83 insertions(+), 49 deletions(-) diff --git a/rohlik_api/auth.py b/rohlik_api/auth.py index bb29215..0ae03ef 100644 --- a/rohlik_api/auth.py +++ b/rohlik_api/auth.py @@ -42,6 +42,7 @@ def __init__( self._is_logged_in: bool = False self._user_id: int | None = None self._address_id: int | None = None + self._login_response: dict[str, Any] = {} @property def is_logged_in(self) -> bool: @@ -61,7 +62,8 @@ def address_id(self) -> int | None: async def login(self) -> dict[str, Any]: """Authenticate with the Rohlik.cz service. - If already logged in, returns cached response without making a new request. + If already logged in, returns the cached login response from the most + recent successful login without making a new request. Returns: dict: The JSON response containing authentication data @@ -72,7 +74,7 @@ async def login(self) -> dict[str, Any]: """ if self._is_logged_in: _LOGGER.debug("Already logged in, skipping login request") - return {"status": 200, "message": "Already logged in"} + return self._login_response login_data = {"email": self._username, "password": self._password, "name": ""} @@ -95,6 +97,7 @@ async def login(self) -> dict[str, Any]: raise RohlikAPIError(f"Unknown error occurred during login: {error_msg}") self._is_logged_in = True + self._login_response = login_response # Extract user and address IDs data = login_response.get("data", {}) @@ -136,10 +139,10 @@ async def logout(self) -> None: if logout_response.get("status") != 200: raise RohlikAPIError(f"Unknown error occurred during logout: {logout_response}") - self._is_logged_in = False + self._reset_session() except httpx.HTTPError as err: - self._is_logged_in = False # Reset state even on error + self._reset_session() # Reset state even on error raise APIRequestFailedError( f"Cannot connect to website! Check your internet connection " f"and try again: {err}" @@ -149,3 +152,15 @@ async def ensure_logged_in(self) -> None: """Ensure the client is logged in, login if not.""" if not self._is_logged_in: await self.login() + + def _reset_session(self) -> None: + """Clear all session state so the next login re-fetches it. + + User and address IDs are cleared too, so that reusing the same instance + across logins picks up a changed delivery address instead of keeping a + stale value. + """ + self._is_logged_in = False + self._user_id = None + self._address_id = None + self._login_response = {} diff --git a/rohlik_api/client.py b/rohlik_api/client.py index cf37218..37e51cd 100644 --- a/rohlik_api/client.py +++ b/rohlik_api/client.py @@ -63,9 +63,8 @@ def __init__( headers: dict[str, str] | None = None, auto_login: bool = True, ) -> None: - if not username or not password: - raise ValueError("Username and password are required") - + # Credential validation is owned by AuthManager (constructed below), + # which raises ValueError on empty username/password. self._auto_login = auto_login self.base_url = base_url.rstrip("/") self.timeout = timeout @@ -196,7 +195,7 @@ async def close(self) -> None: try: await self._auth.logout() except Exception as err: # noqa: BLE001 - best-effort logout on close - _LOGGER.error("Error during logout on close: %s", err) + _LOGGER.warning("Error during logout on close: %s", err) await self._http.close() diff --git a/rohlik_api/services/base.py b/rohlik_api/services/base.py index 4177e19..dd78c52 100644 --- a/rohlik_api/services/base.py +++ b/rohlik_api/services/base.py @@ -18,6 +18,14 @@ class BaseService: Provides common functionality like HTTP client access and authentication. + Error-handling convention: + Read-only / optional fetches (most ``get_*`` and ``search`` methods, and + anything using :meth:`_fetch_endpoint`) return ``None`` on a request + failure, so an aggregate call such as :meth:`RohlikAPI.get_data` can + degrade gracefully. Critical or mutating operations (login, logout, + ``cart.get_content``, ``cart.delete_item``, ``account.get_shopping_list``) + instead raise :class:`~rohlik_api.APIRequestFailedError`. + Args: http_client: The HTTP client instance. auth_manager: The authentication manager instance. diff --git a/tests/test_auth.py b/tests/test_auth.py index 8dfcf5b..7bb7095 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,11 +1,20 @@ """Tests for the AuthManager class.""" +from unittest.mock import AsyncMock, MagicMock + import pytest from rohlik_api.auth import AuthManager from rohlik_api.http_client import HttpClient +def _response(payload): + """Build a mock httpx response returning the given JSON payload.""" + resp = MagicMock() + resp.json.return_value = payload + return resp + + class TestAuthManagerInitialization: """Tests for AuthManager initialization.""" @@ -63,7 +72,6 @@ def test_address_id_initially_none(self): class TestAuthManagerEnsureLoggedIn: """Tests for ensure_logged_in method.""" - @pytest.mark.asyncio async def test_ensure_logged_in_when_not_logged_in(self): """Test that ensure_logged_in attempts login when not logged in.""" http = HttpClient() @@ -73,3 +81,47 @@ async def test_ensure_logged_in_when_not_logged_in(self): # but we're testing that the method is callable assert auth.is_logged_in is False await http.close() + + +class TestAuthManagerSession: + """Tests for login caching and session reset on logout.""" + + async def test_login_caches_response_and_extracts_ids(self): + """Login stores the real response and user/address IDs.""" + http = MagicMock(spec=HttpClient) + http.post = AsyncMock( + return_value=_response( + {"status": 200, "data": {"user": {"id": 1}, "address": {"id": 2}}} + ) + ) + auth = AuthManager(http, "user@example.com", "password123") + + result = await auth.login() + + assert result["status"] == 200 + assert auth.user_id == 1 + assert auth.address_id == 2 + + # A second login returns the cached real response, no new request. + http.post.reset_mock() + cached = await auth.login() + assert cached == result + http.post.assert_not_called() + + async def test_logout_resets_session_state(self): + """Logout clears is_logged_in plus cached user/address IDs.""" + http = MagicMock(spec=HttpClient) + http.post = AsyncMock( + side_effect=[ + _response({"status": 200, "data": {"user": {"id": 1}, "address": {"id": 2}}}), + _response({"status": 200}), + ] + ) + auth = AuthManager(http, "user@example.com", "password123") + + await auth.login() + await auth.logout() + + assert auth.is_logged_in is False + assert auth.user_id is None + assert auth.address_id is None diff --git a/tests/test_client.py b/tests/test_client.py index 9e9a7bf..e84f1f1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -84,7 +84,6 @@ def test_client_base_url_trailing_slash(self): class TestAsyncContextManager: """Tests for async context manager.""" - @pytest.mark.asyncio async def test_async_context_manager(self): """Test client works as async context manager.""" async with RohlikAPI( @@ -92,7 +91,6 @@ async def test_async_context_manager(self): ) as client: assert client.base_url == "https://www.rohlik.cz" - @pytest.mark.asyncio async def test_client_close(self): """Test that client closes without error.""" client = RohlikAPI(username=TEST_USERNAME, password=TEST_PASSWORD, auto_login=False) @@ -148,7 +146,6 @@ def test_credentials_required_on_init(self): with pytest.raises(ValueError, match="Username and password are required"): RohlikAPI(username="", password="") - @pytest.mark.asyncio async def test_get_shopping_list_requires_id(self): """Test that get_shopping_list raises ValueError without ID.""" client = RohlikAPI(username="test@example.com", password="password123", auto_login=False) @@ -156,7 +153,6 @@ async def test_get_shopping_list_requires_id(self): await client.account.get_shopping_list("") await client.close() - @pytest.mark.asyncio async def test_login_delegates_to_auth(self): """Test that client.login() delegates to the auth manager.""" client = RohlikAPI(username=TEST_USERNAME, password=TEST_PASSWORD, auto_login=False) @@ -167,7 +163,6 @@ async def test_login_delegates_to_auth(self): client._auth.login.assert_awaited_once() assert result == {"status": 200} - @pytest.mark.asyncio async def test_logout_delegates_to_auth(self): """Test that client.logout() delegates to the auth manager.""" client = RohlikAPI(username=TEST_USERNAME, password=TEST_PASSWORD, auto_login=False) @@ -177,7 +172,6 @@ async def test_logout_delegates_to_auth(self): client._auth.logout.assert_awaited_once() - @pytest.mark.asyncio async def test_context_manager_auto_login(self): """Test that entering the context manager logs in when auto_login is True.""" client = RohlikAPI(username=TEST_USERNAME, password=TEST_PASSWORD, auto_login=True) diff --git a/tests/test_http_client.py b/tests/test_http_client.py index 7e604d6..497ec58 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -1,7 +1,5 @@ """Tests for the HttpClient class.""" -import pytest - from rohlik_api import BASE_URL from rohlik_api.http_client import HttpClient @@ -77,14 +75,12 @@ def test_is_closed_false_after_access(self): class TestHttpClientClose: """Tests for HttpClient close functionality.""" - @pytest.mark.asyncio async def test_close_without_client(self): """Test closing when client was never created.""" http = HttpClient() await http.close() # Should not raise assert http._client is None - @pytest.mark.asyncio async def test_close_with_client(self): """Test closing after client was created.""" http = HttpClient() @@ -92,7 +88,6 @@ async def test_close_with_client(self): await http.close() assert http._client is None - @pytest.mark.asyncio async def test_close_multiple_times(self): """Test that closing multiple times is safe.""" http = HttpClient() @@ -105,14 +100,12 @@ async def test_close_multiple_times(self): class TestHttpClientContextManager: """Tests for HttpClient async context manager.""" - @pytest.mark.asyncio async def test_context_manager_entry(self): """Test async context manager entry.""" async with HttpClient() as http: assert http is not None assert isinstance(http, HttpClient) - @pytest.mark.asyncio async def test_context_manager_closes_on_exit(self): """Test that context manager closes client on exit.""" http = HttpClient() diff --git a/tests/test_recipes.py b/tests/test_recipes.py index f76923e..57b579a 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -30,7 +30,6 @@ def mock_auth(): class TestRecipeServiceSearch: """Tests for RecipeService.search method.""" - @pytest.mark.asyncio async def test_search_returns_recipes(self, mock_http, mock_auth): """Test search returns properly formatted recipe data.""" mock_response = MagicMock() @@ -74,7 +73,6 @@ async def test_search_returns_recipes(self, mock_http, mock_auth): assert result.recipes[0].is_new is True assert result.recipes[1].is_favorite is True - @pytest.mark.asyncio async def test_search_returns_none_on_error(self, mock_http, mock_auth): """Test search returns None when request fails.""" import httpx @@ -86,7 +84,6 @@ async def test_search_returns_none_on_error(self, mock_http, mock_auth): assert result is None - @pytest.mark.asyncio async def test_search_with_pagination(self, mock_http, mock_auth): """Test search passes pagination parameters.""" mock_response = MagicMock() @@ -106,7 +103,6 @@ async def test_search_with_pagination(self, mock_http, mock_auth): class TestRecipeServiceGetDetail: """Tests for RecipeService.get_detail method.""" - @pytest.mark.asyncio async def test_get_detail_returns_recipe(self, mock_http, mock_auth): """Test get_detail returns properly formatted recipe details.""" mock_response = MagicMock() @@ -166,7 +162,6 @@ async def test_get_detail_returns_recipe(self, mock_http, mock_auth): assert len(result.directions) == 1 assert len(result.directions[0].steps) == 2 - @pytest.mark.asyncio async def test_get_detail_returns_none_on_error(self, mock_http, mock_auth): """Test get_detail returns None when request fails.""" import httpx @@ -182,7 +177,6 @@ async def test_get_detail_returns_none_on_error(self, mock_http, mock_auth): class TestRecipeServiceGetIngredientProducts: """Tests for RecipeService.get_ingredient_products method.""" - @pytest.mark.asyncio async def test_get_ingredient_products_returns_data(self, mock_http, mock_auth): """Test get_ingredient_products returns properly formatted data.""" mock_response = MagicMock() @@ -234,7 +228,6 @@ async def test_get_ingredient_products_returns_data(self, mock_http, mock_auth): assert result.ingredients[0].products[0].in_stock is True assert result.ingredients[0].products[0].is_favorite is True - @pytest.mark.asyncio async def test_get_ingredient_products_sends_correct_payload(self, mock_http, mock_auth): """Test get_ingredient_products sends correct payload.""" mock_response = MagicMock() @@ -251,7 +244,6 @@ async def test_get_ingredient_products_sends_correct_payload(self, mock_http, mo assert call_kwargs["json"]["limit"] == 10 assert call_kwargs["json"]["offset"] == 5 - @pytest.mark.asyncio async def test_get_ingredient_products_returns_none_on_error(self, mock_http, mock_auth): """Test get_ingredient_products returns None when request fails.""" import httpx diff --git a/tests/test_services.py b/tests/test_services.py index b167d84..cb73475 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -47,7 +47,6 @@ def test_base_service_initialization(self, mock_http, mock_auth): assert service._http == mock_http assert service._auth == mock_auth - @pytest.mark.asyncio async def test_ensure_logged_in_calls_auth(self, mock_http, mock_auth): """Test _ensure_logged_in delegates to auth manager.""" service = BaseService(mock_http, mock_auth) @@ -63,7 +62,6 @@ def test_cart_service_initialization(self, mock_http, mock_auth): service = CartService(mock_http, mock_auth) assert isinstance(service, BaseService) - @pytest.mark.asyncio async def test_get_content_calls_auth(self, mock_http, mock_auth): """Test get_content ensures logged in.""" mock_response = MagicMock() @@ -75,7 +73,6 @@ async def test_get_content_calls_auth(self, mock_http, mock_auth): await service.get_content() mock_auth.ensure_logged_in.assert_called() - @pytest.mark.asyncio async def test_get_content_returns_formatted_data(self, mock_http, mock_auth): """Test get_content returns properly formatted cart data.""" mock_response = MagicMock() @@ -107,7 +104,6 @@ async def test_get_content_returns_formatted_data(self, mock_http, mock_auth): assert len(result.products) == 1 assert result.products[0].name == "Test Product" - @pytest.mark.asyncio async def test_add_items_sends_correct_payload(self, mock_http, mock_auth): """Test add_items sends correct payload.""" mock_response = MagicMock() @@ -130,7 +126,6 @@ def test_product_service_initialization(self, mock_http, mock_auth): service = ProductService(mock_http, mock_auth) assert isinstance(service, BaseService) - @pytest.mark.asyncio async def test_search_returns_empty_when_no_products(self, mock_http, mock_auth): """Test search returns empty results when no products found.""" mock_response = MagicMock() @@ -144,7 +139,6 @@ async def test_search_returns_empty_when_no_products(self, mock_http, mock_auth) assert result is not None assert result.results == [] - @pytest.mark.asyncio async def test_search_returns_none_on_error(self, mock_http, mock_auth): """Test search returns None when the request fails.""" import httpx @@ -156,7 +150,6 @@ async def test_search_returns_none_on_error(self, mock_http, mock_auth): assert result is None - @pytest.mark.asyncio async def test_search_filters_promoted_products(self, mock_http, mock_auth): """Test search filters out promoted products.""" mock_response = MagicMock() @@ -187,7 +180,6 @@ async def test_search_filters_promoted_products(self, mock_http, mock_auth): assert len(result.results) == 1 assert result.results[0].name == "Regular" - @pytest.mark.asyncio async def test_get_ai_summary_returns_data(self, mock_http, mock_auth): """Test get_ai_summary returns properly formatted data.""" mock_response = MagicMock() @@ -208,7 +200,6 @@ async def test_get_ai_summary_returns_data(self, mock_http, mock_auth): assert result.title == "AI Souhrn" assert "vepřová panenka" in result.content - @pytest.mark.asyncio async def test_get_ai_summary_returns_none_on_error(self, mock_http, mock_auth): """Test get_ai_summary returns None on error.""" import httpx @@ -220,7 +211,6 @@ async def test_get_ai_summary_returns_none_on_error(self, mock_http, mock_auth): assert result is None - @pytest.mark.asyncio async def test_get_composition_returns_data(self, mock_http, mock_auth): """Test get_composition returns properly formatted data.""" mock_response = MagicMock() @@ -263,7 +253,6 @@ async def test_get_composition_returns_data(self, mock_http, mock_auth): assert "Obiloviny obsahující lepek" in result.allergens.contained assert "Mléko" in result.allergens.possibly_contained - @pytest.mark.asyncio async def test_get_composition_returns_none_on_error(self, mock_http, mock_auth): """Test get_composition returns None on error.""" import httpx @@ -275,7 +264,6 @@ async def test_get_composition_returns_none_on_error(self, mock_http, mock_auth) assert result is None - @pytest.mark.asyncio async def test_get_price_returns_data(self, mock_http, mock_auth): """Test get_price returns properly formatted data.""" mock_response = MagicMock() @@ -298,7 +286,6 @@ async def test_get_price_returns_data(self, mock_http, mock_auth): assert result.price_per_unit == 340.83 assert result.sales == [] - @pytest.mark.asyncio async def test_get_price_returns_none_on_error(self, mock_http, mock_auth): """Test get_price returns None on error.""" import httpx @@ -319,7 +306,6 @@ def test_order_service_initialization(self, mock_http, mock_auth): service = OrderService(mock_http, mock_auth) assert isinstance(service, BaseService) - @pytest.mark.asyncio async def test_get_next_returns_data(self, mock_http, mock_auth): """Test get_next returns order data.""" mock_response = MagicMock() @@ -332,7 +318,6 @@ async def test_get_next_returns_data(self, mock_http, mock_auth): assert result == {"order_id": 123} - @pytest.mark.asyncio async def test_get_delivered_with_pagination(self, mock_http, mock_auth): """Test get_delivered accepts pagination parameters.""" mock_response = MagicMock() @@ -354,7 +339,6 @@ def test_delivery_service_initialization(self, mock_http, mock_auth): service = DeliveryService(mock_http, mock_auth) assert isinstance(service, BaseService) - @pytest.mark.asyncio async def test_get_next_slots_uses_auth_ids(self, mock_http, mock_auth): """Test get_next_slots uses user_id and address_id from auth.""" mock_response = MagicMock() @@ -371,7 +355,6 @@ async def test_get_next_slots_uses_auth_ids(self, mock_http, mock_auth): assert "userId=12345" in url assert "addressId=67890" in url - @pytest.mark.asyncio async def test_get_next_slots_returns_none_without_ids(self, mock_http, mock_auth): """Test get_next_slots returns None when IDs are missing.""" mock_auth.user_id = None @@ -391,7 +374,6 @@ def test_account_service_initialization(self, mock_http, mock_auth): service = AccountService(mock_http, mock_auth) assert isinstance(service, BaseService) - @pytest.mark.asyncio async def test_get_shopping_list_requires_id(self, mock_http, mock_auth): """Test get_shopping_list raises ValueError without ID.""" service = AccountService(mock_http, mock_auth) @@ -399,7 +381,6 @@ async def test_get_shopping_list_requires_id(self, mock_http, mock_auth): with pytest.raises(ValueError, match="Missing argument"): await service.get_shopping_list("") - @pytest.mark.asyncio async def test_get_shopping_list_returns_formatted_data(self, mock_http, mock_auth): """Test get_shopping_list returns properly formatted data.""" mock_response = MagicMock() From ef2e6aad0189fbce9aa2ec0d17d37abafd7c1cf4 Mon Sep 17 00:00:00 2001 From: Daniel Vejsada Date: Sat, 27 Jun 2026 05:10:47 +0000 Subject: [PATCH 4/5] Improve README and stress unofficial/personal-use disclaimer Make the README friendlier (clearer intro, table of contents, a credentials & security section, tidier API reference) and add a prominent "unofficial, personal use only, not endorsed by Rohlik.cz" callout at the top plus a dedicated Disclaimer section. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PmotTwydT558t4JHd5Cnwm --- README.md | 235 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 133 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index 70b144e..fc8a69c 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,50 @@ -# Rohlik API Python Client - -An async Python client library for interacting with the Rohlik.cz API using httpx with HTTP/2 support. +# 🛒 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. + +> ## ⚠️ Unofficial — personal use only +> +> This is an **unofficial** client for Rohlik.cz's **non-public** API. It is +> **not affiliated with, authorized by, or endorsed by Rohlik.cz / Rohlik Group**. +> +> - Intended for **personal, non-commercial use with your own account** only. +> - The private API can change or break **at any time, without notice**. +> - Your use may be subject to **Rohlik.cz's Terms of Service** — review them and +> behave responsibly (don't hammer the API or use it commercially). +> - Provided "as is", with **no warranty**. **Use at your own risk.** + +## Table of contents + +- [Features](#features) +- [Requirements](#requirements) +- [Installation](#installation) +- [Quick start](#quick-start) +- [Credentials & security](#credentials--security) +- [Typed models](#typed-models) +- [Services](#services) +- [API reference](#api-reference) +- [Error handling](#error-handling) +- [Advanced usage](#advanced-usage) +- [Development](#development) +- [Disclaimer](#disclaimer) +- [License](#license) ## Features -- 🚀 HTTP/2 support for improved performance -- 🔐 Secure authentication with automatic session management -- 🎯 Clean service-based API architecture -- 🧩 Typed dataclass models for all parsed responses (fully type-hinted, `py.typed`) -- 🔄 Async context manager support -- 🍳 Recipe search and ingredient products (Rohlík Chef) -- 📦 Product details, composition, and AI summaries +- 🚀 HTTP/2 support for fast, connection-reused requests +- 🔐 Automatic login/logout and session management +- 🎯 Clean, service-based API (`client.cart`, `client.products`, …) +- 🧩 Fully typed dataclass models for parsed responses (`py.typed`) +- 🔄 Works as an async context manager +- 🍳 Recipe search and ingredient shopping (Rohlík Chef) +- 📦 Product details, composition/nutrition, prices, and AI summaries ## Requirements - Python 3.13+ -- [httpx](https://www.python-httpx.org/) with HTTP/2 support (installed automatically) - -> **Disclaimer:** This is an unofficial client for the non-public Rohlik.cz API -> and is not affiliated with or endorsed by Rohlik.cz. The API may change without -> notice. +- [httpx](https://www.python-httpx.org/) with HTTP/2 (installed automatically) ## Installation @@ -27,7 +52,7 @@ An async Python client library for interacting with the Rohlik.cz API using http pip install rohlik-api ``` -## Quick Start +## Quick start ```python import asyncio @@ -51,11 +76,35 @@ async def main(): asyncio.run(main()) ``` -## Typed Models +The async context manager logs you in on entry and logs out + closes the +connection on exit. + +## Credentials & security + +The client authenticates with your normal Rohlik.cz **email and password**. + +- **Never hard-code credentials** in source you commit. Prefer environment + variables or a secrets manager: + + ```python + import os + from rohlik_api import RohlikAPI + + client = RohlikAPI( + username=os.environ["ROHLIK_USERNAME"], + password=os.environ["ROHLIK_PASSWORD"], + ) + ``` + +- Credentials are only ever sent to Rohlik.cz over HTTPS. This library does not + store or transmit them anywhere else. +- Use a dedicated account if you're uncomfortable automating your primary one. + +## Typed models Service methods that parse responses return typed dataclasses (importable from -`rohlik_api`) rather than raw dictionaries, so editors and type checkers know the -shape of the data: +`rohlik_api`) rather than raw dictionaries, so your editor and type checker know +the shape of the data: ```python from dataclasses import asdict @@ -73,37 +122,22 @@ Raw passthrough endpoints (`orders.*`, `delivery.*`, `account.get_premium_profil `account.get_bags_info`, `account.get_announcements`, and `get_data`) return the decoded JSON as `dict` / `list`, since they are not reshaped by the client. -## Configuration - -```python -from rohlik_api import RohlikAPI - -client = RohlikAPI( - username="your_email@example.com", - password="your_password", - base_url="https://www.rohlik.cz", # Optional - timeout=30.0, # Optional - headers={"Custom-Header": "Value"}, # Optional - auto_login=True # Optional, default True -) -``` - ## Services -The client provides access to functionality through service properties: +Functionality is grouped into services, accessed as properties on the client: -| Service | Property | Description | -|---------|----------|-------------| -| Cart | `client.cart` | Shopping cart operations | -| Products | `client.products` | Product search and details | -| Orders | `client.orders` | Order history | -| Delivery | `client.delivery` | Delivery info and timeslots | -| Account | `client.account` | Account data and shopping lists | -| Recipes | `client.recipes` | Recipe search and ingredients (Rohlík Chef) | +| Service | Property | Description | +| -------- | ------------------ | --------------------------------------------- | +| Cart | `client.cart` | Shopping cart operations | +| Products | `client.products` | Product search and details | +| Orders | `client.orders` | Order history | +| Delivery | `client.delivery` | Delivery info and timeslots | +| Account | `client.account` | Account data and shopping lists | +| Recipes | `client.recipes` | Recipe search and ingredients (Rohlík Chef) | -## API Reference +## API reference -### Cart Service (`client.cart`) +### Cart service (`client.cart`) ```python # Get cart contents @@ -113,7 +147,7 @@ cart = await client.cart.get_content() # Add items to cart added = await client.cart.add_items([ {"product_id": 123456, "quantity": 2}, - {"product_id": 789012, "quantity": 1} + {"product_id": 789012, "quantity": 1}, ]) # -> [123456, 789012] (list of product IDs successfully added) @@ -121,7 +155,7 @@ added = await client.cart.add_items([ await client.cart.delete_item(order_field_id="abc123") ``` -### Products Service (`client.products`) +### Products service (`client.products`) ```python # Search for products -> SearchResults | None (None only on request failure) @@ -129,92 +163,66 @@ results = await client.products.search("mléko", limit=10, favourite=False) for product in results.results: # ProductSearchResult: id, name, price, brand, amount print(product.name, product.price) -# Get AI-generated product summary -> AISummary | None +# AI-generated product summary -> AISummary | None summary = await client.products.get_ai_summary(product_id=1384964) -# AISummary(product_id=1384964, rating=..., title="AI Souhrn", content="...") -# Get product composition -> ProductComposition | None +# Composition / nutrition / allergens -> ProductComposition | None composition = await client.products.get_composition(product_id=1425155) -# ProductComposition(product_id, nutritional_values=[NutritionalValue, ...], -# ingredients="...", allergens=Allergens(contained, possibly_contained)) -# Get product price -> ProductPrice | None +# Current price -> ProductPrice | None price = await client.products.get_price(product_id=1425155) -# ProductPrice(product_id=1425155, price=40.9, currency="CZK", price_per_unit=340.83, sales=[]) ``` -### Orders Service (`client.orders`) +### Orders service (`client.orders`) ```python -# Get next (upcoming) order -next_order = await client.orders.get_next() - -# Get last delivered order -last_order = await client.orders.get_last() - -# Get delivered orders with pagination -orders = await client.orders.get_delivered(limit=50, offset=0) +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 ``` -### Delivery Service (`client.delivery`) +### Delivery service (`client.delivery`) ```python -# Get delivery information delivery = await client.delivery.get_info() - -# Get current timeslot reservation timeslot = await client.delivery.get_timeslot_reservation() - -# Get next available delivery slots slots = await client.delivery.get_next_slots() - -# Get delivery announcements announcements = await client.delivery.get_announcements() ``` -### Account Service (`client.account`) +### Account service (`client.account`) ```python -# Get premium profile premium = await client.account.get_premium_profile() - -# Get reusable bags info bags = await client.account.get_bags_info() - -# Get announcements announcements = await client.account.get_announcements() -# Get shopping list by ID -> ShoppingList +# Shopping list by ID -> ShoppingList shopping_list = await client.account.get_shopping_list("list_id_here") # ShoppingList(name="My List", products_in_list=[...]) ``` -### Recipes Service (`client.recipes`) +### Recipes service (`client.recipes`) — Rohlík Chef ```python -# Search for recipes +# Search recipes -> RecipeSearchResults(recipes=[RecipeSummary, ...], total_hits=4) recipes = await client.recipes.search("rajská", limit=10, offset=0) -# -> RecipeSearchResults(recipes=[RecipeSummary, ...], total_hits=4) -# Get recipe details -> RecipeDetail | None +# Recipe details -> RecipeDetail | None recipe = await client.recipes.get_detail(recipe_id=59) -# RecipeDetail(id=59, name="...", ingredients=[IngredientGroup, ...], -# directions=[DirectionSection, ...], author=RecipeAuthor, ...) -# Get products for ingredients -> IngredientProducts | None +# Products for ingredients -> IngredientProducts | None products = await client.recipes.get_ingredient_products(ingredient_ids=[102, 56], limit=5) -# IngredientProducts(ingredients=[IngredientProductGroup(ingredient_id, products, total_hits)]) ``` -### Aggregated Data +### Aggregated data ```python -# Get all account data in a single operation +# Fetch delivery, orders, cart, premium profile, announcements, etc. in one call all_data = await client.get_data() -# Returns dict with: login, delivery, next_order, last_order, cart, premium_profile, etc. ``` -## Error Handling +## Error handling All errors derive from `RohlikAPIError`: @@ -230,17 +238,30 @@ except APIRequestFailedError as err: print(f"Request failed: {err}") ``` -Note on the error contract: +**Error contract:** -- **Write/critical operations** (login, `cart.get_content`, `cart.delete_item`, +- **Critical / mutating operations** (login, `cart.get_content`, `cart.delete_item`, `account.get_shopping_list`) **raise** `APIRequestFailedError` on failure. -- **Read/optional fetches** (most `orders`, `delivery`, `account`, `products`, - and `recipes` getters) **return `None`** on failure so an aggregate fetch can +- **Read / optional fetches** (most `orders`, `delivery`, `account`, `products`, + and `recipes` getters) **return `None`** on failure, so an aggregate fetch can continue gracefully. -## Advanced Usage +## Advanced usage -### Manual Session Management +### Configuration + +```python +client = RohlikAPI( + username="your_email@example.com", + password="your_password", + base_url="https://www.rohlik.cz", # optional + timeout=30.0, # optional + headers={"Custom-Header": "Value"}, # optional + auto_login=True, # optional, default True +) +``` + +### Manual session management ```python from rohlik_api import RohlikAPI @@ -249,7 +270,7 @@ async def main(): client = RohlikAPI( username="email@example.com", password="password", - auto_login=False # Disable auto-login + auto_login=False, # disable auto-login ) try: await client.login() @@ -274,11 +295,21 @@ black --check . mypy rohlik_api ``` -## License +Please make sure `pytest`, `ruff`, `black` and `mypy` all pass before opening a +pull request. + +## Disclaimer -MIT License - see LICENSE file for details. +This project is an **independent, unofficial** client. It is **not affiliated +with, authorized by, or endorsed by Rohlik.cz, Rohlik Group, or any of its +subsidiaries**. "Rohlik", "Rohlik.cz" and "Rohlík Chef" are trademarks of their +respective owners. -## Contributing +It talks to a **private, undocumented API** that may change or stop working at +any time. It is provided for **personal, non-commercial use** only, and comes +with **no warranty of any kind**. You are responsible for complying with +Rohlik.cz's Terms of Service and applicable law. **Use at your own risk.** + +## License -Contributions are welcome! Please feel free to submit a Pull Request. Make sure -`pytest`, `ruff`, `black` and `mypy` all pass before opening one. +[MIT](LICENSE) © Daniel Vejsada From 4721ddfa5305debc08515242386b5053fba597d8 Mon Sep 17 00:00:00 2001 From: Daniel Vejsada Date: Sat, 27 Jun 2026 05:26:48 +0000 Subject: [PATCH 5/5] Address re-review: log levels, simpler login(), stricter mypy - Log optional read failures (return-None paths) at WARNING instead of ERROR across base/products/recipes/orders/delivery and the swallowed per-item cart add, to avoid spurious alerts in monitoring setups - Simplify login() ID extraction: drop the now-dead "if not" guards (cleared by _reset_session) and replace try/except AttributeError with an explicit `or {}` guard for a null address - Comment the deliberate explicit _ensure_logged_in() in get_next_slots - Enforce strict typing for the package (disallow_untyped_defs / disallow_incomplete_defs) to honour the py.typed promise; exclude tests 118 tests passing; ruff, black and (strict) mypy all green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PmotTwydT558t4JHd5Cnwm --- pyproject.toml | 6 ++++-- rohlik_api/auth.py | 21 +++++++++------------ rohlik_api/services/base.py | 2 +- rohlik_api/services/cart.py | 2 +- rohlik_api/services/delivery.py | 4 +++- rohlik_api/services/orders.py | 2 +- rohlik_api/services/products.py | 8 ++++---- rohlik_api/services/recipes.py | 6 +++--- 8 files changed, 26 insertions(+), 25 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index db5695a..a293774 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,8 +80,10 @@ ignore = [] python_version = "3.13" warn_return_any = true warn_unused_configs = true -disallow_untyped_defs = false -disallow_incomplete_defs = false +disallow_untyped_defs = true +disallow_incomplete_defs = true +# Strict typing is enforced for the package; tests are excluded. +exclude = ["^tests/"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/rohlik_api/auth.py b/rohlik_api/auth.py index 0ae03ef..5b9bf09 100644 --- a/rohlik_api/auth.py +++ b/rohlik_api/auth.py @@ -99,19 +99,16 @@ async def login(self) -> dict[str, Any]: self._is_logged_in = True self._login_response = login_response - # Extract user and address IDs + # Extract user and address IDs. ``address`` may be explicitly null + # in the response, so guard with ``or {}``. data = login_response.get("data", {}) - if not self._user_id: - self._user_id = data.get("user", {}).get("id") - - if not self._address_id: - try: - self._address_id = data.get("address", {}).get("id") - except AttributeError: - _LOGGER.error( - "Address cannot be retrieved from login data. Login response: %s", - mask_data(login_response), - ) + self._user_id = data.get("user", {}).get("id") + self._address_id = (data.get("address") or {}).get("id") + if self._address_id is None: + _LOGGER.debug( + "No address ID in login data. Login response: %s", + mask_data(login_response), + ) return login_response diff --git a/rohlik_api/services/base.py b/rohlik_api/services/base.py index dd78c52..d935533 100644 --- a/rohlik_api/services/base.py +++ b/rohlik_api/services/base.py @@ -61,5 +61,5 @@ async def _fetch_endpoint( data: dict[str, Any] = response.json() return data except httpx.HTTPError as err: - _LOGGER.error("Error fetching %s: %s", error_context, err) + _LOGGER.warning("Error fetching %s: %s", error_context, err) return None diff --git a/rohlik_api/services/cart.py b/rohlik_api/services/cart.py index 89691b9..b875683 100644 --- a/rohlik_api/services/cart.py +++ b/rohlik_api/services/cart.py @@ -65,7 +65,7 @@ async def add_items(self, product_list: list[dict[str, Any]]) -> list[int]: response.raise_for_status() added_products.append(product_id) except httpx.HTTPError as err: - _LOGGER.error("Error adding %s due to %s", product_id, err) + _LOGGER.warning("Error adding %s due to %s", product_id, err) return added_products diff --git a/rohlik_api/services/delivery.py b/rohlik_api/services/delivery.py index 4790732..e11d524 100644 --- a/rohlik_api/services/delivery.py +++ b/rohlik_api/services/delivery.py @@ -42,13 +42,15 @@ async def get_next_slots( Returns: dict: Available delivery slots or None if request fails """ + # Log in first (also done by _fetch_endpoint below) so that the auth + # manager's user_id/address_id are populated before we build the URL. await self._ensure_logged_in() uid = user_id or self._auth.user_id aid = address_id or self._auth.address_id if not uid or not aid: - _LOGGER.error("User ID or Address ID not available for timeslots request") + _LOGGER.warning("User ID or Address ID not available for timeslots request") return None url = Endpoints.timeslots(user_id=uid, address_id=aid) diff --git a/rohlik_api/services/orders.py b/rohlik_api/services/orders.py index 216f937..055fdc4 100644 --- a/rohlik_api/services/orders.py +++ b/rohlik_api/services/orders.py @@ -51,5 +51,5 @@ async def get_delivered(self, limit: int = 50, offset: int = 0) -> list[dict[str orders: list[dict[str, Any]] = response.json() return orders except httpx.HTTPError as err: - _LOGGER.error("Error fetching delivered orders: %s", err) + _LOGGER.warning("Error fetching delivered orders: %s", err) return None diff --git a/rohlik_api/services/products.py b/rohlik_api/services/products.py index 79e7a4c..a3c68e2 100644 --- a/rohlik_api/services/products.py +++ b/rohlik_api/services/products.py @@ -49,7 +49,7 @@ async def search( response.raise_for_status() found_products = response.json().get("data", {}).get("productList", []) except httpx.HTTPError as err: - _LOGGER.error("Request failed: %s", err) + _LOGGER.warning("Request failed: %s", err) return None # Remove sponsored content @@ -86,7 +86,7 @@ async def get_ai_summary(self, product_id: int) -> AISummary | None: response.raise_for_status() return AISummary.from_api(response.json()) except httpx.HTTPError as err: - _LOGGER.error("Error fetching AI summary for product %s: %s", product_id, err) + _LOGGER.warning("Error fetching AI summary for product %s: %s", product_id, err) return None async def get_composition(self, product_id: int) -> ProductComposition | None: @@ -105,7 +105,7 @@ async def get_composition(self, product_id: int) -> ProductComposition | None: response.raise_for_status() return ProductComposition.from_api(response.json()) except httpx.HTTPError as err: - _LOGGER.error("Error fetching composition for product %s: %s", product_id, err) + _LOGGER.warning("Error fetching composition for product %s: %s", product_id, err) return None async def get_price(self, product_id: int) -> ProductPrice | None: @@ -124,5 +124,5 @@ async def get_price(self, product_id: int) -> ProductPrice | None: response.raise_for_status() return ProductPrice.from_api(response.json()) except httpx.HTTPError as err: - _LOGGER.error("Error fetching price for product %s: %s", product_id, err) + _LOGGER.warning("Error fetching price for product %s: %s", product_id, err) return None diff --git a/rohlik_api/services/recipes.py b/rohlik_api/services/recipes.py index 5094fa9..b673c22 100644 --- a/rohlik_api/services/recipes.py +++ b/rohlik_api/services/recipes.py @@ -38,7 +38,7 @@ async def search( response.raise_for_status() return RecipeSearchResults.from_api(response.json()) except httpx.HTTPError as err: - _LOGGER.error("Error searching recipes: %s", err) + _LOGGER.warning("Error searching recipes: %s", err) return None async def get_detail(self, recipe_id: int) -> RecipeDetail | None: @@ -58,7 +58,7 @@ async def get_detail(self, recipe_id: int) -> RecipeDetail | None: response.raise_for_status() return RecipeDetail.from_api(response.json()) except httpx.HTTPError as err: - _LOGGER.error("Error fetching recipe detail: %s", err) + _LOGGER.warning("Error fetching recipe detail: %s", err) return None async def get_ingredient_products( @@ -84,5 +84,5 @@ async def get_ingredient_products( response.raise_for_status() return IngredientProducts.from_api(response.json()) except httpx.HTTPError as err: - _LOGGER.error("Error fetching ingredient products: %s", err) + _LOGGER.warning("Error fetching ingredient products: %s", err) return None