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,3 @@
1
+ from karrio.mappers.spring.mapper import Mapper
2
+ from karrio.mappers.spring.proxy import Proxy
3
+ from karrio.mappers.spring.settings import Settings
@@ -0,0 +1,53 @@
1
+ """Karrio Spring client mapper."""
2
+
3
+ import typing
4
+ import karrio.lib as lib
5
+ import karrio.api.mapper as mapper
6
+ import karrio.core.models as models
7
+ import karrio.providers.spring as provider
8
+ import karrio.mappers.spring.settings as provider_settings
9
+ import karrio.universal.providers.rating as universal_provider
10
+
11
+
12
+ class Mapper(mapper.Mapper):
13
+ settings: provider_settings.Settings
14
+
15
+ def create_rate_request(self, payload: models.RateRequest) -> lib.Serializable:
16
+ return universal_provider.rate_request(payload, self.settings)
17
+
18
+ def create_tracking_request(
19
+ self, payload: models.TrackingRequest
20
+ ) -> lib.Serializable:
21
+ return provider.tracking_request(payload, self.settings)
22
+
23
+ def create_shipment_request(
24
+ self, payload: models.ShipmentRequest
25
+ ) -> lib.Serializable:
26
+ return provider.shipment_request(payload, self.settings)
27
+
28
+ def create_cancel_shipment_request(
29
+ self, payload: models.ShipmentCancelRequest
30
+ ) -> lib.Serializable[str]:
31
+ return provider.shipment_cancel_request(payload, self.settings)
32
+
33
+
34
+ def parse_cancel_shipment_response(
35
+ self, response: lib.Deserializable[str]
36
+ ) -> typing.Tuple[models.ConfirmationDetails, typing.List[models.Message]]:
37
+ return provider.parse_shipment_cancel_response(response, self.settings)
38
+
39
+ def parse_rate_response(
40
+ self, response: lib.Deserializable[str]
41
+ ) -> typing.Tuple[typing.List[models.RateDetails], typing.List[models.Message]]:
42
+ return universal_provider.parse_rate_response(response, self.settings)
43
+
44
+ def parse_shipment_response(
45
+ self, response: lib.Deserializable[str]
46
+ ) -> typing.Tuple[models.ShipmentDetails, typing.List[models.Message]]:
47
+ return provider.parse_shipment_response(response, self.settings)
48
+
49
+ def parse_tracking_response(
50
+ self, response: lib.Deserializable[str]
51
+ ) -> typing.Tuple[typing.List[models.TrackingDetails], typing.List[models.Message]]:
52
+ return provider.parse_tracking_response(response, self.settings)
53
+
@@ -0,0 +1,71 @@
1
+ """Karrio Spring client proxy."""
2
+
3
+ import karrio.lib as lib
4
+ import karrio.api.proxy as proxy
5
+ import karrio.mappers.spring.settings as provider_settings
6
+ import karrio.universal.mappers.rating_proxy as rating_proxy
7
+
8
+
9
+ class Proxy(rating_proxy.RatingMixinProxy, proxy.Proxy):
10
+ settings: provider_settings.Settings
11
+
12
+ def get_rates(self, request: lib.Serializable) -> lib.Deserializable[str]:
13
+ return super().get_rates(request)
14
+
15
+ def create_shipment(self, request: lib.Serializable) -> lib.Deserializable[str]:
16
+ """Create shipments using Spring OrderShipment API.
17
+
18
+ Spring is a per-package carrier, so we make parallel requests
19
+ for each package and return a list of responses.
20
+ """
21
+ responses = lib.run_asynchronously(
22
+ lambda req: lib.request(
23
+ url=self.settings.server_url,
24
+ data=lib.to_json(req),
25
+ trace=self.trace_as("json"),
26
+ method="POST",
27
+ headers={"Content-Type": "text/json"},
28
+ ),
29
+ request.serialize(),
30
+ )
31
+
32
+ return lib.Deserializable(
33
+ responses,
34
+ lambda __: [lib.to_dict(res) for res in __],
35
+ )
36
+
37
+ def cancel_shipment(self, request: lib.Serializable) -> lib.Deserializable[str]:
38
+ response = lib.request(
39
+ url=self.settings.server_url,
40
+ data=lib.to_json(request.serialize()),
41
+ trace=self.trace_as("json"),
42
+ method="POST",
43
+ headers={"Content-Type": "text/json"},
44
+ )
45
+
46
+ return lib.Deserializable(response, lib.to_dict)
47
+
48
+ def get_tracking(self, request: lib.Serializable) -> lib.Deserializable[str]:
49
+ """Track shipments using Spring TrackShipment API.
50
+
51
+ Spring tracks one shipment at a time, so we make parallel requests
52
+ for each tracking number and return results as (tracking_number, response) tuples.
53
+ """
54
+ responses = lib.run_asynchronously(
55
+ lambda req: (
56
+ req["Shipment"]["TrackingNumber"],
57
+ lib.request(
58
+ url=self.settings.server_url,
59
+ data=lib.to_json(req),
60
+ trace=self.trace_as("json"),
61
+ method="POST",
62
+ headers={"Content-Type": "text/json"},
63
+ ),
64
+ ),
65
+ request.serialize(),
66
+ )
67
+
68
+ return lib.Deserializable(
69
+ responses,
70
+ lambda __: [(tracking_number, lib.to_dict(res)) for tracking_number, res in __],
71
+ )
@@ -0,0 +1,33 @@
1
+ """Karrio Spring client settings."""
2
+
3
+ import attr
4
+ import typing
5
+ import jstruct
6
+ import karrio.core.models as models
7
+ import karrio.providers.spring.units as provider_units
8
+ import karrio.providers.spring.utils as provider_utils
9
+ import karrio.universal.mappers.rating_proxy as rating_proxy
10
+
11
+
12
+ @attr.s(auto_attribs=True)
13
+ class Settings(provider_utils.Settings, rating_proxy.RatingMixinSettings):
14
+ """Spring connection settings."""
15
+
16
+ # Carrier-specific credentials
17
+ api_key: str
18
+
19
+ # generic properties
20
+ id: str = None
21
+ test_mode: bool = False
22
+ carrier_id: str = "spring"
23
+ services: typing.List[models.ServiceLevel] = jstruct.JList[models.ServiceLevel, False, dict(default=provider_units.DEFAULT_SERVICES)] # type: ignore
24
+ account_country_code: str = None
25
+ metadata: dict = {}
26
+ config: dict = {}
27
+
28
+ @property
29
+ def shipping_services(self) -> typing.List[models.ServiceLevel]:
30
+ if any(self.services or []):
31
+ return self.services
32
+
33
+ return provider_units.DEFAULT_SERVICES
@@ -0,0 +1,20 @@
1
+ import karrio.core.metadata as metadata
2
+ import karrio.mappers.spring as mappers
3
+ import karrio.providers.spring.units as units
4
+
5
+
6
+ METADATA = metadata.PluginMetadata(
7
+ status="beta",
8
+ id="spring",
9
+ label="Spring",
10
+ # Integrations
11
+ Mapper=mappers.Mapper,
12
+ Proxy=mappers.Proxy,
13
+ Settings=mappers.Settings,
14
+ # Data Units
15
+ is_hub=False,
16
+ options=units.ShippingOption,
17
+ services=units.ShippingService,
18
+ service_levels=units.DEFAULT_SERVICES,
19
+ connection_configs=units.ConnectionConfig,
20
+ )
@@ -0,0 +1,12 @@
1
+ """Karrio Spring provider imports."""
2
+ from karrio.providers.spring.utils import Settings
3
+ from karrio.providers.spring.shipment import (
4
+ parse_shipment_cancel_response,
5
+ parse_shipment_response,
6
+ shipment_cancel_request,
7
+ shipment_request,
8
+ )
9
+ from karrio.providers.spring.tracking import (
10
+ parse_tracking_response,
11
+ tracking_request,
12
+ )
@@ -0,0 +1,48 @@
1
+ """Karrio Spring error parser."""
2
+
3
+ import typing
4
+ import karrio.lib as lib
5
+ import karrio.core.models as models
6
+ import karrio.providers.spring.utils as provider_utils
7
+
8
+
9
+ def parse_error_response(
10
+ response: dict,
11
+ settings: provider_utils.Settings,
12
+ **kwargs,
13
+ ) -> typing.List[models.Message]:
14
+ """Parse Spring API error response.
15
+
16
+ Spring API uses ErrorLevel codes:
17
+ - 0: Command successfully completed without any errors
18
+ - 1: Command completed with errors (e.g., shipment created with errors)
19
+ - 10: Fatal error, command is not completed at all
20
+ """
21
+ errors: typing.List[models.Message] = []
22
+
23
+ if not isinstance(response, dict):
24
+ return errors
25
+
26
+ error_level = response.get("ErrorLevel")
27
+ error_message = response.get("Error") or ""
28
+
29
+ # ErrorLevel 0 means success, no errors
30
+ if error_level == 0:
31
+ return errors
32
+
33
+ # ErrorLevel 1 or 10 indicates an error
34
+ if error_level in (1, 10):
35
+ errors.append(
36
+ models.Message(
37
+ carrier_id=settings.carrier_id,
38
+ carrier_name=settings.carrier_name,
39
+ code=str(error_level),
40
+ message=error_message or (
41
+ "Command completed with errors" if error_level == 1
42
+ else "Fatal error, command not completed"
43
+ ),
44
+ details={**kwargs},
45
+ )
46
+ )
47
+
48
+ return errors
@@ -0,0 +1,9 @@
1
+
2
+ from karrio.providers.spring.shipment.create import (
3
+ parse_shipment_response,
4
+ shipment_request,
5
+ )
6
+ from karrio.providers.spring.shipment.cancel import (
7
+ parse_shipment_cancel_response,
8
+ shipment_cancel_request,
9
+ )
@@ -0,0 +1,54 @@
1
+ """Karrio Spring shipment cancellation API implementation."""
2
+
3
+ import karrio.schemas.spring.shipment_cancel_request as spring_req
4
+
5
+ import typing
6
+ import karrio.lib as lib
7
+ import karrio.core.models as models
8
+ import karrio.providers.spring.error as error
9
+ import karrio.providers.spring.utils as provider_utils
10
+
11
+
12
+ def parse_shipment_cancel_response(
13
+ _response: lib.Deserializable[dict],
14
+ settings: provider_utils.Settings,
15
+ ) -> typing.Tuple[models.ConfirmationDetails, typing.List[models.Message]]:
16
+ """Parse VoidShipment response from Spring API."""
17
+ response = _response.deserialize()
18
+ messages = error.parse_error_response(response, settings)
19
+
20
+ # ErrorLevel 0 means success
21
+ success = response.get("ErrorLevel") == 0 and not any(messages)
22
+
23
+ confirmation = (
24
+ models.ConfirmationDetails(
25
+ carrier_id=settings.carrier_id,
26
+ carrier_name=settings.carrier_name,
27
+ operation="Cancel Shipment",
28
+ success=success,
29
+ )
30
+ if success
31
+ else None
32
+ )
33
+
34
+ return confirmation, messages
35
+
36
+
37
+ def shipment_cancel_request(
38
+ payload: models.ShipmentCancelRequest,
39
+ settings: provider_utils.Settings,
40
+ ) -> lib.Serializable:
41
+ """Create a VoidShipment request for Spring API."""
42
+
43
+ # Spring API allows either TrackingNumber or ShipperReference
44
+ # payload.shipment_identifier is used as TrackingNumber
45
+ request = spring_req.ShipmentCancelRequestType(
46
+ Apikey=settings.api_key,
47
+ Command="VoidShipment",
48
+ Shipment=spring_req.ShipmentType(
49
+ TrackingNumber=payload.shipment_identifier,
50
+ ),
51
+ )
52
+
53
+ return lib.Serializable(request, lib.to_dict)
54
+
@@ -0,0 +1,242 @@
1
+ """Karrio Spring shipment API implementation."""
2
+
3
+ import karrio.schemas.spring.shipment_request as spring_req
4
+ import karrio.schemas.spring.shipment_response as spring_res
5
+
6
+ import uuid
7
+ import typing
8
+ import karrio.lib as lib
9
+ import karrio.core.units as units
10
+ import karrio.core.models as models
11
+ import karrio.providers.spring.error as error
12
+ import karrio.providers.spring.utils as provider_utils
13
+ import karrio.providers.spring.units as provider_units
14
+
15
+
16
+ def parse_shipment_response(
17
+ _response: lib.Deserializable[typing.List[dict]],
18
+ settings: provider_utils.Settings,
19
+ ) -> typing.Tuple[typing.Optional[models.ShipmentDetails], typing.List[models.Message]]:
20
+ """Parse shipment responses from Spring API.
21
+
22
+ Spring is a per-package carrier, so we receive a list of responses
23
+ (one per package) and aggregate them using lib.to_multi_piece_shipment().
24
+ """
25
+ responses = _response.deserialize()
26
+
27
+ # Collect all error messages from all responses
28
+ messages: typing.List[models.Message] = sum(
29
+ [error.parse_error_response(response, settings) for response in responses],
30
+ start=[],
31
+ )
32
+
33
+ # Extract shipment details for successful responses
34
+ shipment_details = [
35
+ (
36
+ f"{index}",
37
+ (
38
+ _extract_details(response, settings)
39
+ if response.get("Shipment") and response.get("ErrorLevel") in (0, 1)
40
+ else None
41
+ ),
42
+ )
43
+ for index, response in enumerate(responses, start=1)
44
+ ]
45
+
46
+ # Aggregate multi-piece shipment
47
+ shipment = lib.to_multi_piece_shipment(shipment_details)
48
+
49
+ return shipment, messages
50
+
51
+
52
+ def _extract_details(
53
+ data: dict,
54
+ settings: provider_utils.Settings,
55
+ ) -> models.ShipmentDetails:
56
+ """Extract shipment details from Spring API response."""
57
+ response = lib.to_object(spring_res.ShipmentResponseType, data)
58
+ shipment = response.Shipment
59
+
60
+ # Get tracking number - used as shipment_identifier for cancel operations
61
+ tracking_number = shipment.TrackingNumber
62
+
63
+ # Get label data - label_format defaults to PDF if not specified
64
+ label_format = shipment.LabelFormat or "PDF"
65
+
66
+ # Build documents object
67
+ documents = models.Documents(label=shipment.LabelImage)
68
+
69
+ return models.ShipmentDetails(
70
+ carrier_id=settings.carrier_id,
71
+ carrier_name=settings.carrier_name,
72
+ tracking_number=tracking_number,
73
+ shipment_identifier=tracking_number,
74
+ label_type=label_format,
75
+ docs=documents,
76
+ meta=dict(
77
+ service=shipment.Service,
78
+ carrier=shipment.Carrier,
79
+ shipper_reference=shipment.ShipperReference,
80
+ carrier_tracking_number=shipment.CarrierTrackingNumber,
81
+ carrier_local_tracking_number=shipment.CarrierLocalTrackingNumber,
82
+ carrier_tracking_url=shipment.CarrierTrackingUrl,
83
+ display_id=shipment.DisplayId,
84
+ label_type=shipment.LabelType,
85
+ ),
86
+ )
87
+
88
+
89
+ def shipment_request(
90
+ payload: models.ShipmentRequest,
91
+ settings: provider_utils.Settings,
92
+ ) -> lib.Serializable:
93
+ """Create Spring OrderShipment requests.
94
+
95
+ Spring is a per-package carrier, so we create one request per package
96
+ and return a list of requests to be processed in parallel.
97
+ """
98
+ shipper = lib.to_address(payload.shipper)
99
+ recipient = lib.to_address(payload.recipient)
100
+ packages = lib.to_packages(payload.parcels)
101
+ service = provider_units.ShippingService.map(payload.service).value_or_key
102
+ options = lib.to_shipping_options(
103
+ payload.options,
104
+ package_options=packages.options,
105
+ initializer=provider_units.shipping_options_initializer,
106
+ )
107
+
108
+ # Get customs info for international shipments
109
+ customs = lib.to_customs_info(
110
+ payload.customs,
111
+ shipper=shipper,
112
+ recipient=recipient,
113
+ weight_unit=units.WeightUnit.KG.name,
114
+ )
115
+
116
+ # Determine label format from settings or payload
117
+ label_format = (
118
+ provider_units.LabelFormat.map(payload.label_type).value
119
+ or settings.connection_config.label_format.state
120
+ or "PDF"
121
+ )
122
+
123
+ # Build consignor (shipper) address (same for all packages)
124
+ consignor_address = spring_req.ConsignorAddressType(
125
+ Name=shipper.person_name or shipper.company_name,
126
+ Company=shipper.company_name,
127
+ AddressLine1=shipper.address_line1,
128
+ AddressLine2=shipper.address_line2,
129
+ AddressLine3=None,
130
+ City=shipper.city,
131
+ State=shipper.state_code,
132
+ Zip=shipper.postal_code,
133
+ Country=shipper.country_code,
134
+ Phone=shipper.phone_number,
135
+ Email=shipper.email,
136
+ Vat=options.spring_consignor_vat.state or shipper.tax_id,
137
+ Eori=options.spring_consignor_eori.state,
138
+ NlVat=options.spring_consignor_nl_vat.state,
139
+ EuEori=options.spring_consignor_eu_eori.state,
140
+ GbEori=options.spring_consignor_gb_eori.state,
141
+ Ioss=options.spring_consignor_ioss.state,
142
+ LocalTaxNumber=options.spring_consignor_local_tax_number.state,
143
+ )
144
+
145
+ # Build consignee (recipient) address (same for all packages)
146
+ consignee_address = spring_req.AddressType(
147
+ Name=recipient.person_name or recipient.company_name,
148
+ Company=recipient.company_name,
149
+ AddressLine1=recipient.address_line1,
150
+ AddressLine2=recipient.address_line2,
151
+ AddressLine3=None,
152
+ City=recipient.city,
153
+ State=recipient.state_code,
154
+ Zip=recipient.postal_code,
155
+ Country=recipient.country_code,
156
+ Phone=recipient.phone_number,
157
+ Email=recipient.email,
158
+ Vat=recipient.tax_id,
159
+ PudoLocationId=options.spring_pudo_location_id.state,
160
+ )
161
+
162
+ # Build products array (customs items) - same for all packages
163
+ products = [
164
+ spring_req.ProductType(
165
+ Description=lib.text(item.description or item.title, max=60),
166
+ Sku=item.sku,
167
+ HsCode=item.hs_code,
168
+ OriginCountry=item.origin_country or shipper.country_code,
169
+ Quantity=str(item.quantity or 1),
170
+ Value=str(item.value_amount or 0),
171
+ Weight=str(item.weight or 0),
172
+ )
173
+ for item in (customs.commodities if customs else [])
174
+ ]
175
+
176
+ # Calculate total value from products or use declared value
177
+ total_value = (
178
+ sum(float(p.Value or 0) for p in products)
179
+ if products
180
+ else (customs.duty.declared_value if customs and customs.duty else None)
181
+ )
182
+
183
+ # Get declaration type mapping
184
+ declaration_type = None
185
+ if customs and customs.content_type:
186
+ declaration_type = provider_units.CustomsContentType.map(
187
+ customs.content_type
188
+ ).value
189
+
190
+ # Get customs duty type
191
+ customs_duty = options.spring_customs_duty.state or (
192
+ provider_units.CustomsDuty.map(customs.incoterm).value
193
+ if customs and customs.incoterm
194
+ else "DDU"
195
+ )
196
+
197
+ # Generate base reference for multi-piece shipment
198
+ base_reference = payload.reference or str(uuid.uuid4().hex)
199
+
200
+ # Create one request per package
201
+ requests = [
202
+ spring_req.ShipmentRequestType(
203
+ Apikey=settings.api_key,
204
+ Command="OrderShipment",
205
+ Shipment=spring_req.ShipmentType(
206
+ LabelFormat=label_format,
207
+ # Append package index to reference for multi-piece tracking
208
+ ShipperReference=(
209
+ f"{base_reference}-{index + 1}"
210
+ if len(packages) > 1
211
+ else base_reference
212
+ ),
213
+ OrderReference=options.spring_order_reference.state,
214
+ OrderDate=options.spring_order_date.state,
215
+ DisplayId=options.spring_display_id.state,
216
+ InvoiceNumber=options.spring_invoice_number.state,
217
+ Service=service,
218
+ Weight=str(package.weight.KG),
219
+ WeightUnit="kg",
220
+ Length=str(package.length.CM) if package.length.CM else None,
221
+ Width=str(package.width.CM) if package.width.CM else None,
222
+ Height=str(package.height.CM) if package.height.CM else None,
223
+ DimUnit="cm" if any([package.length.CM, package.width.CM, package.height.CM]) else None,
224
+ Value=str(total_value) if total_value else None,
225
+ ShippingValue=str(options.spring_shipping_value.state) if options.spring_shipping_value.state else None,
226
+ Currency=customs.duty.currency if customs and customs.duty else options.currency.state,
227
+ CustomsDuty=customs_duty,
228
+ Description=package.parcel.description or (customs.content_description if customs else None),
229
+ DeclarationType=declaration_type or options.spring_declaration_type.state,
230
+ DangerousGoods="Y" if options.spring_dangerous_goods.state else "N",
231
+ ExportCarrierName=options.spring_export_carrier_name.state,
232
+ ExportAwb=options.spring_export_awb.state,
233
+ ConsignorAddress=consignor_address,
234
+ ConsigneeAddress=consignee_address,
235
+ # Pass empty list instead of None to avoid jstruct [None] serialization bug
236
+ Products=products if products else [],
237
+ ),
238
+ )
239
+ for index, package in enumerate(packages)
240
+ ]
241
+
242
+ return lib.Serializable(requests, lambda reqs: [lib.to_dict(r) for r in reqs])