karrio-hermes 2026.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,336 @@
1
+ """Karrio Hermes shipment API implementation."""
2
+
3
+ import typing
4
+ import karrio.lib as lib
5
+ import karrio.core.units as units
6
+ import karrio.core.models as models
7
+ import karrio.providers.hermes.error as error
8
+ import karrio.providers.hermes.utils as provider_utils
9
+ import karrio.providers.hermes.units as provider_units
10
+ import karrio.schemas.hermes.shipment_request as hermes_req
11
+ import karrio.schemas.hermes.shipment_response as hermes_res
12
+
13
+
14
+ def _split_name(name: typing.Optional[str]) -> typing.Tuple[str, str]:
15
+ """Split full name into firstname and lastname for Hermes API."""
16
+ if not name:
17
+ return (None, None)
18
+ parts = name.split()
19
+ firstname = parts[0] if parts else None
20
+ lastname = " ".join(parts[1:]) if len(parts) > 1 else firstname
21
+ return (firstname, lastname)
22
+
23
+
24
+ def parse_shipment_response(
25
+ _response: lib.Deserializable[dict],
26
+ settings: provider_utils.Settings,
27
+ ) -> 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)
31
+
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)
37
+
38
+ return shipment, messages
39
+
40
+
41
+ def _extract_details(
42
+ data: dict,
43
+ settings: provider_utils.Settings,
44
+ ) -> models.ShipmentDetails:
45
+ """Extract shipment details from Hermes response."""
46
+ response = lib.to_object(hermes_res.ShipmentResponseType, data)
47
+
48
+ # Hermes uses shipmentID as tracking number (14 or 20 characters)
49
+ tracking_number = response.shipmentID or ""
50
+ shipment_order_id = response.shipmentOrderID or ""
51
+
52
+ # Label is returned as base64 encoded image
53
+ label_image = response.labelImage or ""
54
+
55
+ # Commercial invoice for international shipments
56
+ invoice_image = response.commInvoiceImage or ""
57
+
58
+ # Label media type
59
+ label_type = response.labelMediatype or "PDF"
60
+
61
+ documents = models.Documents(label=label_image)
62
+ if invoice_image:
63
+ documents.invoice = invoice_image
64
+
65
+ return models.ShipmentDetails(
66
+ carrier_id=settings.carrier_id,
67
+ carrier_name=settings.carrier_name,
68
+ tracking_number=tracking_number,
69
+ shipment_identifier=shipment_order_id,
70
+ label_type=label_type,
71
+ docs=documents,
72
+ meta=dict(
73
+ shipment_id=tracking_number,
74
+ shipment_order_id=shipment_order_id,
75
+ ),
76
+ )
77
+
78
+
79
+ def shipment_request(
80
+ payload: models.ShipmentRequest,
81
+ settings: provider_utils.Settings,
82
+ ) -> lib.Serializable:
83
+ """Create a Hermes shipment request."""
84
+ shipper = lib.to_address(payload.shipper)
85
+ recipient = lib.to_address(payload.recipient)
86
+ packages = lib.to_packages(payload.parcels)
87
+ package = packages.single # Hermes handles one parcel per request
88
+ options = lib.to_shipping_options(
89
+ payload.options,
90
+ package_options=packages.options,
91
+ initializer=provider_units.shipping_options_initializer,
92
+ )
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)
106
+
107
+ # Split names for Hermes API
108
+ recipient_firstname, recipient_lastname = _split_name(recipient.person_name)
109
+ shipper_firstname, shipper_lastname = _split_name(shipper.person_name)
110
+
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(
151
+ title=None,
152
+ gender=None,
153
+ firstname=shipper_firstname,
154
+ 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),
168
+ 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",
206
+ )
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 []
301
+ ]
302
+
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
+ ),
336
+ )
@@ -0,0 +1,242 @@
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
+ shipping_options = lib.OptionEnum("shipping_options", list)
12
+ shipping_services = lib.OptionEnum("shipping_services", list)
13
+ label_type = lib.OptionEnum("label_type", str, "PDF")
14
+ language = lib.OptionEnum("language", str, "DE") # DE or EN
15
+
16
+
17
+ class ParcelClass(lib.StrEnum):
18
+ """Hermes parcel size classes."""
19
+
20
+ XS = "XS"
21
+ S = "S"
22
+ M = "M"
23
+ L = "L"
24
+ XL = "XL"
25
+
26
+
27
+ class ProductType(lib.StrEnum):
28
+ """Hermes product types."""
29
+
30
+ BAG = "BAG"
31
+ BIKE = "BIKE"
32
+ LARGE_ITEM = "LARGE_ITEM"
33
+ PARCEL = "PARCEL"
34
+
35
+
36
+ class PackagingType(lib.StrEnum):
37
+ """Carrier specific packaging type."""
38
+
39
+ hermes_parcel = "PARCEL"
40
+ hermes_bag = "BAG"
41
+ hermes_bike = "BIKE"
42
+ hermes_large_item = "LARGE_ITEM"
43
+
44
+ """Unified Packaging type mapping."""
45
+ envelope = hermes_parcel
46
+ pak = hermes_parcel
47
+ tube = hermes_parcel
48
+ pallet = hermes_large_item
49
+ small_box = hermes_parcel
50
+ medium_box = hermes_parcel
51
+ your_packaging = hermes_parcel
52
+
53
+
54
+ class ShippingService(lib.StrEnum):
55
+ """Carrier specific services."""
56
+
57
+ hermes_standard = "hermes_standard"
58
+ hermes_next_day = "hermes_next_day"
59
+ hermes_stated_day = "hermes_stated_day"
60
+ hermes_parcel_shop = "hermes_parcel_shop"
61
+ hermes_international = "hermes_international"
62
+
63
+
64
+ class ShippingOption(lib.Enum):
65
+ """Carrier specific options."""
66
+
67
+ # Hermes services as options
68
+ hermes_tan_service = lib.OptionEnum("tanService", bool)
69
+ hermes_limited_quantities = lib.OptionEnum("limitedQuantitiesService", bool)
70
+ hermes_bulk_goods = lib.OptionEnum("bulkGoodService", bool)
71
+ hermes_household_signature = lib.OptionEnum("householdSignatureService", bool)
72
+ hermes_compact_parcel = lib.OptionEnum("compactParcelService", bool)
73
+ hermes_next_day = lib.OptionEnum("nextDayService", bool)
74
+ hermes_signature = lib.OptionEnum("signatureService", bool)
75
+ hermes_redirection_prohibited = lib.OptionEnum("redirectionProhibitedService", bool)
76
+ hermes_exclude_parcel_shop_auth = lib.OptionEnum("excludeParcelShopAuthorization", bool)
77
+ hermes_late_injection = lib.OptionEnum("lateInjectionService", bool)
78
+
79
+ # Cash on delivery
80
+ hermes_cod_amount = lib.OptionEnum("codAmount", float)
81
+ hermes_cod_currency = lib.OptionEnum("codCurrency", str)
82
+
83
+ # Customer alert service
84
+ hermes_notification_email = lib.OptionEnum("notificationEmail", str)
85
+ hermes_notification_type = lib.OptionEnum("notificationType", str) # EMAIL, SMS, EMAIL_SMS
86
+
87
+ # Stated day service
88
+ hermes_stated_day = lib.OptionEnum("statedDay", str) # YYYY-MM-DD format
89
+
90
+ # Stated time service
91
+ hermes_time_slot = lib.OptionEnum("timeSlot", str) # FORENOON, NOON, AFTERNOON, EVENING
92
+
93
+ # Ident service
94
+ hermes_ident_id = lib.OptionEnum("identID", str)
95
+ hermes_ident_type = lib.OptionEnum("identType", str) # GERMAN_IDENTITY_CARD, etc.
96
+ hermes_ident_fsk = lib.OptionEnum("identVerifyFsk", str) # 18
97
+ hermes_ident_birthday = lib.OptionEnum("identVerifyBirthday", str) # YYYY-MM-DD
98
+
99
+ # Parcel shop delivery
100
+ hermes_parcel_shop_id = lib.OptionEnum("psID", str)
101
+ hermes_parcel_shop_selection_rule = lib.OptionEnum("psSelectionRule", str) # SELECT_BY_ID, SELECT_BY_RECEIVER_ADDRESS
102
+ hermes_parcel_shop_customer_firstname = lib.OptionEnum("psCustomerFirstName", str)
103
+ hermes_parcel_shop_customer_lastname = lib.OptionEnum("psCustomerLastName", str)
104
+
105
+ # Multipart service
106
+ hermes_part_number = lib.OptionEnum("partNumber", int)
107
+ hermes_number_of_parts = lib.OptionEnum("numberOfParts", int)
108
+ hermes_parent_shipment_order_id = lib.OptionEnum("parentShipmentOrderID", str)
109
+
110
+ """Unified Option type mapping."""
111
+ signature_required = hermes_signature
112
+ cash_on_delivery = hermes_cod_amount
113
+
114
+
115
+ def shipping_options_initializer(
116
+ options: dict,
117
+ package_options: units.ShippingOptions = None,
118
+ ) -> units.ShippingOptions:
119
+ """Apply default values to the given options."""
120
+
121
+ if package_options is not None:
122
+ options.update(package_options.content)
123
+
124
+ def items_filter(key: str) -> bool:
125
+ return key in ShippingOption # type: ignore
126
+
127
+ return units.ShippingOptions(options, ShippingOption, items_filter=items_filter)
128
+
129
+
130
+ class TrackingStatus(lib.Enum):
131
+ """Hermes tracking status mapping."""
132
+
133
+ on_hold = ["on_hold"]
134
+ delivered = ["delivered"]
135
+ in_transit = ["in_transit"]
136
+ delivery_failed = ["delivery_failed"]
137
+ delivery_delayed = ["delivery_delayed"]
138
+ out_for_delivery = ["out_for_delivery"]
139
+ ready_for_pickup = ["ready_for_pickup"]
140
+
141
+
142
+ class LabelType(lib.StrEnum):
143
+ """Hermes label formats - use +json variants for JSON response with base64 label."""
144
+
145
+ PDF = "application/shippinglabel-pdf+json"
146
+ ZPL = "application/shippinglabel-zpl+json;dpi=300"
147
+ PNG = "application/shippinglabel-data+json"
148
+
149
+
150
+ class PickupTimeSlot(lib.StrEnum):
151
+ """Hermes pickup time slots per OpenAPI spec."""
152
+
153
+ BETWEEN_10_AND_13 = "BETWEEN_10_AND_13"
154
+ BETWEEN_12_AND_15 = "BETWEEN_12_AND_15"
155
+ BETWEEN_14_AND_17 = "BETWEEN_14_AND_17"
156
+
157
+
158
+ def load_services_from_csv() -> list:
159
+ """
160
+ Load service definitions from CSV file.
161
+ 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
162
+ """
163
+ csv_path = pathlib.Path(__file__).resolve().parent / "services.csv"
164
+
165
+ if not csv_path.exists():
166
+ # Fallback to simple default if CSV doesn't exist
167
+ return [
168
+ models.ServiceLevel(
169
+ service_name="Hermes Standard",
170
+ service_code="hermes_standard",
171
+ currency="EUR",
172
+ domicile=True,
173
+ zones=[models.ServiceZone(rate=0.0)],
174
+ )
175
+ ]
176
+
177
+ # Group zones by service
178
+ services_dict: dict[str, dict] = {}
179
+
180
+ with open(csv_path, "r", encoding="utf-8") as f:
181
+ reader = csv.DictReader(f)
182
+ for row in reader:
183
+ service_code = row["service_code"]
184
+ service_name = row["service_name"]
185
+
186
+ # Map carrier service code to karrio service code
187
+ karrio_service_code = ShippingService.map(service_code).name_or_key
188
+
189
+ # Initialize service if not exists
190
+ if karrio_service_code not in services_dict:
191
+ services_dict[karrio_service_code] = {
192
+ "service_name": service_name,
193
+ "service_code": karrio_service_code,
194
+ "currency": row.get("currency", "EUR"),
195
+ "min_weight": (
196
+ float(row["min_weight"]) if row.get("min_weight") else None
197
+ ),
198
+ "max_weight": (
199
+ float(row["max_weight"]) if row.get("max_weight") else None
200
+ ),
201
+ "max_length": (
202
+ float(row["max_length"]) if row.get("max_length") else None
203
+ ),
204
+ "max_width": (
205
+ float(row["max_width"]) if row.get("max_width") else None
206
+ ),
207
+ "max_height": (
208
+ float(row["max_height"]) if row.get("max_height") else None
209
+ ),
210
+ "weight_unit": "KG",
211
+ "dimension_unit": "CM",
212
+ "domicile": row.get("domicile", "").lower() == "true",
213
+ "international": (
214
+ True if row.get("international", "").lower() == "true" else None
215
+ ),
216
+ "zones": [],
217
+ }
218
+
219
+ # Parse country codes
220
+ country_codes = [
221
+ c.strip() for c in row.get("country_codes", "").split(",") if c.strip()
222
+ ]
223
+
224
+ # Create zone
225
+ zone = models.ServiceZone(
226
+ label=row.get("zone_label", "Default Zone"),
227
+ rate=float(row.get("rate", 0.0)),
228
+ transit_days=(
229
+ int(row["transit_days"].split("-")[0]) if row.get("transit_days") and row["transit_days"].split("-")[0].isdigit() else None
230
+ ),
231
+ country_codes=country_codes if country_codes else None,
232
+ )
233
+
234
+ services_dict[karrio_service_code]["zones"].append(zone)
235
+
236
+ # Convert to ServiceLevel objects
237
+ return [
238
+ models.ServiceLevel(**service_data) for service_data in services_dict.values()
239
+ ]
240
+
241
+
242
+ DEFAULT_SERVICES = load_services_from_csv()
@@ -0,0 +1,102 @@
1
+ import datetime
2
+ import karrio.lib as lib
3
+ import karrio.core as core
4
+ import karrio.core.errors as errors
5
+
6
+
7
+ class Settings(core.Settings):
8
+ """Hermes connection settings."""
9
+
10
+ # OAuth2 credentials (password flow)
11
+ username: str
12
+ password: str
13
+ client_id: str
14
+ client_secret: str
15
+
16
+ @property
17
+ def carrier_name(self):
18
+ return "hermes"
19
+
20
+ @property
21
+ def server_url(self):
22
+ return (
23
+ "https://de-api-int.hermesworld.com/services/hsi"
24
+ if self.test_mode
25
+ else "https://de-api.hermesworld.com/services/hsi"
26
+ )
27
+
28
+ @property
29
+ def token_url(self):
30
+ return (
31
+ "https://authme-int.myhermes.de/authorization-facade/oauth2/access_token"
32
+ if self.test_mode
33
+ else "https://authme.myhermes.de/authorization-facade/oauth2/access_token"
34
+ )
35
+
36
+ @property
37
+ def connection_config(self) -> lib.units.Options:
38
+ from karrio.providers.hermes.units import ConnectionConfig
39
+
40
+ return lib.to_connection_config(
41
+ self.config or {},
42
+ option_type=ConnectionConfig,
43
+ )
44
+
45
+ @property
46
+ def access_token(self):
47
+ """Retrieve the access_token using the username|password pair
48
+ or collect it from the cache if an unexpired access_token exists.
49
+ """
50
+ cache_key = f"{self.carrier_name}|{self.username}|{self.client_id}"
51
+
52
+ return self.connection_cache.thread_safe(
53
+ refresh_func=lambda: login(self),
54
+ cache_key=cache_key,
55
+ buffer_minutes=5,
56
+ ).get_state()
57
+
58
+
59
+ def login(settings: Settings):
60
+ """Authenticate with Hermes OAuth2 password flow."""
61
+ import karrio.providers.hermes.error as error
62
+ import karrio.core.models as models
63
+
64
+ result = lib.request(
65
+ url=settings.token_url,
66
+ trace=settings.trace_as("json"),
67
+ method="POST",
68
+ headers={
69
+ "Content-Type": "application/x-www-form-urlencoded",
70
+ },
71
+ data=lib.to_query_string({
72
+ "grant_type": "password",
73
+ "username": settings.username,
74
+ "password": settings.password,
75
+ "client_id": settings.client_id,
76
+ "client_secret": settings.client_secret,
77
+ }),
78
+ )
79
+ response = lib.to_dict(result)
80
+
81
+ # Handle case where response is not a dict
82
+ if not isinstance(response, dict):
83
+ raise errors.ParsedMessagesError(
84
+ messages=[
85
+ models.Message(
86
+ carrier_id=settings.carrier_id,
87
+ carrier_name=settings.carrier_name,
88
+ code="AUTH_ERROR",
89
+ message=f"Authentication failed - unexpected response: {str(response)[:200]}",
90
+ )
91
+ ]
92
+ )
93
+
94
+ messages = error.parse_error_response(response, settings)
95
+
96
+ if any(messages):
97
+ raise errors.ParsedMessagesError(messages=messages)
98
+
99
+ expiry = datetime.datetime.now() + datetime.timedelta(
100
+ seconds=float(response.get("expires_in", 3600))
101
+ )
102
+ return {**response, "expiry": lib.fdatetime(expiry)}