woltapi 0.3.0__tar.gz → 0.4.1__tar.gz

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.
Files changed (37) hide show
  1. {woltapi-0.3.0/src/woltapi.egg-info → woltapi-0.4.1}/PKG-INFO +58 -4
  2. {woltapi-0.3.0 → woltapi-0.4.1}/README.md +57 -3
  3. {woltapi-0.3.0 → woltapi-0.4.1}/examples/order.py +8 -45
  4. woltapi-0.4.1/examples/verify_saved_basket_rebuild.py +108 -0
  5. {woltapi-0.3.0 → woltapi-0.4.1}/pyproject.toml +1 -1
  6. {woltapi-0.3.0 → woltapi-0.4.1}/src/woltapi/basket.py +123 -2
  7. {woltapi-0.3.0 → woltapi-0.4.1}/src/woltapi/client.py +49 -17
  8. {woltapi-0.3.0 → woltapi-0.4.1}/src/woltapi/purchase.py +1 -0
  9. {woltapi-0.3.0 → woltapi-0.4.1}/src/woltapi/selection.py +32 -0
  10. {woltapi-0.3.0 → woltapi-0.4.1/src/woltapi.egg-info}/PKG-INFO +58 -4
  11. {woltapi-0.3.0 → woltapi-0.4.1}/src/woltapi.egg-info/SOURCES.txt +3 -1
  12. {woltapi-0.3.0 → woltapi-0.4.1}/tests/test_basket.py +133 -1
  13. {woltapi-0.3.0 → woltapi-0.4.1}/tests/test_client.py +227 -30
  14. {woltapi-0.3.0 → woltapi-0.4.1}/tests/test_order_example.py +7 -33
  15. {woltapi-0.3.0 → woltapi-0.4.1}/tests/test_selection.py +80 -0
  16. woltapi-0.4.1/tests/test_verify_saved_basket_rebuild_example.py +103 -0
  17. {woltapi-0.3.0 → woltapi-0.4.1}/LICENSE +0 -0
  18. {woltapi-0.3.0 → woltapi-0.4.1}/MANIFEST.in +0 -0
  19. {woltapi-0.3.0 → woltapi-0.4.1}/RELEASING.md +0 -0
  20. {woltapi-0.3.0 → woltapi-0.4.1}/examples/browse.py +0 -0
  21. {woltapi-0.3.0 → woltapi-0.4.1}/examples/check_session.py +0 -0
  22. {woltapi-0.3.0 → woltapi-0.4.1}/setup.cfg +0 -0
  23. {woltapi-0.3.0 → woltapi-0.4.1}/src/woltapi/__init__.py +0 -0
  24. {woltapi-0.3.0 → woltapi-0.4.1}/src/woltapi/auth.py +0 -0
  25. {woltapi-0.3.0 → woltapi-0.4.1}/src/woltapi/credentials.py +0 -0
  26. {woltapi-0.3.0 → woltapi-0.4.1}/src/woltapi/errors.py +0 -0
  27. {woltapi-0.3.0 → woltapi-0.4.1}/src/woltapi/models.py +0 -0
  28. {woltapi-0.3.0 → woltapi-0.4.1}/src/woltapi/services.py +0 -0
  29. {woltapi-0.3.0 → woltapi-0.4.1}/src/woltapi/transport.py +0 -0
  30. {woltapi-0.3.0 → woltapi-0.4.1}/src/woltapi.egg-info/dependency_links.txt +0 -0
  31. {woltapi-0.3.0 → woltapi-0.4.1}/src/woltapi.egg-info/requires.txt +0 -0
  32. {woltapi-0.3.0 → woltapi-0.4.1}/src/woltapi.egg-info/top_level.txt +0 -0
  33. {woltapi-0.3.0 → woltapi-0.4.1}/tests/__init__.py +0 -0
  34. {woltapi-0.3.0 → woltapi-0.4.1}/tests/test_auth.py +0 -0
  35. {woltapi-0.3.0 → woltapi-0.4.1}/tests/test_browse.py +0 -0
  36. {woltapi-0.3.0 → woltapi-0.4.1}/tests/test_check_session.py +0 -0
  37. {woltapi-0.3.0 → woltapi-0.4.1}/tests/test_purchase.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: woltapi
3
- Version: 0.3.0
3
+ Version: 0.4.1
4
4
  Summary: A small synchronous client for observed Wolt ordering flows.
