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/selection.py ADDED
@@ -0,0 +1,863 @@
1
+ """Local selections, immutable quote snapshots, and explicit wire serializers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from dataclasses import dataclass, field
7
+ import math
8
+ from collections.abc import Mapping, Sequence
9
+ from typing import Any
10
+
11
+ from .errors import ResponseShapeError, SelectionError, UnsupportedSelectionError
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class VenueCheckoutContext:
16
+ """Explicit venue fields for the observed immediate-delivery quote shape."""
17
+
18
+ id: str
19
+ country: str
20
+ currency: str
21
+ self_delivery: bool
22
+ preorder_config: Any
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class DeliverySelection:
27
+ """A saved delivery reference with caller-supplied numeric coordinates."""
28
+
29
+ delivery_info_id: str
30
+ latitude: int | float = field(repr=False)
31
+ longitude: int | float = field(repr=False)
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class OptionValueSelection:
36
+ """A selected root-option value and its explicit count."""
37
+
38
+ id: str
39
+ count: int
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class OptionSelection:
44
+ """A selected item option configuration and its selected values.
45
+
46
+ ``post_checkout_fields`` is an exact, caller-supplied mapping for the
47
+ distinct post-checkout wire format. In particular, its ``type`` is not
48
+ inferred from the assortment root option type.
49
+ """
50
+
51
+ configuration_id: str
52
+ values: Sequence[OptionValueSelection]
53
+ post_checkout_fields: Mapping[str, Any] | None = field(default=None, repr=False)
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class ItemSelection:
58
+ """Explicit local selection data for one catalog item.
59
+
60
+ Prices that cannot be derived safely are caller inputs: ``basket_price``
61
+ and ``end_amount`` remain independent. ``checkout_fields`` and
62
+ ``post_checkout_fields`` carry exact field names for their respective wire
63
+ representations and are copied without filling omitted keys.
64
+ """
65
+
66
+ id: str
67
+ count: int
68
+ basket_name: str
69
+ basket_price: int
70
+ end_amount: int
71
+ substitution_allowed: bool
72
+ checkout_fields: Mapping[str, Any]
73
+ options: Sequence[OptionSelection] = ()
74
+ post_checkout_fields: Mapping[str, Any] | None = field(default=None, repr=False)
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class SavedBasket:
79
+ """Reference returned after the explicit persisted-basket mutation."""
80
+
81
+ id: str
82
+ venue_id: str
83
+
84
+
85
+ class PostCheckoutConfig:
86
+ """Required-consent data returned for a selected post-checkout basket."""
87
+
88
+ __slots__ = ("_required_consents", "_selection_identity")
89
+
90
+ def __init__(
91
+ self, required_consents: Sequence[Any], selection_identity: object | None = None
92
+ ) -> None:
93
+ self._required_consents = deepcopy(list(required_consents))
94
+ self._selection_identity = selection_identity
95
+
96
+ @property
97
+ def required_consents(self) -> list[Any]:
98
+ """Return a detached copy of the server-provided consent list."""
99
+
100
+ return deepcopy(self._required_consents)
101
+
102
+ def __repr__(self) -> str:
103
+ return f"PostCheckoutConfig(required_consents={len(self._required_consents)})"
104
+
105
+ def is_current_for(self, selection: OrderSelection) -> bool:
106
+ """Whether these consents were fetched for the same local selection."""
107
+
108
+ return (
109
+ isinstance(selection, OrderSelection)
110
+ and self._selection_identity is selection._identity_token()
111
+ )
112
+
113
+ @classmethod
114
+ def _from_response(
115
+ cls,
116
+ response: Mapping[str, Any],
117
+ service: str,
118
+ selection: OrderSelection,
119
+ ) -> PostCheckoutConfig:
120
+ required_consents = response.get("required_consents")
121
+ if not _is_array(required_consents):
122
+ raise ResponseShapeError(service)
123
+ return cls(required_consents, selection._identity_token())
124
+
125
+
126
+ class QuoteSnapshot:
127
+ """Private, independent snapshots of selected catalog data, plan, and quote."""
128
+
129
+ __slots__ = ("_selection_data", "_selection_identity", "_plan", "_quote")
130
+
131
+ def __init__(
132
+ self,
133
+ selection_data: Mapping[str, Any],
134
+ selection_identity: object,
135
+ plan: Mapping[str, Any],
136
+ quote: Mapping[str, Any],
137
+ ) -> None:
138
+ self._selection_data = deepcopy(dict(selection_data))
139
+ self._selection_identity = selection_identity
140
+ self._plan = deepcopy(dict(plan))
141
+ self._quote = deepcopy(dict(quote))
142
+
143
+ @property
144
+ def checkout_id(self) -> str:
145
+ return self._quote["id"]
146
+
147
+ @property
148
+ def payable_amount(self) -> int | float:
149
+ return deepcopy(self._quote["payable_amount"])
150
+
151
+ @property
152
+ def purchase_validation(self) -> dict[str, Any]:
153
+ """Return an unchanged, detached copy of the server validation object."""
154
+
155
+ return deepcopy(self._quote["purchase_validation"])
156
+
157
+ def is_current_for(self, selection: OrderSelection) -> bool:
158
+ """Whether this snapshot belongs to the unchanged local selection."""
159
+
160
+ return (
161
+ isinstance(selection, OrderSelection)
162
+ and self._selection_identity is selection._identity_token()
163
+ )
164
+
165
+ def __repr__(self) -> str:
166
+ return f"QuoteSnapshot(checkout_id={self.checkout_id!r})"
167
+
168
+ @classmethod
169
+ def _capture(
170
+ cls,
171
+ selection: OrderSelection,
172
+ plan: Mapping[str, Any],
173
+ quote: Mapping[str, Any],
174
+ service: str,
175
+ ) -> QuoteSnapshot:
176
+ checkout_id = quote.get("id")
177
+ payable_amount = quote.get("payable_amount")
178
+ validation = quote.get("purchase_validation")
179
+ if (
180
+ not _is_nonempty_text(checkout_id)
181
+ or not _is_number(payable_amount)
182
+ or not isinstance(validation, Mapping)
183
+ ):
184
+ raise ResponseShapeError(service)
185
+ return cls(
186
+ selection._snapshot_data(),
187
+ selection._identity_token(),
188
+ plan,
189
+ quote,
190
+ )
191
+
192
+ def _plan_snapshot(self) -> dict[str, Any]:
193
+ """Private handoff for a later, explicitly approved purchase phase."""
194
+
195
+ return deepcopy(self._plan)
196
+
197
+ def _quote_snapshot(self) -> dict[str, Any]:
198
+ """Private handoff for a later, explicitly approved purchase phase."""
199
+
200
+ return deepcopy(self._quote)
201
+
202
+ def _selection_snapshot(self) -> dict[str, Any]:
203
+ """Private handoff for a later, explicitly approved purchase phase."""
204
+
205
+ return deepcopy(self._selection_data)
206
+
207
+
208
+ class OrderSelection:
209
+ """An immutable local selection with separate basket, quote, and consent serializers."""
210
+
211
+ __slots__ = ("_data", "_identity")
212
+
213
+ def __init__(self, data: Mapping[str, Any]) -> None:
214
+ self._data = deepcopy(dict(data))
215
+ self._identity = object()
216
+
217
+ def __repr__(self) -> str:
218
+ return f"OrderSelection(item_count={len(self._data['items'])})"
219
+
220
+ @classmethod
221
+ def _from_assortment(
222
+ cls,
223
+ assortment: Mapping[str, Any],
224
+ *,
225
+ venue: VenueCheckoutContext,
226
+ delivery: DeliverySelection,
227
+ payment_method: Mapping[str, Any],
228
+ courier_tip: int,
229
+ items: Sequence[ItemSelection],
230
+ ) -> OrderSelection:
231
+ if not isinstance(assortment, Mapping):
232
+ raise SelectionError("A selection requires an assortment mapping.")
233
+ if not isinstance(venue, VenueCheckoutContext):
234
+ raise TypeError("venue must be a VenueCheckoutContext instance.")
235
+ if not isinstance(delivery, DeliverySelection):
236
+ raise TypeError("delivery must be a DeliverySelection instance.")
237
+ if not _is_array(items) or not items:
238
+ raise SelectionError("A selection requires at least one item.")
239
+
240
+ catalog_items = _index_by_id(assortment.get("items"))
241
+ root_options = _index_by_id(assortment.get("options"))
242
+ normalized_items = [
243
+ _normalize_item(item, catalog_items, root_options) for item in items
244
+ ]
245
+ item_ids = [item["id"] for item in normalized_items]
246
+ if len(item_ids) != len(set(item_ids)):
247
+ raise SelectionError("A selection cannot contain duplicate item IDs.")
248
+
249
+ return cls(
250
+ {
251
+ "venue": _normalize_venue(venue),
252
+ "delivery": _normalize_delivery(delivery),
253
+ "payment_method": _normalize_payment_method(payment_method),
254
+ "courier_tip": _nonnegative_integer(courier_tip),
255
+ "items": normalized_items,
256
+ }
257
+ )
258
+
259
+ def to_basket_payload(self) -> dict[str, Any]:
260
+ """Serialize the selected items for ``POST /order-xp/v1/baskets``."""
261
+
262
+ return {
263
+ "venue_id": self._data["venue"]["id"],
264
+ "currency": self._data["venue"]["currency"],
265
+ "items": [_serialize_basket_item(item) for item in self._data["items"]],
266
+ }
267
+
268
+ def to_checkout_payload(self) -> dict[str, Any]:
269
+ """Serialize the selected items for the checkout-quote endpoint."""
270
+
271
+ return {
272
+ "purchase_plan": {
273
+ "courier_tip": self._data["courier_tip"],
274
+ "delivery": {
275
+ "delivery_coordinates": {
276
+ "longitude": self._data["delivery"]["longitude"],
277
+ "latitude": self._data["delivery"]["latitude"],
278
+ },
279
+ "delivery_info_id": self._data["delivery"]["delivery_info_id"],
280
+ },
281
+ "delivery_method": "homedelivery",
282
+ "delivery_config": {
283
+ "method": "homedelivery",
284
+ "schedule": "standard",
285
+ "time_slot": None,
286
+ },
287
+ "payment_methods": [deepcopy(self._data["payment_method"])],
288
+ "menu_items": [
289
+ _serialize_checkout_item(item) for item in self._data["items"]
290
+ ],
291
+ "selected_offer_ids": [],
292
+ "use_cash": False,
293
+ "use_credits_and_tokens": False,
294
+ "use_loyalty_points_amount": 0,
295
+ "use_promo_surcharge_ids": [],
296
+ "venue": deepcopy(self._data["venue"]),
297
+ }
298
+ }
299
+
300
+ def to_post_checkout_payload(self) -> dict[str, Any]:
301
+ """Serialize the distinct post-checkout basket representation."""
302
+
303
+ return {
304
+ "venue_id": self._data["venue"]["id"],
305
+ "country": self._data["venue"]["country"],
306
+ "basket": [
307
+ _serialize_post_checkout_item(item) for item in self._data["items"]
308
+ ],
309
+ }
310
+
311
+ def _payment_eligibility_context(self) -> dict[str, Any]:
312
+ """Return the minimal selected-item context used for card eligibility."""
313
+
314
+ items: list[dict[str, Any]] = []
315
+ for item in self._data["items"]:
316
+ post_checkout_item = _serialize_post_checkout_item(item)
317
+ items.append(
318
+ {
319
+ "id": item["id"],
320
+ "alcohol_permille": item["checkout_fields"]["alcohol_permille"],
321
+ "product_hierarchy_tags": post_checkout_item[
322
+ "product_hierarchy_tags"
323
+ ],
324
+ "vat_percentage": post_checkout_item["vat_percentage"],
325
+ "vat_percentage_decimal": post_checkout_item[
326
+ "vat_percentage_decimal"
327
+ ],
328
+ }
329
+ )
330
+ return {
331
+ "venue_id": self._data["venue"]["id"],
332
+ "delivery_method": "homedelivery",
333
+ "items": items,
334
+ }
335
+
336
+ def _snapshot_data(self) -> dict[str, Any]:
337
+ return deepcopy(self._data)
338
+
339
+ def _identity_token(self) -> object:
340
+ return self._identity
341
+
342
+
343
+ _CHECKOUT_REQUIRED_FIELDS = {
344
+ "category_id",
345
+ "category_ids",
346
+ "exclude_from_credits",
347
+ "exclude_from_discounts",
348
+ "exclude_from_discounts_min_basket",
349
+ "alcohol_permille",
350
+ "restrictions",
351
+ }
352
+ _CHECKOUT_RESERVED_FIELDS = {"id", "count", "options", "base_price", "end_amount"}
353
+ _POST_CHECKOUT_REQUIRED_FIELDS = {
354
+ "configIndex",
355
+ "name",
356
+ "category",
357
+ "exclude_from_credits",
358
+ "exclude_from_discounts",
359
+ "exclude_from_discounts_min_basket",
360
+ "from_recommendation",
361
+ "alcohol_percentage",
362
+ "product_hierarchy_tags",
363
+ "vat_percentage",
364
+ "vat_percentage_decimal",
365
+ }
366
+ _POST_CHECKOUT_RESERVED_FIELDS = {
367
+ "id",
368
+ "count",
369
+ "baseprice",
370
+ "end_amount",
371
+ "restrictions",
372
+ "options",
373
+ "checksum",
374
+ }
375
+ _POST_CHECKOUT_OPTION_RESERVED_FIELDS = {"id", "values"}
376
+
377
+
378
+ def _normalize_venue(venue: VenueCheckoutContext) -> dict[str, Any]:
379
+ if (
380
+ not _is_nonempty_text(venue.id)
381
+ or not _is_nonempty_text(venue.country)
382
+ or not _is_nonempty_text(venue.currency)
383
+ or not isinstance(venue.self_delivery, bool)
384
+ ):
385
+ raise SelectionError("The checkout venue context is incomplete.")
386
+ if venue.preorder_config is not None:
387
+ raise UnsupportedSelectionError(
388
+ "Scheduled or preorder selections are unsupported."
389
+ )
390
+ return {
391
+ "id": venue.id,
392
+ "country": venue.country,
393
+ "currency": venue.currency,
394
+ "self_delivery": venue.self_delivery,
395
+ "preorder_config": None,
396
+ }
397
+
398
+
399
+ def _normalize_delivery(delivery: DeliverySelection) -> dict[str, Any]:
400
+ if not _is_nonempty_text(delivery.delivery_info_id):
401
+ raise SelectionError("A saved delivery reference is required.")
402
+ return {
403
+ "delivery_info_id": delivery.delivery_info_id,
404
+ "latitude": _finite_coordinate(delivery.latitude),
405
+ "longitude": _finite_coordinate(delivery.longitude),
406
+ }
407
+
408
+
409
+ def _normalize_payment_method(payment_method: Mapping[str, Any]) -> dict[str, Any]:
410
+ if not isinstance(payment_method, Mapping):
411
+ raise TypeError("payment_method must be a mapping.")
412
+ method = deepcopy(dict(payment_method))
413
+ if not _is_nonempty_text(method.get("id")) or method.get("type") != "card":
414
+ raise SelectionError("A saved card payment method is required.")
415
+ return method
416
+
417
+
418
+ def _normalize_item(
419
+ selected_item: ItemSelection,
420
+ catalog_items: Mapping[str, Mapping[str, Any]],
421
+ root_options: Mapping[str, Mapping[str, Any]],
422
+ ) -> dict[str, Any]:
423
+ if not isinstance(selected_item, ItemSelection):
424
+ raise TypeError("items must contain ItemSelection instances.")
425
+ if not _is_nonempty_text(selected_item.id):
426
+ raise SelectionError("An item ID is required.")
427
+ catalog_item = catalog_items.get(selected_item.id)
428
+ if catalog_item is None:
429
+ raise SelectionError("The selected item is absent from the assortment.")
430
+
431
+ count = _positive_integer(selected_item.count)
432
+ _validate_catalog_item_for_home_delivery(catalog_item)
433
+ base_price = _integer(catalog_item.get("price"))
434
+ checksum = catalog_item.get("checksum")
435
+ if not _is_nonempty_text(checksum):
436
+ raise SelectionError("The selected catalog item is missing a checksum.")
437
+ _validate_item_quantity(catalog_item, count)
438
+
439
+ if not _is_nonempty_text(selected_item.basket_name):
440
+ raise SelectionError("A basket item name is required.")
441
+ if not isinstance(selected_item.substitution_allowed, bool):
442
+ raise SelectionError("The substitution setting must be explicit.")
443
+
444
+ options = _normalize_options(selected_item.options, catalog_item, root_options)
445
+ checkout_fields = _normalize_checkout_fields(
446
+ selected_item.checkout_fields,
447
+ catalog_item["restrictions"],
448
+ )
449
+ post_checkout_fields = _copy_optional_mapping(selected_item.post_checkout_fields)
450
+
451
+ return {
452
+ "id": selected_item.id,
453
+ "count": count,
454
+ "basket_name": selected_item.basket_name,
455
+ "basket_price": _integer(selected_item.basket_price),
456
+ "end_amount": _integer(selected_item.end_amount),
457
+ "substitution_allowed": selected_item.substitution_allowed,
458
+ "base_price": base_price,
459
+ "checksum": checksum,
460
+ "checkout_fields": checkout_fields,
461
+ "post_checkout_fields": post_checkout_fields,
462
+ "options": options,
463
+ "catalog": {
464
+ "item": deepcopy(dict(catalog_item)),
465
+ "options": [deepcopy(option["catalog"]) for option in options],
466
+ },
467
+ }
468
+
469
+
470
+ def _validate_catalog_item_for_home_delivery(catalog_item: Mapping[str, Any]) -> None:
471
+ delivery_methods = catalog_item.get("allowed_delivery_methods")
472
+ restrictions = catalog_item.get("restrictions")
473
+ if not _is_array(delivery_methods) or "homedelivery" not in delivery_methods:
474
+ raise UnsupportedSelectionError(
475
+ "The selected item does not support home delivery."
476
+ )
477
+ if not _is_array(restrictions) or restrictions:
478
+ raise UnsupportedSelectionError("Items with restrictions are unsupported.")
479
+
480
+
481
+ def _validate_item_quantity(catalog_item: Mapping[str, Any], count: int) -> None:
482
+ minimum = catalog_item.get("min_quantity_per_purchase")
483
+ maximum = catalog_item.get("max_quantity_per_purchase")
484
+ if minimum is not None and (not _is_integer(minimum) or count < minimum):
485
+ raise SelectionError("The item count is outside the catalog minimum.")
486
+ if maximum is not None and (not _is_integer(maximum) or count > maximum):
487
+ raise SelectionError("The item count is outside the catalog maximum.")
488
+
489
+
490
+ def _normalize_checkout_fields(
491
+ fields: Mapping[str, Any], catalog_restrictions: Sequence[Any]
492
+ ) -> dict[str, Any]:
493
+ fields_copy = _copy_mapping(fields, "checkout_fields")
494
+ _validate_wire_fields(
495
+ fields_copy,
496
+ required=_CHECKOUT_REQUIRED_FIELDS,
497
+ reserved=_CHECKOUT_RESERVED_FIELDS,
498
+ label="checkout_fields",
499
+ )
500
+ if (
501
+ not _is_nonempty_text(fields_copy["category_id"])
502
+ or not _is_array(fields_copy["category_ids"])
503
+ or not all(
504
+ _is_nonempty_text(category) for category in fields_copy["category_ids"]
505
+ )
506
+ or any(
507
+ not isinstance(fields_copy[field], bool)
508
+ for field in (
509
+ "exclude_from_credits",
510
+ "exclude_from_discounts",
511
+ "exclude_from_discounts_min_basket",
512
+ )
513
+ )
514
+ ):
515
+ raise SelectionError("The checkout item fields are incomplete.")
516
+ if not _is_zero_number(fields_copy["alcohol_permille"]):
517
+ raise UnsupportedSelectionError("Age-restricted items are unsupported.")
518
+ if (
519
+ not _is_array(fields_copy["restrictions"])
520
+ or fields_copy["restrictions"]
521
+ or fields_copy["restrictions"] != catalog_restrictions
522
+ ):
523
+ raise UnsupportedSelectionError("Items with restrictions are unsupported.")
524
+ return fields_copy
525
+
526
+
527
+ def _normalize_options(
528
+ selected_options: Sequence[OptionSelection],
529
+ catalog_item: Mapping[str, Any],
530
+ root_options: Mapping[str, Mapping[str, Any]],
531
+ ) -> list[dict[str, Any]]:
532
+ if not _is_array(selected_options):
533
+ raise SelectionError("Item options must be a sequence.")
534
+ configurations = _index_by_id(catalog_item.get("options"))
535
+ normalized: list[dict[str, Any]] = []
536
+ configuration_ids: set[str] = set()
537
+ for selected_option in selected_options:
538
+ if not isinstance(selected_option, OptionSelection):
539
+ raise TypeError("options must contain OptionSelection instances.")
540
+ config_id = selected_option.configuration_id
541
+ if not _is_nonempty_text(config_id) or config_id in configuration_ids:
542
+ raise SelectionError("Option configuration IDs must be unique.")
543
+ configuration_ids.add(config_id)
544
+ configuration = configurations.get(config_id)
545
+ if configuration is None:
546
+ raise SelectionError("The selected option is not an item configuration.")
547
+ root_option = _root_option_for_configuration(configuration, root_options)
548
+ constraints = _option_constraints(configuration, root_option)
549
+ root_values = _index_by_id(root_option.get("values"))
550
+ if not _is_array(selected_option.values) or not selected_option.values:
551
+ raise SelectionError("Each selected option requires at least one value.")
552
+
553
+ values: list[dict[str, Any]] = []
554
+ value_ids: set[str] = set()
555
+ selected_value_catalog: list[dict[str, Any]] = []
556
+ for selected_value in selected_option.values:
557
+ if not isinstance(selected_value, OptionValueSelection):
558
+ raise TypeError(
559
+ "option values must contain OptionValueSelection instances."
560
+ )
561
+ value_id = selected_value.id
562
+ if not _is_nonempty_text(value_id) or value_id in value_ids:
563
+ raise SelectionError("Option value IDs must be unique.")
564
+ value_ids.add(value_id)
565
+ catalog_value = root_values.get(value_id)
566
+ if catalog_value is None:
567
+ raise SelectionError(
568
+ "The selected option value is absent from the root option."
569
+ )
570
+ values.append(
571
+ {
572
+ "id": value_id,
573
+ "count": _positive_integer(selected_value.count),
574
+ "price": _integer(catalog_value.get("price")),
575
+ }
576
+ )
577
+ selected_value_catalog.append(deepcopy(dict(catalog_value)))
578
+
579
+ selected_total = sum(value["count"] for value in values)
580
+ if (
581
+ selected_total < constraints.total_minimum
582
+ or selected_total > constraints.total_maximum
583
+ or any(
584
+ value["count"] > constraints.maximum_single_selections
585
+ for value in values
586
+ )
587
+ ):
588
+ raise SelectionError("Selected option counts violate catalog constraints.")
589
+
590
+ normalized.append(
591
+ {
592
+ "configuration_id": config_id,
593
+ "values": values,
594
+ "post_checkout_fields": _copy_optional_mapping(
595
+ selected_option.post_checkout_fields
596
+ ),
597
+ "catalog": {
598
+ "configuration": deepcopy(dict(configuration)),
599
+ "root_option": deepcopy(dict(root_option)),
600
+ "values": selected_value_catalog,
601
+ },
602
+ }
603
+ )
604
+
605
+ for configuration_id, configuration in configurations.items():
606
+ if configuration_id in configuration_ids:
607
+ continue
608
+ root_option = _root_option_for_configuration(configuration, root_options)
609
+ constraints = _option_constraints(configuration, root_option)
610
+ if constraints.total_minimum > 0:
611
+ raise SelectionError("A required option configuration was omitted.")
612
+ return normalized
613
+
614
+
615
+ @dataclass(frozen=True)
616
+ class _OptionConstraints:
617
+ total_minimum: int
618
+ total_maximum: int
619
+ maximum_single_selections: int
620
+
621
+
622
+ def _root_option_for_configuration(
623
+ configuration: Mapping[str, Any], root_options: Mapping[str, Mapping[str, Any]]
624
+ ) -> Mapping[str, Any]:
625
+ prerequisites = configuration.get("prerequisite_values")
626
+ if not _is_array(prerequisites) or prerequisites:
627
+ raise UnsupportedSelectionError("Option prerequisites are unsupported.")
628
+ root_option_id = configuration.get("option_id")
629
+ if not _is_nonempty_text(root_option_id):
630
+ raise SelectionError("The item option is missing its root option reference.")
631
+ root_option = root_options.get(root_option_id)
632
+ if root_option is None:
633
+ raise SelectionError("The item option root is absent from the assortment.")
634
+ return root_option
635
+
636
+
637
+ def _option_constraints(
638
+ configuration: Mapping[str, Any], root_option: Mapping[str, Any]
639
+ ) -> _OptionConstraints:
640
+ if root_option.get("type") not in {"choice", "multi_choice"}:
641
+ raise UnsupportedSelectionError("The root option type is unsupported.")
642
+ configuration_rules = configuration.get("multi_choice_config")
643
+ if not isinstance(configuration_rules, Mapping):
644
+ raise UnsupportedSelectionError("Option selection constraints are unavailable.")
645
+ total_range = configuration_rules.get("total_range")
646
+ if not isinstance(total_range, Mapping):
647
+ raise UnsupportedSelectionError("Option selection constraints are unavailable.")
648
+ minimum = total_range.get("min")
649
+ maximum = total_range.get("max")
650
+ maximum_single = configuration_rules.get("max_single_selections")
651
+ free_selections = configuration_rules.get("free_selections")
652
+ if (
653
+ not _is_integer(minimum)
654
+ or not _is_integer(maximum)
655
+ or not _is_integer(maximum_single)
656
+ or not _is_integer(free_selections)
657
+ or minimum < 0
658
+ or maximum < minimum
659
+ or maximum_single <= 0
660
+ or free_selections != 0
661
+ ):
662
+ raise UnsupportedSelectionError("Option selection constraints are unsupported.")
663
+ return _OptionConstraints(minimum, maximum, maximum_single)
664
+
665
+
666
+ def _serialize_basket_item(item: Mapping[str, Any]) -> dict[str, Any]:
667
+ return {
668
+ "id": item["id"],
669
+ "count": item["count"],
670
+ "name": item["basket_name"],
671
+ "price": item["basket_price"],
672
+ "options": [
673
+ {
674
+ "id": option["configuration_id"],
675
+ "values": deepcopy(option["values"]),
676
+ }
677
+ for option in item["options"]
678
+ ],
679
+ "substitution_settings": {"is_allowed": item["substitution_allowed"]},
680
+ }
681
+
682
+
683
+ def _serialize_checkout_item(item: Mapping[str, Any]) -> dict[str, Any]:
684
+ payload = deepcopy(item["checkout_fields"])
685
+ payload.update(
686
+ {
687
+ "id": item["id"],
688
+ "count": item["count"],
689
+ "options": [
690
+ {
691
+ "id": option["configuration_id"],
692
+ "values": deepcopy(option["values"]),
693
+ }
694
+ for option in item["options"]
695
+ ],
696
+ "base_price": item["base_price"],
697
+ "end_amount": item["end_amount"],
698
+ }
699
+ )
700
+ return payload
701
+
702
+
703
+ def _serialize_post_checkout_item(item: Mapping[str, Any]) -> dict[str, Any]:
704
+ fields = item["post_checkout_fields"]
705
+ if fields is None:
706
+ raise UnsupportedSelectionError(
707
+ "Post-checkout item fields must be supplied explicitly."
708
+ )
709
+ fields_copy = _copy_mapping(fields, "post_checkout_fields")
710
+ _validate_wire_fields(
711
+ fields_copy,
712
+ required=_POST_CHECKOUT_REQUIRED_FIELDS,
713
+ reserved=_POST_CHECKOUT_RESERVED_FIELDS,
714
+ label="post_checkout_fields",
715
+ )
716
+ if (
717
+ not _is_integer(fields_copy["configIndex"])
718
+ or not _is_array(fields_copy["name"])
719
+ or not _is_nonempty_text(fields_copy["category"])
720
+ or not _is_array(fields_copy["product_hierarchy_tags"])
721
+ or any(
722
+ not isinstance(fields_copy[field], bool)
723
+ for field in (
724
+ "exclude_from_credits",
725
+ "exclude_from_discounts",
726
+ "exclude_from_discounts_min_basket",
727
+ )
728
+ )
729
+ ):
730
+ raise SelectionError("The post-checkout item fields are incomplete.")
731
+ if fields_copy["from_recommendation"] is not False:
732
+ raise UnsupportedSelectionError("Recommendation selections are unsupported.")
733
+ if not _is_zero_number(fields_copy["alcohol_percentage"]):
734
+ raise UnsupportedSelectionError("Age-restricted items are unsupported.")
735
+
736
+ fields_copy.update(
737
+ {
738
+ "id": item["id"],
739
+ "count": item["count"],
740
+ "baseprice": item["base_price"],
741
+ "end_amount": item["end_amount"],
742
+ "restrictions": deepcopy(item["checkout_fields"]["restrictions"]),
743
+ "options": [
744
+ _serialize_post_checkout_option(option) for option in item["options"]
745
+ ],
746
+ "checksum": item["checksum"],
747
+ }
748
+ )
749
+ return fields_copy
750
+
751
+
752
+ def _serialize_post_checkout_option(option: Mapping[str, Any]) -> dict[str, Any]:
753
+ fields = option["post_checkout_fields"]
754
+ if fields is None:
755
+ raise UnsupportedSelectionError(
756
+ "Post-checkout option fields must be supplied explicitly."
757
+ )
758
+ fields_copy = _copy_mapping(fields, "option post_checkout_fields")
759
+ _validate_wire_fields(
760
+ fields_copy,
761
+ required={"type", "name"},
762
+ reserved=_POST_CHECKOUT_OPTION_RESERVED_FIELDS,
763
+ label="option post_checkout_fields",
764
+ )
765
+ if not _is_nonempty_text(fields_copy["type"]) or not _is_array(fields_copy["name"]):
766
+ raise SelectionError("The post-checkout option fields are incomplete.")
767
+ fields_copy.update(
768
+ {
769
+ "id": option["configuration_id"],
770
+ "values": {value["id"]: value["count"] for value in option["values"]},
771
+ }
772
+ )
773
+ return fields_copy
774
+
775
+
776
+ def _index_by_id(value: Any) -> dict[str, Mapping[str, Any]]:
777
+ if not _is_array(value):
778
+ raise SelectionError("The assortment has an unexpected collection shape.")
779
+ indexed: dict[str, Mapping[str, Any]] = {}
780
+ for record in value:
781
+ if not isinstance(record, Mapping):
782
+ continue
783
+ record_id = record.get("id")
784
+ if not _is_nonempty_text(record_id):
785
+ continue
786
+ if record_id in indexed:
787
+ raise SelectionError("The assortment has duplicate IDs.")
788
+ indexed[record_id] = record
789
+ return indexed
790
+
791
+
792
+ def _validate_wire_fields(
793
+ fields: Mapping[str, Any],
794
+ *,
795
+ required: set[str],
796
+ reserved: set[str],
797
+ label: str,
798
+ ) -> None:
799
+ if required.difference(fields) or reserved.intersection(fields):
800
+ raise SelectionError(f"{label} is missing required or has reserved fields.")
801
+
802
+
803
+ def _copy_mapping(value: Any, label: str) -> dict[str, Any]:
804
+ if not isinstance(value, Mapping):
805
+ raise TypeError(f"{label} must be a mapping.")
806
+ return deepcopy(dict(value))
807
+
808
+
809
+ def _copy_optional_mapping(value: Any) -> dict[str, Any] | None:
810
+ if value is None:
811
+ return None
812
+ return _copy_mapping(value, "post_checkout_fields")
813
+
814
+
815
+ def _is_array(value: Any) -> bool:
816
+ return isinstance(value, Sequence) and not isinstance(value, (str, bytes))
817
+
818
+
819
+ def _is_nonempty_text(value: Any) -> bool:
820
+ return isinstance(value, str) and bool(value.strip())
821
+
822
+
823
+ def _is_integer(value: Any) -> bool:
824
+ return isinstance(value, int) and not isinstance(value, bool)
825
+
826
+
827
+ def _integer(value: Any) -> int:
828
+ if not _is_integer(value):
829
+ raise SelectionError("Amounts and counts must be integer values.")
830
+ return value
831
+
832
+
833
+ def _positive_integer(value: Any) -> int:
834
+ value = _integer(value)
835
+ if value <= 0:
836
+ raise SelectionError("Counts must be positive integers.")
837
+ return value
838
+
839
+
840
+ def _nonnegative_integer(value: Any) -> int:
841
+ value = _integer(value)
842
+ if value < 0:
843
+ raise SelectionError("Amounts must be nonnegative integers.")
844
+ return value
845
+
846
+
847
+ def _is_number(value: Any) -> bool:
848
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
849
+
850
+
851
+ def _is_zero_number(value: Any) -> bool:
852
+ return _is_number(value) and value == 0
853
+
854
+
855
+ def _finite_coordinate(value: Any) -> int | float:
856
+ if not _is_number(value):
857
+ raise SelectionError("Delivery coordinates must be finite numbers.")
858
+ try:
859
+ if not math.isfinite(value):
860
+ raise SelectionError("Delivery coordinates must be finite numbers.")
861
+ except OverflowError:
862
+ raise SelectionError("Delivery coordinates must be finite numbers.") from None
863
+ return value