Metadata-Version: 2.3
Name: pyroski
Version: 0.1.1
Summary: Async client for Eroski Club tickets, coupons, and customer data.
Requires-Dist: cryptography>=42.0.0
Requires-Dist: httpx2>=2.0.0
Requires-Python: >=3.13
Description-Content-Type: text/markdown

# Pyroski

Asynchronous Python client for Eroski Digital Services via the Tempos21 Mobile Gateway (`xpas1.tempos21.com`).

It provides clean, scoped access to digital purchase receipts, itemized line items, PDF receipt downloads, digital saving coupons (*vales de ahorro*), customer loyalty cards, store occupancy (*aforos*), and fuel prices.

---

## Features

- **Long-Lived Bearer Token**: Logs in once using the mobile login flow and persists an OAuth2 Bearer token across runs (~68 years validity).
- **Auto-Healing Token Refresh**: Automatically detects 401 Unauthorized responses and re-authenticates transparently.
- **Modern Scoped Interface**: Logical resource scoping via `api.tickets`, `api.coupons`, `api.customer`, and `api.stores`.
- **Digital Receipt Export**: Fetch itemized receipt details, VAT breakdowns, or download original receipts in PDF format.
- **Batch Coupon Activation**: Activate all eligible savings coupons concurrently with throttled semaphores.
- **Headless 2FA Support**: Custom SMS handlers for headless or automated environments (e.g. bots, webhooks, Home Assistant).

---

## Installation

