Metadata-Version: 2.4
Name: python-sendparcel-dpdpl
Version: 0.1.1
Summary: DPD Poland provider for python-sendparcel.
Author-email: Dominik Kozaczko <dominik@kozaczko.info>
License: MIT
Keywords: courier,dpd,parcel,poland,sendparcel,shipping
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Natural Language :: English
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: anyio>=4.0
Requires-Dist: httpx>=0.27.0
Requires-Dist: python-sendparcel>=0.1.1
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: respx>=0.22.0; extra == 'dev'
Requires-Dist: ruff>=0.9.0; extra == 'dev'
Description-Content-Type: text/markdown

# python-sendparcel-dpdpl

[![PyPI](https://img.shields.io/pypi/v/python-sendparcel-dpdpl.svg)](https://pypi.org/project/python-sendparcel-dpdpl/)
[![Python Version](https://img.shields.io/pypi/pyversions/python-sendparcel-dpdpl.svg)](https://pypi.org/project/python-sendparcel-dpdpl/)
[![License](https://img.shields.io/pypi/l/python-sendparcel-dpdpl.svg)](https://github.com/python-sendparcel/python-sendparcel-dpdpl/blob/main/LICENSE)

DPD Poland API provider for the [python-sendparcel](https://github.com/python-sendparcel/python-sendparcel) shipping ecosystem.

> **Alpha (0.1.1)** — API may change between minor releases. Pin your dependency if you use it in production.

## Features

- **Two providers** — `DPDStandardProvider` (door-to-door courier) and `DPDPickupProvider` (PUDO pickup point) as separate `BaseProvider` subclasses.
- **Honest capability metadata** — both providers declare `ConfirmationMethod.NONE` because this package does not implement DPD status callbacks or polling.
- **Standalone DPD client** — `DPDClient` async HTTP wrapper usable independently of the sendparcel framework.
- **Auto-discovery** — both providers register via the `sendparcel.providers` entry-point group; no manual registration needed.
- **Multiple courier services** — standard, express, next-day, and same-day delivery options.
- **Pickup point support** — PUDO delivery with simplified receiver addressing (contact info only, no full address).
- **Address conversion** — automatic conversion between sendparcel `AddressInfo` and DPD address format, with legacy `name`/`line1` fallback.
- **Structured error handling** — `DPDAPIError` hierarchy inheriting from core `CommunicationError` with status codes and validation details.
- **Async-first** — fully asynchronous with `httpx` and `anyio`.

## Installation

```bash
uv add python-sendparcel-dpdpl
```

Or with pip:

```bash
pip install python-sendparcel-dpdpl
```

Both providers are auto-discovered via the `sendparcel.providers` entry-point group — no manual registration needed.

## Quick Start

### Using providers through sendparcel

The providers integrate with the `sendparcel` flow automatically:

```python
from sendparcel.registry import PluginRegistry

# Providers are discovered via entry points
registry = PluginRegistry()
choices = registry.get_choices()
# [('dpd_standard', 'DPD Kurier'), ('dpd_pickup', 'DPD Pickup'), ...]
```

### Creating a standard courier shipment

```python
provider = DPDStandardProvider(shipment=shipment, config={
    "login": "your-dpd-login",
    "password": "your-dpd-password",
    "master_fid": 1495,
    "sandbox": True,  # use demo environment for testing
})

result = await provider.create_shipment(
    reference="ORDER-123",          # optional: package reference
    cod_amount=150.00,              # optional: cash on delivery
    cod_currency="PLN",             # optional: default "PLN"
    declared_value=500.00,          # optional: declared value
)
# result["external_id"] = "12345678"  (session ID)
# result["tracking_number"] = "0000123456789"  (waybill number)
```

### Creating a pickup point shipment

```python
provider = DPDPickupProvider(shipment=shipment, config={
    "login": "your-dpd-login",
    "password": "your-dpd-password",
    "master_fid": 1495,
    "sandbox": True,
})

result = await provider.create_shipment(
    pickup_point="PL14509",  # required: DPD Pickup point ID
    reference="ORDER-456",   # optional
)
# result["external_id"] = "12345679"  (session ID)
# result["tracking_number"] = "0000123456790"  (waybill number)
```

### Generating a label

After creating a shipment, generate the waybill label using the session ID stored in `shipment.external_id`:

```python
label = await provider.create_label(
    label_format="A4",       # optional: "A4" or "LBL_PRINTER"
    doc_format="PDF",        # optional: "PDF", "ZPL", "EPL"
    label_type="BIC3",       # optional: "BIC3" or "EXTENDED"
)
# label["format"] = "PDF"
# label["content_base64"] = "JVBERi0xLjQ..."  (base64-encoded label)

# Decode the label to raw bytes
from sendparcel_dpdpl import DPDClient
label_bytes = DPDClient.decode_label(label["content_base64"])
with open("label.pdf", "wb") as f:
    f.write(label_bytes)
```

### Using DPDClient standalone

The HTTP client can be used independently of the sendparcel framework:

```python
from sendparcel_dpdpl import DPDClient

async with DPDClient(
    login="your-login",
    password="your-password",
    master_fid=1495,
    sandbox=True,
) as client:
    # Create packages and get waybill numbers
    result = await client.generate_packages_numbers(payload={
        "generationPolicy": "ALL_OR_NOTHING",
        "packages": [{
            "sender": {
                "name": "Nadawca Sp. z o.o.",
                "address": "Krakowska 10",
                "city": "Krakow",
                "postalCode": "30-001",
                "countryCode": "PL",
                "phone": "500100200",
            },
            "receiver": {
                "name": "Jan Kowalski",
                "address": "Warszawska 5/3",
                "city": "Warszawa",
                "postalCode": "00-001",
                "countryCode": "PL",
                "phone": "600200300",
                "email": "jan@example.com",
            },
            "payerFID": 1495,
            "parcels": [{"weight": 2.5, "sizeX": 30, "sizeY": 20, "sizeZ": 15}],
        }],
    })

    # Generate labels (advise parcels)
    session_id = result["sessionId"]
    labels = await client.generate_sped_labels(payload={
        "labelSearchParams": {
            "policy": "STOP_ON_FIRST_ERROR",
            "session": {"sessionId": session_id, "type": "DOMESTIC"},
        },
        "outputDocFormat": "PDF",
        "format": "A4",
        "outputType": "BIC3",
    })

    # Decode base64 label to bytes
    pdf_bytes = DPDClient.decode_label(labels["documentData"])

    # All-in-one: create + label + advise
    shipment = await client.generate_shipment(payload={...})

    # Generate handover protocol
    protocol = await client.generate_protocol(payload={...})

    # Validate postal code
    check = await client.check_postal_code("PL", "30-001")

    # Check courier availability
    avail = await client.get_courier_availability("PL", "30-001")

    # Order courier pickup
    pickup = await client.create_pickup_call(payload={...})

    # Cancel courier pickup
    await client.cancel_pickup_call(
        order_number="12345",
        check_sum="abc123",
    )
```

## Configuration

Provider configuration is passed as a dict either through the `config` constructor parameter or via your framework adapter's settings:

| Key | Type | Default | Description |
|---|---|---|---|
| `login` | `str` | *(required)* | DPD API login |
| `password` | `str` | *(required)* | DPD API password |
| `master_fid` | `int` | *(required)* | DPD master FID number (payer identifier) |
| `sandbox` | `bool` | `False` | Use demo API endpoint |
| `base_url` | `str` | `None` | Override API base URL (takes precedence over `sandbox`) |
| `timeout` | `float` | `30.0` | HTTP request timeout in seconds |

### API endpoints

| Environment | Base URL |
|---|---|
| Production | `https://dpdservices.dpd.com.pl` |
| Sandbox | `https://dpdservicesdemo.dpd.com.pl` |

### Authentication

DPD uses HTTP Basic Auth (login/password) plus a master FID header (`x-dpd-fid`). All three credentials (`login`, `password`, `master_fid`) are required.

### Integration with framework adapters

Pass DPD configuration through your adapter's provider settings:

```python
# Django settings.py
SENDPARCEL_PROVIDER_SETTINGS = {
    "dpd_standard": {
        "login": "your-dpd-login",
        "password": "your-dpd-password",
        "master_fid": 1495,
        "sandbox": True,
    },
    "dpd_pickup": {
        "login": "your-dpd-login",
        "password": "your-dpd-password",
        "master_fid": 1495,
        "sandbox": True,
    },
}

# FastAPI / Litestar
config = SendparcelConfig(
    default_provider="dpd_standard",
    providers={
        "dpd_standard": {
            "login": "your-dpd-login",
            "password": "your-dpd-password",
            "master_fid": 1495,
            "sandbox": True,
        },
    },
)
```

## Providers

### DPDStandardProvider

Door-to-door courier delivery. Supports multiple service levels.

- **Slug**: `dpd_standard`
- **Services**: `dpd_standard`, `dpd_express`, `dpd_next_day`, `dpd_today`
- **Confirmation method**: NONE (no push or pull status updates)
- **Supported countries**: PL

**`create_shipment` parameters:**

| Parameter | Required | Description |
|---|---|---|
| `cod_amount` | no | Cash on delivery amount |
| `cod_currency` | no | COD currency (default `"PLN"`) |
| `declared_value` | no | Declared value amount |
| `declared_currency` | no | Declared value currency (default `"PLN"`) |
| `services` | no | Additional transport services (list of dicts or service code strings) |
| `reference` | no | Package reference |
| `generation_policy` | no | Error handling policy (default `ALL_OR_NOTHING`) |

### DPDPickupProvider

PUDO (Pick Up Drop Off) pickup point delivery. The receiver picks up the parcel from a DPD Pickup point.

- **Slug**: `dpd_pickup`
- **Service**: `dpd_pickup`
- **Confirmation method**: NONE (no push or pull status updates)
- **Supported countries**: PL

**`create_shipment` parameters:**

| Parameter | Required | Description |
|---|---|---|
| `pickup_point` | **yes** | DPD Pickup point ID (e.g. `"PL14509"`) |
| `cod_amount` | no | Cash on delivery amount |
| `cod_currency` | no | COD currency (default `"PLN"`) |
| `services` | no | Additional transport services |
| `reference` | no | Package reference |
| `generation_policy` | no | Error handling policy (default `ALL_OR_NOTHING`) |

PUDO receivers only need contact information (name, phone, email, country code) — the pickup point provides the delivery address.

### Common provider methods

Both providers implement a subset of the `BaseProvider` interface:

| Method | Purpose |
|---|---|
| `create_shipment(**kwargs)` | Create a shipment in DPD (returns session ID + waybill) |
| `create_label(**kwargs)` | Generate waybill label (base64-encoded, PDF/ZPL/EPL) |

**`create_label` parameters** (both providers):

| Parameter | Required | Description |
|---|---|---|
| `label_format` | no | Page format: `"A4"` or `"LBL_PRINTER"` (default `"A4"`) |
| `doc_format` | no | Document format: `"PDF"`, `"ZPL"`, `"EPL"` (default `"PDF"`) |
| `label_type` | no | Label type: `"BIC3"` or `"EXTENDED"` (default `"BIC3"`) |

> **Note**: DPD providers in this package currently support shipment creation and label generation only. They do not implement `fetch_shipment_status`, `cancel_shipment`, `verify_callback`, or `handle_callback`, so their confirmation method is `NONE`.

### Shipment flow

DPD uses a two-step flow:

1. **`create_shipment()`** — calls `generatePackagesNumbers` to register packages and obtain a session ID + waybill numbers.
2. **`create_label()`** — calls `generateSpedLabels` with the session ID to generate and advise the label. The label is returned as a base64-encoded string.

The session ID is stored in `shipment.external_id` and used automatically by `create_label()`. This package does not currently expose shipment status updates after creation.

## Address Handling

The providers accept `sendparcel.types.AddressInfo` and convert it to the DPD address format. Two addressing styles are supported:

**Structured** (preferred):
```python
address: AddressInfo = {
    "first_name": "Jan",
    "last_name": "Kowalski",
    "company": "Firma Sp. z o.o.",
    "street": "Krakowska",
    "building_number": "10",
    "flat_number": "5",
    "city": "Krakow",
    "postal_code": "30-001",
    "country_code": "PL",
    "phone": "500100200",
    "email": "jan@example.com",
}
# Converted to: {"name": "Jan Kowalski", "address": "Krakowska 10/5", ...}
```

**Legacy style** (fallback):
```python
address: AddressInfo = {
    "name": "Jan Kowalski",
    "line1": "Krakowska 10/5",
    "city": "Krakow",
    "postal_code": "30-001",
    "phone": "500100200",
}
```

**PUDO receiver addressing**: For `DPDPickupProvider`, receiver addresses are simplified — only contact information (`name`, `phone`, `email`, `country_code`) is sent. The pickup point provides the physical delivery address.

## Error Handling

All DPD API errors inherit from `sendparcel.exceptions.CommunicationError`:

```python
from sendparcel_dpdpl.exceptions import (
    DPDAPIError,              # base: any non-2xx response
    DPDAuthenticationError,   # 401 Unauthorized
    DPDValidationError,       # 400 Bad Request
)

try:
    result = await client.generate_packages_numbers(payload=payload)
except DPDAuthenticationError:
    # Invalid login/password or master FID
    ...
except DPDValidationError as exc:
    # Payload validation failed
    print(exc.detail)   # human-readable message
    print(exc.errors)   # list of error dicts from DPD API
except DPDAPIError as exc:
    # Other API error
    print(exc.status_code, exc.detail)
```

## DPDClient API Reference

The standalone `DPDClient` provides direct access to DPD Poland API endpoints:

| Method | HTTP | Endpoint | Description |
|---|---|---|---|
| `generate_packages_numbers(payload)` | POST | `/public/shipment/v1/generatePackagesNumbers` | Create packages, get waybill numbers |
| `generate_sped_labels(payload)` | POST | `/public/shipment/v1/generateSpedLabels` | Generate waybill labels (advise parcels) |
| `generate_shipment(payload)` | POST | `/public/shipment/v1/generateShipment` | All-in-one: create + label + advise |
| `generate_protocol(payload)` | POST | `/public/shipment/v1/generateProtocol` | Generate handover protocol |
| `check_postal_code(country_code, zip_code)` | GET | `/public/routing/v1/postalCode` | Validate postal code for delivery |
| `get_courier_availability(country_code, zip_code)` | GET | `/public/courierorder/v1/courierOrderAvailability` | Check courier availability |
| `create_pickup_call(payload)` | POST | `/public/courierorder/v1/packagesPickupCall` | Order courier pickup |
| `cancel_pickup_call(order_number, check_sum)` | DELETE | `/public/courierorder/v1/packagesPickupCall` | Cancel courier pickup |
| `decode_label(document_data)` | — | *(static)* | Decode base64 label to bytes |

## Supported Versions

| Dependency | Version |
|---|---|
| Python | >= 3.12 |
| python-sendparcel | >= 0.1.1 |
| httpx | >= 0.27.0 |
| anyio | >= 4.0 |

## Running Tests

The test suite uses **pytest** with **pytest-asyncio** (`asyncio_mode = "auto"`)
and **respx** for HTTP mocking.

```bash
# Install dev dependencies
uv sync --extra dev

# Run the full test suite
uv run pytest

# With coverage
uv run pytest --cov=sendparcel_dpdpl --cov-report=term-missing
```

## Credits

- **Author**: Dominik Kozaczko ([dominik@kozaczko.info](mailto:dominik@kozaczko.info))
- Built on top of [python-sendparcel](https://github.com/python-sendparcel/python-sendparcel) core library
- Integrates with the [DPD Poland API](https://dpd.com.pl/)

## License

[MIT](https://github.com/python-sendparcel/python-sendparcel-dpdpl/blob/main/LICENSE)
