karrio-spring 2026.1.3__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,142 @@
1
+ """Karrio Spring tracking API implementation."""
2
+
3
+ import karrio.schemas.spring.tracking_request as spring_req
4
+ import karrio.schemas.spring.tracking_response as spring_res
5
+
6
+ import typing
7
+ import karrio.lib as lib
8
+ import karrio.core.models as models
9
+ import karrio.providers.spring.error as error
10
+ import karrio.providers.spring.utils as provider_utils
11
+ import karrio.providers.spring.units as provider_units
12
+
13
+
14
+ def _match_status(code: str) -> typing.Optional[str]:
15
+ """Match code against TrackingStatus enum values."""
16
+ if not code:
17
+ return None
18
+ for status in list(provider_units.TrackingStatus):
19
+ if code in status.value:
20
+ return status.name
21
+ return None
22
+
23
+
24
+ def _match_reason(code: str) -> typing.Optional[str]:
25
+ """Match code against TrackingIncidentReason enum values."""
26
+ if not code:
27
+ return None
28
+ for reason in list(provider_units.TrackingIncidentReason):
29
+ if code in reason.value:
30
+ return reason.name
31
+ return None
32
+
33
+
34
+ def parse_tracking_response(
35
+ _response: lib.Deserializable[typing.List[typing.Tuple[str, dict]]],
36
+ settings: provider_utils.Settings,
37
+ ) -> typing.Tuple[typing.List[models.TrackingDetails], typing.List[models.Message]]:
38
+ """Parse TrackShipment responses from Spring API."""
39
+ responses = _response.deserialize()
40
+
41
+ messages: typing.List[models.Message] = sum(
42
+ [
43
+ error.parse_error_response(response, settings, tracking_number=tracking_number)
44
+ for tracking_number, response in responses
45
+ ],
46
+ start=[],
47
+ )
48
+
49
+ # Only extract details for successful responses (ErrorLevel 0)
50
+ tracking_details = [
51
+ _extract_details(response, settings, tracking_number)
52
+ for tracking_number, response in responses
53
+ if response.get("ErrorLevel") == 0 and response.get("Shipment")
54
+ ]
55
+
56
+ return tracking_details, messages
57
+
58
+
59
+ def _extract_details(
60
+ data: dict,
61
+ settings: provider_utils.Settings,
62
+ tracking_number: str = None,
63
+ ) -> models.TrackingDetails:
64
+ """Extract tracking details from Spring API response."""
65
+ response = lib.to_object(spring_res.TrackingResponseType, data)
66
+ shipment = response.Shipment
67
+
68
+ # Get events and reverse to have most recent first
69
+ events = list(reversed(shipment.Events or []))
70
+
71
+ # Get latest event code for status mapping
72
+ latest_code = str(events[0].Code) if events else None
73
+
74
+ # Map carrier status to karrio standard tracking status
75
+ status = _match_status(latest_code) or provider_units.TrackingStatus.in_transit.name
76
+
77
+ # Build tracking events with all required fields per CARRIER_INTEGRATION_GUIDE.md
78
+ tracking_events = [
79
+ models.TrackingEvent(
80
+ date=lib.fdate(event.DateTime, "%Y-%m-%d %H:%M:%S"),
81
+ description=event.Description,
82
+ code=str(event.Code) if event.Code else None,
83
+ time=lib.flocaltime(event.DateTime, "%Y-%m-%d %H:%M:%S"),
84
+ location=lib.join(event.City, event.State, event.Country, join=True, separator=", "),
85
+ # REQUIRED: timestamp in ISO 8601 format
86
+ timestamp=lib.fiso_timestamp(
87
+ event.DateTime,
88
+ current_format="%Y-%m-%d %H:%M:%S",
89
+ ),
90
+ # REQUIRED: normalized status at event level
91
+ status=_match_status(str(event.Code)),
92
+ # Incident reason for exception events
93
+ reason=_match_reason(str(event.Code)),
94
+ )
95
+ for event in events
96
+ ]
97
+
98
+ return models.TrackingDetails(
99
+ carrier_id=settings.carrier_id,
100
+ carrier_name=settings.carrier_name,
101
+ tracking_number=shipment.TrackingNumber or tracking_number,
102
+ events=tracking_events,
103
+ delivered=status == "delivered",
104
+ status=status,
105
+ info=models.TrackingInfo(
106
+ carrier_tracking_link=shipment.CarrierTrackingUrl,
107
+ package_weight=shipment.Weight,
108
+ package_weight_unit=shipment.WeightUnit,
109
+ ),
110
+ meta=dict(
111
+ service=shipment.Service,
112
+ carrier=shipment.Carrier,
113
+ display_id=shipment.DisplayId,
114
+ shipper_reference=shipment.ShipperReference,
115
+ carrier_tracking_number=shipment.CarrierTrackingNumber,
116
+ carrier_local_tracking_number=shipment.CarrierLocalTrackingNumber,
117
+ ),
118
+ )
119
+
120
+
121
+ def tracking_request(
122
+ payload: models.TrackingRequest,
123
+ settings: provider_utils.Settings,
124
+ ) -> lib.Serializable:
125
+ """Create TrackShipment requests for Spring API.
126
+
127
+ Spring API tracks one shipment at a time, so we create a list of requests
128
+ for each tracking number.
129
+ """
130
+ # Create individual requests for each tracking number using generated schema types
131
+ requests = [
132
+ spring_req.TrackingRequestType(
133
+ Apikey=settings.api_key,
134
+ Command="TrackShipment",
135
+ Shipment=spring_req.ShipmentType(
136
+ TrackingNumber=tracking_number,
137
+ ),
138
+ )
139
+ for tracking_number in payload.tracking_numbers
140
+ ]
141
+
142
+ return lib.Serializable(requests, lambda reqs: [lib.to_dict(r) for r in reqs])
@@ -0,0 +1,382 @@
1
+ import csv
2
+ import pathlib
3
+ import karrio.lib as lib
4
+ import karrio.core.units as units
5
+ import karrio.core.models as models
6
+
7
+
8
+ class ConnectionConfig(lib.Enum):
9
+ """Carrier connection configuration options."""
10
+
11
+ label_format = lib.OptionEnum("label_format", str, "PDF")
12
+
13
+
14
+ class LabelFormat(lib.StrEnum):
15
+ """Spring label formats."""
16
+
17
+ PDF = "PDF"
18
+ PNG = "PNG"
19
+ ZPL300 = "ZPL300" # ZPL 300 dpi
20
+ ZPL200 = "ZPL200" # ZPL 203 dpi
21
+ ZPL = "ZPL" # alias for ZPL300
22
+ EPL = "EPL" # EPL 203 dpi
23
+
24
+
25
+ class PackagingType(lib.StrEnum):
26
+ """Carrier specific packaging type"""
27
+
28
+ PACKAGE = "PACKAGE"
29
+
30
+ """ Unified Packaging type mapping """
31
+ envelope = PACKAGE
32
+ pak = PACKAGE
33
+ tube = PACKAGE
34
+ pallet = PACKAGE
35
+ small_box = PACKAGE
36
+ medium_box = PACKAGE
37
+ your_packaging = PACKAGE
38
+
39
+
40
+ class CustomsContentType(lib.StrEnum):
41
+ """Spring customs declaration types."""
42
+
43
+ sale_of_goods = "SaleOfGoods"
44
+ documents = "Documents"
45
+ gift = "Gift"
46
+ returned_goods = "ReturnedGoods"
47
+ commercial_sample = "CommercialSample"
48
+
49
+
50
+ class CustomsDuty(lib.StrEnum):
51
+ """Spring customs duty types."""
52
+
53
+ DDU = "DDU" # Delivered Duty Unpaid (default)
54
+ DDP = "DDP" # Delivered Duty Paid (Spring Clear)
55
+
56
+
57
+ class ShippingService(lib.StrEnum):
58
+ """Carrier specific services"""
59
+
60
+ # Routed services (auto-select best carrier based on destination/weight)
61
+ spring_tracked = "TRCK"
62
+ spring_signature = "SIGN"
63
+ spring_untracked = "UNTR"
64
+ spring_collect = "CLLCT"
65
+
66
+ # Express and special services
67
+ spring_express = "EXPR"
68
+ spring_import = "IMPRT"
69
+ spring_back_returns = "BACK"
70
+ spring_back_tracked = "BACKT"
71
+ spring_no_label = "NOLABEL"
72
+
73
+ # PostNL Parcel services
74
+ spring_postnl_parcel_eu = "PPLEU"
75
+ spring_postnl_parcel_benelux = "PPND"
76
+ spring_postnl_parcel_benelux_sign = "PPNDS"
77
+ spring_postnl_parcel_benelux_no_neighbor = "PPHD"
78
+ spring_postnl_parcel_benelux_sign_no_neighbor = "PPHDS"
79
+ spring_postnl_parcel_benelux_upu = "PPLUP"
80
+ spring_postnl_parcel_globalpack_ems = "PPLGE"
81
+ spring_postnl_parcel_globalpack_upu = "PPLGU"
82
+ spring_postnl_parcel_epg = "PPLEP"
83
+ spring_postnl_parcel_epg_noneu = "PPNEU"
84
+ spring_postnl_lightweight_china = "PPLLW"
85
+ spring_postnl_collect_service = "PPLCS"
86
+
87
+ # PostNL Packet services (< 2kg)
88
+ spring_postnl_packet_tracked = "PPTT"
89
+ spring_postnl_packet_registered = "PPTR"
90
+ spring_postnl_packet_non_tracked = "PPNT"
91
+ spring_postnl_packet_boxable_bag_trace = "PPBBT"
92
+ spring_postnl_packet_bag_trace = "PPBT"
93
+ spring_postnl_packet_boxable_tracked = "PPBTT"
94
+ spring_postnl_packet_boxable_non_tracked = "PPBNT"
95
+
96
+ # Royal Mail services
97
+ spring_royal_mail_tracked_24 = "RM24"
98
+ spring_royal_mail_tracked_24_sign = "RM24S"
99
+ spring_royal_mail_tracked_48 = "RM48"
100
+ spring_royal_mail_tracked_48_2 = "RM482"
101
+ spring_royal_mail_tracked_48_sign = "RM48S"
102
+
103
+ # Sending services (Spain/Portugal)
104
+ spring_sending_mainland = "SEND"
105
+ spring_sending_islands = "SEND2"
106
+
107
+ # Italian Post services
108
+ spring_italian_post_crono = "ITCR"
109
+ spring_italian_post_crono_express = "ITCRX"
110
+
111
+ # German services
112
+ spring_dpd_de = "DPDDE"
113
+ spring_hermes_sign = "HEHDS"
114
+ spring_hermes_collect = "HEDCS"
115
+
116
+ # French services
117
+ spring_colis_prive = "CPHD"
118
+ spring_colis_prive_sign = "CPHDS"
119
+
120
+ # Spring Commercial services
121
+ spring_com_standard = "SCST"
122
+ spring_com_standard_sign = "SCSTS"
123
+ spring_com_express = "SCEX"
124
+ spring_com_express_sign = "SCEXS"
125
+
126
+ # USA services
127
+ spring_usa_parcel_ground = "UPGR"
128
+ spring_usa_parcel_ground_sign = "UPGRS"
129
+ spring_usa_parcel_express = "UPEX"
130
+ spring_usa_parcel_express_sign = "UPEXS"
131
+ spring_usa_parcel_max = "UPMA"
132
+ spring_usa_parcel_max_sign = "UPMAS"
133
+ spring_usa_parcel_ground_dg = "UPDG"
134
+ spring_usa_parcel_ground_dg_sign = "UDGS"
135
+ spring_usa_parcel_plus_ground_dg = "UPPDG"
136
+ spring_usa_parcel_plus_ground_dg_sign = "UPDGS"
137
+
138
+ # Other carrier services
139
+ spring_packeta = "PACHD"
140
+ spring_mailalliance_boxable = "MABNT"
141
+ spring_austrian_post = "ATEHD"
142
+
143
+
144
+ class ShippingOption(lib.Enum):
145
+ """Carrier specific options"""
146
+
147
+ # Spring-specific options
148
+ spring_customs_duty = lib.OptionEnum("CustomsDuty")
149
+ spring_declaration_type = lib.OptionEnum("DeclarationType")
150
+ spring_dangerous_goods = lib.OptionEnum("DangerousGoods", bool)
151
+ spring_shipping_value = lib.OptionEnum("ShippingValue", float)
152
+ spring_display_id = lib.OptionEnum("DisplayId")
153
+ spring_invoice_number = lib.OptionEnum("InvoiceNumber")
154
+ spring_order_reference = lib.OptionEnum("OrderReference")
155
+ spring_order_date = lib.OptionEnum("OrderDate")
156
+
157
+ # Consignor tax/customs identifiers
158
+ spring_consignor_vat = lib.OptionEnum("ConsignorVat")
159
+ spring_consignor_eori = lib.OptionEnum("ConsignorEori")
160
+ spring_consignor_nl_vat = lib.OptionEnum("ConsignorNlVat")
161
+ spring_consignor_eu_eori = lib.OptionEnum("ConsignorEuEori")
162
+ spring_consignor_gb_eori = lib.OptionEnum("ConsignorGbEori")
163
+ spring_consignor_ioss = lib.OptionEnum("ConsignorIoss")
164
+ spring_consignor_local_tax_number = lib.OptionEnum("ConsignorLocalTaxNumber")
165
+
166
+ # Return label options (BACK service)
167
+ spring_export_carrier_name = lib.OptionEnum("ExportCarrierName")
168
+ spring_export_awb = lib.OptionEnum("ExportAwb")
169
+
170
+ # Collect service option
171
+ spring_pudo_location_id = lib.OptionEnum("PudoLocationId")
172
+
173
+ """ Unified Option type mapping """
174
+ dangerous_goods = spring_dangerous_goods
175
+ shipment_date = spring_order_date
176
+
177
+
178
+ def shipping_options_initializer(
179
+ options: dict,
180
+ package_options: units.ShippingOptions = None,
181
+ ) -> units.ShippingOptions:
182
+ """
183
+ Apply default values to the given options.
184
+ """
185
+
186
+ if package_options is not None:
187
+ options.update(package_options.content)
188
+
189
+ def items_filter(key: str) -> bool:
190
+ return key in ShippingOption # type: ignore
191
+
192
+ return units.ShippingOptions(options, ShippingOption, items_filter=items_filter)
193
+
194
+
195
+ class TrackingStatus(lib.Enum):
196
+ """
197
+ Spring tracking event codes mapped to Karrio unified statuses.
198
+ Based on Spring XBS API documentation section 3.
199
+ """
200
+
201
+ pending = [
202
+ "0", # PARCEL CREATED
203
+ "12", # PREPARATION PROCESS
204
+ ]
205
+ on_hold = [
206
+ "40", # IN CUSTOMS
207
+ "41", # CUSTOMS EXCEPTION
208
+ "31", # DELIVERY EXCEPTION – ACTION REQUIRED
209
+ ]
210
+ delivered = [
211
+ "100", # DELIVERED
212
+ "101", # DELIVERED TO DESTINATION COUNTRY
213
+ ]
214
+ in_transit = [
215
+ "15", # COLLECTION TRANSPORT
216
+ "18", # COLLECTION
217
+ "19", # PROCESSING DEPOT
218
+ "20", # ACCEPTED
219
+ "21", # INTERNATIONAL TRANSPORT
220
+ "22", # CROSSDOCK
221
+ "25", # END OF TRACKING UPDATES
222
+ "93", # AT LOCAL DEPOT LMC
223
+ "2101", # IN TRANSIT - EXPORTED
224
+ "2102", # ITEM RELEASED FROM CUSTOMS
225
+ "2103", # IN TRANSIT - IMPORTED
226
+ "9101", # AT TRANSFER DEPOT LMC
227
+ "9102", # IN TRANSIT
228
+ "9999", # INFORMATION
229
+ ]
230
+ delivery_failed = [
231
+ "91", # DELIVERY ATTEMPTED
232
+ "111", # LOST OR DESTROYED
233
+ "1001", # ITEM INCOMPLETE DATA
234
+ "4106", # CONSIGNMENT CANCELLED
235
+ ]
236
+ delivery_delayed = [
237
+ "9302", # DELIVERY EXCEPTION - DELAYED
238
+ ]
239
+ out_for_delivery = [
240
+ "9301", # OUT FOR DELIVERY
241
+ ]
242
+ ready_for_pickup = [
243
+ "92", # DELIVERY AWAITING COLLECTION
244
+ ]
245
+ return_to_sender = [
246
+ "124", # RETURN IN TRANSIT
247
+ "125", # RETURN RECEIVED
248
+ "12406", # RETURN DELIVERED BY CARRIER
249
+ "12501", # RETURN RECEIVED - REFUSED
250
+ "12502", # RETURN RECEIVED - UNDELIVERABLE
251
+ "12503", # RETURN RECEIVED - DAMAGED
252
+ "12504", # RETURN RECEIVED - NOT COLLECTED
253
+ "12505", # RETURN RECEIVED - ACCORDING TO AGREEMENT
254
+ "12506", # RETURN RECEIVED - DESTROYED
255
+ ]
256
+
257
+
258
+ class TrackingIncidentReason(lib.Enum):
259
+ """Maps Spring exception codes to normalized incident reasons.
260
+
261
+ Maps carrier-specific exception/status codes to standardized
262
+ incident reasons for tracking events. The reason field helps
263
+ identify why a delivery exception occurred.
264
+ """
265
+
266
+ # Carrier-caused issues
267
+ carrier_parcel_lost = ["111"] # LOST OR DESTROYED
268
+ carrier_damaged_parcel = ["12503"] # RETURN RECEIVED - DAMAGED
269
+
270
+ # Consignee-caused issues
271
+ consignee_refused = ["12501"] # RETURN RECEIVED - REFUSED
272
+ consignee_not_available = [
273
+ "91", # DELIVERY ATTEMPTED
274
+ "12504", # RETURN RECEIVED - NOT COLLECTED
275
+ ]
276
+ consignee_incorrect_address = ["12502"] # RETURN RECEIVED - UNDELIVERABLE
277
+
278
+ # Customs-related issues
279
+ customs_delay = [
280
+ "40", # IN CUSTOMS
281
+ "41", # CUSTOMS EXCEPTION
282
+ ]
283
+
284
+ # Delivery exceptions
285
+ delivery_exception_delayed = ["9302"] # DELIVERY EXCEPTION - DELAYED
286
+ delivery_exception_action_required = ["31"] # DELIVERY EXCEPTION – ACTION REQUIRED
287
+ delivery_exception_cancelled = ["4106"] # CONSIGNMENT CANCELLED
288
+ delivery_exception_incomplete_data = ["1001"] # ITEM INCOMPLETE DATA
289
+
290
+ # Return reasons
291
+ return_by_agreement = ["12505"] # RETURN RECEIVED - ACCORDING TO AGREEMENT
292
+ return_destroyed = ["12506"] # RETURN RECEIVED - DESTROYED
293
+
294
+ # Unknown
295
+ unknown = []
296
+
297
+
298
+ def load_services_from_csv() -> list:
299
+ """
300
+ Load service definitions from CSV file.
301
+ CSV format: service_code,service_name,zone_label,country_codes,min_weight,max_weight,max_length,max_width,max_height,rate,currency,transit_days,domicile,international
302
+ """
303
+ csv_path = pathlib.Path(__file__).resolve().parent / "services.csv"
304
+
305
+ if not csv_path.exists():
306
+ # Fallback to simple default if CSV doesn't exist
307
+ return [
308
+ models.ServiceLevel(
309
+ service_name="Spring Tracked",
310
+ service_code="spring_tracked",
311
+ currency="EUR",
312
+ international=True,
313
+ zones=[models.ServiceZone(rate=0.0)],
314
+ )
315
+ ]
316
+
317
+ # Group zones by service
318
+ services_dict: dict[str, dict] = {}
319
+
320
+ with open(csv_path, "r", encoding="utf-8") as f:
321
+ reader = csv.DictReader(f)
322
+ for row in reader:
323
+ service_code = row["service_code"]
324
+ service_name = row["service_name"]
325
+
326
+ # Map carrier service code to karrio service code
327
+ karrio_service_code = ShippingService.map(service_code).name_or_key
328
+
329
+ # Initialize service if not exists
330
+ if karrio_service_code not in services_dict:
331
+ services_dict[karrio_service_code] = {
332
+ "service_name": service_name,
333
+ "service_code": karrio_service_code,
334
+ "currency": row.get("currency", "EUR"),
335
+ "min_weight": (
336
+ float(row["min_weight"]) if row.get("min_weight") else None
337
+ ),
338
+ "max_weight": (
339
+ float(row["max_weight"]) if row.get("max_weight") else None
340
+ ),
341
+ "max_length": (
342
+ float(row["max_length"]) if row.get("max_length") else None
343
+ ),
344
+ "max_width": (
345
+ float(row["max_width"]) if row.get("max_width") else None
346
+ ),
347
+ "max_height": (
348
+ float(row["max_height"]) if row.get("max_height") else None
349
+ ),
350
+ "weight_unit": "KG",
351
+ "dimension_unit": "CM",
352
+ "domicile": (row.get("domicile") or "").lower() == "true",
353
+ "international": (
354
+ True if (row.get("international") or "").lower() == "true" else None
355
+ ),
356
+ "zones": [],
357
+ }
358
+
359
+ # Parse country codes
360
+ country_codes = [
361
+ c.strip() for c in row.get("country_codes", "").split(",") if c.strip()
362
+ ]
363
+
364
+ # Create zone
365
+ zone = models.ServiceZone(
366
+ label=row.get("zone_label", "Default Zone"),
367
+ rate=float(row.get("rate", 0.0)),
368
+ transit_days=(
369
+ int(row["transit_days"]) if row.get("transit_days") else None
370
+ ),
371
+ country_codes=country_codes if country_codes else None,
372
+ )
373
+
374
+ services_dict[karrio_service_code]["zones"].append(zone)
375
+
376
+ # Convert to ServiceLevel objects
377
+ return [
378
+ models.ServiceLevel(**service_data) for service_data in services_dict.values()
379
+ ]
380
+
381
+
382
+ DEFAULT_SERVICES = load_services_from_csv()
@@ -0,0 +1,34 @@
1
+
2
+ import karrio.lib as lib
3
+ import karrio.core as core
4
+
5
+
6
+ class Settings(core.Settings):
7
+ """Spring connection settings."""
8
+
9
+ api_key: str
10
+
11
+ @property
12
+ def carrier_name(self):
13
+ return "spring"
14
+
15
+ @property
16
+ def server_url(self):
17
+ return (
18
+ "https://mtapi.net/?testMode=1"
19
+ if self.test_mode
20
+ else "https://mtapi.net/"
21
+ )
22
+
23
+ @property
24
+ def tracking_url(self):
25
+ return "https://www.mailingtechnology.com/tracking/?tn={}"
26
+
27
+ @property
28
+ def connection_config(self) -> lib.units.Options:
29
+ from karrio.providers.spring.units import ConnectionConfig
30
+
31
+ return lib.to_connection_config(
32
+ self.config or {},
33
+ option_type=ConnectionConfig,
34
+ )
File without changes
@@ -0,0 +1,9 @@
1
+ import attr
2
+ import jstruct
3
+ import typing
4
+
5
+
6
+ @attr.s(auto_attribs=True)
7
+ class ErrorResponseType:
8
+ ErrorLevel: typing.Optional[int] = None
9
+ Error: typing.Optional[str] = None
@@ -0,0 +1,16 @@
1
+ import attr
2
+ import jstruct
3
+ import typing
4
+
5
+
6
+ @attr.s(auto_attribs=True)
7
+ class ShipmentType:
8
+ TrackingNumber: typing.Optional[str] = None
9
+ ShipperReference: typing.Optional[str] = None
10
+
11
+
12
+ @attr.s(auto_attribs=True)
13
+ class ShipmentCancelRequestType:
14
+ Apikey: typing.Optional[str] = None
15
+ Command: typing.Optional[str] = None
16
+ Shipment: typing.Optional[ShipmentType] = jstruct.JStruct[ShipmentType]
@@ -0,0 +1,16 @@
1
+ import attr
2
+ import jstruct
3
+ import typing
4
+
5
+
6
+ @attr.s(auto_attribs=True)
7
+ class ShipmentType:
8
+ TrackingNumber: typing.Optional[str] = None
9
+ ShipperReference: typing.Optional[str] = None
10
+
11
+
12
+ @attr.s(auto_attribs=True)
13
+ class ShipmentCancelResponseType:
14
+ ErrorLevel: typing.Optional[int] = None
15
+ Error: typing.Optional[str] = None
16
+ Shipment: typing.Optional[ShipmentType] = jstruct.JStruct[ShipmentType]