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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/python-sendparcel-inpost.svg)](https://pypi.org/project/python-sendparcel-inpost/)
32
+ [![Python Version](https://img.shields.io/pypi/pyversions/python-sendparcel-inpost.svg)](https://pypi.org/project/python-sendparcel-inpost/)
33
+ [![License](https://img.shields.io/pypi/l/python-sendparcel-inpost.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [sendparcel.providers]
2
+ inpost_courier = sendparcel_inpost.providers.courier:InPostCourierProvider
3
+ inpost_locker = sendparcel_inpost.providers.locker:InPostLockerProvider
@@ -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)
@@ -0,0 +1,6 @@
1
+ """InPost sendparcel providers."""
2
+
3
+ from sendparcel_inpost.providers.courier import InPostCourierProvider
4
+ from sendparcel_inpost.providers.locker import InPostLockerProvider
5
+
6
+ __all__ = ["InPostCourierProvider", "InPostLockerProvider"]