5
5
  License-Expression: AGPL-3.0-only
6
6
  Project-URL: Repository, https://github.com/skorokithakis/woltapi
@@ -243,6 +243,7 @@ longer use this path; prefer `RefreshTokenCredentials`.
243
243
  | `client.get_venue_dynamic(slug, latitude, longitude)` | Current opening and delivery information. |
244
244
  | `client.get_orders_page()` | A dictionary containing the current page of order history. |
245
245
  | `client.list_delivery_targets()` | References to your saved delivery addresses, with saved labels and address details. |
246
+ | `client.get_venue_checkout_context(slug)` | The `VenueCheckoutContext` for a restaurant, read from its static page. Needed for `create_selection` and `save_basket_items`. |
246
247
  | `client.get_order_status(purchase_id)` | An order's status and some price information. |
247
248
  | `derive_checkout_fields(assortment, item)` | The checkout metadata fields for one menu item, derived from the assortment. Raises an error for items in zero or multiple categories. |
248
249
 
@@ -301,12 +302,65 @@ baskets you see in the Wolt app. The library can read and replace them:
301
302
 
302
303
  | Call | What it does |
303
304
  | --- | --- |
304
- | `client.save_basket(selection)` | Replaces the server basket for that restaurant with your selection. This does not place an order. |
305
+ | `client.save_basket_items(assortment, venue=..., items=...)` | Replaces the whole server basket for that restaurant. Needs only the menu, a venue context, and items. This does not place an order. |
306
+ | `client.save_basket(selection)` | Also replaces the whole server basket, but takes a full `OrderSelection`. |
305
307
  | `client.get_basket_count()` | The number of baskets stored on the server. |
306
- | `client.get_venue_basket(venue_id)` | The server basket for one restaurant, or `None` if there is none. |
307
308
  | `client.get_baskets_page(latitude, longitude)` | The full baskets page as a dictionary. It can contain personal data; do not log it raw. |
309
+ | `client.get_venue_checkout_context(slug)` | The `VenueCheckoutContext` for a restaurant, read from its static page. |
308
310
 
309
- Deleting a server basket is not supported.
311
+ There are two ways to save. `save_basket_items()` needs no saved address and no
312
+ card, because a basket costs nothing. Use it when you only want to change a
313
+ basket. `save_basket()` takes an `OrderSelection`, which you can only build
314
+ after `list_delivery_targets()` and `get_payment_methods()` on the same client,
315
+ so it needs a saved delivery address and an enabled card. That cost is worth
316
+ paying only when you go on to request a checkout quote with the same selection.
317
+
318
+ Deleting a server basket is not supported. There is also no call that adds or
319
+ removes a single item: every save replaces the whole basket.
320
+
321
+ #### Change a saved basket
322
+
323
+ Editing means four steps: read the saved basket, rebuild it locally, apply
324
+ your change, and save the result.
325
+
326
+ Read with `get_baskets_page()`. It returns each basket with its restaurant's
327
+ slug and the selected options, which is what a rebuild needs.
328
+
329
+ ```python
330
+ page = client.get_baskets_page(60.17, 24.94)
331
+ saved = next(
332
+ entry for entry in page["baskets"]
333
+ if entry["venue"]["slug"] == "<restaurant slug>"
334
+ )
335
+
336
+ menu = client.get_assortment("<restaurant slug>")
337
+ basket = Basket.from_saved_basket(menu, saved, "en")
338
+
339
+ basket.set_count("<item id>", 3)
340
+
341
+ client.save_basket_items(
342
+ menu,
343
+ venue=client.get_venue_checkout_context("<restaurant slug>"),
344
+ items=basket.item_selections(),
345
+ )
346
+ ```
347
+
348
+ `Basket.from_saved_basket()` handles the response quirks for you. A saved item
349
+ lists every option configuration it has, including ones you did not choose
350
+ from; those come back with an empty `values` list and are skipped. Option
351
+ values read back from the server carry no price; this does not matter, because
352
+ `Basket` recomputes every price from the menu you pass in. Substitution
353
+ settings are restored as saved.
354
+
355
+ Three caveats:
356
+
357
+ - The rebuild raises `SelectionError` naming the item ID when a saved item is
358
+ no longer on the restaurant's menu. Remove that item from the saved data or
359
+ start a fresh `Basket`.
360
+ - The rebuild raises `ResponseShapeError` when a saved basket entry does not
361
+ match the shape the server has been observed to return.
362
+ - A save sends only the restaurant, the currency, and the items. A customer
363
+ comment attached to the basket in the Wolt app may not survive a save.
310
364
 
