karrio-hermes 2026.1.1__py3-none-any.whl → 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.
@@ -2,7 +2,6 @@
2
2
 
3
3
  import typing
4
4
  import karrio.lib as lib
5
- import karrio.core.units as units
6
5
  import karrio.core.models as models
7
6
  import karrio.providers.hermes.error as error
8
7
  import karrio.providers.hermes.utils as provider_utils
@@ -22,18 +21,31 @@ def _split_name(name: typing.Optional[str]) -> typing.Tuple[str, str]:
22
21
 
23
22
 
24
23
  def parse_shipment_response(
25
- _response: lib.Deserializable[dict],
24
+ _response: lib.Deserializable[typing.List[dict]],
26
25
  settings: provider_utils.Settings,
27
26
  ) -> typing.Tuple[typing.Optional[models.ShipmentDetails], typing.List[models.Message]]:
28
- """Parse Hermes shipment response."""
29
- response = _response.deserialize()
30
- messages = error.parse_error_response(response, settings)
27
+ """Parse Hermes shipment response for single or multi-piece shipments."""
28
+ responses = _response.deserialize()
31
29
 
32
- # Check if we have valid shipment data (shipmentID indicates success)
33
- # Only proceed if response is a dict
34
- shipment = None
35
- if isinstance(response, dict) and (response.get("shipmentID") or response.get("shipmentOrderID")):
36
- shipment = _extract_details(response, settings)
30
+ # Collect all messages from all responses
31
+ messages: typing.List[models.Message] = sum(
32
+ [error.parse_error_response(response, settings) for response in responses],
33
+ start=[],
34
+ )
35
+
36
+ # Extract shipment details from each valid response
37
+ shipment_details = [
38
+ (
39
+ f"{idx}",
40
+ _extract_details(response, settings),
41
+ )
42
+ for idx, response in enumerate(responses, start=1)
43
+ if isinstance(response, dict)
44
+ and (response.get("shipmentID") or response.get("shipmentOrderID"))
45
+ ]
46
+
47
+ # Use lib.to_multi_piece_shipment() to aggregate multi-piece shipments
48
+ shipment = lib.to_multi_piece_shipment(shipment_details) if shipment_details else None
37
49
 
38
50
  return shipment, messages
39
51
 
