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,305 @@
1
+ """InPost Courier provider."""
2
+
3
+ import base64
4
+ import ipaddress
5
+ import logging
6
+ from typing import Any, ClassVar, cast
7
+
8
+ from sendparcel.enums import ConfirmationMethod, LabelFormat
9
+ from sendparcel.exceptions import InvalidCallbackError
10
+ from sendparcel.provider import (
11
+ BaseProvider,
12
+ CancellableProvider,
13
+ LabelProvider,
14
+ PullStatusProvider,
15
+ PushCallbackProvider,
16
+ )
17
+ from sendparcel.types import (
18
+ AddressInfo,
19
+ LabelInfo,
20
+ ParcelInfo,
21
+ ShipmentCreateResult,
22
+ ShipmentStatusResponse,
23
+ )
24
+
25
+ from sendparcel_inpost.client import ShipXClient
26
+ from sendparcel_inpost.exceptions import ShipXAPIError
27
+ from sendparcel_inpost.status_mapping import map_shipx_status
28
+ from sendparcel_inpost.types import ShipXAddress, ShipXPeer
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ INPOST_WEBHOOK_NETWORK = ipaddress.ip_network("91.216.25.0/24")
33
+
34
+
35
+ class InPostCourierProvider(
36
+ BaseProvider,
37
+ LabelProvider,
38
+ PushCallbackProvider,
39
+ PullStatusProvider,
40
+ CancellableProvider,
41
+ ):
42
+ """InPost courier delivery provider."""
43
+
44
+ slug: ClassVar[str] = "inpost_courier"
45
+ display_name: ClassVar[str] = "InPost Kurier"
46
+ supported_countries: ClassVar[list[str]] = ["PL"]
47
+ supported_services: ClassVar[list[str]] = [
48
+ "inpost_courier_standard",
49
+ ]
50
+ confirmation_method: ClassVar[ConfirmationMethod] = ConfirmationMethod.PUSH
51
+ user_selectable: ClassVar[bool] = True
52
+ config_schema: ClassVar[dict[str, Any]] = {
53
+ "token": {
54
+ "type": "str",
55
+ "required": True,
56
+ "secret": True,
57
+ "description": "ShipX API bearer token",
58
+ },
59
+ "organization_id": {
60
+ "type": "int",
61
+ "required": True,
62
+ "secret": False,
63
+ "description": "ShipX organization ID",
64
+ },
65
+ "sandbox": {
66
+ "type": "bool",
67
+ "required": False,
68
+ "secret": False,
69
+ "description": "Use sandbox environment",
70
+ "default": False,
71
+ },
72
+ "base_url": {
73
+ "type": "str",
74
+ "required": False,
75
+ "secret": False,
76
+ "description": "Custom API base URL (overrides sandbox flag)",
77
+ },
78
+ "timeout": {
79
+ "type": "float",
80
+ "required": False,
81
+ "secret": False,
82
+ "description": "HTTP request timeout in seconds",
83
+ "default": 30.0,
84
+ },
85
+ }
86
+
87
+ def _get_client(self) -> ShipXClient:
88
+ """Build a ShipXClient from provider config."""
89
+ return ShipXClient(
90
+ token=self.get_setting("token", ""),
91
+ organization_id=self.get_setting("organization_id", 0),
92
+ sandbox=self.get_setting("sandbox", False),
93
+ base_url=self.get_setting("base_url"),
94
+ timeout=self.get_setting("timeout", 30.0),
95
+ )
96
+
97
+ def _address_to_peer(self, addr: AddressInfo) -> ShipXPeer:
98
+ """Convert sendparcel AddressInfo to ShipX peer dict."""
99
+ first_name = addr.get("first_name", "")
100
+ last_name = addr.get("last_name", "")
101
+
102
+ if not first_name and not last_name:
103
+ name = addr.get("name", "")
104
+ parts = name.split(None, 1)
105
+ first_name = parts[0] if parts else ""
106
+ last_name = parts[1] if len(parts) > 1 else ""
107
+
108
+ peer: ShipXPeer = {}
109
+ if first_name:
110
+ peer["first_name"] = first_name
111
+ if last_name:
112
+ peer["last_name"] = last_name
113
+
114
+ company = addr.get("company", "")
115
+ if company:
116
+ peer["company_name"] = company
117
+
118
+ phone = addr.get("phone", "")
119
+ if phone:
120
+ peer["phone"] = phone
121
+
122
+ email = addr.get("email", "")
123
+ if email:
124
+ peer["email"] = email
125
+
126
+ street = addr.get("street", "") or addr.get("line1", "")
127
+ building_number = addr.get("building_number", "")
128
+ city = addr.get("city", "")
129
+ postal_code = addr.get("postal_code", "")
130
+ country_code = addr.get("country_code", "")
131
+
132
+ if street or city:
133
+ shipx_addr: ShipXAddress = {}
134
+ if street:
135
+ shipx_addr["street"] = street
136
+ if building_number:
137
+ shipx_addr["building_number"] = building_number
138
+ flat_number = addr.get("flat_number", "")
139
+ if flat_number:
140
+ shipx_addr["flat_number"] = flat_number
141
+ if city:
142
+ shipx_addr["city"] = city
143
+ if postal_code:
144
+ shipx_addr["post_code"] = postal_code
145
+ if country_code:
146
+ shipx_addr["country_code"] = country_code
147
+ peer["address"] = shipx_addr
148
+
149
+ return peer
150
+
151
+ def _parcels_to_shipx(
152
+ self, parcels: list[ParcelInfo]
153
+ ) -> list[dict[str, Any]]:
154
+ """Convert parcels to ShipX parcel dicts with dimensions."""
155
+ result = []
156
+ for parcel in parcels:
157
+ shipx_parcel: dict[str, Any] = {}
158
+
159
+ length = parcel.get("length_cm")
160
+ width = parcel.get("width_cm")
161
+ height = parcel.get("height_cm")
162
+ if length and width and height:
163
+ shipx_parcel["dimensions"] = {
164
+ "length": float(length) * 10, # cm -> mm
165
+ "width": float(width) * 10,
166
+ "height": float(height) * 10,
167
+ "unit": "mm",
168
+ }
169
+
170
+ weight = parcel.get("weight_kg")
171
+ if weight:
172
+ shipx_parcel["weight"] = {
173
+ "amount": float(weight),
174
+ "unit": "kg",
175
+ }
176
+
177
+ result.append(shipx_parcel)
178
+
179
+ return result or [{"weight": {"amount": 1.0, "unit": "kg"}}]
180
+
181
+ async def create_shipment(
182
+ self,
183
+ *,
184
+ sender_address: AddressInfo,
185
+ receiver_address: AddressInfo,
186
+ parcels: list[ParcelInfo],
187
+ **kwargs: Any,
188
+ ) -> ShipmentCreateResult:
189
+ """Create an InPost courier shipment."""
190
+ receiver_peer = self._address_to_peer(receiver_address)
191
+
192
+ payload: dict[str, Any] = {
193
+ "receiver": dict(receiver_peer),
194
+ "parcels": self._parcels_to_shipx(parcels),
195
+ "service": "inpost_courier_standard",
196
+ }
197
+
198
+ sender_peer = self._address_to_peer(sender_address)
199
+ if sender_peer:
200
+ payload["sender"] = dict(sender_peer)
201
+
202
+ client = self._get_client()
203
+ try:
204
+ response = await client.create_shipment(payload=payload)
205
+ finally:
206
+ await client.close()
207
+
208
+ return ShipmentCreateResult(
209
+ external_id=str(response["id"]),
210
+ tracking_number=response.get("tracking_number", ""),
211
+ )
212
+
213
+ async def create_label(self, **kwargs: Any) -> LabelInfo:
214
+ """Fetch label PDF for the shipment."""
215
+ shipment_id = int(self.shipment.external_id)
216
+ label_format = kwargs.get("label_format", "Pdf")
217
+
218
+ client = self._get_client()
219
+ try:
220
+ content = await client.get_label(
221
+ shipment_id=shipment_id,
222
+ label_format=label_format,
223
+ )
224
+ finally:
225
+ await client.close()
226
+
227
+ return LabelInfo(
228
+ format=cast(
229
+ LabelFormat, "PDF" if label_format == "Pdf" else label_format
230
+ ),
231
+ content_base64=base64.b64encode(content).decode("ascii"),
232
+ )
233
+
234
+ async def verify_callback(
235
+ self,
236
+ data: dict[str, Any],
237
+ headers: dict[str, Any],
238
+ **kwargs: Any,
239
+ ) -> None:
240
+ """Verify InPost webhook by source IP."""
241
+ ip_str = headers.get("x-forwarded-for", "").split(",")[0].strip()
242
+ if not ip_str:
243
+ raise InvalidCallbackError("Missing source IP in webhook request")
244
+ try:
245
+ ip_addr = ipaddress.ip_address(ip_str)
246
+ except ValueError as exc:
247
+ raise InvalidCallbackError(f"Invalid source IP: {ip_str}") from exc
248
+
249
+ if ip_addr not in INPOST_WEBHOOK_NETWORK:
250
+ raise InvalidCallbackError(
251
+ f"Source IP {ip_str} not in InPost webhook range"
252
+ )
253
+
254
+ async def handle_callback(
255
+ self,
256
+ data: dict[str, Any],
257
+ headers: dict[str, Any],
258
+ **kwargs: Any,
259
+ ) -> None:
260
+ """Process InPost webhook payload."""
261
+ payload = data.get("payload", {})
262
+ shipx_status = payload.get("status", "")
263
+ sendparcel_status = map_shipx_status(shipx_status)
264
+ if sendparcel_status:
265
+ logger.info(
266
+ "InPost webhook: %s -> %s (shipment %s)",
267
+ shipx_status,
268
+ sendparcel_status,
269
+ payload.get("shipment_id"),
270
+ )
271
+
272
+ async def fetch_shipment_status(
273
+ self,
274
+ **kwargs: Any,
275
+ ) -> ShipmentStatusResponse:
276
+ """Fetch current status from ShipX API."""
277
+ shipment_id = int(self.shipment.external_id)
278
+
279
+ client = self._get_client()
280
+ try:
281
+ response = await client.get_shipment(
282
+ shipment_id=shipment_id,
283
+ )
284
+ finally:
285
+ await client.close()
286
+
287
+ shipx_status = response.get("status", "")
288
+ sendparcel_status = map_shipx_status(shipx_status)
289
+
290
+ return ShipmentStatusResponse(
291
+ status=sendparcel_status.value if sendparcel_status else None,
292
+ )
293
+
294
+ async def cancel_shipment(self, **kwargs: Any) -> bool:
295
+ """Cancel shipment via ShipX API."""
296
+ shipment_id = int(self.shipment.external_id)
297
+
298
+ client = self._get_client()
299
+ try:
300
+ await client.cancel_shipment(shipment_id=shipment_id)
301
+ return True
302
+ except ShipXAPIError:
303
+ return False
304
+ finally:
305
+ await client.close()
@@ -0,0 +1,320 @@
1
+ """InPost Locker (Paczkomat) provider."""
2
+
3
+ import base64
4
+ import ipaddress
5
+ import logging
6
+ from typing import Any, ClassVar
7
+
8
+ from sendparcel.enums import ConfirmationMethod, LabelFormat
9
+ from sendparcel.exceptions import InvalidCallbackError
10
+ from sendparcel.provider import (
11
+ BaseProvider,
12
+ CancellableProvider,
13
+ LabelProvider,
14
+ PullStatusProvider,
15
+ PushCallbackProvider,
16
+ )
17
+ from sendparcel.types import (
18
+ AddressInfo,
19
+ LabelInfo,
20
+ ParcelInfo,
21
+ ShipmentCreateResult,
22
+ ShipmentStatusResponse,
23
+ )
24
+
25
+ from sendparcel_inpost.client import ShipXClient
26
+ from sendparcel_inpost.exceptions import ShipXAPIError
27
+ from sendparcel_inpost.status_mapping import map_shipx_status
28
+ from sendparcel_inpost.types import ShipXAddress, ShipXPeer
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ INPOST_WEBHOOK_NETWORK = ipaddress.ip_network("91.216.25.0/24")
33
+
34
+
35
+ class InPostLockerProvider(
36
+ BaseProvider,
37
+ LabelProvider,
38
+ PushCallbackProvider,
39
+ PullStatusProvider,
40
+ CancellableProvider,
41
+ ):
42
+ """InPost Paczkomat locker delivery provider."""
43
+
44
+ slug: ClassVar[str] = "inpost_locker"
45
+ display_name: ClassVar[str] = "InPost Paczkomat"
46
+ supported_countries: ClassVar[list[str]] = ["PL"]
47
+ supported_services: ClassVar[list[str]] = [
48
+ "inpost_locker_standard",
49
+ ]
50
+ confirmation_method: ClassVar[ConfirmationMethod] = ConfirmationMethod.PUSH
51
+ user_selectable: ClassVar[bool] = True
52
+ config_schema: ClassVar[dict[str, Any]] = {
53
+ "token": {
54
+ "type": "str",
55
+ "required": True,
56
+ "secret": True,
57
+ "description": "ShipX API bearer token",
58
+ },
59
+ "organization_id": {
60
+ "type": "int",
61
+ "required": True,
62
+ "secret": False,
63
+ "description": "ShipX organization ID",
64
+ },
65
+ "sandbox": {
66
+ "type": "bool",
67
+ "required": False,
68
+ "secret": False,
69
+ "description": "Use sandbox environment",
70
+ "default": False,
71
+ },
72
+ "base_url": {
73
+ "type": "str",
74
+ "required": False,
75
+ "secret": False,
76
+ "description": "Custom API base URL (overrides sandbox flag)",
77
+ },
78
+ "timeout": {
79
+ "type": "float",
80
+ "required": False,
81
+ "secret": False,
82
+ "description": "HTTP request timeout in seconds",
83
+ "default": 30.0,
84
+ },
85
+ }
86
+
87
+ def _get_client(self) -> ShipXClient:
88
+ """Build a ShipXClient from provider config."""
89
+ return ShipXClient(
90
+ token=self.get_setting("token", ""),
91
+ organization_id=self.get_setting("organization_id", 0),
92
+ sandbox=self.get_setting("sandbox", False),
93
+ base_url=self.get_setting("base_url"),
94
+ timeout=self.get_setting("timeout", 30.0),
95
+ )
96
+
97
+ def _address_to_peer(self, addr: AddressInfo) -> ShipXPeer:
98
+ """Convert sendparcel AddressInfo to ShipX peer dict."""
99
+ first_name = addr.get("first_name", "")
100
+ last_name = addr.get("last_name", "")
101
+
102
+ if not first_name and not last_name:
103
+ name = addr.get("name", "")
104
+ parts = name.split(None, 1)
105
+ first_name = parts[0] if parts else ""
106
+ last_name = parts[1] if len(parts) > 1 else ""
107
+
108
+ peer: ShipXPeer = {}
109
+ if first_name:
110
+ peer["first_name"] = first_name
111
+ if last_name:
112
+ peer["last_name"] = last_name
113
+
114
+ company = addr.get("company", "")
115
+ if company:
116
+ peer["company_name"] = company
117
+
118
+ phone = addr.get("phone", "")
119
+ if phone:
120
+ peer["phone"] = phone
121
+
122
+ email = addr.get("email", "")
123
+ if email:
124
+ peer["email"] = email
125
+
126
+ street = addr.get("street", "") or addr.get("line1", "")
127
+ building_number = addr.get("building_number", "")
128
+ city = addr.get("city", "")
129
+ postal_code = addr.get("postal_code", "")
130
+ country_code = addr.get("country_code", "")
131
+
132
+ if street or city:
133
+ shipx_addr: ShipXAddress = {}
134
+ if street:
135
+ shipx_addr["street"] = street
136
+ if building_number:
137
+ shipx_addr["building_number"] = building_number
138
+ flat_number = addr.get("flat_number", "")
139
+ if flat_number:
140
+ shipx_addr["flat_number"] = flat_number
141
+ if city:
142
+ shipx_addr["city"] = city
143
+ if postal_code:
144
+ shipx_addr["post_code"] = postal_code
145
+ if country_code:
146
+ shipx_addr["country_code"] = country_code
147
+ peer["address"] = shipx_addr
148
+
149
+ return peer
150
+
151
+ def _parcel_template_from_parcels(self, parcels: list[ParcelInfo]) -> str:
152
+ """Determine locker parcel template from parcels.
153
+
154
+ For locker shipments, defaults to 'small' if dimensions
155
+ don't clearly indicate a larger size.
156
+ """
157
+ if not parcels:
158
+ return "small"
159
+
160
+ parcel = parcels[0]
161
+ height_cm = float(parcel.get("height_cm", 0))
162
+
163
+ if height_cm > 19:
164
+ return "large"
165
+ if height_cm > 8:
166
+ return "medium"
167
+ return "small"
168
+
169
+ async def create_shipment(
170
+ self,
171
+ *,
172
+ sender_address: AddressInfo,
173
+ receiver_address: AddressInfo,
174
+ parcels: list[ParcelInfo],
175
+ **kwargs: Any,
176
+ ) -> ShipmentCreateResult:
177
+ """Create an InPost locker shipment.
178
+
179
+ Required kwargs:
180
+ target_point: Locker machine ID (e.g. "KRA010")
181
+
182
+ Optional kwargs:
183
+ sending_method: How to dispatch (default "dispatch_order")
184
+ parcel_template: Override parcel size ("small"/"medium"/"large")
185
+ """
186
+ target_point = kwargs.get("target_point")
187
+ if not target_point:
188
+ raise ValueError("target_point is required for locker shipments")
189
+
190
+ sending_method = kwargs.get("sending_method", "dispatch_order")
191
+ template = kwargs.get(
192
+ "parcel_template",
193
+ self._parcel_template_from_parcels(parcels),
194
+ )
195
+
196
+ receiver_peer = self._address_to_peer(receiver_address)
197
+
198
+ payload = {
199
+ "receiver": dict(receiver_peer),
200
+ "parcels": [{"template": template}],
201
+ "service": "inpost_locker_standard",
202
+ "custom_attributes": {
203
+ "target_point": target_point,
204
+ "sending_method": sending_method,
205
+ },
206
+ }
207
+
208
+ sender_peer = self._address_to_peer(sender_address)
209
+ if sender_peer:
210
+ payload["sender"] = dict(sender_peer)
211
+
212
+ client = self._get_client()
213
+ try:
214
+ response = await client.create_shipment(payload=payload)
215
+ finally:
216
+ await client.close()
217
+
218
+ return ShipmentCreateResult(
219
+ external_id=str(response["id"]),
220
+ tracking_number=response.get("tracking_number", ""),
221
+ )
222
+
223
+ async def create_label(self, **kwargs: Any) -> LabelInfo:
224
+ """Fetch label PDF for the shipment."""
225
+ shipment_id = int(self.shipment.external_id)
226
+ label_format = kwargs.get("label_format", "Pdf")
227
+
228
+ client = self._get_client()
229
+ try:
230
+ content = await client.get_label(
231
+ shipment_id=shipment_id,
232
+ label_format=label_format,
233
+ )
234
+ finally:
235
+ await client.close()
236
+
237
+ format_value: LabelFormat = (
238
+ LabelFormat.PDF
239
+ if label_format == "Pdf"
240
+ else LabelFormat(label_format)
241
+ )
242
+ return LabelInfo(
243
+ format=format_value,
244
+ content_base64=base64.b64encode(content).decode("ascii"),
245
+ )
246
+
247
+ async def verify_callback(
248
+ self,
249
+ data: dict[str, Any],
250
+ headers: dict[str, Any],
251
+ **kwargs: Any,
252
+ ) -> None:
253
+ """Verify InPost webhook by source IP."""
254
+ ip_str = headers.get("x-forwarded-for", "").split(",")[0].strip()
255
+ if not ip_str:
256
+ raise InvalidCallbackError("Missing source IP in webhook request")
257
+ try:
258
+ ip_addr = ipaddress.ip_address(ip_str)
259
+ except ValueError as exc:
260
+ raise InvalidCallbackError(f"Invalid source IP: {ip_str}") from exc
261
+
262
+ if ip_addr not in INPOST_WEBHOOK_NETWORK:
263
+ raise InvalidCallbackError(
264
+ f"Source IP {ip_str} not in InPost webhook range"
265
+ )
266
+
267
+ async def handle_callback(
268
+ self,
269
+ data: dict[str, Any],
270
+ headers: dict[str, Any],
271
+ **kwargs: Any,
272
+ ) -> None:
273
+ """Process InPost webhook payload.
274
+
275
+ The actual FSM transition is handled by ShipmentFlow.
276
+ This method extracts and normalizes the status.
277
+ """
278
+ payload = data.get("payload", {})
279
+ shipx_status = payload.get("status", "")
280
+ sendparcel_status = map_shipx_status(shipx_status)
281
+ if sendparcel_status:
282
+ logger.info(
283
+ "InPost webhook: %s -> %s (shipment %s)",
284
+ shipx_status,
285
+ sendparcel_status,
286
+ payload.get("shipment_id"),
287
+ )
288
+
289
+ async def fetch_shipment_status(
290
+ self,
291
+ **kwargs: Any,
292
+ ) -> ShipmentStatusResponse:
293
+ """Fetch current status from ShipX API."""
294
+ shipment_id = int(self.shipment.external_id)
295
+
296
+ client = self._get_client()
297
+ try:
298
+ response = await client.get_shipment(shipment_id=shipment_id)
299
+ finally:
300
+ await client.close()
301
+
302
+ shipx_status = response.get("status", "")
303
+ sendparcel_status = map_shipx_status(shipx_status)
304
+
305
+ return ShipmentStatusResponse(
306
+ status=sendparcel_status.value if sendparcel_status else None,
307
+ )
308
+
309
+ async def cancel_shipment(self, **kwargs: Any) -> bool:
310
+ """Cancel shipment via ShipX API."""
311
+ shipment_id = int(self.shipment.external_id)
312
+
313
+ client = self._get_client()
314
+ try:
315
+ await client.cancel_shipment(shipment_id=shipment_id)
316
+ return True
317
+ except ShipXAPIError:
318
+ return False
319
+ finally:
320
+ await client.close()
@@ -0,0 +1,46 @@
1
+ """ShipX status to sendparcel status mapping."""
2
+
3
+ from sendparcel.enums import ShipmentStatus
4
+
5
+ SHIPX_TO_SENDPARCEL_STATUS: dict[str, ShipmentStatus] = {
6
+ # CREATED
7
+ "created": ShipmentStatus.CREATED,
8
+ "offers_prepared": ShipmentStatus.CREATED,
9
+ "offer_selected": ShipmentStatus.CREATED,
10
+ # LABEL_READY
11
+ "confirmed": ShipmentStatus.LABEL_READY,
12
+ # IN_TRANSIT
13
+ "dispatched_by_sender": ShipmentStatus.IN_TRANSIT,
14
+ "collected_from_sender": ShipmentStatus.IN_TRANSIT,
15
+ "taken_by_courier": ShipmentStatus.IN_TRANSIT,
16
+ "adopted_at_source_branch": ShipmentStatus.IN_TRANSIT,
17
+ "sent_from_source_branch": ShipmentStatus.IN_TRANSIT,
18
+ "adopted_at_sorting_center": ShipmentStatus.IN_TRANSIT,
19
+ # OUT_FOR_DELIVERY
20
+ "out_for_delivery": ShipmentStatus.OUT_FOR_DELIVERY,
21
+ "ready_to_pickup": ShipmentStatus.OUT_FOR_DELIVERY,
22
+ "pickup_reminder_sent": ShipmentStatus.OUT_FOR_DELIVERY,
23
+ "avizo": ShipmentStatus.OUT_FOR_DELIVERY,
24
+ "stack_in_box_machine": ShipmentStatus.OUT_FOR_DELIVERY,
25
+ "stack_in_customer_service_point": ShipmentStatus.OUT_FOR_DELIVERY,
26
+ # DELIVERED
27
+ "delivered": ShipmentStatus.DELIVERED,
28
+ # CANCELLED
29
+ "canceled": ShipmentStatus.CANCELLED,
30
+ # RETURNED
31
+ "returned_to_sender": ShipmentStatus.RETURNED,
32
+ # FAILED
33
+ "rejected_by_receiver": ShipmentStatus.FAILED,
34
+ "undelivered": ShipmentStatus.FAILED,
35
+ "oversized": ShipmentStatus.FAILED,
36
+ "missing": ShipmentStatus.FAILED,
37
+ "claim_created": ShipmentStatus.FAILED,
38
+ }
39
+
40
+
41
+ def map_shipx_status(shipx_status: str) -> ShipmentStatus | None:
42
+ """Map a ShipX status string to a sendparcel ShipmentStatus.
43
+
44
+ Returns None if the status is not recognized.
45
+ """
46
+ return SHIPX_TO_SENDPARCEL_STATUS.get(shipx_status)