311
365
  ## Can it order food?
312
366
 
@@ -225,6 +225,7 @@ longer use this path; prefer `RefreshTokenCredentials`.
225
225
  | `client.get_venue_dynamic(slug, latitude, longitude)` | Current opening and delivery information. |
226
226
  | `client.get_orders_page()` | A dictionary containing the current page of order history. |
227
227
  | `client.list_delivery_targets()` | References to your saved delivery addresses, with saved labels and address details. |
228
+ | `client.get_venue_checkout_context(slug)` | The `VenueCheckoutContext` for a restaurant, read from its static page. Needed for `create_selection` and `save_basket_items`. |
228
229
  | `client.get_order_status(purchase_id)` | An order's status and some price information. |
229
230
  | `derive_checkout_fields(assortment, item)` | The checkout metadata fields for one menu item, derived from the assortment. Raises an error for items in zero or multiple categories. |
230
231
 
@@ -283,12 +284,65 @@ baskets you see in the Wolt app. The library can read and replace them:
283
284
 
284
285
  | Call | What it does |
285
286
  | --- | --- |
286
- | `client.save_basket(selection)` | Replaces the server basket for that restaurant with your selection. This does not place an order. |
287
+ | `client.save_basket_items(assortment, venue=..., items=...)` | Replaces the whole server basket for that restaurant. Needs only the menu, a venue context, and items. This does not place an order. |
288
+ | `client.save_basket(selection)` | Also replaces the whole server basket, but takes a full `OrderSelection`. |
287
289
  | `client.get_basket_count()` | The number of baskets stored on the server. |
288
- | `client.get_venue_basket(venue_id)` | The server basket for one restaurant, or `None` if there is none. |
289
290
  | `client.get_baskets_page(latitude, longitude)` | The full baskets page as a dictionary. It can contain personal data; do not log it raw. |
291
+ | `client.get_venue_checkout_context(slug)` | The `VenueCheckoutContext` for a restaurant, read from its static page. |
290
292
 
291
- Deleting a server basket is not supported.
293
+ There are two ways to save. `save_basket_items()` needs no saved address and no
294
+ card, because a basket costs nothing. Use it when you only want to change a
295
+ basket. `save_basket()` takes an `OrderSelection`, which you can only build
296
+ after `list_delivery_targets()` and `get_payment_methods()` on the same client,
297
+ so it needs a saved delivery address and an enabled card. That cost is worth
298
+ paying only when you go on to request a checkout quote with the same selection.
299
+
300
+ Deleting a server basket is not supported. There is also no call that adds or
301
+ removes a single item: every save replaces the whole basket.
302
+
303
+ #### Change a saved basket
304
+
305
+ Editing means four steps: read the saved basket, rebuild it locally, apply
306
+ your change, and save the result.
307
+
308
+ Read with `get_baskets_page()`. It returns each basket with its restaurant's
309
+ slug and the selected options, which is what a rebuild needs.
310
+
311
+ ```python
312
+ page = client.get_baskets_page(60.17, 24.94)
313
+ saved = next(
314
+ entry for entry in page["baskets"]
315
+ if entry["venue"]["slug"] == "<restaurant slug>"
316
+ )
317
+
318
+ menu = client.get_assortment("<restaurant slug>")
319
+ basket = Basket.from_saved_basket(menu, saved, "en")
320
+
321
+ basket.set_count("<item id>", 3)
322
+
323
+ client.save_basket_items(
324
+ menu,
325
+ venue=client.get_venue_checkout_context("<restaurant slug>"),
326
+ items=basket.item_selections(),
327
+ )
328
+ ```
329
+
330
+ `Basket.from_saved_basket()` handles the response quirks for you. A saved item
331
+ lists every option configuration it has, including ones you did not choose
332
+ from; those come back with an empty `values` list and are skipped. Option
333
+ values read back from the server carry no price; this does not matter, because
334
+ `Basket` recomputes every price from the menu you pass in. Substitution
335
+ settings are restored as saved.
336
+
337
+ Three caveats:
338
+
339
+ - The rebuild raises `SelectionError` naming the item ID when a saved item is
340
+ no longer on the restaurant's menu. Remove that item from the saved data or
341
+ start a fresh `Basket`.
342
+ - The rebuild raises `ResponseShapeError` when a saved basket entry does not
343
+ match the shape the server has been observed to return.
344
+ - A save sends only the restaurant, the currency, and the items. A customer
345
+ comment attached to the basket in the Wolt app may not survive a save.
292
346
 
