python-sendparcel-inpost 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_sendparcel_inpost-0.1.1.dist-info/METADATA +371 -0
- python_sendparcel_inpost-0.1.1.dist-info/RECORD +13 -0
- python_sendparcel_inpost-0.1.1.dist-info/WHEEL +4 -0
- python_sendparcel_inpost-0.1.1.dist-info/entry_points.txt +3 -0
- sendparcel_inpost/__init__.py +14 -0
- sendparcel_inpost/client.py +177 -0
- sendparcel_inpost/enums.py +18 -0
- sendparcel_inpost/exceptions.py +45 -0
- sendparcel_inpost/providers/__init__.py +6 -0
- sendparcel_inpost/providers/courier.py +305 -0
- sendparcel_inpost/providers/locker.py +320 -0
- sendparcel_inpost/status_mapping.py +46 -0
- sendparcel_inpost/types.py +72 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-sendparcel-inpost
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: InPost ShipX provider for python-sendparcel.
|
|
5
|
+
Author-email: Dominik Kozaczko <dominik@kozaczko.info>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: inpost,parcel,sendparcel,shipping,shipx
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Natural Language :: English
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: anyio>=4.0
|
|
19
|
+
Requires-Dist: httpx>=0.27.0
|
|
20
|
+
Requires-Dist: python-sendparcel>=0.1.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: respx>=0.22.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff>=0.9.0; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# python-sendparcel-inpost
|
|
30
|
+
|
|
31
|
+
[](https://pypi.org/project/python-sendparcel-inpost/)
|
|
32
|
+
[](https://pypi.org/project/python-sendparcel-inpost/)
|
|
33
|
+
[](https://github.com/python-sendparcel/python-sendparcel-inpost/blob/main/LICENSE)
|
|
34
|
+
|
|
35
|
+
InPost ShipX API provider for the [python-sendparcel](https://github.com/python-sendparcel/python-sendparcel) shipping ecosystem.
|
|
36
|
+
|
|
37
|
+
> **Alpha (0.1.0)** — API may change between minor releases. Pin your dependency if you use it in production.
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- **Two providers** — `InPostLockerProvider` (Paczkomat locker) and `InPostCourierProvider` (door-to-door courier) as separate `BaseProvider` subclasses.
|
|
42
|
+
- **Standalone ShipX client** — `ShipXClient` async HTTP wrapper usable independently of the sendparcel framework.
|
|
43
|
+
- **Auto-discovery** — both providers register via the `sendparcel.providers` entry-point group; no manual registration needed.
|
|
44
|
+
- **Status mapping** — 24 ShipX statuses mapped to 8 sendparcel lifecycle states.
|
|
45
|
+
- **Webhook support** — callback verification by InPost source IP range (`91.216.25.0/24`).
|
|
46
|
+
- **Address conversion** — automatic conversion between sendparcel `AddressInfo` and ShipX peer format, with legacy name-splitting fallback.
|
|
47
|
+
- **Structured error handling** — `ShipXAPIError` hierarchy inheriting from core `CommunicationError` with status codes and validation details.
|
|
48
|
+
- **Async-first** — fully asynchronous with `httpx` and `anyio`.
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uv add python-sendparcel-inpost
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Or with pip:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install python-sendparcel-inpost
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Both providers are auto-discovered via the `sendparcel.providers` entry-point group — no manual registration needed.
|
|
63
|
+
|
|
64
|
+
## Quick Start
|
|
65
|
+
|
|
66
|
+
### Using providers through sendparcel
|
|
67
|
+
|
|
68
|
+
The providers integrate with the `sendparcel` flow automatically:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from sendparcel.registry import PluginRegistry
|
|
72
|
+
|
|
73
|
+
# Providers are discovered via entry points
|
|
74
|
+
registry = PluginRegistry()
|
|
75
|
+
choices = registry.get_choices()
|
|
76
|
+
# [('inpost_locker', 'InPost Paczkomat'), ('inpost_courier', 'InPost Kurier'), ...]
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Creating a locker shipment
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
provider = InPostLockerProvider(shipment=shipment, config={
|
|
83
|
+
"token": "your-shipx-api-token",
|
|
84
|
+
"organization_id": 12345,
|
|
85
|
+
"sandbox": True, # use sandbox for testing
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
result = await provider.create_shipment(
|
|
89
|
+
target_point="KRA010", # required: locker machine ID
|
|
90
|
+
parcel_template="small", # optional: "small", "medium", "large"
|
|
91
|
+
sending_method="dispatch_order", # optional
|
|
92
|
+
)
|
|
93
|
+
# result["external_id"] = "123456789"
|
|
94
|
+
# result["tracking_number"] = "6100..."
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Creating a courier shipment
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
provider = InPostCourierProvider(shipment=shipment, config={
|
|
101
|
+
"token": "your-shipx-api-token",
|
|
102
|
+
"organization_id": 12345,
|
|
103
|
+
"sandbox": True,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
result = await provider.create_shipment()
|
|
107
|
+
# Parcels are passed as explicit parameters to create_shipment()
|
|
108
|
+
# Dimensions are converted from cm to mm automatically
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Using ShipXClient standalone
|
|
112
|
+
|
|
113
|
+
The HTTP client can be used independently of the sendparcel framework:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from sendparcel_inpost import ShipXClient
|
|
117
|
+
|
|
118
|
+
async with ShipXClient(
|
|
119
|
+
token="your-token",
|
|
120
|
+
organization_id=12345,
|
|
121
|
+
sandbox=True,
|
|
122
|
+
) as client:
|
|
123
|
+
# Create shipment
|
|
124
|
+
result = await client.create_shipment(payload={
|
|
125
|
+
"receiver": {
|
|
126
|
+
"first_name": "Jan",
|
|
127
|
+
"last_name": "Kowalski",
|
|
128
|
+
"phone": "500100200",
|
|
129
|
+
"email": "jan@example.com",
|
|
130
|
+
},
|
|
131
|
+
"parcels": [{"template": "small"}],
|
|
132
|
+
"service": "inpost_locker_standard",
|
|
133
|
+
"custom_attributes": {
|
|
134
|
+
"target_point": "KRA010",
|
|
135
|
+
"sending_method": "dispatch_order",
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
# Get shipment details
|
|
140
|
+
shipment = await client.get_shipment(result["id"])
|
|
141
|
+
|
|
142
|
+
# Download label
|
|
143
|
+
label_pdf = await client.get_label(result["id"])
|
|
144
|
+
|
|
145
|
+
# Track (public, no auth required)
|
|
146
|
+
tracking = await client.get_tracking("6100123456789")
|
|
147
|
+
|
|
148
|
+
# Cancel (only for created/offers_prepared/offer_selected statuses)
|
|
149
|
+
await client.cancel_shipment(result["id"])
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Configuration
|
|
153
|
+
|
|
154
|
+
Provider configuration is passed as a dict either through the `config` constructor parameter or via your framework adapter's settings:
|
|
155
|
+
|
|
156
|
+
| Key | Type | Default | Description |
|
|
157
|
+
|---|---|---|---|
|
|
158
|
+
| `token` | `str` | *(required)* | ShipX API bearer token |
|
|
159
|
+
| `organization_id` | `int` | *(required)* | ShipX organization ID |
|
|
160
|
+
| `sandbox` | `bool` | `False` | Use sandbox API endpoint |
|
|
161
|
+
| `base_url` | `str` | `None` | Override API base URL (takes precedence over `sandbox`) |
|
|
162
|
+
| `timeout` | `float` | `30.0` | HTTP request timeout in seconds |
|
|
163
|
+
|
|
164
|
+
### API endpoints
|
|
165
|
+
|
|
166
|
+
| Environment | Base URL |
|
|
167
|
+
|---|---|
|
|
168
|
+
| Production | `https://api-shipx-pl.easypack24.net` |
|
|
169
|
+
| Sandbox | `https://sandbox-api-shipx-pl.easypack24.net` |
|
|
170
|
+
|
|
171
|
+
### Integration with framework adapters
|
|
172
|
+
|
|
173
|
+
Pass InPost configuration through your adapter's provider settings:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
# Django settings.py
|
|
177
|
+
SENDPARCEL_PROVIDER_SETTINGS = {
|
|
178
|
+
"inpost_locker": {
|
|
179
|
+
"token": "your-shipx-token",
|
|
180
|
+
"organization_id": 12345,
|
|
181
|
+
"sandbox": True,
|
|
182
|
+
},
|
|
183
|
+
"inpost_courier": {
|
|
184
|
+
"token": "your-shipx-token",
|
|
185
|
+
"organization_id": 12345,
|
|
186
|
+
"sandbox": True,
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
# FastAPI / Litestar
|
|
191
|
+
config = SendparcelConfig(
|
|
192
|
+
default_provider="inpost_locker",
|
|
193
|
+
providers={
|
|
194
|
+
"inpost_locker": {
|
|
195
|
+
"token": "your-shipx-token",
|
|
196
|
+
"organization_id": 12345,
|
|
197
|
+
"sandbox": True,
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Providers
|
|
204
|
+
|
|
205
|
+
### InPostLockerProvider
|
|
206
|
+
|
|
207
|
+
Paczkomat locker delivery. The receiver picks up the parcel from a self-service locker machine.
|
|
208
|
+
|
|
209
|
+
- **Slug**: `inpost_locker`
|
|
210
|
+
- **Service**: `inpost_locker_standard`
|
|
211
|
+
- **Confirmation method**: PUSH (webhook-based)
|
|
212
|
+
- **Supported countries**: PL
|
|
213
|
+
|
|
214
|
+
**`create_shipment` parameters:**
|
|
215
|
+
|
|
216
|
+
| Parameter | Required | Description |
|
|
217
|
+
|---|---|---|
|
|
218
|
+
| `target_point` | yes | Locker machine ID (e.g. `"KRA010"`) |
|
|
219
|
+
| `parcel_template` | no | Size: `"small"`, `"medium"`, or `"large"`. Auto-detected from parcel dimensions if omitted. |
|
|
220
|
+
| `sending_method` | no | Default: `"dispatch_order"` |
|
|
221
|
+
|
|
222
|
+
Parcel template auto-detection logic (based on height):
|
|
223
|
+
- height > 19 cm: `large`
|
|
224
|
+
- height > 8 cm: `medium`
|
|
225
|
+
- otherwise: `small`
|
|
226
|
+
|
|
227
|
+
### InPostCourierProvider
|
|
228
|
+
|
|
229
|
+
Door-to-door courier delivery.
|
|
230
|
+
|
|
231
|
+
- **Slug**: `inpost_courier`
|
|
232
|
+
- **Service**: `inpost_courier_standard`
|
|
233
|
+
- **Confirmation method**: PUSH (webhook-based)
|
|
234
|
+
- **Supported countries**: PL
|
|
235
|
+
|
|
236
|
+
Parcel dimensions are received as explicit `parcels` parameter and converted from cm to mm for the ShipX API. If no parcels are provided, a default 1 kg parcel is used.
|
|
237
|
+
|
|
238
|
+
### Common provider methods
|
|
239
|
+
|
|
240
|
+
Both providers implement the full `BaseProvider` interface:
|
|
241
|
+
|
|
242
|
+
| Method | Purpose |
|
|
243
|
+
|---|---|
|
|
244
|
+
| `create_shipment(**kwargs)` | Create a shipment in ShipX |
|
|
245
|
+
| `create_label(**kwargs)` | Download shipping label (PDF by default) |
|
|
246
|
+
| `fetch_shipment_status(**kwargs)` | Poll ShipX API for current status |
|
|
247
|
+
| `cancel_shipment(**kwargs)` | Cancel the shipment (returns `True`/`False`) |
|
|
248
|
+
| `verify_callback(data, headers, **kwargs)` | Verify webhook source IP is in InPost's `91.216.25.0/24` range |
|
|
249
|
+
| `handle_callback(data, headers, **kwargs)` | Process webhook payload, map ShipX status to sendparcel status |
|
|
250
|
+
|
|
251
|
+
## Address Handling
|
|
252
|
+
|
|
253
|
+
The providers accept `sendparcel.types.AddressInfo` and convert it to the ShipX peer format. Two addressing styles are supported:
|
|
254
|
+
|
|
255
|
+
**InPost-style** (preferred):
|
|
256
|
+
```python
|
|
257
|
+
address: AddressInfo = {
|
|
258
|
+
"first_name": "Jan",
|
|
259
|
+
"last_name": "Kowalski",
|
|
260
|
+
"street": "Krakowska",
|
|
261
|
+
"building_number": "10",
|
|
262
|
+
"flat_number": "5",
|
|
263
|
+
"city": "Krakow",
|
|
264
|
+
"postal_code": "30-001",
|
|
265
|
+
"country_code": "PL",
|
|
266
|
+
"phone": "500100200",
|
|
267
|
+
"email": "jan@example.com",
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
**Legacy style** (auto-split):
|
|
272
|
+
```python
|
|
273
|
+
address: AddressInfo = {
|
|
274
|
+
"name": "Jan Kowalski", # split on first space -> first_name + last_name
|
|
275
|
+
"line1": "Krakowska 10/5", # used as street fallback
|
|
276
|
+
"city": "Krakow",
|
|
277
|
+
"postal_code": "30-001",
|
|
278
|
+
"phone": "500100200",
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## Status Mapping
|
|
283
|
+
|
|
284
|
+
ShipX uses 24 internal statuses. These are mapped to 8 sendparcel statuses:
|
|
285
|
+
|
|
286
|
+
| sendparcel status | ShipX statuses |
|
|
287
|
+
|---|---|
|
|
288
|
+
| `CREATED` | `created`, `offers_prepared`, `offer_selected` |
|
|
289
|
+
| `LABEL_READY` | `confirmed` |
|
|
290
|
+
| `IN_TRANSIT` | `dispatched_by_sender`, `collected_from_sender`, `taken_by_courier`, `adopted_at_source_branch`, `sent_from_source_branch`, `adopted_at_sorting_center` |
|
|
291
|
+
| `OUT_FOR_DELIVERY` | `out_for_delivery`, `ready_to_pickup`, `pickup_reminder_sent`, `avizo`, `stack_in_box_machine`, `stack_in_customer_service_point` |
|
|
292
|
+
| `DELIVERED` | `delivered` |
|
|
293
|
+
| `CANCELLED` | `canceled` |
|
|
294
|
+
| `RETURNED` | `returned_to_sender` |
|
|
295
|
+
| `FAILED` | `rejected_by_receiver`, `undelivered`, `oversized`, `missing`, `claim_created` |
|
|
296
|
+
|
|
297
|
+
## Error Handling
|
|
298
|
+
|
|
299
|
+
All ShipX API errors inherit from `sendparcel.exceptions.CommunicationError`:
|
|
300
|
+
|
|
301
|
+
```python
|
|
302
|
+
from sendparcel_inpost.exceptions import (
|
|
303
|
+
ShipXAPIError, # base: any non-2xx response
|
|
304
|
+
ShipXAuthenticationError, # 401 Unauthorized
|
|
305
|
+
ShipXValidationError, # 422 Unprocessable Entity
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
result = await client.create_shipment(payload=payload)
|
|
310
|
+
except ShipXAuthenticationError:
|
|
311
|
+
# Invalid or expired token
|
|
312
|
+
...
|
|
313
|
+
except ShipXValidationError as exc:
|
|
314
|
+
# Payload validation failed
|
|
315
|
+
print(exc.detail) # human-readable message
|
|
316
|
+
print(exc.errors) # list of field-level error dicts from ShipX
|
|
317
|
+
except ShipXAPIError as exc:
|
|
318
|
+
# Other API error
|
|
319
|
+
print(exc.status_code, exc.detail)
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
## Webhooks
|
|
323
|
+
|
|
324
|
+
Both providers support InPost webhook callbacks for real-time status updates.
|
|
325
|
+
|
|
326
|
+
**Verification**: Webhook source IP must be in the `91.216.25.0/24` range. The IP is read from the `X-Forwarded-For` header (first entry). Invalid or missing IPs raise `sendparcel.exceptions.InvalidCallbackError`.
|
|
327
|
+
|
|
328
|
+
**Payload format** (expected from InPost):
|
|
329
|
+
```json
|
|
330
|
+
{
|
|
331
|
+
"payload": {
|
|
332
|
+
"shipment_id": 123456,
|
|
333
|
+
"status": "delivered"
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## Supported Versions
|
|
339
|
+
|
|
340
|
+
| Dependency | Version |
|
|
341
|
+
|---|---|
|
|
342
|
+
| Python | >= 3.12 |
|
|
343
|
+
| python-sendparcel | >= 0.1.0 |
|
|
344
|
+
| httpx | >= 0.27.0 |
|
|
345
|
+
| anyio | >= 4.0 |
|
|
346
|
+
|
|
347
|
+
## Running Tests
|
|
348
|
+
|
|
349
|
+
The test suite uses **pytest** with **pytest-asyncio** (`asyncio_mode = "auto"`)
|
|
350
|
+
and **respx** for HTTP mocking.
|
|
351
|
+
|
|
352
|
+
```bash
|
|
353
|
+
# Install dev dependencies
|
|
354
|
+
uv sync --extra dev
|
|
355
|
+
|
|
356
|
+
# Run the full test suite
|
|
357
|
+
uv run pytest
|
|
358
|
+
|
|
359
|
+
# With coverage
|
|
360
|
+
uv run pytest --cov=sendparcel_inpost --cov-report=term-missing
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
## Credits
|
|
364
|
+
|
|
365
|
+
- **Author**: Dominik Kozaczko ([dominik@kozaczko.info](mailto:dominik@kozaczko.info))
|
|
366
|
+
- Built on top of [python-sendparcel](https://github.com/python-sendparcel/python-sendparcel) core library
|
|
367
|
+
- Integrates with the [InPost ShipX API](https://docs.inpost24.com/)
|
|
368
|
+
|
|
369
|
+
## License
|
|
370
|
+
|
|
371
|
+
[MIT](https://github.com/python-sendparcel/python-sendparcel-inpost/blob/main/LICENSE)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
sendparcel_inpost/__init__.py,sha256=GO9AZGUt7-_2hKMcAI47hu6d8xC_0HoiRhgcCAsI4ZQ,372
|
|
2
|
+
sendparcel_inpost/client.py,sha256=bWAHEJCGMiC92JmFZCDvh_Cmth6ccWXwftwmwgNokEU,5305
|
|
3
|
+
sendparcel_inpost/enums.py,sha256=8R0BPxTc_PQdPTjYXMIFlFjNju4X6hL8Xq3EEUe5J94,384
|
|
4
|
+
sendparcel_inpost/exceptions.py,sha256=ifYu5MDT-8MlIC4PilDDcNPc-n09CNR_GimtmhVbKz4,1229
|
|
5
|
+
sendparcel_inpost/status_mapping.py,sha256=3NFdZcpgx8wGf1Q-YjapFAEumNICklS55Qph8fy0AAo,1755
|
|
6
|
+
sendparcel_inpost/types.py,sha256=Pu-OJpC6te4_gY6PLv-Ag0o2IoulYytdr9t0FGf4GIA,1426
|
|
7
|
+
sendparcel_inpost/providers/__init__.py,sha256=P1vuLdzmw6Tx8h3pZf7T1AyHJODL9fqR6OrGgd992C4,235
|
|
8
|
+
sendparcel_inpost/providers/courier.py,sha256=2bZo2WGjMAtafVkEDJ6Ry5WQ_NZFZxjQXMONlq4QUBg,9726
|
|
9
|
+
sendparcel_inpost/providers/locker.py,sha256=naEgv11nfEyb6nP6elL0dA5MQE9s1xUi-EEgCs3z1kE,10163
|
|
10
|
+
python_sendparcel_inpost-0.1.1.dist-info/METADATA,sha256=hS2bjEoI2IvgURDkmy35Qar3QqXI4FDxOQLhZWSNenM,12098
|
|
11
|
+
python_sendparcel_inpost-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
12
|
+
python_sendparcel_inpost-0.1.1.dist-info/entry_points.txt,sha256=XuQdmE0LIuIc3TvGPWnYjoB14zXCEof6Q8h1tMp2arE,170
|
|
13
|
+
python_sendparcel_inpost-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""InPost ShipX provider for python-sendparcel."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from sendparcel_inpost.client import ShipXClient
|
|
6
|
+
from sendparcel_inpost.providers.courier import InPostCourierProvider
|
|
7
|
+
from sendparcel_inpost.providers.locker import InPostLockerProvider
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"InPostCourierProvider",
|
|
11
|
+
"InPostLockerProvider",
|
|
12
|
+
"ShipXClient",
|
|
13
|
+
"__version__",
|
|
14
|
+
]
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""ShipX API async HTTP client."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from sendparcel_inpost.exceptions import (
|
|
9
|
+
ShipXAPIError,
|
|
10
|
+
ShipXAuthenticationError,
|
|
11
|
+
ShipXValidationError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
PRODUCTION_BASE_URL = "https://api-shipx-pl.easypack24.net"
|
|
15
|
+
SANDBOX_BASE_URL = "https://sandbox-api-shipx-pl.easypack24.net"
|
|
16
|
+
|
|
17
|
+
DEFAULT_TIMEOUT = 30.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ShipXClient:
|
|
21
|
+
"""Async HTTP client for InPost ShipX API.
|
|
22
|
+
|
|
23
|
+
Can be used standalone (independent of sendparcel providers).
|
|
24
|
+
|
|
25
|
+
Usage::
|
|
26
|
+
|
|
27
|
+
async with ShipXClient(token="...", organization_id=123) as client:
|
|
28
|
+
result = await client.create_shipment(payload={...})
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
token: str,
|
|
34
|
+
organization_id: int,
|
|
35
|
+
*,
|
|
36
|
+
sandbox: bool = False,
|
|
37
|
+
base_url: str | None = None,
|
|
38
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
39
|
+
) -> None:
|
|
40
|
+
if base_url is not None:
|
|
41
|
+
self.base_url = base_url
|
|
42
|
+
elif sandbox:
|
|
43
|
+
self.base_url = SANDBOX_BASE_URL
|
|
44
|
+
else:
|
|
45
|
+
self.base_url = PRODUCTION_BASE_URL
|
|
46
|
+
self.organization_id = organization_id
|
|
47
|
+
self.timeout = timeout
|
|
48
|
+
self._http = httpx.AsyncClient(
|
|
49
|
+
base_url=self.base_url,
|
|
50
|
+
headers={
|
|
51
|
+
"Authorization": f"Bearer {token}",
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
},
|
|
54
|
+
timeout=timeout,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
async def __aenter__(self) -> "ShipXClient":
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
async def __aexit__(
|
|
61
|
+
self,
|
|
62
|
+
exc_type: type[BaseException] | None,
|
|
63
|
+
exc_val: BaseException | None,
|
|
64
|
+
exc_tb: TracebackType | None,
|
|
65
|
+
) -> None:
|
|
66
|
+
await self.close()
|
|
67
|
+
|
|
68
|
+
async def close(self) -> None:
|
|
69
|
+
"""Close the underlying HTTP client."""
|
|
70
|
+
await self._http.aclose()
|
|
71
|
+
|
|
72
|
+
async def create_shipment(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
73
|
+
"""Create a shipment via simplified flow.
|
|
74
|
+
|
|
75
|
+
POST /v1/organizations/{org_id}/shipments
|
|
76
|
+
"""
|
|
77
|
+
url = f"/v1/organizations/{self.organization_id}/shipments"
|
|
78
|
+
response = await self._http.post(url, json=payload)
|
|
79
|
+
self._raise_for_status(response)
|
|
80
|
+
result: dict[str, Any] = response.json()
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
async def get_shipment(self, shipment_id: int) -> dict[str, Any]:
|
|
84
|
+
"""Fetch shipment details.
|
|
85
|
+
|
|
86
|
+
GET /v1/shipments/{shipment_id}
|
|
87
|
+
"""
|
|
88
|
+
response = await self._http.get(f"/v1/shipments/{shipment_id}")
|
|
89
|
+
self._raise_for_status(response)
|
|
90
|
+
result: dict[str, Any] = response.json()
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
async def get_label(
|
|
94
|
+
self,
|
|
95
|
+
shipment_id: int,
|
|
96
|
+
*,
|
|
97
|
+
label_format: str = "Pdf",
|
|
98
|
+
label_type: str = "normal",
|
|
99
|
+
) -> bytes:
|
|
100
|
+
"""Fetch shipping label as binary content.
|
|
101
|
+
|
|
102
|
+
GET /v1/shipments/{shipment_id}/label?format=...&type=...
|
|
103
|
+
"""
|
|
104
|
+
response = await self._http.get(
|
|
105
|
+
f"/v1/shipments/{shipment_id}/label",
|
|
106
|
+
params={"format": label_format, "type": label_type},
|
|
107
|
+
)
|
|
108
|
+
self._raise_for_status(response)
|
|
109
|
+
return response.content
|
|
110
|
+
|
|
111
|
+
async def cancel_shipment(self, shipment_id: int) -> None:
|
|
112
|
+
"""Cancel a shipment.
|
|
113
|
+
|
|
114
|
+
DELETE /v1/shipments/{shipment_id}
|
|
115
|
+
"""
|
|
116
|
+
response = await self._http.delete(f"/v1/shipments/{shipment_id}")
|
|
117
|
+
self._raise_for_status(response)
|
|
118
|
+
|
|
119
|
+
async def get_tracking(self, tracking_number: str) -> dict[str, Any]:
|
|
120
|
+
"""Fetch public tracking data (no auth required).
|
|
121
|
+
|
|
122
|
+
GET /v1/tracking/{tracking_number}
|
|
123
|
+
"""
|
|
124
|
+
response = await self._http.get(f"/v1/tracking/{tracking_number}")
|
|
125
|
+
self._raise_for_status(response)
|
|
126
|
+
result: dict[str, Any] = response.json()
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
async def get_statuses(self, lang: str = "pl") -> list[dict[str, Any]]:
|
|
130
|
+
"""Fetch list of all ShipX statuses.
|
|
131
|
+
|
|
132
|
+
GET /v1/statuses
|
|
133
|
+
"""
|
|
134
|
+
response = await self._http.get(
|
|
135
|
+
"/v1/statuses",
|
|
136
|
+
params={"lang": lang},
|
|
137
|
+
)
|
|
138
|
+
self._raise_for_status(response)
|
|
139
|
+
result: list[dict[str, Any]] = response.json()
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
async def get_services(self) -> list[dict[str, Any]]:
|
|
143
|
+
"""Fetch list of all ShipX services.
|
|
144
|
+
|
|
145
|
+
GET /v1/services
|
|
146
|
+
"""
|
|
147
|
+
response = await self._http.get("/v1/services")
|
|
148
|
+
self._raise_for_status(response)
|
|
149
|
+
result: list[dict[str, Any]] = response.json()
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
def _raise_for_status(self, response: httpx.Response) -> None:
|
|
153
|
+
"""Raise ShipXAPIError subclasses for non-2xx responses."""
|
|
154
|
+
if response.is_success:
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
body = response.json()
|
|
159
|
+
except Exception:
|
|
160
|
+
body = {}
|
|
161
|
+
|
|
162
|
+
detail = body.get("message") or body.get("error") or response.text
|
|
163
|
+
errors = body.get("details", [])
|
|
164
|
+
status_code = response.status_code
|
|
165
|
+
|
|
166
|
+
if status_code == 401:
|
|
167
|
+
raise ShipXAuthenticationError(detail=str(detail))
|
|
168
|
+
if status_code == 422:
|
|
169
|
+
raise ShipXValidationError(
|
|
170
|
+
detail=str(detail),
|
|
171
|
+
errors=errors,
|
|
172
|
+
)
|
|
173
|
+
raise ShipXAPIError(
|
|
174
|
+
status_code=status_code,
|
|
175
|
+
detail=str(detail),
|
|
176
|
+
errors=errors,
|
|
177
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""ShipX-specific enumerations."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ShipXService(StrEnum):
|
|
7
|
+
"""ShipX shipment service types."""
|
|
8
|
+
|
|
9
|
+
INPOST_LOCKER_STANDARD = "inpost_locker_standard"
|
|
10
|
+
INPOST_COURIER_STANDARD = "inpost_courier_standard"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ShipXParcelTemplate(StrEnum):
|
|
14
|
+
"""Locker parcel size templates."""
|
|
15
|
+
|
|
16
|
+
SMALL = "small"
|
|
17
|
+
MEDIUM = "medium"
|
|
18
|
+
LARGE = "large"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""ShipX provider exceptions."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from sendparcel.exceptions import CommunicationError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ShipXAPIError(CommunicationError):
|
|
9
|
+
"""ShipX API returned an error response."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
status_code: int,
|
|
14
|
+
detail: str,
|
|
15
|
+
errors: list[dict[str, Any]] | None = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
self.detail = detail
|
|
19
|
+
self.errors = errors or []
|
|
20
|
+
super().__init__(
|
|
21
|
+
f"ShipX API error {status_code}: {detail}",
|
|
22
|
+
context={
|
|
23
|
+
"status_code": status_code,
|
|
24
|
+
"detail": detail,
|
|
25
|
+
"errors": self.errors,
|
|
26
|
+
},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ShipXAuthenticationError(ShipXAPIError):
|
|
31
|
+
"""ShipX API authentication failed (401)."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, detail: str = "Authentication failed") -> None:
|
|
34
|
+
super().__init__(status_code=401, detail=detail)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ShipXValidationError(ShipXAPIError):
|
|
38
|
+
"""ShipX API validation error (422)."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
detail: str = "Validation failed",
|
|
43
|
+
errors: list[dict[str, Any]] | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
super().__init__(status_code=422, detail=detail, errors=errors)
|