woltapi 0.0.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.
woltapi/__init__.py ADDED
@@ -0,0 +1,90 @@
1
+ """Synchronous selection and explicit purchase client for observed Wolt hosts."""
2
+
3
+ from .client import WoltClient
4
+ from .credentials import SessionCredentials
5
+ from .errors import (
6
+ DuplicatePurchaseAttempt,
7
+ HTTPStatusError,
8
+ OrderOutcomeUnknown,
9
+ PurchaseAttemptStoreError,
10
+ PurchaseAuthorizationError,
11
+ PurchasePreparationError,
12
+ RequestFailedError,
13
+ RequestTimeoutError,
14
+ ResponseDecodeError,
15
+ ResponseShapeError,
16
+ SelectionError,
17
+ UnsupportedSelectionError,
18
+ WoltApiError,
19
+ WoltTransportError,
20
+ )
21
+ from .models import DeliveryTarget, OrderStatus, PaymentMethod, Venue
22
+ from .purchase import (
23
+ PurchaseAttemptStore,
24
+ PurchaseAuthorization,
25
+ PurchaseConfirmationSummary,
26
+ PurchaseContext,
27
+ PurchaseLineSummary,
28
+ PurchaseNameSummary,
29
+ PurchaseOptionSummary,
30
+ PurchaseOptionValueSummary,
31
+ PurchaseResult,
32
+ PreparedOrder,
33
+ authorize_prepared_order,
34
+ )
35
+ from .selection import (
36
+ DeliverySelection,
37
+ ItemSelection,
38
+ OptionSelection,
39
+ OptionValueSelection,
40
+ OrderSelection,
41
+ PostCheckoutConfig,
42
+ QuoteSnapshot,
43
+ SavedBasket,
44
+ VenueCheckoutContext,
45
+ )
46
+ from .transport import WoltTransport
47
+
48
+ __all__ = [
49
+ "DeliveryTarget",
50
+ "DeliverySelection",
51
+ "DuplicatePurchaseAttempt",
52
+ "HTTPStatusError",
53
+ "ItemSelection",
54
+ "OptionSelection",
55
+ "OptionValueSelection",
56
+ "OrderOutcomeUnknown",
57
+ "OrderStatus",
58
+ "OrderSelection",
59
+ "PaymentMethod",
60
+ "PostCheckoutConfig",
61
+ "PreparedOrder",
62
+ "PurchaseAttemptStore",
63
+ "PurchaseAttemptStoreError",
64
+ "PurchaseAuthorization",
65
+ "PurchaseAuthorizationError",
66
+ "PurchaseConfirmationSummary",
67
+ "PurchaseContext",
68
+ "PurchaseLineSummary",
69
+ "PurchaseNameSummary",
70
+ "PurchaseOptionSummary",
71
+ "PurchaseOptionValueSummary",
72
+ "PurchasePreparationError",
73
+ "PurchaseResult",
74
+ "QuoteSnapshot",
75
+ "RequestFailedError",
76
+ "RequestTimeoutError",
77
+ "ResponseDecodeError",
78
+ "ResponseShapeError",
79
+ "SavedBasket",
80
+ "SelectionError",
81
+ "SessionCredentials",
82
+ "UnsupportedSelectionError",
83
+ "Venue",
84
+ "VenueCheckoutContext",
85
+ "WoltApiError",
86
+ "WoltClient",
87
+ "WoltTransport",
88
+ "WoltTransportError",
89
+ "authorize_prepared_order",
90
+ ]
woltapi/client.py ADDED
@@ -0,0 +1,565 @@
1
+ """Synchronous Wolt discovery, selection, quote, and purchase client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ from collections.abc import Mapping, Sequence
9
+ from typing import Any
10
+ from urllib.parse import quote
11
+
12
+ from .credentials import SessionCredentials
13
+ from .errors import ResponseShapeError, SelectionError
14
+ from .models import DeliveryTarget, OrderStatus, PaymentMethod, Venue
15
+ from .purchase import (
16
+ PurchaseAttemptStore,
17
+ PurchaseAuthorization,
18
+ PurchaseContext,
19
+ PurchaseResult,
20
+ PreparedOrder,
21
+ _prepare_purchase_order,
22
+ _submit_prepared_order,
23
+ )
24
+ from .selection import (
25
+ DeliverySelection,
26
+ ItemSelection,
27
+ OrderSelection,
28
+ PostCheckoutConfig,
29
+ QuoteSnapshot,
30
+ SavedBasket,
31
+ VenueCheckoutContext,
32
+ )
33
+ from .services import ServiceHost
34
+ from .transport import DEFAULT_TIMEOUT_SECONDS, WoltTransport
35
+
36
+
37
+ class WoltClient:
38
+ """Read discovery data and make explicit selection and purchase operations.
39
+
40
+ Purchase submission requires a caller-created authorization and an explicit,
41
+ durable attempt store. The class has no login, refresh, asynchronous,
42
+ WebSocket, card-enrollment, challenge, cancellation, refund, or autonomous
43
+ ordering methods. Local selections are immutable; a changed selection must
44
+ be rebuilt and cannot silently reuse an earlier quote snapshot.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ credentials: SessionCredentials,
50
+ *,
51
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
52
+ _transport: WoltTransport | None = None,
53
+ ) -> None:
54
+ if not isinstance(credentials, SessionCredentials):
55
+ raise TypeError("credentials must be a SessionCredentials instance.")
56
+ if _transport is not None and not isinstance(_transport, WoltTransport):
57
+ raise TypeError("_transport must be a WoltTransport instance.")
58
+ self._transport = _transport or WoltTransport(credentials, timeout=timeout)
59
+ self._delivery_target_ids: set[str] = set()
60
+ self._payment_eligibility_by_id: dict[str, str] = {}
61
+
62
+ def search_venues(
63
+ self, query: str, latitude: int | float, longitude: int | float
64
+ ) -> tuple[Venue, ...]:
65
+ """Search venues and extract results with a usable ID and slug."""
66
+
67
+ query = _required_text(query, "query")
68
+ latitude = _coordinate(latitude, "latitude")
69
+ longitude = _coordinate(longitude, "longitude")
70
+ response = self._transport.request(
71
+ ServiceHost.RESTAURANT,
72
+ "POST",
73
+ "/v1/pages/search",
74
+ json_body={"q": query, "target": None, "lat": latitude, "lon": longitude},
75
+ )
76
+ sections = _required_list(response, "sections", ServiceHost.RESTAURANT)
77
+
78
+ venues: list[Venue] = []
79
+ for section in sections:
80
+ if not isinstance(section, Mapping):
81
+ continue
82
+ items = section.get("items")
83
+ if not isinstance(items, Sequence) or isinstance(items, (str, bytes)):
84
+ continue
85
+ for item in items:
86
+ if not isinstance(item, Mapping):
87
+ continue
88
+ venue = item.get("venue")
89
+ if not isinstance(venue, Mapping):
90
+ continue
91
+ venue_id = venue.get("id")
92
+ venue_slug = venue.get("slug")
93
+ if not _is_nonempty_string(venue_id) or not _is_nonempty_string(
94
+ venue_slug
95
+ ):
96
+ continue
97
+ venues.append(
98
+ Venue(
99
+ id=venue_id,
100
+ slug=venue_slug,
101
+ title=_optional_string(item.get("title")),
102
+ currency=_optional_string(venue.get("currency")),
103
+ delivers=_optional_bool(venue.get("delivers")),
104
+ online=_optional_bool(venue.get("online")),
105
+ )
106
+ )
107
+ return tuple(venues)
108
+
109
+ def get_orders_page(self) -> dict[str, Any]:
110
+ """Read the current order-history page in server-provided order.
111
+
112
+ The detached page can contain private order data. Do not log it raw.
113
+ """
114
+ return self._transport.request(
115
+ ServiceHost.CONSUMER,
116
+ "GET",
117
+ "/order-xp/web/v1/pages/orders",
118
+ )
119
+
120
+ def get_venue_static(self, venue_slug: str) -> dict[str, Any]:
121
+ """Read the static venue page for a slug."""
122
+
123
+ slug = _path_segment(venue_slug, "venue_slug")
124
+ return self._transport.request(
125
+ ServiceHost.CONSUMER,
126
+ "GET",
127
+ f"/order-xp/web/v1/pages/venue/slug/{slug}/static",
128
+ )
129
+
130
+ def get_venue_dynamic(
131
+ self,
132
+ venue_slug: str,
133
+ latitude: int | float,
134
+ longitude: int | float,
135
+ *,
136
+ selected_delivery_method: str = "homedelivery",
137
+ ) -> dict[str, Any]:
138
+ """Read the location-aware venue page, preserving its trailing slash."""
139
+
140
+ slug = _path_segment(venue_slug, "venue_slug")
141
+ latitude = _coordinate(latitude, "latitude")
142
+ longitude = _coordinate(longitude, "longitude")
143
+ selected_delivery_method = _required_text(
144
+ selected_delivery_method, "selected_delivery_method"
145
+ )
146
+ return self._transport.request(
147
+ ServiceHost.CONSUMER,
148
+ "GET",
149
+ f"/order-xp/web/v1/venue/slug/{slug}/dynamic/",
150
+ query={
151
+ "lat": latitude,
152
+ "lon": longitude,
153
+ "selected_delivery_method": selected_delivery_method,
154
+ },
155
+ )
156
+
157
+ def get_venue_content(self, venue_slug: str) -> dict[str, Any]:
158
+ """Read the server-driven venue-content document for a slug."""
159
+
160
+ slug = _path_segment(venue_slug, "venue_slug")
161
+ return self._transport.request(
162
+ ServiceHost.CONSUMER,
163
+ "GET",
164
+ f"/consumer-api/venue-content-api/v3/web/venue-content/slug/{slug}",
165
+ )
166
+
167
+ def get_assortment(self, venue_slug: str) -> dict[str, Any]:
168
+ """Read the normalized assortment without dropping catalog fields."""
169
+
170
+ slug = _path_segment(venue_slug, "venue_slug")
171
+ return self._transport.request(
172
+ ServiceHost.CONSUMER,
173
+ "GET",
174
+ f"/consumer-api/consumer-assortment/v1/venues/slug/{slug}/assortment",
175
+ )
176
+
177
+ def get_item(
178
+ self, venue_id: str, menu_item_id: str, *, language: str
179
+ ) -> dict[str, Any]:
180
+ """Read an item-detail page without transforming its catalog data."""
181
+
182
+ venue_id = _path_segment(venue_id, "venue_id")
183
+ menu_item_id = _path_segment(menu_item_id, "menu_item_id")
184
+ language = _required_text(language, "language")
185
+ return self._transport.request(
186
+ ServiceHost.CONSUMER,
187
+ "GET",
188
+ f"/order-xp/web/v1/pages/venue/{venue_id}/item/{menu_item_id}",
189
+ query={"language": language},
190
+ )
191
+
192
+ def list_delivery_targets(self) -> tuple[DeliveryTarget, ...]:
193
+ """List opaque saved delivery IDs without exposing address details."""
194
+
195
+ self._delivery_target_ids = set()
196
+ response = self._transport.request(
197
+ ServiceHost.RESTAURANT,
198
+ "GET",
199
+ "/v2/delivery/info",
200
+ )
201
+ results = _required_list(response, "results", ServiceHost.RESTAURANT)
202
+
203
+ targets: list[DeliveryTarget] = []
204
+ target_ids: set[str] = set()
205
+ for result in results:
206
+ if not isinstance(result, Mapping):
207
+ continue
208
+ target_id = result.get("id")
209
+ if not _is_nonempty_string(target_id):
210
+ continue
211
+ targets.append(DeliveryTarget(id=target_id))
212
+ target_ids.add(target_id)
213
+ self._delivery_target_ids = target_ids
214
+ return tuple(targets)
215
+
216
+ def get_payment_methods(
217
+ self, context: Mapping[str, Any]
218
+ ) -> tuple[PaymentMethod, ...]:
219
+ """Return enabled saved-card references from a payment element tree."""
220
+
221
+ if not isinstance(context, Mapping):
222
+ raise TypeError("context must be a mapping.")
223
+ eligibility_binding = _payment_eligibility_binding(context)
224
+ self._payment_eligibility_by_id = {}
225
+ response = self._transport.request(
226
+ ServiceHost.PAYMENT,
227
+ "POST",
228
+ "/v1/payment-methods/checkout",
229
+ json_body=dict(context),
230
+ )
231
+ root = response.get("root_element")
232
+ if not isinstance(root, Mapping):
233
+ raise ResponseShapeError(ServiceHost.PAYMENT.value)
234
+
235
+ methods: list[PaymentMethod] = []
236
+ eligibility_by_id: dict[str, str] = {}
237
+ stack: list[Mapping[str, Any]] = [root]
238
+ while stack:
239
+ node = stack.pop()
240
+ children = node.get("children")
241
+ if isinstance(children, Sequence) and not isinstance(
242
+ children, (str, bytes)
243
+ ):
244
+ stack.extend(
245
+ child for child in reversed(children) if isinstance(child, Mapping)
246
+ )
247
+
248
+ if (
249
+ node.get("element_type") != "payment-method"
250
+ or node.get("is_enabled") is not True
251
+ ):
252
+ continue
253
+ method = node.get("method")
254
+ if not isinstance(method, Mapping):
255
+ continue
256
+ method_id = method.get("id")
257
+ method_type = method.get("type")
258
+ if not _is_nonempty_string(method_id) or method_type != "card":
259
+ continue
260
+ methods.append(
261
+ PaymentMethod(
262
+ id=method_id,
263
+ type=method_type,
264
+ is_selected=node.get("is_selected") is True,
265
+ is_default=node.get("is_default") is True,
266
+ )
267
+ )
268
+ eligibility_by_id[method_id] = eligibility_binding
269
+ self._payment_eligibility_by_id = eligibility_by_id
270
+ return tuple(methods)
271
+
272
+ def create_selection(
273
+ self,
274
+ assortment: Mapping[str, Any],
275
+ *,
276
+ venue: VenueCheckoutContext,
277
+ delivery: DeliverySelection,
278
+ payment_method: Mapping[str, Any],
279
+ courier_tip: int,
280
+ items: Sequence[ItemSelection],
281
+ ) -> OrderSelection:
282
+ """Build an immutable local selection from current catalog data.
283
+
284
+ A caller must first discover the saved delivery target and enabled card
285
+ through this client. The supplied payment mapping is copied exactly;
286
+ this client does not derive checkout-card fields from payment UI data.
287
+ """
288
+
289
+ if not isinstance(delivery, DeliverySelection):
290
+ raise TypeError("delivery must be a DeliverySelection instance.")
291
+ if delivery.delivery_info_id not in self._delivery_target_ids:
292
+ raise SelectionError("The saved delivery reference is not current.")
293
+ if not isinstance(payment_method, Mapping):
294
+ raise TypeError("payment_method must be a mapping.")
295
+ payment_method_id = payment_method.get("id")
296
+ if (
297
+ not _is_nonempty_string(payment_method_id)
298
+ or payment_method_id not in self._payment_eligibility_by_id
299
+ ):
300
+ raise SelectionError("The saved card reference is not current.")
301
+ selection = OrderSelection._from_assortment(
302
+ assortment,
303
+ venue=venue,
304
+ delivery=delivery,
305
+ payment_method=payment_method,
306
+ courier_tip=courier_tip,
307
+ items=items,
308
+ )
309
+ if self._payment_eligibility_by_id[
310
+ payment_method_id
311
+ ] != _payment_eligibility_binding(selection._payment_eligibility_context()):
312
+ raise SelectionError(
313
+ "The saved card eligibility does not match this selection."
314
+ )
315
+ return selection
316
+
317
+ def save_basket(self, selection: OrderSelection) -> SavedBasket:
318
+ """Persist a basket explicitly; this mutation does not place an order."""
319
+
320
+ selection = _order_selection(selection)
321
+ response = self._transport.request(
322
+ ServiceHost.CONSUMER,
323
+ "POST",
324
+ "/order-xp/v1/baskets",
325
+ json_body=selection.to_basket_payload(),
326
+ )
327
+ basket_id = response.get("id")
328
+ venue_id = response.get("venue_id")
329
+ if not _is_nonempty_string(basket_id) or not _is_nonempty_string(venue_id):
330
+ raise ResponseShapeError(ServiceHost.CONSUMER.value)
331
+ return SavedBasket(id=basket_id, venue_id=venue_id)
332
+
333
+ def quote_checkout(self, selection: OrderSelection) -> QuoteSnapshot:
334
+ """Request one checkout quote and capture independent plan/response snapshots."""
335
+
336
+ selection = _order_selection(selection)
337
+ payload = selection.to_checkout_payload()
338
+ response = self._transport.request(
339
+ ServiceHost.CONSUMER,
340
+ "POST",
341
+ "/order-xp/web/v2/pages/checkout",
342
+ json_body=payload,
343
+ )
344
+ return QuoteSnapshot._capture(
345
+ selection,
346
+ payload["purchase_plan"],
347
+ response,
348
+ ServiceHost.CONSUMER.value,
349
+ )
350
+
351
+ def get_post_checkout_config(self, selection: OrderSelection) -> PostCheckoutConfig:
352
+ """Discover required consents using the distinct post-checkout payload."""
353
+
354
+ selection = _order_selection(selection)
355
+ response = self._transport.request(
356
+ ServiceHost.RESTAURANT,
357
+ "POST",
358
+ "/v1/post-checkout-config",
359
+ json_body=selection.to_post_checkout_payload(),
360
+ )
361
+ return PostCheckoutConfig._from_response(
362
+ response,
363
+ ServiceHost.RESTAURANT.value,
364
+ selection,
365
+ )
366
+
367
+ def prepare_purchase(
368
+ self,
369
+ selection: OrderSelection,
370
+ quote: QuoteSnapshot,
371
+ consents: PostCheckoutConfig,
372
+ context: PurchaseContext,
373
+ ) -> PreparedOrder:
374
+ """Prepare a one-card, home-delivery purchase without a network effect.
375
+
376
+ The selected delivery target and saved card must still be present in this
377
+ client's most recently discovered eligible references. This does not
378
+ establish a server quote expiry or replace the caller confirmation step.
379
+ """
380
+
381
+ selection = _order_selection(selection)
382
+ selection_eligibility = _payment_eligibility_binding(
383
+ selection._payment_eligibility_context()
384
+ )
385
+ eligible_payment_ids = {
386
+ payment_id
387
+ for payment_id, eligibility in self._payment_eligibility_by_id.items()
388
+ if eligibility == selection_eligibility
389
+ }
390
+ return _prepare_purchase_order(
391
+ selection,
392
+ quote,
393
+ consents,
394
+ context,
395
+ enabled_payment_ids=eligible_payment_ids,
396
+ delivery_target_ids=set(self._delivery_target_ids),
397
+ )
398
+
399
+ def submit_prepared_order(
400
+ self,
401
+ store: PurchaseAttemptStore,
402
+ prepared_order: PreparedOrder,
403
+ authorization: PurchaseAuthorization,
404
+ ) -> PurchaseResult:
405
+ """Send one explicit purchase attempt through this client's transport."""
406
+
407
+ return _submit_prepared_order(
408
+ self._transport,
409
+ store,
410
+ prepared_order,
411
+ authorization,
412
+ )
413
+
414
+ def get_order_status(self, purchase_id: str) -> OrderStatus:
415
+ """Read the dedicated tracking state without constraining status values."""
416
+
417
+ purchase_id = _required_text(purchase_id, "purchase_id")
418
+ encoded_purchase_id = quote(purchase_id, safe="")
419
+ response = self._transport.request(
420
+ ServiceHost.RESTAURANT,
421
+ "GET",
422
+ f"/v2/order_details/purchase_tracking/{encoded_purchase_id}",
423
+ )
424
+ details = response.get("order_details")
425
+ if not isinstance(details, Mapping):
426
+ raise ResponseShapeError(ServiceHost.RESTAURANT.value)
427
+ order_id = details.get("order_id")
428
+ status = details.get("status")
429
+ if (
430
+ not _is_nonempty_string(order_id)
431
+ or order_id != purchase_id
432
+ or not isinstance(status, str)
433
+ ):
434
+ raise ResponseShapeError(ServiceHost.RESTAURANT.value)
435
+
436
+ return OrderStatus(
437
+ purchase_id=order_id,
438
+ status=status,
439
+ currency=_optional_string(details.get("currency")),
440
+ payment_amount=_optional_number(details.get("payment_amount")),
441
+ total_price=_optional_number(details.get("total_price")),
442
+ delivery_price=_optional_number(details.get("delivery_price")),
443
+ delivery_method=_optional_string(details.get("delivery_method")),
444
+ )
445
+
446
+
447
+ def _required_text(value: object, name: str) -> str:
448
+ if not _is_nonempty_string(value):
449
+ raise ValueError(f"{name} must be a non-empty string.")
450
+ return value
451
+
452
+
453
+ def _path_segment(value: object, name: str) -> str:
454
+ return quote(_required_text(value, name), safe="")
455
+
456
+
457
+ def _coordinate(value: object, name: str) -> int | float:
458
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
459
+ raise TypeError(f"{name} must be a finite number.")
460
+ try:
461
+ is_finite = math.isfinite(value)
462
+ except OverflowError:
463
+ is_finite = False
464
+ if not is_finite:
465
+ raise ValueError(f"{name} must be a finite number.")
466
+ return value
467
+
468
+
469
+ def _required_list(
470
+ response: Mapping[str, Any], field: str, service: ServiceHost
471
+ ) -> Sequence[Any]:
472
+ value = response.get(field)
473
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
474
+ raise ResponseShapeError(service.value)
475
+ return value
476
+
477
+
478
+ def _is_nonempty_string(value: object) -> bool:
479
+ return isinstance(value, str) and bool(value.strip())
480
+
481
+
482
+ def _optional_string(value: object) -> str | None:
483
+ return value if isinstance(value, str) else None
484
+
485
+
486
+ def _optional_bool(value: object) -> bool | None:
487
+ return value if isinstance(value, bool) else None
488
+
489
+
490
+ def _optional_number(value: object) -> int | float | None:
491
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
492
+ return None
493
+ return value
494
+
495
+
496
+ def _order_selection(value: object) -> OrderSelection:
497
+ if not isinstance(value, OrderSelection):
498
+ raise TypeError("selection must be an OrderSelection instance.")
499
+ return value
500
+
501
+
502
+ def _payment_eligibility_binding(context: Mapping[str, Any]) -> str:
503
+ """Hash the minimum observed card-eligibility context without retaining it."""
504
+
505
+ venue_id = context.get("venue_id")
506
+ delivery_method = context.get("delivery_method")
507
+ items = context.get("items")
508
+ if (
509
+ not _is_nonempty_string(venue_id)
510
+ or not _is_nonempty_string(delivery_method)
511
+ or not isinstance(items, Sequence)
512
+ or isinstance(items, (str, bytes))
513
+ or not items
514
+ ):
515
+ raise SelectionError("The payment eligibility context is incomplete.")
516
+
517
+ normalized_items: list[dict[str, Any]] = []
518
+ for item in items:
519
+ if not isinstance(item, Mapping):
520
+ raise SelectionError("The payment eligibility context is incomplete.")
521
+ item_id = item.get("id")
522
+ alcohol_permille = item.get("alcohol_permille")
523
+ tags = item.get("product_hierarchy_tags")
524
+ vat_percentage = item.get("vat_percentage")
525
+ vat_percentage_decimal = item.get("vat_percentage_decimal")
526
+ if (
527
+ not _is_nonempty_string(item_id)
528
+ or not _is_number(alcohol_permille)
529
+ or not isinstance(tags, Sequence)
530
+ or isinstance(tags, (str, bytes))
531
+ or not _is_number(vat_percentage)
532
+ or not _is_nonempty_string(vat_percentage_decimal)
533
+ ):
534
+ raise SelectionError("The payment eligibility context is incomplete.")
535
+ normalized_items.append(
536
+ {
537
+ "id": item_id,
538
+ "alcohol_permille": alcohol_permille,
539
+ "product_hierarchy_tags": list(tags),
540
+ "vat_percentage": vat_percentage,
541
+ "vat_percentage_decimal": vat_percentage_decimal,
542
+ }
543
+ )
544
+
545
+ try:
546
+ encoded = json.dumps(
547
+ {
548
+ "venue_id": venue_id,
549
+ "delivery_method": delivery_method,
550
+ "items": normalized_items,
551
+ },
552
+ allow_nan=False,
553
+ ensure_ascii=False,
554
+ separators=(",", ":"),
555
+ sort_keys=True,
556
+ ).encode("utf-8")
557
+ except (TypeError, ValueError, UnicodeEncodeError):
558
+ raise SelectionError(
559
+ "The payment eligibility context is unsupported."
560
+ ) from None
561
+ return hashlib.sha256(encoded).hexdigest()
562
+
563
+
564
+ def _is_number(value: object) -> bool:
565
+ return isinstance(value, (int, float)) and not isinstance(value, bool)