293
347
  ## Can it order food?
294
348
 
@@ -2,7 +2,6 @@
2
2
  """Interactively test checkout quotes. Never submits a purchase or charges a card."""
3
3
 
4
4
  import argparse
5
- import json
6
5
  import math
7
6
  from pathlib import Path
8
7
 
@@ -14,7 +13,6 @@ from woltapi import (
14
13
  HTTPStatusError,
15
14
  OptionSelection,
16
15
  OptionValueSelection,
17
- VenueCheckoutContext,
18
16
  WoltApiError,
19
17
  WoltClient,
20
18
  )
@@ -74,25 +72,6 @@ def card_description(card):
74
72
  )
75
73
 
76
74
 
77
- def fields(source, supplied, names, section):
78
- if not isinstance(source, dict) or not isinstance(supplied, dict):
79
- raise CheckoutInputError(f"Expected an object for {section}.")
80
- missing = [name for name in names if name not in source and name not in supplied]
81
- if missing:
82
- raise CheckoutInputError(
83
- f"Missing {section}: {', '.join(missing)}. "
84
- "Supply current browser values using --context-file; no defaults were invented."
85
- )
86
- if any(
87
- name in source and name in supplied and source[name] != supplied[name]
88
- for name in names
89
- ):
90
- raise CheckoutInputError(
91
- f"The {section} context conflicts with current server data."
92
- )
93
- return {name: source[name] if name in source else supplied[name] for name in names}
94
-
95
-
96
75
  def select_options(item, assortment, language):
97
76
  configurations = item.get("options")
98
77
  roots = assortment.get("options")
@@ -152,27 +131,19 @@ def select_options(item, assortment, language):
152
131
  return selected, descriptions
153
132
 
154
133
 
155
- def checkout(client, args, context):
134
+ def checkout(client, args):
156
135
  found = client.search_venues(args.query, args.latitude, args.longitude)
157
136
  venue = choose("restaurant", found, lambda v: text(v.title or v.slug))
158
- static = client.get_venue_static(venue.slug).get("venue")
159
- if not isinstance(static, dict) or static.get("id") != venue.id:
137
+ venue_context = client.get_venue_checkout_context(venue.slug)
138
+ if venue_context.id != venue.id:
160
139
  raise CheckoutInputError(
161
140
  "Static venue response does not match the selected restaurant."
162
141
  )
163
- if context and context.get("venue_id") != venue.id:
164
- raise CheckoutInputError("Context venue_id must match the selected restaurant.")
165
- venue_fields = fields(
166
- static,
167
- context.get("venue", {}),
168
- ("country", "currency", "self_delivery"),
169
- "venue",
170
- )
171
- if venue.currency is not None and venue_fields["currency"] != venue.currency:
142
+ if venue.currency is not None and venue_context.currency != venue.currency:
172
143
  raise CheckoutInputError(
173
144
  "Venue currency changed; start again with current data."
174
145
  )
175
- currency = venue_fields["currency"]
146
+ currency = venue_context.currency
176
147
  assortment = client.get_assortment(venue.slug)
177
148
  catalog_items = assortment.get("items")