Install using [`uv`](https://docs.astral.sh/uv/) (recommended) or `pip`:

```bash
# Using uv
uv add pyroski

# Or using pip
pip install pyroski
```

---

## Configuration & Initialization

Credentials can be passed explicitly in code or loaded automatically from environment variables:

```bash
export EROSKI_EMAIL="your_email@example.com"
export EROSKI_PASSWORD="your_password"
export EROSKI_TOKEN_PATH=".eroski_token.json"  # optional, default: .eroski_token.json
```

### Initialization Options

The `Api` constructor supports several configuration options:

```python
Api(
    email: str | None = None,
    password: str | None = None,
    token_path: str | Path | None = None,
    sms_handler: Callable[[], Awaitable[str] | str] | None = None,
    timeout: float = 30.0,
    api_version: str = "v20",
)
```

| Parameter | Type | Default | Description |
|---|---|---|---|
| `email` | `str \| None` | `os.getenv("EROSKI_EMAIL")` | Account email address. Required if env var is unset. |
| `password` | `str \| None` | `os.getenv("EROSKI_PASSWORD")` | Account password. Required if env var is unset. |
| `token_path` | `str \| Path \| None` | `".eroski_token.json"` | File path where the OAuth2 Bearer token is stored and reused. Set via arg or `EROSKI_TOKEN_PATH`. |
| `sms_handler` | `Callable \| None` | `None` (CLI `input()`) | Custom sync or async callback returning the 5-digit SMS 2FA code. Ideal for headless servers, Telegram bots, or webhooks. |
| `timeout` | `float` | `30.0` | HTTP request timeout in seconds for all gateway calls. |
| `api_version` | `str` | `"v20"` | Tempos21 gateway API version prefix (e.g. `"v20"`). |

#### Examples

**1. Standard Initialization (Environment Variables)**:
```python
from pyroski import Api

# Reads EROSKI_EMAIL and EROSKI_PASSWORD from environment
api = Api()
```

**2. Custom Token Location & Timeout**:
```python
api = Api(
    email="user@example.com",
    password="secretpassword",
    token_path="/var/data/eroski_token.json",
    timeout=45.0,
)
```

**3. Headless / Automated SMS 2FA Handler**:
```python
async def fetch_sms_from_telegram_or_db() -> str:
    # Await code from external notification, webhook, or queue
    return await external_2fa_service.get_latest_code()


api = Api(
    email="user@example.com",
    password="secretpassword",
    sms_handler=fetch_sms_from_telegram_or_db,
)
```

---

## Quickstart

```python
import asyncio
from pyroski import Api


async def main():
    async with Api() as api:
        # 1. Customer profile
        customer = await api.customer.get()
        print(f"Customer: {customer.name} {customer.surname} | Card: {customer.club_card}")

        # 2. Latest receipts
        recent_tickets = await api.tickets.latest()
        for t in recent_tickets:
            print(f"Ticket #{t.ticket_number}: {t.total_amount}€ at {t.center_name}")

        # 3. Activate all coupons
        activated = await api.coupons.activate_all()
        print(f"Activated {activated} coupon(s)")


if __name__ == "__main__":
    asyncio.run(main())
```

---

## API Endpoints

The API is organized into resource-scoped services:

### 1. Tickets (`api.tickets`)

Manage and retrieve digital purchase receipts:

| Method | Description |
|---|---|
| `await api.tickets.latest()` | Retrieves the last 5 purchase receipts. |
| `await api.tickets.by_month(year: int, month: int)` | Retrieves all tickets for a specific month, automatically handling pagination. |
| `await api.tickets.by_year(year: int)` | Retrieves all tickets for an entire calendar year (all 12 months). |
| `await api.tickets.since(year: int, month: int)` | Retrieves all tickets starting from a specific year and month up to the current date. |
| `await api.tickets.get(ticket_id: str)` | Fetches the full itemized receipt breakdown, including lines, prices, discounts, and VAT items. |
| `await api.tickets.pdf(ticket_id: str)` | Downloads the receipt as a `TicketPdf` model containing raw PDF bytes and metadata. |
| `await api.tickets.save_pdf(ticket_id: str, path: str \| Path)` | Downloads and writes the ticket receipt PDF directly to a file on disk. |

```python
# Download latest receipt as PDF
tickets = await api.tickets.latest()
if tickets:
    pdf_path = await api.tickets.save_pdf(tickets[0].ticket_id, "./receipt.pdf")
    print(f"Saved PDF receipt to {pdf_path}")
```

### 2. Coupons (`api.coupons`)

Retrieve and activate digital saving coupons (*vales*):

| Method | Description |
|---|---|
| `await api.coupons.all(limit: int = 100, offset: int = 0)` | Retrieves available and active saving coupons for the customer. |
| `await api.coupons.activate(num_offer: str \| int, num_sequence: str \| int = "")` | Activates an individual coupon by offer number and optional sequence. |
| `await api.coupons.activate_all(max_concurrent: int = 5)` | Concurrently activates all eligible unactivated coupons. |

```python
# List and activate all coupons
coupons = await api.coupons.all()
print(f"Found {len(coupons)} coupon(s)")

count = await api.coupons.activate_all(max_concurrent=3)
print(f"Activated {count} coupon(s)")
```

### 3. Customer Profile (`api.customer`)

Access customer loyalty account and membership data:

| Method | Description |
|---|---|
| `await api.customer.get()` | Returns profile information including loyalty card number, customer barcode, tier, and favorite store ID. |

```python
customer = await api.customer.get()
print(f"Club Card: {customer.club_card} | Favorite Store CECO: {customer.favourite_shop}")
```

### 4. Stores (`api.stores`)

Access live store metadata, affluence, and fuel pricing:

| Method | Description |
|---|---|
| `await api.stores.influx(shop_id: str)` | Retrieves real-time occupancy and estimated hourly affluence for a store. |
| `await api.stores.fuel_prices(shop_id: str)` | Retrieves real-time fuel prices for a specific Eroski gas station. |

```python
# Check store affluence
occupancy = await api.stores.influx("159")
print(f"Current affluence: {occupancy}")
```

---

## Error Handling

Pyroski defines specific exception types for authentication and API errors:

- `PyroskiError`: Base exception for all library errors.
- `AuthenticationError`: Raised on invalid credentials or failed OAuth2 token exchange.
- `TwoFactorAuthError`: Raised when SMS 2FA is challenged or verification fails.
