Metadata-Version: 2.4
Name: duffelbag
Version: 0.1.2
Summary: An unofficial Python client library for the Duffel travel booking API
License-Expression: MIT
License-File: LICENSE.txt
Requires-Python: >=3.12
Requires-Dist: httpx<1,>=0.27
Requires-Dist: pydantic<3,>=2.0
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: respx>=0.22; extra == 'dev'
Requires-Dist: ruff>=0.8; extra == 'dev'
Description-Content-Type: text/markdown

# duffelbag

An unofficial Python client library for the [Duffel](https://duffel.com) travel booking API.

Duffel provides a single API for searching and booking flights across hundreds of airlines,
hotel stays, and managing payments.
Duffel discontinued support for their own Python library in 2023. While there are other Python
libraries that wrap parts of their API, none appear to be complete.

**duffelbag** aims to provide a comprehensive, typed, Pythonic interface to the API with both
synchronous and async client support. 

## Features

- Sync and async clients with identical API surfaces
- Pydantic v2 models for all request and response types
- Automatic pagination with lazy iterators
- Built-in rate limit handling (auto-retry on 429)
- Typed exceptions mapped to HTTP status codes
- Covers flights, stays, payments, webhooks, and identity APIs

## Installation

Install using `pip` (or better still, `uv pip`) with:

```bash
pip install duffelbag
```

You can install the very latest version from source directly from GitHub with:

```bash
pip install git+https://github.com/nickovs/duffelbag.git
```

**duffelbag** is designed to run on Python 3.12 or later, but may run on earlier versions of Python.


## Quick start

Sign up at [duffel.com](https://duffel.com) and grab your API token from the dashboard. Test tokens start with `duffel_test_` and let you make bookings against Duffel's simulated airlines at no cost.

### Search for flights

```python
from duffelbag import Duffel

client = Duffel(token="duffel_test_...")

offer_request = client.offer_requests.create(
    slices=[{
        "origin": "LHR",
        "destination": "JFK",
        "departure_date": "2026-06-15",
    }],
    passengers=[{"type": "adult"}],
    cabin_class="economy",
    return_offers=True,
)

for offer in sorted(offer_request.offers, key=lambda o: float(o.total_amount))[:5]:
    print(f"{offer.owner.name:20s}  {offer.total_amount} {offer.total_currency}")

client.close()
```

### Book a flight

```python
from duffelbag import Duffel

client = Duffel(token="duffel_test_...")

# 1. Search
offer_request = client.offer_requests.create(
    slices=[{"origin": "LHR", "destination": "JFK", "departure_date": "2026-06-15"}],
    passengers=[{"type": "adult"}],
    return_offers=True,
)

# 2. Pick an offer
offer = sorted(offer_request.offers, key=lambda o: float(o.total_amount))[0]

# 3. Create an order
order = client.orders.create(
    selected_offers=[offer.id],
    passengers=[{
        "id": offer.passengers[0].id,
        "title": "mr",
        "gender": "m",
        "given_name": "Test",
        "family_name": "Traveller",
        "born_on": "1990-01-01",
        "email": "test@example.com",
        "phone_number": "+442080000000",
    }],
    type="instant",
    payments=[{
        "type": "balance",
        "amount": offer.total_amount,
        "currency": offer.total_currency,
    }],
)

print(f"Booked! Reference: {order.booking_reference}")
client.close()
```

### Async usage

```python
import asyncio
from duffelbag import AsyncDuffel

async def main():
    async with AsyncDuffel(token="duffel_test_...") as client:
        offer_request = await client.offer_requests.create(
            slices=[{"origin": "LHR", "destination": "CDG", "departure_date": "2026-07-01"}],
            passengers=[{"type": "adult"}],
            return_offers=True,
        )

        async for offer in client.offers.list(offer_request.id, sort="total_amount"):
            print(f"{offer.owner.name}  {offer.total_amount} {offer.total_currency}")

asyncio.run(main())
```

### Browse reference data

```python
from duffelbag import Duffel

client = Duffel(token="duffel_test_...")

# Airlines, airports, aircraft, cities — .page() fetches one page
for airline in client.airlines.list(limit=10).page():
    print(f"[{airline.iata_code}] {airline.name}")

# Place search
for place in client.places.suggest("Tokyo"):
    print(f"{place.type}: {place.name} ({place.iata_code})")

client.close()
```

## API coverage

| Section | Resources |
|---------|-----------|
| **Flights** | Offer requests, offers, orders, order changes, order cancellations, seat maps, payments, partial offer requests, batch offer requests, airline-initiated changes, airline credits |
| **Stays** | Search, quotes, bookings, accommodation, brands, negotiated rates, loyalty programmes |
| **Payments** | Cards (PCI-compliant endpoint), 3D Secure sessions |
| **Notifications** | Webhooks, webhook events, webhook deliveries |
| **Identity** | Customer users, customer user groups, component client keys |
| **Reference data** | Airlines, airports, aircraft, cities, places, loyalty programmes |

Every resource is available through both the sync `Duffel` client and the async `AsyncDuffel` client.

## Pagination

List endpoints return lazy iterators that fetch pages on demand:

```python
# Iterate through all results automatically
for airline in client.airlines.list():
    print(airline.name)

# Or fetch one page at a time
iterator = client.airports.list(limit=50, iata_country_code="US")
first_page = iterator.page()
second_page = iterator.page()
```

Async pagination works with `async for`:

```python
async for offer in client.offers.list(offer_request_id):
    print(offer.total_amount)
```

## Error handling

API errors are raised as typed exceptions:

```python
from duffelbag import Duffel, NotFoundError, ValidationError, AuthenticationError

client = Duffel(token="duffel_test_...")

try:
    client.orders.get("ord_nonexistent")
except NotFoundError as e:
    print(f"Not found: {e}")
except ValidationError as e:
    print(f"Validation error: {e}")
    for error in e.errors:
        print(f"  {error.code}: {error.message}")
except AuthenticationError:
    print("Check your API token")
```

## Examples

See the [`examples/`](examples/) directory for runnable scripts:

- **[search_flights.py](examples/search_flights.py)** -- Search and display flight offers
- **[book_and_cancel_flight.py](examples/book_and_cancel_flight.py)** -- Full booking lifecycle
- **[async_search.py](examples/async_search.py)** -- Async round-trip flight search
- **[explore_reference_data.py](examples/explore_reference_data.py)** -- Browse airlines, airports, and places
- **[seat_maps.py](examples/seat_maps.py)** -- Fetch seat map data for an offer

Set your token via environment variable or a `test_api_key` file:

```bash
export DUFFEL_TOKEN="duffel_test_..."
python examples/search_flights.py
```

## Duffel API documentation

- [API overview](https://duffel.com/docs/api/overview/welcome)
- [Flights guide](https://duffel.com/docs/guides/getting-started-with-flights)
- [Stays guide](https://duffel.com/docs/guides/getting-started-with-stays)
- [API reference](https://duffel.com/docs/api)

## Development

```bash
# Clone and install
git clone https://github.com/nickovs/duffelbag.git
cd duffelbag
uv pip install -e ".[dev]"

# Run tests (mocked, no API key needed)
.venv/bin/pytest tests/ --ignore=tests/test_live.py -v

# Run live integration tests (requires test_api_key file)
.venv/bin/pytest tests/test_live.py -v

# Lint
.venv/bin/ruff check src/ tests/
```

## License

MIT