178
149
  if not isinstance(catalog_items, list) or not all(
@@ -218,7 +189,7 @@ def checkout(client, args, context):
218
189
  return
219
190
  card_context = {
220
191
  "venue_id": venue.id,
221
- "country": venue_fields["country"],
192
+ "country": venue_context.country,
222
193
  "delivery_method": "homedelivery",
223
194
  "available_methods": ["card"],
224
195
  "items": [
@@ -237,7 +208,7 @@ def checkout(client, args, context):
237
208
  )
238
209
  selection = client.create_selection(
239
210
  assortment,
240
- venue=VenueCheckoutContext(id=venue.id, preorder_config=None, **venue_fields),
211
+ venue=venue_context,
241
212
  delivery=DeliverySelection(delivery.id, args.latitude, args.longitude),
242
213
  payment_method={"id": card.id, "type": card.type},
243
214
  courier_tip=tip,
@@ -317,11 +288,6 @@ def main():
317
288
  parser.add_argument("--token-file", type=Path, required=True)
318
289
  parser.add_argument("--query", default="pizza")
319
290
  parser.add_argument("--language", default="en")
320
- parser.add_argument(
321
- "--context-file",
322
- type=Path,
323
- help="Optional current browser metadata for venue fields missing from static data",
324
- )
325
291
  parser.add_argument(
326
292
  "--save-basket",
327
293
  action="store_true",
@@ -337,11 +303,8 @@ def main():
337
303
  parser.error("Provide valid latitude and longitude.")
338
304
  check_token_file(parser, args.token_file)
339
305
  try:
340
- context = json.loads(args.context_file.read_text()) if args.context_file else {}
341
- if not isinstance(context, dict):
342
- raise CheckoutInputError("The context file must contain a JSON object.")
343
306
  client = WoltClient(refresh_credentials(args.language, args.token_file))
344
- checkout(client, args, context)
307
+ checkout(client, args)
345
308
  except CheckoutInputError as exc:
346
309
  print(f"Checkout stopped: {exc}")
347
310
  return 1
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env python3
2
+ """Verify saved baskets can be rebuilt from their current assortments.
3
+
4
+ Read-only: this script only reads saved baskets and assortments. Its output is
5
+ restricted to venue slugs, item counts, and integer cent totals.
6
+ """
7
+
8
+ import argparse
9
+ import math
10
+ import re
11
+ from collections.abc import Mapping
12
+ from pathlib import Path
13
+
14
+ from browse import TokenFileError, check_token_file, refresh_credentials
15
+
16
+ from woltapi import Basket, WoltApiError, WoltClient
17
+
18
+
19
+ class VerificationInputError(ValueError):
20
+ """A local saved-basket shape problem safe to report to the owner."""
21
+
22
+
23
+ def venue_slug(saved_basket):
24
+ """Return the saved basket's public venue slug without exposing other fields."""
25
+ if not isinstance(saved_basket, Mapping):
26
+ raise VerificationInputError("Saved basket is not an object.")
27
+ venue = saved_basket.get("venue")
28
+ if not isinstance(venue, Mapping):
29
+ raise VerificationInputError("Saved basket venue is not an object.")
30
+ slug = venue.get("slug")
31
+ if not isinstance(slug, str) or re.fullmatch(r"[a-z0-9][a-z0-9-]*", slug) is None:
32
+ raise VerificationInputError("Saved basket venue has no usable slug.")
33
+ return slug
34
+
35
+
36
+ def failure_line(slug, error):
37
+ """Format only local static error messages, never response-derived data."""
38
+ if isinstance(error, VerificationInputError):
39
+ message = str(error)
40
+ else:
41
+ message = "Details omitted."
42
+ return f"FAIL venue={slug}: {type(error).__name__}: {message}"
43
+
44
+
45
+ def verify_baskets(client, args):
46
+ """Read, rebuild, and report each saved basket; return whether all passed."""
47
+ try:
48
+ page = client.get_baskets_page(args.latitude, args.longitude)
49
+ baskets = page.get("baskets") if isinstance(page, Mapping) else None
50
+ if not isinstance(baskets, list):
51
+ raise VerificationInputError("Saved baskets page has no baskets list.")
52
+ except (VerificationInputError, WoltApiError, TypeError, ValueError) as exc:
53
+ print(failure_line("unknown", exc))
54
+ return False
55
+
56
+ if not baskets:
57
+ # Keep the zero-basket note in the same privacy-safe output format.
58
+ print("PASS venue=no-saved-baskets items=0 total=0")
59
+ return True
60
+
61
+ passed = True
62
+ for saved_basket in baskets:
63
+ slug = "unknown"
64
+ try:
65
+ slug = venue_slug(saved_basket)
66
+ assortment = client.get_assortment(slug)
67
+ basket = Basket.from_saved_basket(assortment, saved_basket, args.language)
68
+ selections = basket.item_selections()
69
+ item_count = sum(selection.count for selection in selections)
70
+ total = sum(selection.basket_price for selection in selections)
71
+ print(f"PASS venue={slug} items={item_count} total={total}")
72
+ except (VerificationInputError, WoltApiError, TypeError, ValueError) as exc:
73
+ print(failure_line(slug, exc))
74
+ passed = False
75
+ return passed
76
+
77
+
78
+ def main():
79
+ parser = argparse.ArgumentParser(description=__doc__)
80
+ parser.add_argument("--latitude", type=float, required=True)
81
+ parser.add_argument("--longitude", type=float, required=True)
82
+ parser.add_argument("--token-file", type=Path, required=True)
83
+ parser.add_argument("--language", default="en")
84
+ args = parser.parse_args()
85
+ if not (
86
+ math.isfinite(args.latitude)
87
+ and -90 <= args.latitude <= 90
88
+ and math.isfinite(args.longitude)
89
+ and -180 <= args.longitude <= 180
90
+ ):
91
+ parser.error("Provide finite latitude [-90, 90] and longitude [-180, 180].")
92
+ check_token_file(parser, args.token_file)
93
+ try:
94
+ client = WoltClient(refresh_credentials(args.language, args.token_file))
95
+ return 0 if verify_baskets(client, args) else 1
96
+ except TokenFileError:
97
+ print("FAIL venue=unknown: TokenFileError: Could not save the refresh token.")
98
+ return 1
99
+ except (EOFError, KeyboardInterrupt) as exc:
100
+ print(f"FAIL venue=unknown: {type(exc).__name__}: Cancelled.")
101
+ return 1
102
+ except (OSError, UnicodeError, ValueError, WoltApiError) as exc:
103
+ print(failure_line("unknown", exc))
104
+ return 1
105
+
106
+
107
+ if __name__ == "__main__":
108
+ raise SystemExit(main())
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "woltapi"
7
- version = "0.3.0"
7
+ version = "0.4.1"
8
8
  description = "A small synchronous client for observed Wolt ordering flows."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -8,7 +8,7 @@ from collections.abc import Mapping, Sequence
8
8
  from types import MappingProxyType
9
9
  from typing import Any
10
10
 
11
- from .errors import SelectionError
11
+ from .errors import ResponseShapeError, SelectionError
12
12
  from .selection import (
13
13
  ItemSelection,
14
14
  OptionSelection,
@@ -66,6 +66,30 @@ class Basket:
66
66
  self._default_substitution_allowed = substitution_allowed
67
67
  self._items: dict[str, _BasketItem] = {}
68
68
 
69
+ @classmethod
70
+ def from_saved_basket(
71
+ cls,
72
+ assortment: Mapping[str, Any],
73
+ saved_basket: Mapping[str, Any],
74
+ language: str,
75
+ ) -> Basket:
76
+ """Rebuild a local basket from one saved consumer-basket entry."""
77
+
78
+ saved_items = _saved_basket_items(saved_basket)
79
+ basket = cls(assortment, language)
80
+ for item_id, count, options, substitution_allowed in saved_items:
81
+ if item_id not in basket._catalog_items:
82
+ raise SelectionError(
83
+ f"Saved basket item {item_id!r} is absent from the assortment."
84
+ )
85
+ basket.add_item(
86
+ item_id,
87
+ count,
88
+ options,
89
+ substitution_allowed=substitution_allowed,
90
+ )
91
+ return basket
92
+
69
93
  @property
70
94
  def contents(self) -> Mapping[str, ItemSelection]:
71
95
  """Return a detached, read-only view of the current basket contents."""
@@ -241,6 +265,10 @@ def _index_by_id(value: Any) -> dict[str, Mapping[str, Any]]:
241
265
 
242
266
 
243
267
  def _translated_name(value: Any, language: str) -> str:
268
+ # Catalog/read endpoints return strings; only restaurant-api purchase payloads
269
+ # (/v2/purchases, /v1/post-checkout-config) use list-of-{lang, value} names.
270
+ if _is_nonempty_text(value):
271
+ return value
244
272
  if not _is_array(value):
245
273
  raise SelectionError("The selected item is missing translated names.")
246
274
  for translation in value:
@@ -250,7 +278,12 @@ def _translated_name(value: Any, language: str) -> str:
250
278
  name = translation.get("value")
251
279
  if translation_language == language and _is_nonempty_text(name):
252
280
  return name
253
- raise SelectionError("The selected item has no name for the basket language.")
281
+ for translation in value:
282
+ if isinstance(translation, Mapping) and _is_nonempty_text(
283
+ name := translation.get("value")
284
+ ):
285
+ return name
286
+ raise SelectionError("The selected item has no usable name.")
254
287
 
255
288
 
256
289
  def _required_fields(
@@ -267,6 +300,94 @@ def _copy_options(value: Any) -> tuple[OptionSelection, ...]:
267
300
  return tuple(deepcopy(value))
268
301
 
269
302
 
303
+ def _saved_basket_items(
304
+ value: Any,
305
+ ) -> tuple[tuple[str, int, tuple[OptionSelection, ...], bool], ...]:
306
+ if not isinstance(value, Mapping):
307
+ _saved_basket_shape_error()
308
+ _validate_saved_venue(value.get("venue"))
309
+ saved_items = value.get("items")
310
+ if not _is_array(saved_items):
311
+ _saved_basket_shape_error()
312
+
313
+ items = []
314
+ for saved_item in saved_items:
315
+ if not isinstance(saved_item, Mapping):
316
+ _saved_basket_shape_error()
317
+ item_id = _saved_nonempty_text(saved_item, "id")
318
+ _saved_nonempty_text(saved_item, "name")
319
+ if "price" not in saved_item:
320
+ _saved_basket_shape_error()
321
+ count = saved_item.get("count")
322
+ if not _is_integer(count) or count <= 0:
323
+ _saved_basket_shape_error()
324
+ substitution_allowed = _saved_substitution_allowed(
325
+ saved_item.get("substitution_settings")
326
+ )
327
+ if "options" not in saved_item:
328
+ _saved_basket_shape_error()
329
+ options = _saved_item_options(saved_item["options"])
330
+ items.append((item_id, count, options, substitution_allowed))
331
+ return tuple(items)
332
+
333
+
334
+ def _validate_saved_venue(value: Any) -> None:
335
+ if not isinstance(value, Mapping):
336
+ _saved_basket_shape_error()
337
+ for field_name in ("id", "name", "slug", "country"):
338
+ _saved_nonempty_text(value, field_name)
339
+ if not isinstance(value.get("available"), bool):
340
+ _saved_basket_shape_error()
341
+
342
+
343
+ def _saved_substitution_allowed(value: Any) -> bool:
344
+ if not isinstance(value, Mapping):
345
+ _saved_basket_shape_error()
346
+ is_allowed = value.get("is_allowed")
347
+ if not isinstance(is_allowed, bool):
348
+ _saved_basket_shape_error()
349
+ return is_allowed
350
+
351
+
352
+ def _saved_item_options(value: Any) -> tuple[OptionSelection, ...]:
353
+ if value is None:
354
+ return ()
355
+ if not _is_array(value):
356
+ _saved_basket_shape_error()
357
+
358
+ options = []
359
+ for option in value:
360
+ if not isinstance(option, Mapping):
361
+ _saved_basket_shape_error()
362
+ configuration_id = _saved_nonempty_text(option, "id")
363
+ saved_values = option.get("values")
364
+ if not _is_array(saved_values):
365
+ _saved_basket_shape_error()
366
+ values = []
367
+ for saved_value in saved_values:
368
+ if not isinstance(saved_value, Mapping):
369
+ _saved_basket_shape_error()
370
+ value_id = _saved_nonempty_text(saved_value, "id")
371
+ count = saved_value.get("count")
372
+ if not _is_integer(count) or count <= 0:
373
+ _saved_basket_shape_error()
374
+ values.append(OptionValueSelection(value_id, count))
375
+ if values:
376
+ options.append(OptionSelection(configuration_id, values))
377
+ return tuple(options)
378
+
379
+
380
+ def _saved_nonempty_text(value: Mapping[str, Any], field_name: str) -> str:
381
+ field_value = value.get(field_name)
382
+ if not _is_nonempty_text(field_value):
383
+ _saved_basket_shape_error()
384
+ return field_value
385
+
386
+
387
+ def _saved_basket_shape_error() -> None:
388
+ raise ResponseShapeError("consumer")
389
+
390
+
270
391
  def _item_id(value: Any) -> str:
271
392
  if not _is_nonempty_text(value):
272
393
  raise SelectionError("An item ID is required.")
@@ -10,7 +10,7 @@ from typing import Any
10
10
  from urllib.parse import quote
11
11
 
12
12
  from .credentials import SessionCredentials
13
- from .errors import HTTPStatusError, ResponseShapeError, SelectionError
13
+ from .errors import ResponseShapeError, SelectionError
14
14
  from .models import DeliveryTarget, OrderStatus, PaymentMethod, Venue
15
15
  from .purchase import (
16
16
  PurchaseAttemptStore,
@@ -29,6 +29,7 @@ from .selection import (
29
29
  QuoteSnapshot,
30
30
  SavedBasket,
31
31
  VenueCheckoutContext,
32
+ _build_basket_payload,
32
33
  )
33
34
  from .services import ServiceHost
34
35
  from .transport import DEFAULT_TIMEOUT_SECONDS, WoltTransport
@@ -131,22 +132,6 @@ class WoltClient:
131
132
  raise ResponseShapeError(ServiceHost.CONSUMER.value)
132
133
  return count
133
134
 
134
- def get_venue_basket(self, venue_id: str) -> dict[str, Any] | None:
135
- """Read a venue's server basket, returning ``None`` when it is absent."""
136
-
137
- venue_id = _required_text(venue_id, "venue_id")
138
- try:
139
- return self._transport.request(
140
- ServiceHost.CONSUMER,
141
- "GET",
142
- "/order-xp/v1/baskets/venue",
143
- query={"venue_id": venue_id},
144
- )
145
- except HTTPStatusError as error:
146
- if error.status_code == 404:
147
- return None
148
- raise
149
-
150
135
  def get_baskets_page(
151
136
  self, latitude: int | float, longitude: int | float
152
137
  ) -> dict[str, Any]:
@@ -174,6 +159,32 @@ class WoltClient:
174
159
  f"/order-xp/web/v1/pages/venue/slug/{slug}/static",
175
160
  )
176
161
 
162
+ def get_venue_checkout_context(self, venue_slug: str) -> VenueCheckoutContext:
163
+ """Read the checkout fields supplied by a venue's static page."""
164
+
165
+ response = self.get_venue_static(venue_slug)
166
+ venue = response.get("venue")
167
+ if not isinstance(venue, Mapping):
168
+ raise ResponseShapeError(ServiceHost.CONSUMER.value)
169
+ venue_id = venue.get("id")
170
+ country = venue.get("country")
171
+ currency = venue.get("currency")
172
+ self_delivery = venue.get("self_delivery")
173
+ if (
174
+ not _is_nonempty_string(venue_id)
175
+ or not _is_nonempty_string(country)
176
+ or not _is_nonempty_string(currency)
177
+ or not isinstance(self_delivery, bool)
178
+ ):
179
+ raise ResponseShapeError(ServiceHost.CONSUMER.value)
180
+ return VenueCheckoutContext(
181
+ id=venue_id,
182
+ country=country,
183
+ currency=currency,
184
+ self_delivery=self_delivery,
185
+ preorder_config=None,
186
+ )
187
+
177
188
  def get_venue_dynamic(
178
189
  self,
179
190
  venue_slug: str,
@@ -394,6 +405,27 @@ class WoltClient:
394
405
  raise ResponseShapeError(ServiceHost.CONSUMER.value)
395
406
  return SavedBasket(id=basket_id, venue_id=venue_id)
396
407
 
408
+ def save_basket_items(
409
+ self,
410
+ assortment: Mapping[str, Any],
411
+ *,
412
+ venue: VenueCheckoutContext,
413
+ items: Sequence[ItemSelection],
414
+ ) -> SavedBasket:
415
+ """Explicitly upsert a per-venue basket without delivery or payment data."""
416
+
417
+ response = self._transport.request(
418
+ ServiceHost.CONSUMER,
419
+ "POST",
420
+ "/order-xp/v1/baskets",
421
+ json_body=_build_basket_payload(assortment, venue=venue, items=items),
422
+ )
423
+ basket_id = response.get("id")
424
+ venue_id = response.get("venue_id")
425
+ if not _is_nonempty_string(basket_id) or not _is_nonempty_string(venue_id):
426
+ raise ResponseShapeError(ServiceHost.CONSUMER.value)
427
+ return SavedBasket(id=basket_id, venue_id=venue_id)
428
+
397
429
  def quote_checkout(self, selection: OrderSelection) -> QuoteSnapshot:
398
430
  """Request one checkout quote and capture independent plan/response snapshots."""
399
431
 
@@ -937,6 +937,7 @@ def _purchase_option_summary(option: Mapping[str, Any]) -> PurchaseOptionSummary
937
937
 
938
938
 
939
939
  def _purchase_name_summaries(names: Any, label: str) -> tuple[PurchaseNameSummary, ...]:
940
+ # This list shape is correct for /v2/purchases; do not accept strings (verified by capture).
940
941
  if not _is_array(names) or not names:
941
942
  raise PurchasePreparationError(f"{label} are incomplete.")
942
943
  summaries: list[PurchaseNameSummary] = []