diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..98a5d8a --- /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.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..fc8a69c 100644 --- a/README.md +++ b/README.md @@ -1,15 +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 -- 🔄 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 (installed automatically) ## Installation @@ -17,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 @@ -25,166 +60,208 @@ 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()) ``` -## Configuration +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 your editor and type checker know +the shape of the data: ```python -from rohlik_api import RohlikAPI +from dataclasses import asdict +from rohlik_api import Cart, SearchResults -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 -) +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. + ## 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 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} + {"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`) +### 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 +# AI-generated product summary -> AISummary | None summary = await client.products.get_ai_summary(product_id=1384964) -# Returns: {"product_id": 1384964, "title": "AI Souhrn", "content": "..."} -# Get product composition (nutritional values, allergens) +# Composition / nutrition / allergens -> ProductComposition | None composition = await client.products.get_composition(product_id=1425155) -# Returns: {"nutritional_values": [...], "ingredients": "...", "allergens": {...}} -# Get product price +# Current 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} ``` -### 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 +# 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`) +### 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) -# Returns: {"recipes": [{"id": 59, "name": "Rajská omáčka", "image": "...", ...}], "total_hits": 4} -# Get recipe details +# Recipe details -> RecipeDetail | None recipe = await client.recipes.get_detail(recipe_id=59) -# Returns: {"id": 59, "name": "...", "ingredients": [...], "directions": [...], ...} -# Get products for ingredients +# 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}]} ``` -### 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. ``` -## Advanced Usage +## Error handling -### Manual Session Management +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}") +``` + +**Error contract:** + +- **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 + continue gracefully. + +## Advanced usage + +### 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 @@ -193,20 +270,46 @@ 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._auth.login() + await client.login() cart = await client.cart.get_content() - await client._auth.logout() + await client.logout() finally: await client.close() ``` -## License +## Development -MIT License - see LICENSE file for details. +```bash +# Install with development dependencies +pip install -e ".[dev]" + +# Run the test suite +pytest -## Contributing +# Lint, format check and type check +ruff check . +black --check . +mypy rohlik_api +``` + +Please make sure `pytest`, `ruff`, `black` and `mypy` all pass before opening a +pull request. + +## Disclaimer + +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. + +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. +[MIT](LICENSE) © Daniel Vejsada diff --git a/example.py b/example.py index 5a9dcf9..20d3594 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.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..a293774 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,32 +1,27 @@ [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.13" +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 +31,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 +44,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 = ["py313"] [tool.ruff] line-length = 100 -target-version = "py38" +target-version = "py313" [tool.ruff.lint] select = [ @@ -79,17 +77,20 @@ ignore = [] "__init__.py" = ["F401"] [tool.mypy] -python_version = "3.8" +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"] 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..ffd6d8c 100644 --- a/rohlik_api/__init__.py +++ b/rohlik_api/__init__.py @@ -1,26 +1,72 @@ """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 .helpers import mask_data -from .errors import RohlikAPIError, InvalidCredentialsError, APIRequestFailedError +from .endpoints import BASE_URL, Endpoints +from .errors import APIRequestFailedError, InvalidCredentialsError, RohlikAPIError +from .helpers import format_price, mask_data from .http_client import HttpClient -from .auth import AuthManager -from .endpoints import Endpoints, BASE_URL +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__ = [ # Main client (facade) "RohlikAPI", - # Utilities - "mask_data", # Errors "RohlikAPIError", "InvalidCredentialsError", "APIRequestFailedError", - # Advanced: Low-level components + # 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", "Endpoints", diff --git a/rohlik_api/auth.py b/rohlik_api/auth.py index f9dff64..5b9bf09 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,9 @@ 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 + self._login_response: dict[str, Any] = {} @property def is_logged_in(self) -> bool: @@ -47,19 +50,20 @@ 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. + 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 @@ -70,47 +74,49 @@ 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": "" - } + 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 + 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(f"Address cannot be retrieved from login data. Login response: {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 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. @@ -130,15 +136,28 @@ 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 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.""" 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 b78c5e0..37e51cd 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. - - 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. + """Async client for interacting with the Rohlik.cz API. - 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,15 @@ 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 - """ - if not username or not password: - raise ValueError("Username and password are required") - + ) -> None: + # 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 - + # Initialize HTTP client self._http = HttpClient( base_url=base_url, @@ -158,7 +127,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 +135,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.warning("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 +233,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 index 7c1e3f8..28e296c 100644 --- a/rohlik_api/models.py +++ b/rohlik_api/models.py @@ -1,12 +1,28 @@ -"""Data models for Rohlik.cz API.""" +"""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 List, Optional +from typing import Any + +from .helpers import format_price +# --------------------------------------------------------------------------- +# Cart +# --------------------------------------------------------------------------- -@dataclass + +@dataclass(slots=True) class CartItem: - """Represents an item in the shopping cart.""" + """A single item in the shopping cart.""" + id: str cart_item_id: str name: str @@ -15,48 +31,438 @@ class CartItem: 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 +@dataclass(slots=True) class Cart: - """Represents the shopping cart.""" + """The current shopping cart.""" + total_price: float total_items: int can_make_order: bool - products: List[CartItem] = field(default_factory=list) + 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 + +@dataclass(slots=True) class ProductSearchResult: - """Represents a product from search results.""" - id: int - name: str + """A product entry from a search response.""" + + id: int | None + name: str | None price: str - brand: Optional[str] = None - amount: Optional[str] = None + 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 +@dataclass(slots=True) class SearchResults: """Container for product search results.""" - results: List[ProductSearchResult] = field(default_factory=list) + results: list[ProductSearchResult] = field(default_factory=list) -@dataclass -class ShoppingListItem: - """Represents an item in a shopping list.""" - product_id: int - quantity: int +@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 -@dataclass + @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: - """Represents a shopping list.""" - name: str - products: List[ShoppingListItem] = field(default_factory=list) + """A saved shopping list.""" + name: str | None + products_in_list: list[Any] = field(default_factory=list) -@dataclass -class AddToCartRequest: - """Request to add a product to cart.""" - product_id: int - quantity: int + @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/__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..7ee8346 100644 --- a/rohlik_api/services/account.py +++ b/rohlik_api/services/account.py @@ -1,13 +1,16 @@ """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 ..models import ShoppingList +from .base import BaseService _LOGGER = logging.getLogger(__name__) @@ -15,7 +18,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 +26,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 +34,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,18 +42,18 @@ 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) -> 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") @@ -62,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(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..d935533 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__) @@ -16,12 +18,20 @@ 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 + 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 +42,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.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 0f91137..b875683 100644 --- a/rohlik_api/services/cart.py +++ b/rohlik_api/services/cart.py @@ -1,13 +1,16 @@ """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 ..models import Cart +from .base import BaseService _LOGGER = logging.getLogger(__name__) @@ -15,110 +18,74 @@ 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(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]]) -> 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] = [] + 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" + "source": "true:Shopping Lists", } 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.warning("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() 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() - 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..e11d524 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: @@ -45,25 +42,26 @@ 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) 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..055fdc4 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.warning("Error fetching delivered orders: %s", err) return None diff --git a/rohlik_api/services/products.py b/rohlik_api/services/products.py index 92a8e06..a3c68e2 100644 --- a/rohlik_api/services/products.py +++ b/rohlik_api/services/products.py @@ -1,12 +1,14 @@ """Products service for Rohlik.cz API.""" +from __future__ import annotations + import logging -from typing import Dict, Any, List, Optional import httpx -from .base import BaseService from ..endpoints import Endpoints +from ..models import AISummary, ProductComposition, ProductPrice, ProductSearchResult, SearchResults +from .base import BaseService _LOGGER = logging.getLogger(__name__) @@ -18,17 +20,18 @@ async def search( self, product_name: str, limit: int = 10, - favourite: bool = False - ) -> Optional[Dict[str, Any]]: + 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() @@ -38,156 +41,88 @@ 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", []) - - # 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 = {"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") - }) - 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.warning("Request failed: %s", err) return None - async def get_ai_summary(self, product_id: int) -> Optional[Dict[str, Any]]: - """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.warning("Error fetching AI summary for product %s: %s", 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) -> 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.warning("Error fetching composition for product %s: %s", product_id, err) return None - async def get_price(self, product_id: int) -> Optional[Dict[str, Any]]: - """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.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 18495d1..b673c22 100644 --- a/rohlik_api/services/recipes.py +++ b/rohlik_api/services/recipes.py @@ -1,12 +1,14 @@ """Recipe service for Rohlik.cz API.""" +from __future__ import annotations + import logging -from typing import Dict, Any, List, Optional import httpx -from .base import BaseService from ..endpoints import Endpoints +from ..models import IngredientProducts, RecipeDetail, RecipeSearchResults +from .base import BaseService _LOGGER = logging.getLogger(__name__) @@ -15,20 +17,18 @@ 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]]: + 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() @@ -36,161 +36,53 @@ async def search( 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.warning("Error searching recipes: %s", err) return None - async def get_detail(self, recipe_id: int) -> Optional[Dict[str, Any]]: + 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.warning("Error fetching recipe detail: %s", err) return None async def get_ingredient_products( - self, - ingredient_ids: List[int], - limit: int = 5, - offset: int = 0 - ) -> Optional[Dict[str, Any]]: - """Get products for specific ingredients. + self, ingredient_ids: list[int], limit: int = 5, offset: int = 0 + ) -> 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() - 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", {}) - - 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": 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), - }) - - return {"ingredients": ingredients_data} - + return IngredientProducts.from_api(response.json()) except httpx.HTTPError as err: - _LOGGER.error(f"Error fetching ingredient products: {err}") + _LOGGER.warning("Error fetching ingredient products: %s", err) return None diff --git a/tests/test_auth.py b/tests/test_auth.py index da71d1f..7bb7095 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,8 +1,18 @@ """Tests for the AuthManager class.""" +from unittest.mock import AsyncMock, MagicMock + import pytest -from rohlik_api.http_client import HttpClient + 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: @@ -62,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() @@ -72,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 35d01d3..e84f1f1 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,20 +75,22 @@ 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" 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(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 async def test_client_close(self): """Test that client closes without error.""" client = RohlikAPI(username=TEST_USERNAME, password=TEST_PASSWORD, auto_login=False) @@ -141,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) @@ -149,6 +153,45 @@ async def test_get_shopping_list_requires_id(self): await client.account.get_shopping_list("") await client.close() + 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} + + 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() + + 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 +201,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..497ec58 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -1,8 +1,7 @@ """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: @@ -76,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() @@ -91,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() @@ -104,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_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..57b579a 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 @@ -29,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() @@ -54,10 +54,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 @@ -66,17 +66,17 @@ 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 - - @pytest.mark.asyncio + 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 + 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) @@ -84,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() @@ -104,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() @@ -116,10 +114,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 +126,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 +137,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 @@ -157,20 +152,20 @@ 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 - - @pytest.mark.asyncio + 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 + 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) @@ -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() @@ -201,7 +195,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 +205,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 @@ -226,15 +220,14 @@ 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 - - @pytest.mark.asyncio + 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 + 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,10 +244,10 @@ 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 + 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..cb73475 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, ) @@ -46,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) @@ -62,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() @@ -74,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() @@ -87,11 +85,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() @@ -100,13 +98,12 @@ 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): """Test add_items sends correct payload.""" mock_response = MagicMock() @@ -117,7 +114,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() @@ -129,9 +126,8 @@ 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_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() @@ -140,17 +136,38 @@ 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 == [] + + 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 async def test_search_filters_promoted_products(self, mock_http, mock_auth): """Test search filters out promoted products.""" mock_response = MagicMock() 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č"}, + }, ] } } @@ -160,10 +177,9 @@ 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): """Test get_ai_summary returns properly formatted data.""" mock_response = MagicMock() @@ -171,7 +187,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 @@ -180,14 +196,14 @@ 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): """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) @@ -195,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() @@ -213,15 +228,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 @@ -230,18 +245,18 @@ 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"] - - @pytest.mark.asyncio + 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 + 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) @@ -249,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() @@ -257,7 +271,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 @@ -266,16 +280,16 @@ 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): """Test get_price returns None on error.""" import httpx + mock_http.get.side_effect = httpx.HTTPError("Connection failed") service = ProductService(mock_http, mock_auth) @@ -292,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() @@ -305,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() @@ -327,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() @@ -344,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 @@ -364,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) @@ -372,13 +381,12 @@ 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() 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 @@ -386,5 +394,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