@@ -55,8 +67,9 @@ def _extract_details(
55
67
  # Commercial invoice for international shipments
56
68
  invoice_image = response.commInvoiceImage or ""
57
69
 
58
- # Label media type
59
- label_type = response.labelMediatype or "PDF"
70
+ # Label media type - convert MIME type to format (e.g., "application/pdf" -> "PDF")
71
+ label_mediatype = response.labelMediatype or "application/pdf"
72
+ label_type = label_mediatype.split("/")[-1].upper() if "/" in label_mediatype else label_mediatype
60
73
 
61
74
  documents = models.Documents(label=label_image)
62
75
  if invoice_image:
@@ -80,257 +93,277 @@ def shipment_request(
80
93
  payload: models.ShipmentRequest,
81
94
  settings: provider_utils.Settings,
82
95
  ) -> lib.Serializable:
83
- """Create a Hermes shipment request."""
96
+ """Create Hermes shipment request(s) for single or multi-piece shipments.
97
+
98
+ For multi-piece shipments (Pattern B - Per-Package Request):
99
+ - First package: partNumber=1, numberOfParts=N, no parentShipmentOrderID
100
+ - Subsequent packages: partNumber=2,3,..., numberOfParts=N, parentShipmentOrderID=<first shipmentOrderID>
101
+
102
+ The proxy handles the sequential API calls and injects parentShipmentOrderID
103
+ from the first response into subsequent requests.
104
+ """
84
105
  shipper = lib.to_address(payload.shipper)
85
106
  recipient = lib.to_address(payload.recipient)
86
107
  packages = lib.to_packages(payload.parcels)
87
- package = packages.single # Hermes handles one parcel per request
88
108
  options = lib.to_shipping_options(
89
109
  payload.options,
90
110
  package_options=packages.options,
91
111
  initializer=provider_units.shipping_options_initializer,
92
112
  )
93
-
94
- # Determine product type
95
- product_type = provider_units.PackagingType.map(
96
- package.packaging_type or "your_packaging"
97
- ).value
98
-
99
- # Build services object based on options
100
- service = _build_service(options)
101
-
102
- # Build customs for international shipments
103
- customs = None
104
- if payload.customs:
105
- customs = _build_customs(payload.customs, shipper)
113
+ customs = payload.customs
106
114
 
107
115
  # Split names for Hermes API
108
116
  recipient_firstname, recipient_lastname = _split_name(recipient.person_name)
109
117
  shipper_firstname, shipper_lastname = _split_name(shipper.person_name)
110
118
 
111
- # Create the request using generated schema types
112
- # Field length limits per OpenAPI spec:
113
- # - street: 50, houseNumber: 5, town: 30
114
- # - addressAddition: 50, addressAddition2: 20, addressAddition3: 20
115
- # - clientReference: 20, clientReference2: 20, phone: 20
116
- request = hermes_req.ShipmentRequestType(
117
- clientReference=lib.text(payload.reference, max=20) or "",
118
- clientReference2=lib.text((payload.options or {}).get("clientReference2"), max=20),
119
- # Receiver name
120
- receiverName=hermes_req.ErNameType(
121
- title=None,
122
- gender=None,
123
- firstname=recipient_firstname,
124
- middlename=None,
125
- lastname=recipient_lastname,
126
- ),
127
- # Receiver address
128
- receiverAddress=hermes_req.ErAddressType(
129
- street=lib.text(recipient.street_name, max=50),
130
- houseNumber=lib.text(recipient.street_number, max=5) or "",
131
- zipCode=recipient.postal_code,
132
- town=lib.text(recipient.city, max=30),
133
- countryCode=recipient.country_code,
134
- addressAddition=lib.text(recipient.address_line2, max=50),
135
- addressAddition2=None,
136
- addressAddition3=lib.text(recipient.company_name, max=20),
137
- ),
138
- # Receiver contact
139
- receiverContact=lib.identity(
140
- hermes_req.ReceiverContactType(
141
- phone=lib.text(recipient.phone_number, max=20),
142
- mobile=None,
143
- mail=recipient.email,
144
- )
145
- if recipient.phone_number or recipient.email
146
- else None
147
- ),
148
- # Sender (divergent sender if different from account default)
149
- senderName=lib.identity(
150
- hermes_req.ErNameType(
119
+ # Determine if this is a multi-piece shipment
120
+ is_multi_piece = len(packages) > 1
121
+ number_of_parts = len(packages) if is_multi_piece else None
122
+
123
+ # Create a request for each package - single tree instantiation
124
+ requests = [
125
+ hermes_req.ShipmentRequestType(
126
+ clientReference=lib.text(payload.reference, max=20) or "",
127
+ clientReference2=lib.text((payload.options or {}).get("clientReference2"), max=20),
128
+ # Receiver name
129
+ receiverName=hermes_req.ErNameType(
151
130
  title=None,
152
131
  gender=None,
153
- firstname=shipper_firstname,
132
+ firstname=recipient_firstname,
154
133
  middlename=None,
155
- lastname=shipper_lastname,
156
- )
157
- if shipper.person_name
158
- else None
159
- ),
160
- senderAddress=lib.identity(
161
- hermes_req.ErAddressType(
162
- street=lib.text(shipper.street_name, max=50),
163
- houseNumber=lib.text(shipper.street_number, max=5) or "",
164
- zipCode=shipper.postal_code,
165
- town=lib.text(shipper.city, max=30),
166
- countryCode=shipper.country_code,
167
- addressAddition=lib.text(shipper.address_line2, max=50),
134
+ lastname=recipient_lastname,
135
+ ),
136
+ # Receiver address
137
+ receiverAddress=hermes_req.ErAddressType(
138
+ street=lib.text(recipient.street_name, max=50),
139
+ houseNumber=lib.text(recipient.street_number, max=5) or "",
140
+ zipCode=recipient.postal_code,
141
+ town=lib.text(recipient.city, max=30),
142
+ countryCode=recipient.country_code,
143
+ addressAddition=lib.text(recipient.address_line2, max=50),
168
144
  addressAddition2=None,
169
- addressAddition3=lib.text(shipper.company_name, max=20),
170
- )
171
- if shipper.street
172
- else None
173
- ),
174
- # Parcel details (weight in grams)
175
- parcel=hermes_req.ParcelType(
176
- parcelClass=None, # Optional, calculated from dimensions
177
- parcelHeight=lib.to_int(package.height.MM) if package.height else None,
178
- parcelWidth=lib.to_int(package.width.MM) if package.width else None,
179
- parcelDepth=lib.to_int(package.length.MM) if package.length else None,
180
- parcelWeight=lib.to_int(package.weight.G), # Weight in grams
181
- parcelVolume=None, # Optional
182
- productType=product_type,
183
- ),
184
- # Services
185
- service=service if any([
186
- getattr(service, attr) for attr in dir(service)
187
- if not attr.startswith('_') and getattr(service, attr) is not None
188
- ]) else None,
189
- # Customs for international
190
- customsAndTaxes=customs,
191
- )
192
-
193
- return lib.Serializable(request, lib.to_dict)
194
-
195
-
196
- def _build_service(options: units.ShippingOptions) -> hermes_req.ServiceType:
197
- """Build Hermes service object from shipping options."""
198
- # Cash on delivery
199
- cod_service = None
200
- if options.hermes_cod_amount.state:
201
- cod_service = hermes_req.CashOnDeliveryServiceType(
202
- amount=options.hermes_cod_amount.state,
203
- currency=options.hermes_cod_currency.state or "EUR",
204
- bankTransferAmount=options.hermes_cod_amount.state,
205
- bankTransferCurrency=options.hermes_cod_currency.state or "EUR",
145
+ addressAddition3=lib.text(recipient.company_name, max=20),
146
+ ),
147
+ # Receiver contact
148
+ receiverContact=lib.identity(
149
+ hermes_req.ReceiverContactType(
150
+ phone=lib.text(recipient.phone_number, max=20),
151
+ mobile=None,
152
+ mail=recipient.email,
153
+ )
154
+ if recipient.phone_number or recipient.email
155
+ else None
156
+ ),
157
+ # Sender (divergent sender if different from account default)
158
+ senderName=lib.identity(
159
+ hermes_req.ErNameType(
160
+ title=None,
161
+ gender=None,
162
+ firstname=shipper_firstname,
163
+ middlename=None,
164
+ lastname=shipper_lastname,
165
+ )
166
+ if shipper.person_name
167
+ else None
168
+ ),
169
+ senderAddress=lib.identity(
170
+ hermes_req.ErAddressType(
171
+ street=lib.text(shipper.street_name, max=50),
172
+ houseNumber=lib.text(shipper.street_number, max=5) or "",
173
+ zipCode=shipper.postal_code,
174
+ town=lib.text(shipper.city, max=30),
175
+ countryCode=shipper.country_code,
176
+ addressAddition=lib.text(shipper.address_line2, max=50),
177
+ addressAddition2=None,
178
+ addressAddition3=lib.text(shipper.company_name, max=20),
179
+ )
180
+ if shipper.street
181
+ else None
182
+ ),
183
+ # Parcel details (weight in grams)
184
+ parcel=hermes_req.ParcelType(
185
+ parcelClass=None,
186
+ parcelHeight=lib.to_int(package.height.MM),
187
+ parcelWidth=lib.to_int(package.width.MM),
188
+ parcelDepth=lib.to_int(package.length.MM),
189
+ parcelWeight=lib.to_int(package.weight.G),
190
+ parcelVolume=None,
191
+ productType=provider_units.PackagingType.map(
192
+ package.packaging_type or "your_packaging"
193
+ ).value,
194
+ ),
195
+ # Services - single tree instantiation with all options inline
196
+ service=lib.identity(
197
+ hermes_req.ServiceType(
198
+ tanService=options.hermes_tan_service.state,
199
+ # Multipart service for multi-piece shipments
200
+ multipartService=(
201
+ hermes_req.MultipartServiceType(
202
+ partNumber=index,
203
+ numberOfParts=number_of_parts,
204
+ parentShipmentOrderID=None, # Injected by proxy for parts 2+
205
+ )
206
+ if is_multi_piece
207
+ else (
208
+ hermes_req.MultipartServiceType(
209
+ partNumber=options.hermes_part_number.state or 1,
210
+ numberOfParts=options.hermes_number_of_parts.state,
211
+ parentShipmentOrderID=options.hermes_parent_shipment_order_id.state,
212
+ )
213
+ if options.hermes_number_of_parts.state
214
+ else None
215
+ )
216
+ ),
217
+ limitedQuantitiesService=options.hermes_limited_quantities.state,
218
+ # Cash on delivery service
219
+ cashOnDeliveryService=(
220
+ hermes_req.CashOnDeliveryServiceType(
221
+ amount=options.hermes_cod_amount.state,
222
+ currency=options.hermes_cod_currency.state or "EUR",
223
+ bankTransferAmount=options.hermes_cod_amount.state,
224
+ bankTransferCurrency=options.hermes_cod_currency.state or "EUR",
225
+ )
226
+ if options.hermes_cod_amount.state
227
+ else None
228
+ ),
229
+ bulkGoodService=options.hermes_bulk_goods.state,
230
+ # Stated time service
231
+ statedTimeService=(
232
+ hermes_req.StatedTimeServiceType(
233
+ timeSlot=options.hermes_time_slot.state,
234
+ )
235
+ if options.hermes_time_slot.state
236
+ else None
237
+ ),
238
+ householdSignatureService=options.hermes_household_signature.state,
239
+ # Customer alert service
240
+ customerAlertService=(
241
+ hermes_req.CustomerAlertServiceType(
242
+ notificationType=options.hermes_notification_type.state or "EMAIL",
243
+ notificationEmail=options.hermes_notification_email.state,
244
+ notificationNumber=None,
245
+ )
246
+ if options.hermes_notification_email.state
247
+ else None
248
+ ),
249
+ # Parcel shop delivery service
250
+ parcelShopDeliveryService=(
251
+ hermes_req.ParcelShopDeliveryServiceType(
252
+ psCustomerFirstName=options.hermes_parcel_shop_customer_firstname.state,
253
+ psCustomerLastName=options.hermes_parcel_shop_customer_lastname.state,
254
+ psID=options.hermes_parcel_shop_id.state,
255
+ psSelectionRule=options.hermes_parcel_shop_selection_rule.state or "SELECT_BY_ID",
256
+ )
257
+ if options.hermes_parcel_shop_id.state
258
+ else None
259
+ ),
260
+ compactParcelService=options.hermes_compact_parcel.state,
261
+ # Ident service
262
+ identService=(
263
+ hermes_req.IdentServiceType(
264
+ identID=options.hermes_ident_id.state,
265
+ identType=options.hermes_ident_type.state,
266
+ identVerifyFsk=options.hermes_ident_fsk.state,
267
+ identVerifyBirthday=options.hermes_ident_birthday.state,
268
+ )
269
+ if options.hermes_ident_fsk.state or options.hermes_ident_id.state
270
+ else None
271
+ ),
272
+ # Stated day service
273
+ statedDayService=(
274
+ hermes_req.StatedDayServiceType(
275
+ statedDay=options.hermes_stated_day.state,
276
+ )
277
+ if options.hermes_stated_day.state
278
+ else None
279
+ ),
280
+ nextDayService=options.hermes_next_day.state,
281
+ signatureService=options.hermes_signature.state,
282
+ redirectionProhibitedService=options.hermes_redirection_prohibited.state,
283
+ excludeParcelShopAuthorization=options.hermes_exclude_parcel_shop_auth.state,
284
+ lateInjectionService=options.hermes_late_injection.state,
285
+ )
286
+ if any([
287
+ options.hermes_tan_service.state,
288
+ is_multi_piece,
289
+ options.hermes_number_of_parts.state,
290
+ options.hermes_limited_quantities.state,
291
+ options.hermes_cod_amount.state,
292
+ options.hermes_bulk_goods.state,
293
+ options.hermes_time_slot.state,
294
+ options.hermes_household_signature.state,
295
+ options.hermes_notification_email.state,
296
+ options.hermes_parcel_shop_id.state,
297
+ options.hermes_compact_parcel.state,
298
+ options.hermes_ident_fsk.state,
299
+ options.hermes_ident_id.state,
300
+ options.hermes_stated_day.state,
301
+ options.hermes_next_day.state,
302
+ options.hermes_signature.state,
303
+ options.hermes_redirection_prohibited.state,
304
+ options.hermes_exclude_parcel_shop_auth.state,
305
+ options.hermes_late_injection.state,
306
+ ])
307
+ else None
308
+ ),
309
+ # Customs for international shipments - inline
310
+ customsAndTaxes=(
311
+ hermes_req.CustomsAndTaxesType(
312
+ currency=lib.identity(customs.duty.currency if customs.duty else "EUR"),
313
+ shipmentCost=None,
314
+ items=[
315
+ hermes_req.ItemType(
316
+ sku=item.sku,
317
+ category=None,
318
+ countryCodeOfManufacture=item.origin_country,
319
+ value=lib.to_int(item.value_amount * 100) if item.value_amount else None,
320
+ weight=lib.to_int(item.weight * 1000) if item.weight else None,
321
+ quantity=item.quantity or 1,
322
+ description=item.description or item.title,
323
+ exportDescription=None,
324
+ exportHsCode=None,
325
+ hsCode=item.hs_code,
326
+ url=None,
327
+ )
328
+ for item in (customs.commodities or [])
329
+ ] or None,
330
+ invoiceReferences=None,
331
+ value=None,
332
+ exportCustomsClearance=None,
333
+ client=None,
334
+ shipmentOriginAddress=lib.identity(
335
+ hermes_req.ShipmentOriginAddressType(
336
+ title=None,
337
+ firstname=shipper_firstname,
338
+ lastname=shipper_lastname,
339
+ company=shipper.company_name,
340
+ street=shipper.street_name,
341
+ houseNumber=shipper.street_number or "",
342
+ zipCode=shipper.postal_code,
343
+ town=shipper.city,
344
+ state=shipper.state_code,
345
+ countryCode=shipper.country_code,
346
+ addressAddition=shipper.address_line2,
347
+ addressAddition2=None,
348
+ addressAddition3=None,
349
+ phone=shipper.phone_number,
350
+ fax=None,
351
+ mobile=None,
352
+ mail=shipper.email,
353
+ )
354
+ if shipper
355
+ else None
356
+ ),
357
+ )
358
+ if customs
359
+ else None
360
+ ),
206
361
  )
207
-
208
- # Customer alert service
209
- alert_service = None
210
- if options.hermes_notification_email.state:
211
- alert_service = hermes_req.CustomerAlertServiceType(
212
- notificationType=options.hermes_notification_type.state or "EMAIL",
213
- notificationEmail=options.hermes_notification_email.state,
214
- notificationNumber=None,
215
- )
216
-
217
- # Ident service
218
- ident_service = None
219
- if options.hermes_ident_fsk.state or options.hermes_ident_id.state:
220
- ident_service = hermes_req.IdentServiceType(
221
- identID=options.hermes_ident_id.state,
222
- identType=options.hermes_ident_type.state,
223
- identVerifyFsk=options.hermes_ident_fsk.state,
224
- identVerifyBirthday=options.hermes_ident_birthday.state,
225
- )
226
-
227
- # Parcel shop delivery
228
- parcel_shop_service = None
229
- if options.hermes_parcel_shop_id.state:
230
- parcel_shop_service = hermes_req.ParcelShopDeliveryServiceType(
231
- psCustomerFirstName=options.hermes_parcel_shop_customer_firstname.state,
232
- psCustomerLastName=options.hermes_parcel_shop_customer_lastname.state,
233
- psID=options.hermes_parcel_shop_id.state,
234
- psSelectionRule=options.hermes_parcel_shop_selection_rule.state or "SELECT_BY_ID",
235
- )
236
-
237
- # Stated day service
238
- stated_day_service = None
239
- if options.hermes_stated_day.state:
240
- stated_day_service = hermes_req.StatedDayServiceType(
241
- statedDay=options.hermes_stated_day.state,
242
- )
243
-
244
- # Stated time service
245
- stated_time_service = None
246
- if options.hermes_time_slot.state:
247
- stated_time_service = hermes_req.StatedTimeServiceType(
248
- timeSlot=options.hermes_time_slot.state,
249
- )
250
-
251
- # Multipart service
252
- multipart_service = None
253
- if options.hermes_number_of_parts.state:
254
- multipart_service = hermes_req.MultipartServiceType(
255
- partNumber=options.hermes_part_number.state or 1,
256
- numberOfParts=options.hermes_number_of_parts.state,
257
- parentShipmentOrderID=options.hermes_parent_shipment_order_id.state,
258
- )
259
-
260
- return hermes_req.ServiceType(
261
- tanService=options.hermes_tan_service.state,
262
- multipartService=multipart_service,
263
- limitedQuantitiesService=options.hermes_limited_quantities.state,
264
- cashOnDeliveryService=cod_service,
265
- bulkGoodService=options.hermes_bulk_goods.state,
266
- statedTimeService=stated_time_service,
267
- householdSignatureService=options.hermes_household_signature.state,
268
- customerAlertService=alert_service,
269
- parcelShopDeliveryService=parcel_shop_service,
270
- compactParcelService=options.hermes_compact_parcel.state,
271
- identService=ident_service,
272
- statedDayService=stated_day_service,
273
- nextDayService=options.hermes_next_day.state,
274
- signatureService=options.hermes_signature.state,
275
- redirectionProhibitedService=options.hermes_redirection_prohibited.state,
276
- excludeParcelShopAuthorization=options.hermes_exclude_parcel_shop_auth.state,
277
- lateInjectionService=options.hermes_late_injection.state,
278
- )
279
-
280
-
281
- def _build_customs(
282
- customs: models.Customs,
283
- shipper,
284
- ) -> hermes_req.CustomsAndTaxesType:
285
- """Build customs and taxes for international shipments."""
286
- items = [
287
- hermes_req.ItemType(
288
- sku=item.sku,
289
- category=None,
290
- countryCodeOfManufacture=item.origin_country,
291
- value=lib.to_int(item.value_amount * 100) if item.value_amount else None, # In cents
292
- weight=lib.to_int(item.weight * 1000) if item.weight else None, # In grams
293
- quantity=item.quantity or 1,
294
- description=item.description or item.title,
295
- exportDescription=None,
296
- exportHsCode=None,
297
- hsCode=item.hs_code,
298
- url=None,
299
- )
300
- for item in customs.commodities or []
362
+ for index, package in enumerate(packages, start=1)
301
363
  ]
302
364
 
303
- shipper_firstname, shipper_lastname = _split_name(shipper.person_name) if shipper else (None, None)
304
-
305
- return hermes_req.CustomsAndTaxesType(
306
- currency=lib.identity(customs.duty.currency if customs.duty else "EUR"),
307
- shipmentCost=None,
308
- items=items or None,
309
- invoiceReferences=None,
310
- value=None,
311
- exportCustomsClearance=None,
312
- client=None,
313
- shipmentOriginAddress=lib.identity(
314
- hermes_req.ShipmentOriginAddressType(
315
- title=None,
316
- firstname=shipper_firstname,
317
- lastname=shipper_lastname,
318
- company=shipper.company_name,
319
- street=shipper.street_name,
320
- houseNumber=shipper.street_number or "",
321
- zipCode=shipper.postal_code,
322
- town=shipper.city,
323
- state=shipper.state_code,
324
- countryCode=shipper.country_code,
325
- addressAddition=shipper.address_line2,
326
- addressAddition2=None,
327
- addressAddition3=None,
328
- phone=shipper.phone_number,
329
- fax=None,
330
- mobile=None,
331
- mail=shipper.email,
332
- )
333
- if shipper
334
- else None
335
- ),
365
+ return lib.Serializable(
366
+ requests,
367
+ lambda reqs: [lib.to_dict(req) for req in reqs],
368
+ dict(is_multi_piece=is_multi_piece),
336
369
  )